From 743f1830dad08085db31d8087a38ddd53a93830b Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 23:24:04 +0800 Subject: [PATCH] Add W3C Baggage propagation trace_context carries trace/span identity across an HTTP boundary, but nothing propagated cross-cutting key-value context (run_id / tenant / experiment). Add the W3C Baggage header: an immutable Baggage value with parse/format and case-insensitive inject/extract over a headers dict. Wired through facade, executor (AC_baggage_parse / AC_baggage_format), MCP, and the Script Builder with a headless test batch and EN/Zh docs. --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v84_features_doc.rst | 41 +++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v84_features_doc.rst | 35 ++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 6 ++ .../gui/script_builder/command_schema.py | 16 +++ je_auto_control/utils/baggage/__init__.py | 9 ++ je_auto_control/utils/baggage/baggage.py | 102 ++++++++++++++++++ .../utils/executor/action_executor.py | 17 +++ .../utils/mcp_server/tools/_factories.py | 24 ++++- .../utils/mcp_server/tools/_handlers.py | 10 ++ test/unit_test/headless/test_baggage_batch.py | 75 +++++++++++++ 15 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v84_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v84_features_doc.rst create mode 100644 je_auto_control/utils/baggage/__init__.py create mode 100644 je_auto_control/utils/baggage/baggage.py create mode 100644 test/unit_test/headless/test_baggage_batch.py diff --git a/README.md b/README.md index 1c301865..1e7a8153 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — W3C Baggage Propagation](#whats-new-2026-06-21--w3c-baggage-propagation) - [What's new (2026-06-21) — Dataset Diff (Row-Set Change Report)](#whats-new-2026-06-21--dataset-diff-row-set-change-report) - [What's new (2026-06-21) — Distribution Drift Detection](#whats-new-2026-06-21--distribution-drift-detection) - [What's new (2026-06-21) — Layered Configuration Resolver](#whats-new-2026-06-21--layered-configuration-resolver) @@ -136,6 +137,12 @@ --- +## What's new (2026-06-21) — W3C Baggage Propagation + +Carry cross-cutting key-value context across HTTP. Full reference: [`docs/source/Eng/doc/new_features/v84_features_doc.rst`](docs/source/Eng/doc/new_features/v84_features_doc.rst). + +- **`Baggage` / `parse_baggage` / `format_baggage` / `inject_baggage` / `extract_baggage`** (`AC_baggage_parse`, `AC_baggage_format`): `trace_context` carried trace/span identity but nothing propagated cross-cutting context (`run_id`/`tenant`/`experiment`). This implements the W3C Baggage header — a percent-encoded `key=value` list — with an immutable `Baggage` (set/remove return new instances) and case-insensitive inject/extract over a headers dict. Pairs with `trace_context`. Pure-stdlib, deterministic. + ## What's new (2026-06-21) — Dataset Diff (Row-Set Change Report) Diff two tabular extracts by key. Full reference: [`docs/source/Eng/doc/new_features/v83_features_doc.rst`](docs/source/Eng/doc/new_features/v83_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 97b3405a..89d23a3a 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — W3C Baggage 传播](#本次更新-2026-06-21--w3c-baggage-传播) - [本次更新 (2026-06-21) — 数据集差异(数据行变更报告)](#本次更新-2026-06-21--数据集差异数据行变更报告) - [本次更新 (2026-06-21) — 分布漂移检测](#本次更新-2026-06-21--分布漂移检测) - [本次更新 (2026-06-21) — 分层配置解析器](#本次更新-2026-06-21--分层配置解析器) @@ -135,6 +136,12 @@ --- +## 本次更新 (2026-06-21) — W3C Baggage 传播 + +跨 HTTP 携带横切键值上下文。完整参考:[`docs/source/Zh/doc/new_features/v84_features_doc.rst`](../docs/source/Zh/doc/new_features/v84_features_doc.rst)。 + +- **`Baggage` / `parse_baggage` / `format_baggage` / `inject_baggage` / `extract_baggage`**(`AC_baggage_parse`、`AC_baggage_format`):`trace_context` 携带 trace/span 身份,但没有东西传播横切上下文(`run_id`/`tenant`/`experiment`)。本功能实现 W3C Baggage 标头 —— percent-encoded 的 `key=value` 列表 —— 以不可变的 `Baggage`(set/remove 返回新实例)与不分大小写的 inject/extract。与 `trace_context` 搭配。纯标准库、确定。 + ## 本次更新 (2026-06-21) — 数据集差异(数据行变更报告) 按键比对两份表格式提取。完整参考:[`docs/source/Zh/doc/new_features/v83_features_doc.rst`](../docs/source/Zh/doc/new_features/v83_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c02aab77..2e974cab 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — W3C Baggage 傳播](#本次更新-2026-06-21--w3c-baggage-傳播) - [本次更新 (2026-06-21) — 資料集差異(資料列變更報告)](#本次更新-2026-06-21--資料集差異資料列變更報告) - [本次更新 (2026-06-21) — 分布漂移偵測](#本次更新-2026-06-21--分布漂移偵測) - [本次更新 (2026-06-21) — 分層設定解析器](#本次更新-2026-06-21--分層設定解析器) @@ -135,6 +136,12 @@ --- +## 本次更新 (2026-06-21) — W3C Baggage 傳播 + +跨 HTTP 攜帶橫切鍵值脈絡。完整參考:[`docs/source/Zh/doc/new_features/v84_features_doc.rst`](../docs/source/Zh/doc/new_features/v84_features_doc.rst)。 + +- **`Baggage` / `parse_baggage` / `format_baggage` / `inject_baggage` / `extract_baggage`**(`AC_baggage_parse`、`AC_baggage_format`):`trace_context` 攜帶 trace/span 身分,但沒有東西傳播橫切脈絡(`run_id`/`tenant`/`experiment`)。本功能實作 W3C Baggage 標頭 —— percent-encoded 的 `key=value` 清單 —— 以不可變的 `Baggage`(set/remove 回傳新實例)與不分大小寫的 inject/extract。與 `trace_context` 搭配。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-21) — 資料集差異(資料列變更報告) 依鍵比對兩份表格式萃取。完整參考:[`docs/source/Zh/doc/new_features/v83_features_doc.rst`](../docs/source/Zh/doc/new_features/v83_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v84_features_doc.rst b/docs/source/Eng/doc/new_features/v84_features_doc.rst new file mode 100644 index 00000000..1a93fb17 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v84_features_doc.rst @@ -0,0 +1,41 @@ +W3C Baggage Propagation +======================= + +``trace_context`` carries trace and span identity across an HTTP boundary, but +there was no way to propagate cross-cutting key-value context (``run_id`` / +``tenant`` / ``experiment``) alongside it. This implements the W3C Baggage +header — a percent-encoded ``key=value`` list — so a run can attach such context +to outgoing requests and read it back on the other side. + +Pure standard library (``urllib.parse``); imports no ``PySide6``. ``Baggage`` is +immutable (mutators return a new instance), so propagation is deterministic. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import Baggage, inject_baggage, extract_baggage + + bag = Baggage({"tenant": "acme"}).set("run_id", "42") + headers = inject_baggage(outgoing_headers, bag) + # headers["baggage"] == "tenant=acme,run_id=42" + + received = extract_baggage(request_headers) + tenant = received.get("tenant") + +``Baggage`` wraps an immutable key-value map: ``get`` reads, ``set`` / ``remove`` +return new instances, and ``to_dict`` exports the entries. ``parse_baggage`` +reads the header (dropping optional ``;metadata`` and rejecting empty keys), +``format_baggage`` percent-encodes keys and values back into a header value, +and ``inject_baggage`` / ``extract_baggage`` write and read the ``baggage`` +header on a request dict (extraction is case-insensitive). Pairs naturally with +``trace_context`` to carry context alongside the trace. + +Executor commands +----------------- + +``AC_baggage_parse`` parses a ``header`` into ``{items}``; ``AC_baggage_format`` +serialises an ``items`` object into ``{header}``. Both are exposed as MCP tools +(``ac_baggage_parse`` / ``ac_baggage_format``) and as Script Builder commands +under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index e70d10cc..2322af8d 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -106,6 +106,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v81_features_doc doc/new_features/v82_features_doc doc/new_features/v83_features_doc + doc/new_features/v84_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/v84_features_doc.rst b/docs/source/Zh/doc/new_features/v84_features_doc.rst new file mode 100644 index 00000000..ec5c5b9d --- /dev/null +++ b/docs/source/Zh/doc/new_features/v84_features_doc.rst @@ -0,0 +1,35 @@ +W3C Baggage 傳播 +=============== + +``trace_context`` 能跨 HTTP 邊界攜帶 trace 與 span 身分,但沒有辦法在旁邊一併傳播橫切的鍵值脈絡 +(``run_id`` / ``tenant`` / ``experiment``)。本功能實作 W3C Baggage 標頭 —— 一個 percent-encoded 的 +``key=value`` 清單 —— 讓一次執行能把這類脈絡附加到外送請求,並在另一端讀回。 + +純標準函式庫(``urllib.parse``);不匯入 ``PySide6``。``Baggage`` 不可變(變更操作回傳新實例),因此 +傳播具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import Baggage, inject_baggage, extract_baggage + + bag = Baggage({"tenant": "acme"}).set("run_id", "42") + headers = inject_baggage(outgoing_headers, bag) + # headers["baggage"] == "tenant=acme,run_id=42" + + received = extract_baggage(request_headers) + tenant = received.get("tenant") + +``Baggage`` 包裝一個不可變的鍵值對應:``get`` 讀取,``set`` / ``remove`` 回傳新實例,``to_dict`` 匯出 +條目。``parse_baggage`` 解析標頭(去除選用的 ``;metadata`` 並拒絕空鍵),``format_baggage`` 將鍵與值 +percent-encode 回標頭值,``inject_baggage`` / ``extract_baggage`` 在請求 dict 上寫入與讀取 ``baggage`` +標頭(讀取不分大小寫)。與 ``trace_context`` 自然搭配,在 trace 之外攜帶脈絡。 + +執行器命令 +---------- + +``AC_baggage_parse`` 把 ``header`` 解析成 ``{items}``;``AC_baggage_format`` 把 ``items`` 物件序列化成 +``{header}``。兩者皆以 MCP 工具(``ac_baggage_parse`` / ``ac_baggage_format``)以及 Script Builder 中 +**Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 3118319b..b7db05f7 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -106,6 +106,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v81_features_doc doc/new_features/v82_features_doc doc/new_features/v83_features_doc + doc/new_features/v84_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 9b803a80..7600e37d 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -410,6 +410,10 @@ format_traceparent, format_tracestate, inject_context, new_root_context, new_span_id, new_trace_id, parse_traceparent, parse_tracestate, ) +# W3C Baggage propagation (cross-cutting key-value context) +from je_auto_control.utils.baggage import ( + Baggage, extract_baggage, format_baggage, inject_baggage, parse_baggage, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -922,6 +926,8 @@ def start_autocontrol_gui(*args, **kwargs): "format_traceparent", "format_tracestate", "inject_context", "new_root_context", "new_span_id", "new_trace_id", "parse_traceparent", "parse_tracestate", + "Baggage", "extract_baggage", "format_baggage", "inject_baggage", + "parse_baggage", # 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 f163d670..a14e18ea 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1624,6 +1624,22 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Extract the W3C trace context from request headers.", )) + specs.append(CommandSpec( + "AC_baggage_parse", "Data", "Baggage: Parse Header", + fields=( + FieldSpec("header", FieldType.STRING, + placeholder="tenant=acme,run=42"), + ), + description="Parse a W3C baggage header into key-value items.", + )) + specs.append(CommandSpec( + "AC_baggage_format", "Data", "Baggage: Format Header", + fields=( + FieldSpec("items", FieldType.STRING, + placeholder='{"tenant": "acme", "run": "42"}'), + ), + description="Serialise items into a percent-encoded baggage header.", + )) specs.append(CommandSpec( "AC_profile_rows", "Data", "Data Profile: Profile Rows", fields=( diff --git a/je_auto_control/utils/baggage/__init__.py b/je_auto_control/utils/baggage/__init__.py new file mode 100644 index 00000000..73b5faee --- /dev/null +++ b/je_auto_control/utils/baggage/__init__.py @@ -0,0 +1,9 @@ +"""W3C Baggage propagation for AutoControl runs.""" +from je_auto_control.utils.baggage.baggage import ( + Baggage, extract_baggage, format_baggage, inject_baggage, parse_baggage, +) + +__all__ = [ + "Baggage", "extract_baggage", "format_baggage", "inject_baggage", + "parse_baggage", +] diff --git a/je_auto_control/utils/baggage/baggage.py b/je_auto_control/utils/baggage/baggage.py new file mode 100644 index 00000000..90388ca8 --- /dev/null +++ b/je_auto_control/utils/baggage/baggage.py @@ -0,0 +1,102 @@ +"""W3C Baggage propagation (cross-cutting key-value context). + +The just-shipped ``trace_context`` carries trace/span identity across an HTTP +boundary, but there was no way to propagate cross-cutting key-value context +(``run_id`` / ``tenant`` / ``experiment``) alongside it. This implements the +W3C Baggage header — a percent-encoded ``key=value`` list — so a run can attach +such context to outgoing requests and read it back on the other side. + +Pure standard library (``urllib.parse``); imports no ``PySide6``. ``Baggage`` is +immutable (mutators return a new instance), so propagation is deterministic in +CI. +""" +from typing import Dict, List, Mapping, Optional, Tuple +from urllib.parse import quote, unquote + +_MAX_ENTRIES = 180 + + +class Baggage: + """An immutable set of W3C baggage key-value entries.""" + + def __init__(self, items: Optional[Mapping[str, str]] = None) -> None: + self._items: Dict[str, str] = {str(key): str(value) + for key, value in (items or {}).items()} + + def to_dict(self) -> Dict[str, str]: + """Return the entries as a plain dict.""" + return dict(self._items) + + def get(self, key: str, default: Optional[str] = None) -> Optional[str]: + """Return the value for ``key`` or ``default``.""" + return self._items.get(key, default) + + def set(self, key: str, value: str) -> "Baggage": + """Return a new baggage with ``key`` set to ``value``.""" + updated = dict(self._items) + updated[str(key)] = str(value) + return Baggage(updated) + + def remove(self, key: str) -> "Baggage": + """Return a new baggage with ``key`` removed.""" + updated = dict(self._items) + updated.pop(key, None) + return Baggage(updated) + + def __len__(self) -> int: + return len(self._items) + + def __contains__(self, key: object) -> bool: + return key in self._items + + def __eq__(self, other: object) -> bool: + return isinstance(other, Baggage) and other._items == self._items + + def __repr__(self) -> str: + return f"Baggage({self._items!r})" + + +def _parse_member(member: str) -> Optional[Tuple[str, str]]: + pair = member.split(";", 1)[0].strip() # drop optional ;metadata + if not pair: + return None + key, sep, value = pair.partition("=") + if not sep or not key.strip(): + return None + return unquote(key.strip()), unquote(value.strip()) + + +def parse_baggage(header: Optional[str]) -> Baggage: + """Parse a W3C ``baggage`` header into a :class:`Baggage`.""" + items: Dict[str, str] = {} + for member in (header or "").split(","): + parsed = _parse_member(member) + if parsed is not None: + items[parsed[0]] = parsed[1] + return Baggage(items) + + +def format_baggage(baggage: Baggage) -> str: + """Serialise a :class:`Baggage` into a percent-encoded header value.""" + parts: List[str] = [] + for key, value in list(baggage.to_dict().items())[:_MAX_ENTRIES]: + parts.append(f"{quote(key, safe='')}={quote(value, safe='')}") + return ",".join(parts) + + +def inject_baggage(headers: Optional[Dict[str, str]], + baggage: Baggage) -> Dict[str, str]: + """Return ``headers`` with the ``baggage`` header set (when non-empty).""" + out = dict(headers or {}) + encoded = format_baggage(baggage) + if encoded: + out["baggage"] = encoded + return out + + +def extract_baggage(headers: Optional[Dict[str, str]]) -> Baggage: + """Extract a :class:`Baggage` from request headers (case-insensitive).""" + for key, value in (headers or {}).items(): + if str(key).lower() == "baggage": + return parse_baggage(value) + return Baggage() diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 61a4e74b..c0d71ba4 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3000,6 +3000,21 @@ def _trace_extract(headers: Any) -> Dict[str, Any]: return {"context": ctx.to_dict() if ctx is not None else None} +def _baggage_parse(header: str) -> Dict[str, Any]: + """Adapter: parse a W3C baggage header into {items}.""" + from je_auto_control.utils.baggage import parse_baggage + return {"items": parse_baggage(header).to_dict()} + + +def _baggage_format(items: Any) -> Dict[str, Any]: + """Adapter: serialise an items dict into a W3C baggage {header}.""" + import json + from je_auto_control.utils.baggage import Baggage, format_baggage + if isinstance(items, str): + items = json.loads(items) + return {"header": format_baggage(Baggage(items))} + + def _profile_rows(rows: Any, columns: Any = None) -> Dict[str, Any]: """Adapter: profile a row-set into per-column statistics.""" import json @@ -4203,6 +4218,8 @@ def __init__(self): "AC_http_replay": _http_replay, "AC_trace_inject": _trace_inject, "AC_trace_extract": _trace_extract, + "AC_baggage_parse": _baggage_parse, + "AC_baggage_format": _baggage_format, "AC_profile_rows": _profile_rows, "AC_infer_schema": _infer_schema, "AC_parse_problem": _parse_problem, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 92ddf3d0..acd444d5 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3509,6 +3509,27 @@ def data_profile_tools() -> List[MCPTool]: ] +def baggage_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_baggage_parse", + description=("Parse a W3C 'baggage' header (percent-encoded " + "key=value list, optional ;metadata) into {items}."), + input_schema=schema({"header": {"type": "string"}}, ["header"]), + handler=h.baggage_parse, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_baggage_format", + description=("Serialise an 'items' object into a percent-encoded " + "W3C baggage {header}."), + input_schema=schema({"items": {"type": "object"}}, ["items"]), + handler=h.baggage_format, + annotations=READ_ONLY, + ), + ] + + def trace_context_tools() -> List[MCPTool]: return [ MCPTool( @@ -5070,7 +5091,8 @@ def media_assert_tools() -> List[MCPTool]: search_index_tools, stats_tools, recurrence_tools, text_diff_tools, feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, slo_tools, percentiles_tools, bulkhead_tools, http_cassette_tools, - trace_context_tools, data_profile_tools, http_problem_tools, dotenv_tools, + trace_context_tools, baggage_tools, + data_profile_tools, http_problem_tools, dotenv_tools, sse_client_tools, layered_config_tools, data_drift_tools, dataset_diff_tools, saga_tools, decision_table_tools, locator_repair_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 15195059..553be36e 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1726,6 +1726,16 @@ def trace_extract(headers): return _trace_extract(headers) +def baggage_parse(header): + from je_auto_control.utils.executor.action_executor import _baggage_parse + return _baggage_parse(header) + + +def baggage_format(items): + from je_auto_control.utils.executor.action_executor import _baggage_format + return _baggage_format(items) + + def profile_rows(rows, columns=None): from je_auto_control.utils.executor.action_executor import _profile_rows return _profile_rows(rows, columns) diff --git a/test/unit_test/headless/test_baggage_batch.py b/test/unit_test/headless/test_baggage_batch.py new file mode 100644 index 00000000..85b40236 --- /dev/null +++ b/test/unit_test/headless/test_baggage_batch.py @@ -0,0 +1,75 @@ +"""Headless tests for W3C Baggage propagation. Pure stdlib, no Qt.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.baggage import ( + Baggage, extract_baggage, format_baggage, inject_baggage, parse_baggage, +) + + +def test_parse_basic_and_metadata(): + bag = parse_baggage("tenant=acme,run=42;meta=x") + assert bag.get("tenant") == "acme" + assert bag.get("run") == "42" # ;metadata stripped + assert len(bag) == 2 + + +def test_percent_encoding_round_trip(): + bag = Baggage({"key one": "a,b=c"}) + header = format_baggage(bag) + assert "," not in header.split("=", 1)[1].split("=")[0] # value encoded + assert parse_baggage(header).get("key one") == "a,b=c" + + +def test_immutability_set_and_remove(): + base = Baggage({"a": "1"}) + extended = base.set("b", "2") + assert "b" not in base and extended.get("b") == "2" + assert base.remove("a").to_dict() == {} and base.get("a") == "1" + + +def test_inject_and_extract_case_insensitive(): + bag = Baggage({"tenant": "acme"}) + headers = inject_baggage({"accept": "application/json"}, bag) + assert headers["baggage"] == "tenant=acme" + assert headers["accept"] == "application/json" + assert extract_baggage({"Baggage": "tenant=acme"}).get("tenant") == "acme" + + +def test_inject_empty_omits_header_and_extract_default(): + assert "baggage" not in inject_baggage({}, Baggage()) + assert extract_baggage({"x": "y"}) == Baggage() + + +def test_parse_empty_and_malformed(): + assert parse_baggage("") == Baggage() + assert parse_baggage("nokeyvalue,,=novalue").to_dict() == {} + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_baggage_format", {"items": json.dumps({"tenant": "acme"})}]]) + header = next(v for v in rec.values() if isinstance(v, dict))["header"] + assert header == "tenant=acme" + rec2 = ac.execute_action([["AC_baggage_parse", {"header": header}]]) + items = next(v for v in rec2.values() if isinstance(v, dict))["items"] + assert items == {"tenant": "acme"} + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_baggage_parse", "AC_baggage_format"} <= set(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_baggage_parse", "ac_baggage_format"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_baggage_parse", "AC_baggage_format"} <= specs + + +def test_facade_exports(): + for attr in ("Baggage", "parse_baggage", "format_baggage", + "inject_baggage", "extract_baggage"): + assert hasattr(ac, attr) and attr in ac.__all__