From 2509c7d7d159948ca0d00f878fa68453b4bf49b3 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sat, 20 Jun 2026 14:06:57 +0800 Subject: [PATCH] Add outbound CloudEvents emitter --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v49_features_doc.rst | 43 +++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v49_features_doc.rst | 39 ++++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 ++ .../gui/script_builder/command_schema.py | 15 ++++ je_auto_control/utils/events/__init__.py | 6 ++ je_auto_control/utils/events/cloud_events.py | 76 ++++++++++++++++ .../utils/executor/action_executor.py | 20 +++++ .../utils/mcp_server/tools/_factories.py | 21 ++++- .../utils/mcp_server/tools/_handlers.py | 10 +++ .../headless/test_cloudevents_batch.py | 89 +++++++++++++++++++ 15 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v49_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v49_features_doc.rst create mode 100644 je_auto_control/utils/events/__init__.py create mode 100644 je_auto_control/utils/events/cloud_events.py create mode 100644 test/unit_test/headless/test_cloudevents_batch.py diff --git a/README.md b/README.md index e4cbaa1..0aee772 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-20) — Outbound CloudEvents Emitter](#whats-new-2026-06-20--outbound-cloudevents-emitter) - [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) @@ -101,6 +102,12 @@ --- +## What's new (2026-06-20) — Outbound CloudEvents Emitter + +Emit run/automation events as CloudEvents. Full reference: [`docs/source/Eng/doc/new_features/v49_features_doc.rst`](docs/source/Eng/doc/new_features/v49_features_doc.rst). + +- **`to_cloudevent` / `EventEmitter` / `post_cloudevent`** (`AC_emit_event`, `ac_emit_event`): the repo could receive webhooks but not **emit** events — this wraps run-lifecycle/assertion/failure data in a CloudEvents 1.0 (CNCF) envelope and optionally POSTs it over the egress-guarded HTTP client (interop with Knative, Azure Event Grid, iPaaS, generic webhooks). The `sink`/`poster` transport is injectable, so emission is unit-tested with no network. + ## 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). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index eabe34d..609fe4b 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-20) — 对外 CloudEvents 发送器](#本次更新-2026-06-20--对外-cloudevents-发送器) - [本次更新 (2026-06-20) — 环境范围的带类型资产存储](#本次更新-2026-06-20--环境范围的带类型资产存储) - [本次更新 (2026-06-20) — 任务 / 流程挖掘(自动化候选发现)](#本次更新-2026-06-20--任务--流程挖掘自动化候选发现) - [本次更新 (2026-06-20) — 卡循环守卫(Agent Loop 进度检测)](#本次更新-2026-06-20--卡循环守卫agent-loop-进度检测) @@ -100,6 +101,12 @@ --- +## 本次更新 (2026-06-20) — 对外 CloudEvents 发送器 + +将运行/自动化事件以 CloudEvents 发送。完整参考:[`docs/source/Zh/doc/new_features/v49_features_doc.rst`](../docs/source/Zh/doc/new_features/v49_features_doc.rst)。 + +- **`to_cloudevent` / `EventEmitter` / `post_cloudevent`**(`AC_emit_event`、`ac_emit_event`):本项目能接收 webhook 却无法**发送**事件 —— 本功能将运行生命周期/断言/失败数据包进 CloudEvents 1.0(CNCF)信封,并可通过受出口守卫保护的 HTTP 客户端 POST 出去(与 Knative、Azure Event Grid、iPaaS、一般 webhook 互通)。`sink`/`poster` 传输可注入,因此发送在无网络下即可单元测试。 + ## 本次更新 (2026-06-20) — 环境范围的带类型资产存储 依环境的带类型配置 + credential 引用。完整参考:[`docs/source/Zh/doc/new_features/v48_features_doc.rst`](../docs/source/Zh/doc/new_features/v48_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index b558340..c6cddfe 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-20) — 對外 CloudEvents 發送器](#本次更新-2026-06-20--對外-cloudevents-發送器) - [本次更新 (2026-06-20) — 環境範圍的具型別資產儲存](#本次更新-2026-06-20--環境範圍的具型別資產儲存) - [本次更新 (2026-06-20) — 任務 / 流程探勘(自動化候選發現)](#本次更新-2026-06-20--任務--流程探勘自動化候選發現) - [本次更新 (2026-06-20) — 卡迴圈守衛(Agent Loop 進度偵測)](#本次更新-2026-06-20--卡迴圈守衛agent-loop-進度偵測) @@ -100,6 +101,12 @@ --- +## 本次更新 (2026-06-20) — 對外 CloudEvents 發送器 + +將執行/自動化事件以 CloudEvents 發送。完整參考:[`docs/source/Zh/doc/new_features/v49_features_doc.rst`](../docs/source/Zh/doc/new_features/v49_features_doc.rst)。 + +- **`to_cloudevent` / `EventEmitter` / `post_cloudevent`**(`AC_emit_event`、`ac_emit_event`):本專案能接收 webhook 卻無法**發送**事件 —— 本功能將執行生命週期/斷言/失敗資料包進 CloudEvents 1.0(CNCF)信封,並可透過受出口守衛保護的 HTTP 用戶端 POST 出去(與 Knative、Azure Event Grid、iPaaS、一般 webhook 互通)。`sink`/`poster` 傳輸可注入,因此發送在無網路下即可單元測試。 + ## 本次更新 (2026-06-20) — 環境範圍的具型別資產儲存 依環境的具型別設定 + credential 參照。完整參考:[`docs/source/Zh/doc/new_features/v48_features_doc.rst`](../docs/source/Zh/doc/new_features/v48_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v49_features_doc.rst b/docs/source/Eng/doc/new_features/v49_features_doc.rst new file mode 100644 index 0000000..cf36a4a --- /dev/null +++ b/docs/source/Eng/doc/new_features/v49_features_doc.rst @@ -0,0 +1,43 @@ +Outbound CloudEvents Emitter +============================ + +AutoControl can *receive* webhooks but had no way to *emit* events outbound. +CloudEvents 1.0 (CNCF) is the interop standard for event payloads — Knative, +Azure Event Grid, iPaaS, and generic webhook consumers all speak it. This wraps +run-lifecycle / assertion / failure data in a CloudEvents envelope and +(optionally) POSTs it over the HTTP binding, reusing the framework's egress +allowlist guard. + +The transport is injectable (a ``sink`` / ``poster`` callable), so emission is +unit-testable with no network. Pure standard library; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import to_cloudevent, post_cloudevent, EventEmitter + + event = to_cloudevent("com.example.run.finished", "/runs/42", + {"status": "passed"}, subject="run-42") + # -> {specversion, id, source, type, time, datacontenttype, subject, data} + + post_cloudevent("https://hooks.example.com/ce", event) # egress-guarded POST + + emitter = EventEmitter(source="je_auto_control") + emitter.emit("run.started", {"flow": "checkout"}) + emitter.events # captured envelopes + +``to_cloudevent`` fills ``specversion`` / ``id`` (a fresh UUID) / ``time`` (now, +UTC) automatically; pass ``event_id`` / ``time`` to override. ``EventEmitter`` +binds a fixed ``source`` and dispatches each envelope to a ``sink`` (an in-memory +log by default — inject your own to forward to a bus). ``post_cloudevent`` +accepts a ``poster`` to inject a transport in tests. + +Executor command +---------------- + +``AC_emit_event`` takes ``event_type`` (+ optional ``data`` / ``source`` / +``subject`` / ``url``); it returns ``{event}`` and, when ``url`` is given, +``{event, status}`` after POSTing. The same operation is exposed as the MCP tool +``ac_emit_event`` and as a Script Builder command under **Tools**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 3b92dd1..5f3c742 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -71,6 +71,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v46_features_doc doc/new_features/v47_features_doc doc/new_features/v48_features_doc + doc/new_features/v49_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/v49_features_doc.rst b/docs/source/Zh/doc/new_features/v49_features_doc.rst new file mode 100644 index 0000000..526a5be --- /dev/null +++ b/docs/source/Zh/doc/new_features/v49_features_doc.rst @@ -0,0 +1,39 @@ +對外 CloudEvents 發送器 +======================= + +AutoControl 能*接收* webhook,但一直無法*對外發送*事件。CloudEvents 1.0(CNCF)是事件 +酬載的互通標準 —— Knative、Azure Event Grid、iPaaS 與一般 webhook 消費端皆採用。本功能 +將執行生命週期 / 斷言 / 失敗資料包進 CloudEvents 信封,並(選擇性地)透過 HTTP binding +POST 出去,重用框架的出口允許清單守衛。 + +傳輸可注入(``sink`` / ``poster`` 可呼叫物件),因此發送在無網路下即可單元測試。純標準函 +式庫;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import to_cloudevent, post_cloudevent, EventEmitter + + event = to_cloudevent("com.example.run.finished", "/runs/42", + {"status": "passed"}, subject="run-42") + # -> {specversion, id, source, type, time, datacontenttype, subject, data} + + post_cloudevent("https://hooks.example.com/ce", event) # 受出口守衛保護的 POST + + emitter = EventEmitter(source="je_auto_control") + emitter.emit("run.started", {"flow": "checkout"}) + emitter.events #擷取到的信封 + +``to_cloudevent`` 會自動填入 ``specversion`` / ``id``(新的 UUID)/ ``time``(現在, +UTC);可傳 ``event_id`` / ``time`` 覆寫。``EventEmitter`` 綁定固定的 ``source`` 並將每個 +信封派送到 ``sink``(預設為記憶體內日誌 —— 可注入自己的以轉發到匯流排)。 +``post_cloudevent`` 接受 ``poster`` 以在測試中注入傳輸。 + +執行器指令 +---------- + +``AC_emit_event`` 接受 ``event_type``(以及選用的 ``data`` / ``source`` / ``subject`` / +``url``);回傳 ``{event}``,當提供 ``url`` 時於 POST 後回傳 ``{event, status}``。相同操作 +亦提供為 MCP 工具 ``ac_emit_event``,以及 Script Builder 中 **Tools** 分類下的指令。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index b5a7a73..df10f67 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -71,6 +71,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v46_features_doc doc/new_features/v47_features_doc doc/new_features/v48_features_doc + doc/new_features/v49_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 b1651f7..679f2ad 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -268,6 +268,10 @@ from je_auto_control.utils.assets import ( Asset, AssetStore, AssetValue, active_environment, ) +# Outbound CloudEvents emitter +from je_auto_control.utils.events import ( + EventEmitter, post_cloudevent, to_cloudevent, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -728,6 +732,7 @@ def start_autocontrol_gui(*args, **kwargs): "find_repeated_sequences", "mine_action_log", "rank_automation_candidates", "Asset", "AssetStore", "AssetValue", "active_environment", + "EventEmitter", "post_cloudevent", "to_cloudevent", # 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 5917f68..612c8b1 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1099,6 +1099,21 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="List assets (name/type/environment, no values).", )) + specs.append(CommandSpec( + "AC_emit_event", "Tools", "Emit CloudEvent", + fields=( + FieldSpec("event_type", FieldType.STRING, + placeholder="com.example.run.finished"), + FieldSpec("data", FieldType.STRING, optional=True, + placeholder='{"run_id": "42"}'), + FieldSpec("source", FieldType.STRING, optional=True, + default="je_auto_control"), + FieldSpec("subject", FieldType.STRING, optional=True), + FieldSpec("url", FieldType.STRING, optional=True, + placeholder="https://hooks.example.com/ce"), + ), + description="Wrap data in a CloudEvents envelope; optionally POST it.", + )) specs.append(CommandSpec( "AC_generate_sop", "Report", "Generate SOP Document", fields=( diff --git a/je_auto_control/utils/events/__init__.py b/je_auto_control/utils/events/__init__.py new file mode 100644 index 0000000..4ce65b9 --- /dev/null +++ b/je_auto_control/utils/events/__init__.py @@ -0,0 +1,6 @@ +"""Outbound CloudEvents emitter for run-lifecycle/automation events.""" +from je_auto_control.utils.events.cloud_events import ( + EventEmitter, post_cloudevent, to_cloudevent, +) + +__all__ = ["EventEmitter", "post_cloudevent", "to_cloudevent"] diff --git a/je_auto_control/utils/events/cloud_events.py b/je_auto_control/utils/events/cloud_events.py new file mode 100644 index 0000000..fac7a68 --- /dev/null +++ b/je_auto_control/utils/events/cloud_events.py @@ -0,0 +1,76 @@ +"""Emit automation events as CloudEvents (CNCF spec 1.0). + +AutoControl can *receive* webhooks but had no way to *emit* events outbound. +CloudEvents 1.0 is the interop standard for event payloads (Knative, Azure Event +Grid, iPaaS, generic webhooks). This wraps run-lifecycle / assertion / failure +data in a CloudEvents envelope and (optionally) POSTs it over the HTTP binding, +reusing the framework's egress allowlist guard. + +The transport is injectable (a ``sink`` callable), so emission is unit-testable +with no network. Pure standard library; imports no ``PySide6``. +""" +import datetime +import uuid +from typing import Any, Callable, Dict, List, Mapping, Optional + +SPEC_VERSION = "1.0" + + +def to_cloudevent(event_type: str, source: str, data: Any, *, + subject: Optional[str] = None, + event_id: Optional[str] = None, + time: Optional[str] = None) -> Dict[str, Any]: + """Wrap ``data`` in a CloudEvents 1.0 (structured JSON) envelope.""" + envelope: Dict[str, Any] = { + "specversion": SPEC_VERSION, + "id": event_id or uuid.uuid4().hex, + "source": source, + "type": event_type, + "time": time or datetime.datetime.now( + datetime.timezone.utc).isoformat(), + "datacontenttype": "application/json", + "data": data, + } + if subject is not None: + envelope["subject"] = subject + return envelope + + +def post_cloudevent(url: str, event: Mapping[str, Any], *, + timeout: float = 10.0, + poster: Optional[Callable[..., Any]] = None) -> int: + """POST a CloudEvent to ``url`` (structured mode); return the status code. + + Uses the framework's egress-guarded HTTP client by default; pass ``poster`` + to inject a transport in tests. + """ + if poster is not None: + return int(poster(url, event)) + from je_auto_control.utils.http_client.http_client import http_request + headers = {"Content-Type": "application/cloudevents+json"} + response = http_request(url, method="POST", json_body=dict(event), + headers=headers, timeout=timeout) + return int(response.get("status", 0)) + + +class EventEmitter: + """Builds CloudEvents from a fixed source and dispatches them to a sink.""" + + def __init__(self, sink: Optional[Callable[[Dict[str, Any]], Any]] = None, + *, source: str = "je_auto_control") -> None: + """``sink(event)`` receives each envelope; defaults to an in-memory log.""" + self._source = source + self._log: List[Dict[str, Any]] = [] + self._sink = sink if sink is not None else self._log.append + + def emit(self, event_type: str, data: Any, *, + subject: Optional[str] = None) -> Dict[str, Any]: + """Build a CloudEvent and hand it to the sink; return the envelope.""" + event = to_cloudevent(event_type, self._source, data, subject=subject) + self._sink(event) + return event + + @property + def events(self) -> List[Dict[str, Any]]: + """Envelopes captured by the default in-memory sink.""" + return list(self._log) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 5da9e9a..1850f3a 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3268,6 +3268,25 @@ def _list_assets(environment: Optional[str] = None, return store_list(environment=environment, db=db) +def _emit_event(event_type: str, data: Any = None, + source: str = "je_auto_control", + subject: Optional[str] = None, + url: Optional[str] = None) -> Dict[str, Any]: + """Adapter: build a CloudEvent; optionally POST it (egress-guarded).""" + from je_auto_control.utils.events import post_cloudevent, to_cloudevent + if isinstance(data, str): + import json + try: + data = json.loads(data) + except ValueError: + pass + event = to_cloudevent(event_type, source, data, subject=subject) + result: Dict[str, Any] = {"event": event} + if url: + result["status"] = post_cloudevent(url, event) + return result + + class Executor: """ Executor @@ -3546,6 +3565,7 @@ def __init__(self): "AC_set_asset": _set_asset, "AC_get_asset": _get_asset, "AC_list_assets": _list_assets, + "AC_emit_event": _emit_event, "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 29557ec..fdacad8 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3161,6 +3161,25 @@ def asset_tools() -> List[MCPTool]: ] +def events_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_emit_event", + description=("Wrap 'data' in a CloudEvents 1.0 envelope " + "('event_type', 'source', optional 'subject') and " + "optionally POST it to 'url' over the egress-guarded " + "HTTP client. Returns {event, status?}."), + input_schema=schema( + {"event_type": {"type": "string"}, "data": {}, + "source": {"type": "string"}, + "subject": {"type": "string"}, "url": {"type": "string"}}, + ["event_type"]), + handler=h.emit_event, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -4223,7 +4242,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, asset_tools, + process_mining_tools, asset_tools, events_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 1db22c4..5e628bd 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1528,6 +1528,16 @@ def list_assets(environment=None, db=None): return store_list(environment=environment, db=db) +def emit_event(event_type, data=None, source="je_auto_control", + subject=None, url=None): + from je_auto_control.utils.events import post_cloudevent, to_cloudevent + event = to_cloudevent(event_type, source, data, subject=subject) + result = {"event": event} + if url: + result["status"] = post_cloudevent(url, event) + return result + + 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_cloudevents_batch.py b/test/unit_test/headless/test_cloudevents_batch.py new file mode 100644 index 0000000..d0b9b6f --- /dev/null +++ b/test/unit_test/headless/test_cloudevents_batch.py @@ -0,0 +1,89 @@ +"""Headless tests for the CloudEvents emitter. The transport is injected, so +no network is used. Pure stdlib, no Qt imports.""" +import je_auto_control as ac +from je_auto_control.utils.events import ( + EventEmitter, post_cloudevent, to_cloudevent) + + +def test_envelope_has_required_fields(): + event = to_cloudevent("com.example.run.finished", "/runs/42", + {"ok": True}, subject="run-42") + assert event["specversion"] == "1.0" + assert event["type"] == "com.example.run.finished" + assert event["source"] == "/runs/42" + assert event["subject"] == "run-42" + assert event["data"] == {"ok": True} + assert event["id"] and event["time"] + assert event["datacontenttype"] == "application/json" + + +def test_ids_are_unique(): + a = to_cloudevent("t", "s", None) + b = to_cloudevent("t", "s", None) + assert a["id"] != b["id"] + + +def test_explicit_id_and_time_preserved(): + event = to_cloudevent("t", "s", None, event_id="fixed", time="2026-06-20T00:00:00Z") + assert event["id"] == "fixed" and event["time"] == "2026-06-20T00:00:00Z" + + +def test_emitter_uses_default_sink(): + emitter = EventEmitter(source="je_auto_control") + event = emitter.emit("run.started", {"flow": "x"}) + assert emitter.events == [event] + assert event["source"] == "je_auto_control" + + +def test_emitter_uses_injected_sink(): + captured = [] + emitter = EventEmitter(sink=captured.append, source="svc") + emitter.emit("a", 1) + emitter.emit("b", 2) + assert [e["type"] for e in captured] == ["a", "b"] + + +def test_post_cloudevent_uses_injected_poster(): + seen = {} + + def poster(url, event): + seen["url"] = url + seen["event"] = event + return 202 + + status = post_cloudevent("https://hooks.test/ce", + to_cloudevent("t", "s", {"k": 1}), poster=poster) + assert status == 202 + assert seen["url"] == "https://hooks.test/ce" + assert seen["event"]["data"] == {"k": 1} + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_emit_event", + {"event_type": "run.finished", "data": {"run_id": "42"}, + "source": "/ci"}, + ]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + assert out["event"]["type"] == "run.finished" + assert out["event"]["data"] == {"run_id": "42"} + assert "status" not in out # no url -> not posted + + +def test_wiring(): + assert "AC_emit_event" in 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_emit_event" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert "AC_emit_event" in cmds + + +def test_facade_exports(): + for attr in ("EventEmitter", "to_cloudevent", "post_cloudevent"): + assert hasattr(ac, attr) + assert attr in ac.__all__