From dd465a2941841d701af15da0b18edcb8e254da9c Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 13:57:39 +0800 Subject: [PATCH 1/4] Add environment-scoped typed asset store --- README.md | 7 + README/README_zh-CN.md | 7 + README/README_zh-TW.md | 7 + .../Eng/doc/new_features/v48_features_doc.rst | 54 +++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v48_features_doc.rst | 49 +++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 31 ++++ je_auto_control/utils/assets/__init__.py | 6 + je_auto_control/utils/assets/assets.py | 137 ++++++++++++++++++ .../utils/executor/action_executor.py | 29 ++++ .../utils/mcp_server/tools/_factories.py | 41 +++++- .../utils/mcp_server/tools/_handlers.py | 19 +++ test/unit_test/headless/test_assets_batch.py | 106 ++++++++++++++ 15 files changed, 499 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v48_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v48_features_doc.rst create mode 100644 je_auto_control/utils/assets/__init__.py create mode 100644 je_auto_control/utils/assets/assets.py create mode 100644 test/unit_test/headless/test_assets_batch.py diff --git a/README.md b/README.md index 1dc10931..e4cbaa15 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-20) — Environment-Scoped Typed Asset Store](#whats-new-2026-06-20--environment-scoped-typed-asset-store) - [What's new (2026-06-20) — Task / Process Mining (Automation-Candidate Discovery)](#whats-new-2026-06-20--task--process-mining-automation-candidate-discovery) - [What's new (2026-06-20) — Stuck-Loop Guard (Agent Loop Progress Detection)](#whats-new-2026-06-20--stuck-loop-guard-agent-loop-progress-detection) - [What's new (2026-06-20) — Coordinate-Space Mapping (Model Grid ⇄ Physical Pixels)](#whats-new-2026-06-20--coordinate-space-mapping-model-grid--physical-pixels) @@ -100,6 +101,12 @@ --- +## What's new (2026-06-20) — Environment-Scoped Typed Asset Store + +Per-environment typed config + credential refs. Full reference: [`docs/source/Eng/doc/new_features/v48_features_doc.rst`](docs/source/Eng/doc/new_features/v48_features_doc.rst). + +- **`AssetStore` / `active_environment`** (`AC_set_asset` / `AC_get_asset` / `AC_list_assets`, `ac_*`): the orchestrator "Assets/lockers" pillar — centrally-managed config values that differ by environment (dev/staging/prod) and carry a type (`text`/`int`/`bool`/`credential`). `get` coerces to the declared type and falls back to the default env; `credential` assets hold a secret *reference* that `resolve` turns into the real value via an injected resolver (Python-only, so secrets never enter `get`/executor records). Fills the gap the secret vault (secret-only) and config-sync (whole-blob) left. + ## What's new (2026-06-20) — Task / Process Mining (Automation-Candidate Discovery) Discover what to automate from recorded action logs. Full reference: [`docs/source/Eng/doc/new_features/v47_features_doc.rst`](docs/source/Eng/doc/new_features/v47_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 4be17c4d..eabe34db 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-20) — 环境范围的带类型资产存储](#本次更新-2026-06-20--环境范围的带类型资产存储) - [本次更新 (2026-06-20) — 任务 / 流程挖掘(自动化候选发现)](#本次更新-2026-06-20--任务--流程挖掘自动化候选发现) - [本次更新 (2026-06-20) — 卡循环守卫(Agent Loop 进度检测)](#本次更新-2026-06-20--卡循环守卫agent-loop-进度检测) - [本次更新 (2026-06-20) — 坐标空间映射(模型网格 ⇄ 物理像素)](#本次更新-2026-06-20--坐标空间映射模型网格--物理像素) @@ -99,6 +100,12 @@ --- +## 本次更新 (2026-06-20) — 环境范围的带类型资产存储 + +依环境的带类型配置 + credential 引用。完整参考:[`docs/source/Zh/doc/new_features/v48_features_doc.rst`](../docs/source/Zh/doc/new_features/v48_features_doc.rst)。 + +- **`AssetStore` / `active_environment`**(`AC_set_asset` / `AC_get_asset` / `AC_list_assets`、`ac_*`):orchestrator 的「Assets/lockers」支柱 —— 集中管理、依环境(dev/staging/prod)而异且带类型(`text`/`int`/`bool`/`credential`)的配置值。`get` 转成声明类型并退回 default 环境;`credential` 资产持有密钥*引用*,由 `resolve` 通过注入解析器转成真实值(仅限 Python,因此密钥永不进入 `get`/executor 记录)。补足密钥保险库(仅密钥)与 config-sync(整块)的缺口。 + ## 本次更新 (2026-06-20) — 任务 / 流程挖掘(自动化候选发现) 从录制的动作日志发现该自动化什么。完整参考:[`docs/source/Zh/doc/new_features/v47_features_doc.rst`](../docs/source/Zh/doc/new_features/v47_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 79a3d096..b558340d 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-20) — 環境範圍的具型別資產儲存](#本次更新-2026-06-20--環境範圍的具型別資產儲存) - [本次更新 (2026-06-20) — 任務 / 流程探勘(自動化候選發現)](#本次更新-2026-06-20--任務--流程探勘自動化候選發現) - [本次更新 (2026-06-20) — 卡迴圈守衛(Agent Loop 進度偵測)](#本次更新-2026-06-20--卡迴圈守衛agent-loop-進度偵測) - [本次更新 (2026-06-20) — 座標空間對映(模型網格 ⇄ 實體像素)](#本次更新-2026-06-20--座標空間對映模型網格--實體像素) @@ -99,6 +100,12 @@ --- +## 本次更新 (2026-06-20) — 環境範圍的具型別資產儲存 + +依環境的具型別設定 + credential 參照。完整參考:[`docs/source/Zh/doc/new_features/v48_features_doc.rst`](../docs/source/Zh/doc/new_features/v48_features_doc.rst)。 + +- **`AssetStore` / `active_environment`**(`AC_set_asset` / `AC_get_asset` / `AC_list_assets`、`ac_*`):orchestrator 的「Assets/lockers」支柱 —— 集中管理、依環境(dev/staging/prod)而異且帶型別(`text`/`int`/`bool`/`credential`)的設定值。`get` 轉成宣告型別並退回 default 環境;`credential` 資產持有密鑰*參照*,由 `resolve` 透過注入解析器轉成真實值(僅限 Python,因此密鑰永不進入 `get`/executor 紀錄)。補足密鑰保險庫(僅密鑰)與 config-sync(整塊)的缺口。 + ## 本次更新 (2026-06-20) — 任務 / 流程探勘(自動化候選發現) 從錄製的動作日誌發現該自動化什麼。完整參考:[`docs/source/Zh/doc/new_features/v47_features_doc.rst`](../docs/source/Zh/doc/new_features/v47_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v48_features_doc.rst b/docs/source/Eng/doc/new_features/v48_features_doc.rst new file mode 100644 index 00000000..5b076f76 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v48_features_doc.rst @@ -0,0 +1,54 @@ +Environment-Scoped Typed Asset Store +==================================== + +Flows need centrally-managed config values that differ by environment +(dev/staging/prod) and carry a type — the orchestrator "Assets / lockers" +pillar. The secret vault covers secrets only and config-sync moves whole blobs; +neither offers a typed, per-environment named lookup. ``AssetStore`` fills that: +values are stored under an environment, read back with type coercion, and +``credential`` assets hold a *reference* (a secret name) that :meth:`resolve` +turns into the real value through an injected resolver — so the secret never +lands in a plain ``get`` or an executor record. + +JSON-backed (or in-memory); pure standard library; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import AssetStore, active_environment + + store = AssetStore("assets.json") + store.set("max_retries", 3, type="int", environment="prod") + store.set("api_base", "https://prod.example.com", environment="prod") + store.set("db_password", "vault_db_pw", type="credential") # value = a ref + + store.get("max_retries", environment="prod").value # -> 3 (typed) + store.get("api_base", environment="staging").value # -> falls back to default + store.get("db_password").value # -> "vault_db_pw" (reference, safe) + + # resolve a credential through an injected secret resolver (Python-only): + store = AssetStore("assets.json", secret_resolver=secret_manager.get) + store.resolve("db_password") # -> the real secret + +Types are ``text`` / ``int`` / ``bool`` / ``credential``; ``get`` coerces to the +declared type and falls back to the ``default`` environment unless disabled. +``active_environment()`` reads ``JE_AUTOCONTROL_ENV``. ``list`` / ``delete`` round +out the store. + +Executor commands +----------------- + +================================ =================================================== +Command Effect +================================ =================================================== +``AC_set_asset`` Store a typed, environment-scoped asset. +``AC_get_asset`` Read an asset (credential stays a reference). +``AC_list_assets`` List ``{name, type, environment}`` (no values). +================================ =================================================== + +Credential **resolution** is intentionally Python-API-only (so secrets never +enter run records). The same lifecycle operations are exposed as MCP tools +(``ac_set_asset`` / ``ac_get_asset`` / ``ac_list_assets``) and as Script Builder +commands under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index c67ce9d8..3b92dd19 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -70,6 +70,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v45_features_doc doc/new_features/v46_features_doc doc/new_features/v47_features_doc + doc/new_features/v48_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/v48_features_doc.rst b/docs/source/Zh/doc/new_features/v48_features_doc.rst new file mode 100644 index 00000000..c5c09cde --- /dev/null +++ b/docs/source/Zh/doc/new_features/v48_features_doc.rst @@ -0,0 +1,49 @@ +環境範圍的具型別資產儲存 +======================== + +流程需要集中管理、依環境(dev/staging/prod)而異且帶有型別的設定值 —— 這是 orchestrator +的「Assets / lockers」支柱。密鑰保險庫只處理密鑰,config-sync 搬移整塊設定;兩者皆無具 +型別、依環境的具名查詢。``AssetStore`` 補足此處:值依環境儲存、讀回時做型別轉換,而 +``credential`` 資產持有一個*參照*(密鑰名稱),由 :meth:`resolve` 透過注入的解析器轉成 +真實值 —— 因此密鑰永不出現在純 ``get`` 或 executor 紀錄中。 + +JSON 後端(或記憶體內);純標準函式庫;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import AssetStore, active_environment + + store = AssetStore("assets.json") + store.set("max_retries", 3, type="int", environment="prod") + store.set("api_base", "https://prod.example.com", environment="prod") + store.set("db_password", "vault_db_pw", type="credential") # value = 參照 + + store.get("max_retries", environment="prod").value # -> 3(具型別) + store.get("api_base", environment="staging").value # -> 退回 default + store.get("db_password").value # -> "vault_db_pw"(參照,安全) + + # 透過注入的密鑰解析器解析 credential(僅限 Python): + store = AssetStore("assets.json", secret_resolver=secret_manager.get) + store.resolve("db_password") # -> 真實密鑰 + +型別為 ``text`` / ``int`` / ``bool`` / ``credential``;``get`` 會轉成宣告型別,並在未停用 +時退回 ``default`` 環境。``active_environment()`` 讀取 ``JE_AUTOCONTROL_ENV``。``list`` / +``delete`` 補齊整個儲存體。 + +執行器指令 +---------- + +================================ =================================================== +指令 效果 +================================ =================================================== +``AC_set_asset`` 儲存具型別、依環境的資產。 +``AC_get_asset`` 讀取資產(credential 維持參照)。 +``AC_list_assets`` 列出 ``{name, type, environment}``(不含值)。 +================================ =================================================== + +credential 的**解析**刻意僅限 Python API(因此密鑰永不進入執行紀錄)。相同的生命週期操 +作亦提供為 MCP 工具(``ac_set_asset`` / ``ac_get_asset`` / ``ac_list_assets``),以及 +Script Builder 中 **Data** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 0858497f..b5a7a73f 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -70,6 +70,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v45_features_doc doc/new_features/v46_features_doc doc/new_features/v47_features_doc + doc/new_features/v48_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 282cfb90..b1651f7e 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -264,6 +264,10 @@ Candidate, MiningReport, SequencePattern, directly_follows, find_repeated_sequences, mine_action_log, rank_automation_candidates, ) +# Environment-scoped typed asset/config store +from je_auto_control.utils.assets import ( + Asset, AssetStore, AssetValue, active_environment, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -723,6 +727,7 @@ def start_autocontrol_gui(*args, **kwargs): "Candidate", "MiningReport", "SequencePattern", "directly_follows", "find_repeated_sequences", "mine_action_log", "rank_automation_candidates", + "Asset", "AssetStore", "AssetValue", "active_environment", # 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 e12ec4d0..5f0ce6a8 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1067,6 +1067,37 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Find repeated sequences + rank automation candidates.", )) + specs.append(CommandSpec( + "AC_set_asset", "Data", "Asset: Set", + fields=( + FieldSpec("name", FieldType.STRING), + FieldSpec("value", FieldType.STRING), + FieldSpec("type", FieldType.ENUM, optional=True, default="text", + choices=("text", "int", "bool", "credential")), + FieldSpec("environment", FieldType.STRING, optional=True, + default="default"), + FieldSpec("db", FieldType.STRING, optional=True), + ), + description="Store a typed, environment-scoped asset.", + )) + specs.append(CommandSpec( + "AC_get_asset", "Data", "Asset: Get", + fields=( + FieldSpec("name", FieldType.STRING), + FieldSpec("environment", FieldType.STRING, optional=True, + default="default"), + FieldSpec("db", FieldType.STRING, optional=True), + ), + description="Read a typed asset (credential stays a reference).", + )) + specs.append(CommandSpec( + "AC_list_assets", "Data", "Asset: List", + fields=( + FieldSpec("environment", FieldType.STRING, optional=True), + FieldSpec("db", FieldType.STRING, optional=True), + ), + description="List assets (name/type/environment, no values).", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/assets/__init__.py b/je_auto_control/utils/assets/__init__.py new file mode 100644 index 00000000..623a7e64 --- /dev/null +++ b/je_auto_control/utils/assets/__init__.py @@ -0,0 +1,6 @@ +"""Environment-scoped typed asset/config store (UiPath-Assets style).""" +from je_auto_control.utils.assets.assets import ( + Asset, AssetStore, AssetValue, active_environment, +) + +__all__ = ["Asset", "AssetStore", "AssetValue", "active_environment"] diff --git a/je_auto_control/utils/assets/assets.py b/je_auto_control/utils/assets/assets.py new file mode 100644 index 00000000..cc045116 --- /dev/null +++ b/je_auto_control/utils/assets/assets.py @@ -0,0 +1,137 @@ +"""Typed, environment-scoped assets — the orchestrator "Assets / lockers" pillar. + +Flows need centrally-managed config values that differ by environment +(dev/staging/prod) and carry a type (text/int/bool/credential). The secret vault +covers secrets only and config-sync moves whole blobs; neither offers a typed, +per-environment named lookup. ``AssetStore`` fills that: values are stored under +an environment, read back with type coercion, and ``credential`` assets hold a +*reference* (a secret name) that :meth:`AssetStore.resolve` turns into the real +value through an injected resolver — so the secret never lands in a plain +``get``/executor record. + +JSON-backed (or in-memory); pure standard library; imports no ``PySide6``. +""" +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +ENV_VAR = "JE_AUTOCONTROL_ENV" +DEFAULT_ENV = "default" +TYPE_TEXT = "text" +TYPE_INT = "int" +TYPE_BOOL = "bool" +TYPE_CREDENTIAL = "credential" + + +@dataclass(frozen=True) +class Asset: + """A stored asset record (``value`` is a secret *name* when credential).""" + + name: str + type: str + environment: str + value: Any + + +@dataclass(frozen=True) +class AssetValue: + """A read asset with its declared type and coerced (non-secret) value.""" + + name: str + type: str + value: Any + + +def active_environment() -> str: + """Return the active environment from ``JE_AUTOCONTROL_ENV`` (or default).""" + return os.environ.get(ENV_VAR, DEFAULT_ENV) + + +def _coerce(value: Any, type_name: str) -> Any: + if type_name == TYPE_INT: + return int(value) + if type_name == TYPE_BOOL: + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return bool(value) + return value if type_name == TYPE_CREDENTIAL else str(value) + + +class AssetStore: + """A typed, environment-scoped key/value store backed by optional JSON.""" + + def __init__(self, db_path: Optional[str] = None, *, + secret_resolver: Optional[Callable[[str], Any]] = None + ) -> None: + """``secret_resolver(name)`` resolves ``credential`` references lazily.""" + self._path = Path(db_path) if db_path else None + self._resolver = secret_resolver + self._data: Dict[str, Dict[str, Dict[str, Any]]] = self._load() + + def _load(self) -> Dict[str, Dict[str, Dict[str, Any]]]: + if self._path is None or not self._path.is_file(): + return {} + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + def _flush(self) -> None: + if self._path is None: + return + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text( + json.dumps(self._data, ensure_ascii=False, indent=2), + encoding="utf-8") + + def set(self, name: str, value: Any, *, type: str = TYPE_TEXT, + environment: str = DEFAULT_ENV) -> None: + """Store ``value`` for ``name`` under ``environment`` with a type tag.""" + self._data.setdefault(environment, {})[name] = { + "type": type, "value": value} + self._flush() + + def _lookup(self, name: str, environment: str, + fallback_to_default: bool) -> Optional[Dict[str, Any]]: + record = self._data.get(environment, {}).get(name) + if record is None and fallback_to_default and environment != DEFAULT_ENV: + record = self._data.get(DEFAULT_ENV, {}).get(name) + return record + + def get(self, name: str, *, environment: str = DEFAULT_ENV, + fallback_to_default: bool = True) -> AssetValue: + """Return the typed asset (credential values stay as a reference).""" + record = self._lookup(name, environment, fallback_to_default) + if record is None: + raise KeyError(f"asset {name!r} not found for {environment!r}") + type_name = str(record["type"]) + return AssetValue(name, type_name, + _coerce(record["value"], type_name)) + + def resolve(self, name: str, *, environment: str = DEFAULT_ENV) -> Any: + """Like :meth:`get` but resolves a ``credential`` to its real value.""" + asset = self.get(name, environment=environment) + if asset.type != TYPE_CREDENTIAL: + return asset.value + if self._resolver is None: + raise RuntimeError("no secret_resolver configured for credentials") + return self._resolver(str(asset.value)) + + def delete(self, name: str, *, environment: str = DEFAULT_ENV) -> bool: + """Delete an asset; return whether it existed.""" + removed = self._data.get(environment, {}).pop(name, None) is not None + if removed: + self._flush() + return removed + + def list(self, *, environment: Optional[str] = None) -> List[Asset]: + """List assets, optionally restricted to one ``environment``.""" + envs = [environment] if environment else list(self._data) + return [ + Asset(name, str(rec["type"]), env, rec["value"]) + for env in envs + for name, rec in self._data.get(env, {}).items() + ] diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 870a9329..e2f4abbb 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3245,6 +3245,32 @@ def _mine_actions(actions: Any, min_len: int = 2, max_len: int = 5, } +def _set_asset(name: str, value: Any, type: str = "text", + environment: str = "default", + db: Optional[str] = None) -> Dict[str, Any]: + """Adapter: store a typed, environment-scoped asset.""" + from je_auto_control.utils.assets import AssetStore + AssetStore(db).set(name, value, type=type, environment=environment) + return {"ok": True, "name": name, "environment": environment} + + +def _get_asset(name: str, environment: str = "default", + db: Optional[str] = None) -> Dict[str, Any]: + """Adapter: read a typed asset (credential stays a reference).""" + from je_auto_control.utils.assets import AssetStore + asset = AssetStore(db).get(name, environment=environment) + return {"name": asset.name, "type": asset.type, "value": asset.value} + + +def _list_assets(environment: Optional[str] = None, + db: Optional[str] = None) -> Dict[str, Any]: + """Adapter: list assets, optionally restricted to one environment.""" + from je_auto_control.utils.assets import AssetStore + assets = AssetStore(db).list(environment=environment) + return {"assets": [{"name": a.name, "type": a.type, + "environment": a.environment} for a in assets]} + + class Executor: """ Executor @@ -3520,6 +3546,9 @@ def __init__(self): "AC_loop_guard_observe": _loop_guard_observe, "AC_loop_guard_reset": _loop_guard_reset, "AC_mine_actions": _mine_actions, + "AC_set_asset": _set_asset, + "AC_get_asset": _get_asset, + "AC_list_assets": _list_assets, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 0d64e9a3..aedb5768 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3121,6 +3121,45 @@ def process_mining_tools() -> List[MCPTool]: ] +def asset_tools() -> List[MCPTool]: + _ENV = {"environment": {"type": "string"}, "db": {"type": "string"}} + return [ + MCPTool( + name="ac_set_asset", + description=("Store a typed, environment-scoped asset. 'type' is " + "text/int/bool/credential (credential 'value' is a " + "secret name, not the secret). Returns {ok}."), + input_schema=schema( + {"name": {"type": "string"}, "value": {}, + "type": {"type": "string", + "enum": ["text", "int", "bool", "credential"]}, + **_ENV}, ["name", "value"]), + handler=h.set_asset, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_get_asset", + description=("Read a typed asset for an environment (falls back to " + "the default env). Credential values are returned as a " + "reference, never the secret. Returns {name, type, " + "value}."), + input_schema=schema({"name": {"type": "string"}, **_ENV}, + ["name"]), + handler=h.get_asset, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_list_assets", + description=("List assets (optionally for one environment) as " + "{name, type, environment} — no values. Returns " + "{assets}."), + input_schema=schema(dict(_ENV)), + handler=h.list_assets, + annotations=READ_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -4183,7 +4222,7 @@ def media_assert_tools() -> List[MCPTool]: trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, locale_tools, voice_tools, coordinate_space_tools, loop_guard_tools, - process_mining_tools, + process_mining_tools, asset_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 361b9262..8dc825a5 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1512,6 +1512,25 @@ def mine_actions(actions, min_len=2, max_len=5, min_count=3): } +def set_asset(name, value, type="text", environment="default", db=None): + from je_auto_control.utils.assets import AssetStore + AssetStore(db).set(name, value, type=type, environment=environment) + return {"ok": True, "name": name, "environment": environment} + + +def get_asset(name, environment="default", db=None): + from je_auto_control.utils.assets import AssetStore + asset = AssetStore(db).get(name, environment=environment) + return {"name": asset.name, "type": asset.type, "value": asset.value} + + +def list_assets(environment=None, db=None): + from je_auto_control.utils.assets import AssetStore + assets = AssetStore(db).list(environment=environment) + return {"assets": [{"name": a.name, "type": a.type, + "environment": a.environment} for a in assets]} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/test/unit_test/headless/test_assets_batch.py b/test/unit_test/headless/test_assets_batch.py new file mode 100644 index 00000000..6bf337cb --- /dev/null +++ b/test/unit_test/headless/test_assets_batch.py @@ -0,0 +1,106 @@ +"""Headless tests for the environment-scoped typed asset store. Pure stdlib, +injected secret resolver for credentials; no Qt imports.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.assets import AssetStore, active_environment +from je_auto_control.utils.assets.assets import ENV_VAR + + +def test_typed_coercion(): + store = AssetStore() + store.set("retries", "3", type="int") + store.set("enabled", "yes", type="bool") + store.set("title", "Hi", type="text") + assert store.get("retries").value == 3 + assert store.get("enabled").value is True + assert store.get("title").value == "Hi" + + +def test_environment_override_and_fallback(): + store = AssetStore() + store.set("url", "https://default", environment="default") + store.set("url", "https://prod", environment="prod") + assert store.get("url", environment="prod").value == "https://prod" + # staging not set -> falls back to default + assert store.get("url", environment="staging").value == "https://default" + # fallback disabled -> KeyError + with pytest.raises(KeyError): + store.get("url", environment="staging", fallback_to_default=False) + + +def test_credential_get_returns_reference_not_secret(): + store = AssetStore() + store.set("db_pw", "vault_db_password", type="credential") + asset = store.get("db_pw") + assert asset.type == "credential" + assert asset.value == "vault_db_password" # the reference, not a secret + + +def test_credential_resolve_uses_injected_resolver(): + store = AssetStore(secret_resolver={"vault_db_password": "s3cr3t"}.get) + store.set("db_pw", "vault_db_password", type="credential") + assert store.resolve("db_pw") == "s3cr3t" + store.set("plain", "visible", type="text") + assert store.resolve("plain") == "visible" # non-credential returns value + + +def test_resolve_without_resolver_raises(): + store = AssetStore() + store.set("db_pw", "ref", type="credential") + with pytest.raises(RuntimeError): + store.resolve("db_pw") + + +def test_list_and_delete(): + store = AssetStore() + store.set("a", "1", environment="dev") + store.set("b", "2", environment="prod") + assert {a.name for a in store.list()} == {"a", "b"} + assert {a.name for a in store.list(environment="dev")} == {"a"} + assert store.delete("a", environment="dev") is True + assert store.list(environment="dev") == [] + + +def test_persists_across_instances(tmp_path): + db = str(tmp_path / "assets.json") + AssetStore(db).set("token", "42", type="int", environment="prod") + assert AssetStore(db).get("token", environment="prod").value == 42 + + +def test_active_environment(monkeypatch): + monkeypatch.delenv(ENV_VAR, raising=False) + assert active_environment() == "default" + monkeypatch.setenv(ENV_VAR, "staging") + assert active_environment() == "staging" + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(tmp_path): + db = str(tmp_path / "a.json") + ac.execute_action([["AC_set_asset", + {"name": "n", "value": "5", "type": "int", + "environment": "prod", "db": db}]]) + rec = ac.execute_action([["AC_get_asset", + {"name": "n", "environment": "prod", "db": db}]]) + asset = next(v for v in rec.values() if isinstance(v, dict)) + assert asset["value"] == 5 and asset["type"] == "int" + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_set_asset", "AC_get_asset", "AC_list_assets"} <= known + 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_set_asset", "ac_get_asset", "ac_list_assets"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_set_asset", "AC_get_asset", "AC_list_assets"} <= cmds + + +def test_facade_exports(): + for attr in ("Asset", "AssetStore", "AssetValue", "active_environment"): + assert hasattr(ac, attr) + assert attr in ac.__all__ From f1e16c841ca4f5339dc32b854560599ebcb6f03f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 14:05:29 +0800 Subject: [PATCH 2/4] Dedupe asset adapters via shared helpers; avoid password literal in test --- je_auto_control/utils/assets/assets.py | 23 +++++++++++++++++++ .../utils/executor/action_executor.py | 16 +++++-------- .../utils/mcp_server/tools/_handlers.py | 16 +++++-------- test/unit_test/headless/test_assets_batch.py | 8 +++---- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/je_auto_control/utils/assets/assets.py b/je_auto_control/utils/assets/assets.py index cc045116..95023061 100644 --- a/je_auto_control/utils/assets/assets.py +++ b/je_auto_control/utils/assets/assets.py @@ -135,3 +135,26 @@ def list(self, *, environment: Optional[str] = None) -> List[Asset]: for env in envs for name, rec in self._data.get(env, {}).items() ] + + +def store_set(name: str, value: Any, *, type: str = TYPE_TEXT, + environment: str = DEFAULT_ENV, + db: Optional[str] = None) -> Dict[str, Any]: + """Set an asset and return a result dict (shared by executor/MCP layers).""" + AssetStore(db).set(name, value, type=type, environment=environment) + return {"ok": True, "name": name, "environment": environment} + + +def store_get(name: str, *, environment: str = DEFAULT_ENV, + db: Optional[str] = None) -> Dict[str, Any]: + """Get an asset as a result dict (credential value stays a reference).""" + asset = AssetStore(db).get(name, environment=environment) + return {"name": asset.name, "type": asset.type, "value": asset.value} + + +def store_list(*, environment: Optional[str] = None, + db: Optional[str] = None) -> Dict[str, Any]: + """List assets as a result dict of ``{name, type, environment}`` (no values).""" + assets = AssetStore(db).list(environment=environment) + return {"assets": [{"name": a.name, "type": a.type, + "environment": a.environment} for a in assets]} diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index e2f4abbb..ffc32532 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3249,26 +3249,22 @@ def _set_asset(name: str, value: Any, type: str = "text", environment: str = "default", db: Optional[str] = None) -> Dict[str, Any]: """Adapter: store a typed, environment-scoped asset.""" - from je_auto_control.utils.assets import AssetStore - AssetStore(db).set(name, value, type=type, environment=environment) - return {"ok": True, "name": name, "environment": environment} + from je_auto_control.utils.assets.assets import store_set + return store_set(name, value, type=type, environment=environment, db=db) def _get_asset(name: str, environment: str = "default", db: Optional[str] = None) -> Dict[str, Any]: """Adapter: read a typed asset (credential stays a reference).""" - from je_auto_control.utils.assets import AssetStore - asset = AssetStore(db).get(name, environment=environment) - return {"name": asset.name, "type": asset.type, "value": asset.value} + from je_auto_control.utils.assets.assets import store_get + return store_get(name, environment=environment, db=db) def _list_assets(environment: Optional[str] = None, db: Optional[str] = None) -> Dict[str, Any]: """Adapter: list assets, optionally restricted to one environment.""" - from je_auto_control.utils.assets import AssetStore - assets = AssetStore(db).list(environment=environment) - return {"assets": [{"name": a.name, "type": a.type, - "environment": a.environment} for a in assets]} + from je_auto_control.utils.assets.assets import store_list + return store_list(environment=environment, db=db) class Executor: diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 8dc825a5..a0d12373 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1513,22 +1513,18 @@ def mine_actions(actions, min_len=2, max_len=5, min_count=3): def set_asset(name, value, type="text", environment="default", db=None): - from je_auto_control.utils.assets import AssetStore - AssetStore(db).set(name, value, type=type, environment=environment) - return {"ok": True, "name": name, "environment": environment} + from je_auto_control.utils.assets.assets import store_set + return store_set(name, value, type=type, environment=environment, db=db) def get_asset(name, environment="default", db=None): - from je_auto_control.utils.assets import AssetStore - asset = AssetStore(db).get(name, environment=environment) - return {"name": asset.name, "type": asset.type, "value": asset.value} + from je_auto_control.utils.assets.assets import store_get + return store_get(name, environment=environment, db=db) def list_assets(environment=None, db=None): - from je_auto_control.utils.assets import AssetStore - assets = AssetStore(db).list(environment=environment) - return {"assets": [{"name": a.name, "type": a.type, - "environment": a.environment} for a in assets]} + from je_auto_control.utils.assets.assets import store_list + return store_list(environment=environment, db=db) def vlm_locate(description: str, diff --git a/test/unit_test/headless/test_assets_batch.py b/test/unit_test/headless/test_assets_batch.py index 6bf337cb..f3c6b2b0 100644 --- a/test/unit_test/headless/test_assets_batch.py +++ b/test/unit_test/headless/test_assets_batch.py @@ -31,15 +31,15 @@ def test_environment_override_and_fallback(): def test_credential_get_returns_reference_not_secret(): store = AssetStore() - store.set("db_pw", "vault_db_password", type="credential") + store.set("db_pw", "vault_db_ref", type="credential") asset = store.get("db_pw") assert asset.type == "credential" - assert asset.value == "vault_db_password" # the reference, not a secret + assert asset.value == "vault_db_ref" # the reference, not a secret def test_credential_resolve_uses_injected_resolver(): - store = AssetStore(secret_resolver={"vault_db_password": "s3cr3t"}.get) - store.set("db_pw", "vault_db_password", type="credential") + store = AssetStore(secret_resolver={"vault_db_ref": "s3cr3t"}.get) + store.set("db_pw", "vault_db_ref", type="credential") assert store.resolve("db_pw") == "s3cr3t" store.set("plain", "visible", type="text") assert store.resolve("plain") == "visible" # non-credential returns value From 381699d4c12a94f30746bbce1b885b50c62aa9df Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 14:13:31 +0800 Subject: [PATCH 3/4] Extract shared json_store helper to remove duplicated persistence boilerplate --- je_auto_control/utils/assets/assets.py | 26 +++++----------- .../utils/governance/governance.py | 25 ++++----------- je_auto_control/utils/json_store/__init__.py | 6 ++++ .../utils/json_store/json_store.py | 31 +++++++++++++++++++ 4 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 je_auto_control/utils/json_store/__init__.py create mode 100644 je_auto_control/utils/json_store/json_store.py diff --git a/je_auto_control/utils/assets/assets.py b/je_auto_control/utils/assets/assets.py index 95023061..0451a6ae 100644 --- a/je_auto_control/utils/assets/assets.py +++ b/je_auto_control/utils/assets/assets.py @@ -11,12 +11,12 @@ JSON-backed (or in-memory); pure standard library; imports no ``PySide6``. """ -import json import os from dataclasses import dataclass -from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from je_auto_control.utils.json_store import read_json_dict, write_json_dict + ENV_VAR = "JE_AUTOCONTROL_ENV" DEFAULT_ENV = "default" TYPE_TEXT = "text" @@ -66,26 +66,14 @@ def __init__(self, db_path: Optional[str] = None, *, secret_resolver: Optional[Callable[[str], Any]] = None ) -> None: """``secret_resolver(name)`` resolves ``credential`` references lazily.""" - self._path = Path(db_path) if db_path else None + self._path = db_path self._resolver = secret_resolver - self._data: Dict[str, Dict[str, Dict[str, Any]]] = self._load() - - def _load(self) -> Dict[str, Dict[str, Dict[str, Any]]]: - if self._path is None or not self._path.is_file(): - return {} - try: - data = json.loads(self._path.read_text(encoding="utf-8")) - except (OSError, ValueError): - return {} - return data if isinstance(data, dict) else {} + self._data: Dict[str, Dict[str, Dict[str, Any]]] = read_json_dict( + db_path) def _flush(self) -> None: - if self._path is None: - return - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text( - json.dumps(self._data, ensure_ascii=False, indent=2), - encoding="utf-8") + if self._path is not None: + write_json_dict(self._path, self._data) def set(self, name: str, value: Any, *, type: str = TYPE_TEXT, environment: str = DEFAULT_ENV) -> None: diff --git a/je_auto_control/utils/governance/governance.py b/je_auto_control/utils/governance/governance.py index be10de9b..b788abb1 100644 --- a/je_auto_control/utils/governance/governance.py +++ b/je_auto_control/utils/governance/governance.py @@ -9,12 +9,12 @@ Pure standard library; imports no ``PySide6``. Tokens use :mod:`secrets`. """ -import json import secrets import time -from pathlib import Path from typing import Dict, List, Optional +from je_auto_control.utils.json_store import read_json_dict, write_json_dict + STATUS_PENDING = "pending" STATUS_APPROVED = "approved" STATUS_REJECTED = "rejected" @@ -25,25 +25,12 @@ class ApprovalGate: def __init__(self, db_path: Optional[str] = None) -> None: """Open the gate; ``db_path`` persists state across processes.""" - self._path = Path(db_path) if db_path else None - self._items: Dict[str, Dict[str, object]] = self._load() - - def _load(self) -> Dict[str, Dict[str, object]]: - if self._path is None or not self._path.is_file(): - return {} - try: - data = json.loads(self._path.read_text(encoding="utf-8")) - except (OSError, ValueError): - return {} - return data if isinstance(data, dict) else {} + self._path = db_path + self._items: Dict[str, Dict[str, object]] = read_json_dict(db_path) def _flush(self) -> None: - if self._path is None: - return - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text( - json.dumps(self._items, ensure_ascii=False, indent=2), - encoding="utf-8") + if self._path is not None: + write_json_dict(self._path, self._items) def request(self, action: str, requester: str = "") -> str: """File an approval request for ``action``; return its token.""" diff --git a/je_auto_control/utils/json_store/__init__.py b/je_auto_control/utils/json_store/__init__.py new file mode 100644 index 00000000..14d4c990 --- /dev/null +++ b/je_auto_control/utils/json_store/__init__.py @@ -0,0 +1,6 @@ +"""Tiny shared helper for JSON-dict file persistence (internal plumbing).""" +from je_auto_control.utils.json_store.json_store import ( + read_json_dict, write_json_dict, +) + +__all__ = ["read_json_dict", "write_json_dict"] diff --git a/je_auto_control/utils/json_store/json_store.py b/je_auto_control/utils/json_store/json_store.py new file mode 100644 index 00000000..06097e55 --- /dev/null +++ b/je_auto_control/utils/json_store/json_store.py @@ -0,0 +1,31 @@ +"""Read/write a JSON object to a file, tolerating a missing/corrupt file. + +Several stores (asset store, approval gate, …) persist a single JSON dict to +disk with identical boilerplate; this centralises it so they don't duplicate the +load/flush logic. Pure standard library; imports no ``PySide6``. +""" +import json +from pathlib import Path +from typing import Any, Dict, Optional, Union + + +def read_json_dict(path: Optional[Union[str, Path]]) -> Dict[str, Any]: + """Return the JSON object at ``path``, or ``{}`` if missing/unreadable.""" + if path is None: + return {} + file_path = Path(path) + if not file_path.is_file(): + return {} + try: + data = json.loads(file_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def write_json_dict(path: Union[str, Path], data: Dict[str, Any]) -> None: + """Write ``data`` as indented JSON to ``path`` (creating parent dirs).""" + file_path = Path(path) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") From d1ace35c5586a4884cb36bb3012d158d471cbbf2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 14:24:16 +0800 Subject: [PATCH 4/4] Rename asset 'type' arg to 'asset_type' (Pylint W0622 redefined-builtin) --- .../Eng/doc/new_features/v48_features_doc.rst | 4 ++-- .../Zh/doc/new_features/v48_features_doc.rst | 4 ++-- .../gui/script_builder/command_schema.py | 3 ++- je_auto_control/utils/assets/assets.py | 9 +++++---- .../utils/executor/action_executor.py | 5 +++-- .../utils/mcp_server/tools/_factories.py | 9 +++++---- .../utils/mcp_server/tools/_handlers.py | 5 +++-- test/unit_test/headless/test_assets_batch.py | 18 +++++++++--------- 8 files changed, 31 insertions(+), 26 deletions(-) diff --git a/docs/source/Eng/doc/new_features/v48_features_doc.rst b/docs/source/Eng/doc/new_features/v48_features_doc.rst index 5b076f76..8185b744 100644 --- a/docs/source/Eng/doc/new_features/v48_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v48_features_doc.rst @@ -20,9 +20,9 @@ Headless API from je_auto_control import AssetStore, active_environment store = AssetStore("assets.json") - store.set("max_retries", 3, type="int", environment="prod") + store.set("max_retries", 3, asset_type="int", environment="prod") store.set("api_base", "https://prod.example.com", environment="prod") - store.set("db_password", "vault_db_pw", type="credential") # value = a ref + store.set("db_password", "vault_db_pw", asset_type="credential") # value = a ref store.get("max_retries", environment="prod").value # -> 3 (typed) store.get("api_base", environment="staging").value # -> falls back to default diff --git a/docs/source/Zh/doc/new_features/v48_features_doc.rst b/docs/source/Zh/doc/new_features/v48_features_doc.rst index c5c09cde..b7cca85b 100644 --- a/docs/source/Zh/doc/new_features/v48_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v48_features_doc.rst @@ -17,9 +17,9 @@ JSON 後端(或記憶體內);純標準函式庫;不匯入 ``PySide6``。 from je_auto_control import AssetStore, active_environment store = AssetStore("assets.json") - store.set("max_retries", 3, type="int", environment="prod") + store.set("max_retries", 3, asset_type="int", environment="prod") store.set("api_base", "https://prod.example.com", environment="prod") - store.set("db_password", "vault_db_pw", type="credential") # value = 參照 + store.set("db_password", "vault_db_pw", asset_type="credential") # value = 參照 store.get("max_retries", environment="prod").value # -> 3(具型別) store.get("api_base", environment="staging").value # -> 退回 default diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 5f0ce6a8..5917f684 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1072,7 +1072,8 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: fields=( FieldSpec("name", FieldType.STRING), FieldSpec("value", FieldType.STRING), - FieldSpec("type", FieldType.ENUM, optional=True, default="text", + FieldSpec("asset_type", FieldType.ENUM, optional=True, + default="text", choices=("text", "int", "bool", "credential")), FieldSpec("environment", FieldType.STRING, optional=True, default="default"), diff --git a/je_auto_control/utils/assets/assets.py b/je_auto_control/utils/assets/assets.py index 0451a6ae..e007212a 100644 --- a/je_auto_control/utils/assets/assets.py +++ b/je_auto_control/utils/assets/assets.py @@ -75,11 +75,11 @@ def _flush(self) -> None: if self._path is not None: write_json_dict(self._path, self._data) - def set(self, name: str, value: Any, *, type: str = TYPE_TEXT, + def set(self, name: str, value: Any, *, asset_type: str = TYPE_TEXT, environment: str = DEFAULT_ENV) -> None: """Store ``value`` for ``name`` under ``environment`` with a type tag.""" self._data.setdefault(environment, {})[name] = { - "type": type, "value": value} + "type": asset_type, "value": value} self._flush() def _lookup(self, name: str, environment: str, @@ -125,11 +125,12 @@ def list(self, *, environment: Optional[str] = None) -> List[Asset]: ] -def store_set(name: str, value: Any, *, type: str = TYPE_TEXT, +def store_set(name: str, value: Any, *, asset_type: str = TYPE_TEXT, environment: str = DEFAULT_ENV, db: Optional[str] = None) -> Dict[str, Any]: """Set an asset and return a result dict (shared by executor/MCP layers).""" - AssetStore(db).set(name, value, type=type, environment=environment) + AssetStore(db).set(name, value, asset_type=asset_type, + environment=environment) return {"ok": True, "name": name, "environment": environment} diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index ffc32532..5da9e9a6 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3245,12 +3245,13 @@ def _mine_actions(actions: Any, min_len: int = 2, max_len: int = 5, } -def _set_asset(name: str, value: Any, type: str = "text", +def _set_asset(name: str, value: Any, asset_type: str = "text", environment: str = "default", db: Optional[str] = None) -> Dict[str, Any]: """Adapter: store a typed, environment-scoped asset.""" from je_auto_control.utils.assets.assets import store_set - return store_set(name, value, type=type, environment=environment, db=db) + return store_set(name, value, asset_type=asset_type, + environment=environment, db=db) def _get_asset(name: str, environment: str = "default", diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index aedb5768..29557ec2 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3126,13 +3126,14 @@ def asset_tools() -> List[MCPTool]: return [ MCPTool( name="ac_set_asset", - description=("Store a typed, environment-scoped asset. 'type' is " - "text/int/bool/credential (credential 'value' is a " + description=("Store a typed, environment-scoped asset. 'asset_type' " + "is text/int/bool/credential (credential 'value' is a " "secret name, not the secret). Returns {ok}."), input_schema=schema( {"name": {"type": "string"}, "value": {}, - "type": {"type": "string", - "enum": ["text", "int", "bool", "credential"]}, + "asset_type": {"type": "string", + "enum": ["text", "int", "bool", + "credential"]}, **_ENV}, ["name", "value"]), handler=h.set_asset, annotations=SIDE_EFFECT_ONLY, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index a0d12373..1db22c44 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1512,9 +1512,10 @@ def mine_actions(actions, min_len=2, max_len=5, min_count=3): } -def set_asset(name, value, type="text", environment="default", db=None): +def set_asset(name, value, asset_type="text", environment="default", db=None): from je_auto_control.utils.assets.assets import store_set - return store_set(name, value, type=type, environment=environment, db=db) + return store_set(name, value, asset_type=asset_type, + environment=environment, db=db) def get_asset(name, environment="default", db=None): diff --git a/test/unit_test/headless/test_assets_batch.py b/test/unit_test/headless/test_assets_batch.py index f3c6b2b0..bee589ee 100644 --- a/test/unit_test/headless/test_assets_batch.py +++ b/test/unit_test/headless/test_assets_batch.py @@ -9,9 +9,9 @@ def test_typed_coercion(): store = AssetStore() - store.set("retries", "3", type="int") - store.set("enabled", "yes", type="bool") - store.set("title", "Hi", type="text") + store.set("retries", "3", asset_type="int") + store.set("enabled", "yes", asset_type="bool") + store.set("title", "Hi", asset_type="text") assert store.get("retries").value == 3 assert store.get("enabled").value is True assert store.get("title").value == "Hi" @@ -31,7 +31,7 @@ def test_environment_override_and_fallback(): def test_credential_get_returns_reference_not_secret(): store = AssetStore() - store.set("db_pw", "vault_db_ref", type="credential") + store.set("db_pw", "vault_db_ref", asset_type="credential") asset = store.get("db_pw") assert asset.type == "credential" assert asset.value == "vault_db_ref" # the reference, not a secret @@ -39,15 +39,15 @@ def test_credential_get_returns_reference_not_secret(): def test_credential_resolve_uses_injected_resolver(): store = AssetStore(secret_resolver={"vault_db_ref": "s3cr3t"}.get) - store.set("db_pw", "vault_db_ref", type="credential") + store.set("db_pw", "vault_db_ref", asset_type="credential") assert store.resolve("db_pw") == "s3cr3t" - store.set("plain", "visible", type="text") + store.set("plain", "visible", asset_type="text") assert store.resolve("plain") == "visible" # non-credential returns value def test_resolve_without_resolver_raises(): store = AssetStore() - store.set("db_pw", "ref", type="credential") + store.set("db_pw", "ref", asset_type="credential") with pytest.raises(RuntimeError): store.resolve("db_pw") @@ -64,7 +64,7 @@ def test_list_and_delete(): def test_persists_across_instances(tmp_path): db = str(tmp_path / "assets.json") - AssetStore(db).set("token", "42", type="int", environment="prod") + AssetStore(db).set("token", "42", asset_type="int", environment="prod") assert AssetStore(db).get("token", environment="prod").value == 42 @@ -80,7 +80,7 @@ def test_active_environment(monkeypatch): def test_executor_round_trip(tmp_path): db = str(tmp_path / "a.json") ac.execute_action([["AC_set_asset", - {"name": "n", "value": "5", "type": "int", + {"name": "n", "value": "5", "asset_type": "int", "environment": "prod", "db": db}]]) rec = ac.execute_action([["AC_get_asset", {"name": "n", "environment": "prod", "db": db}]])