From 6165f8e5a421c6635315ff627fc0896916bcd1a2 Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Tue, 8 Sep 2026 22:03:45 +0000 Subject: [PATCH 1/5] feat(rooms-yaml): wire aliases through RoomSpec provision and export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoomSpec gains an `aliases` field (agent name → room-scoped alias) and provision() passes it to RoomCreateConfig so aliases are seeded at room creation. Export reads aliases back from the room_agents table and emits them keyed by agent name. CHOO-2657 --- core/switch_core/rooms_yaml.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/core/switch_core/rooms_yaml.py b/core/switch_core/rooms_yaml.py index 2ef5eb0da..fbb7eaa76 100644 --- a/core/switch_core/rooms_yaml.py +++ b/core/switch_core/rooms_yaml.py @@ -221,6 +221,7 @@ class RoomSpec(BaseModel): roles: list[RoleSpec] = [] references: list[ExternalReferenceEntry] = [] docs: list[DocSpec] = [] + aliases: dict[str, str] | None = None class ProvisionResult(BaseModel): @@ -354,6 +355,7 @@ async def provision( write_visibility=spec.write_visibility, roles=spec.roles or None, reference_ids=attached_ref_ids or None, + aliases=spec.aliases, ) result = await self._rooms.create_room(config) room_id = result.room.id @@ -544,13 +546,20 @@ async def export( if agents: agent_ids = await self._room_store.get_agent_ids(session, room.id) - names: list[str] = [] + id_to_name: dict[str, str] = {} for aid in agent_ids: agent = await self._agent_store.get(session, aid) if agent is not None: - names.append(agent.name) - if names: - data["agents"] = sorted(names) + id_to_name[aid] = agent.name + if id_to_name: + data["agents"] = sorted(id_to_name.values()) + alias_map = await self._room_store.list_aliases(session, room.id) + if alias_map: + data["aliases"] = { + id_to_name[aid]: alias + for aid, alias in alias_map.items() + if aid in id_to_name + } if users and room.bridge_id: client_ids = await self._room_store.get_client_ids(session, room.id) From 27868db316456fc38aa9a273dc7974cd48d79490 Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Tue, 8 Sep 2026 22:09:27 +0000 Subject: [PATCH 2/5] feat(rooms-yaml): add group document shape and provision_group Introduces a second document shape discriminated by a top-level `group:` key (vs `room:` for single-room). A group document declares a room group (name, description, optional color), a list of rooms (`rooms:`), and optional directed links between them (`links:`). Params are interpolated across the entire document before validation. `provision_group()` creates the group row first, then each room with `group_id` set, then resolves intra-document links by room name. Partial failure on a room is reported (not rolled back), matching the existing single-room precedent. The `/rooms/from-yaml` endpoint accepts either shape and returns `ProvisionResult` or `GroupProvisionResult` accordingly. CHOO-2657 --- core/switch_core/gateway/dependencies.py | 1 + core/switch_core/gateway/rooms.py | 25 +- core/switch_core/rooms_yaml.py | 289 ++++++++++++++++++++-- core/tests/switch_core/test_rooms_yaml.py | 2 + 4 files changed, 288 insertions(+), 29 deletions(-) diff --git a/core/switch_core/gateway/dependencies.py b/core/switch_core/gateway/dependencies.py index 5858cdab3..19d6e1681 100644 --- a/core/switch_core/gateway/dependencies.py +++ b/core/switch_core/gateway/dependencies.py @@ -87,6 +87,7 @@ def get_room_yaml_service() -> RoomYamlService: agent_store=_state["agent_store"], bridge_store=_state["bridge_store"], external_user_store=_state["external_user_store"], + room_group_store=_state["room_group_store"], room_role_store=protocol.room_role_store, session_factory=_state["session_factory"], ) diff --git a/core/switch_core/gateway/rooms.py b/core/switch_core/gateway/rooms.py index 061b6faeb..33f7e25ea 100644 --- a/core/switch_core/gateway/rooms.py +++ b/core/switch_core/gateway/rooms.py @@ -52,7 +52,12 @@ RoomUsersRequest, ) from switch_core.room_service import RoleSpec, RoomCreateConfig, RoomService -from switch_core.rooms_yaml import ProvisionResult, RoomYamlService +from switch_core.rooms_yaml import ( + GroupProvisionResult, + GroupSpec, + ProvisionResult, + RoomYamlService, +) logger = logging.getLogger(__name__) @@ -373,8 +378,13 @@ async def create_room_from_yaml( request: Request, rooms_yaml: Annotated[RoomYamlService, Depends(get_room_yaml_service)], user: Annotated[User, Depends(get_current_user)], -) -> ProvisionResult: - """Provision a single room and its attachments from a YAML spec. +) -> ProvisionResult | GroupProvisionResult: + """Provision room(s) from a YAML spec. + + The document shape determines the result: a ``room:`` document provisions + a single room (returns ``ProvisionResult``); a ``group:`` + ``rooms:`` + document provisions a room group with its rooms and links (returns + ``GroupProvisionResult``). Two content types are accepted: @@ -399,9 +409,12 @@ async def create_room_from_yaml( text = (await request.body()).decode("utf-8") inputs = None spec = rooms_yaml.parse(text, inputs=inputs) - return await rooms_yaml.provision( - spec, user_id=user.id, is_admin=user.role == "admin" - ) + is_admin = user.role == "admin" + if isinstance(spec, GroupSpec): + return await rooms_yaml.provision_group( + spec, user_id=user.id, is_admin=is_admin + ) + return await rooms_yaml.provision(spec, user_id=user.id, is_admin=is_admin) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e except PermissionError as e: diff --git a/core/switch_core/rooms_yaml.py b/core/switch_core/rooms_yaml.py index fbb7eaa76..9fbbc02d9 100644 --- a/core/switch_core/rooms_yaml.py +++ b/core/switch_core/rooms_yaml.py @@ -1,19 +1,19 @@ -"""Declarative provisioning of a single room from YAML, and export back. +"""Declarative provisioning of rooms from YAML, and export back. -v0 supports a ``params:`` block beside ``room:`` that declares typed, -defaultable placeholders. ``parse(text, inputs)`` resolves them and -interpolates ``{name}`` throughout the ``room:`` tree before validation, so -one file can stamp out many rooms with different inputs. A literal -``{word}`` that collides with a declared param name *is* substituted — this -is accepted for v0; ``sensitive: true`` is deferred to a later version. +Two document shapes are supported, discriminated by top-level key: -An optional top-level ``version:`` key (default ``0``) is accepted and -validated as an integer, but not acted on yet. +* **Single-room** (``room:`` key): provisions one room with its attachments. +* **Group** (``group:`` + ``rooms:`` keys): provisions a room group, several + rooms filed under it, and optional directed links between them. -Provisioning is room-first and best-effort: the room is created first (which -fails loud on bad agents / refs / config), then inline references and docs are -attached, with any post-creation failures collected into ``failed_attachments`` -rather than silently dropped. +Both shapes accept a ``params:`` block and an optional ``version:`` key. +``parse(text, inputs)`` resolves params and interpolates ``{name}`` +throughout the document tree before validation. A literal ``{word}`` that +collides with a declared param name *is* substituted — this is accepted for +v0; ``sensitive: true`` is deferred to a later version. + +Provisioning is best-effort: rooms are created in order, and a failure on one +room does not roll back earlier ones — partial results are reported honestly. Export emits resolved rooms and never emits ``params:``. """ @@ -40,6 +40,7 @@ CollaborationBridgeStore, ) from switch_core.db.stores.external_user_store import ExternalUserStore + from switch_core.db.stores.room_group_store import RoomGroupStore from switch_core.db.stores.room_role_store import RoomRoleStore from switch_core.db.stores.room_store import RoomStore from switch_core.room_service import RoomService @@ -234,6 +235,44 @@ class ProvisionResult(BaseModel): failed_attachments: list[dict[str, Any]] = [] +# ── Group document models ──────────────────────────────────────────────── + + +class GroupLinkSpec(BaseModel): + model_config = {"extra": "forbid"} + from_: str # room name within the document + to: str + label: str + + @model_validator(mode="before") + @classmethod + def _rename_from(cls, data: Any) -> Any: + """Accept ``from`` in YAML (a Python keyword) as ``from_``.""" + if isinstance(data, dict) and "from" in data: + data = {**data, "from_": data.pop("from")} + return data + + +class GroupMeta(BaseModel): + model_config = {"extra": "forbid"} + name: str + description: str | None = None + color: str | None = None + + +class GroupSpec(BaseModel): + group: GroupMeta + rooms: list[RoomSpec] + links: list[GroupLinkSpec] = [] + + +class GroupProvisionResult(BaseModel): + group_id: str + group_name: str + rooms: list[ProvisionResult] = [] + errors: list[dict[str, Any]] = [] + + # ── YAML literal-block dumper (keeps multiline doc content readable) ──────── @@ -250,7 +289,7 @@ def _str_representer(dumper: yaml.SafeDumper, data: str) -> Any: class RoomYamlService: - """Parse / provision / export a single room as YAML. Free of HTTP concerns + """Parse / provision / export rooms from YAML. Free of HTTP concerns so it is unit-testable and reusable for future MCP / CLI surfaces.""" def __init__( @@ -262,6 +301,7 @@ def __init__( agent_store: AgentStore, bridge_store: CollaborationBridgeStore, external_user_store: ExternalUserStore, + room_group_store: RoomGroupStore, room_role_store: RoomRoleStore, session_factory: async_sessionmaker[AsyncSession], ) -> None: @@ -271,25 +311,33 @@ def __init__( self._agent_store = agent_store self._bridge_store = bridge_store self._external_users = external_user_store + self._room_groups = room_group_store self._room_roles = room_role_store self._session_factory = session_factory # ── Parse ───────────────────────────────────────────────────────────── - def parse(self, text: str, inputs: dict[str, Any] | None = None) -> RoomSpec: + @staticmethod + def _load_and_resolve( + text: str, + inputs: dict[str, Any] | None, + allowed_keys: set[str], + ) -> dict[str, Any]: + """YAML load → version check → params resolution → interpolation. + + Returns the top-level dict with string values already interpolated. + """ try: data = yaml.safe_load(text) except yaml.YAMLError as e: raise ValueError(f"Invalid YAML: {e}") from e - if not isinstance(data, dict) or "room" not in data: - raise ValueError("YAML must have a single top-level 'room:' mapping") + if not isinstance(data, dict): + raise ValueError("YAML document must be a mapping") - allowed_keys = {"room", "params", "version"} extra = set(data) - allowed_keys if extra: raise ValueError(f"Unknown top-level key(s): {', '.join(sorted(extra))}") - # version: accepted, not acted on yet. version = data.get("version", 0) if not isinstance(version, int): raise ValueError( @@ -314,15 +362,76 @@ def parse(self, text: str, inputs: dict[str, Any] | None = None) -> RoomSpec: if declared: values = resolve_params(declared, inputs) - room_data = interpolate(data["room"], values) - else: - room_data = data["room"] + return {k: interpolate(v, values) for k, v in data.items()} + return data + + def parse( + self, text: str, inputs: dict[str, Any] | None = None + ) -> RoomSpec | GroupSpec: + """Parse a YAML template into a RoomSpec (single room) or GroupSpec. + + The top-level key discriminates: ``room:`` → RoomSpec, ``group:`` + + ``rooms:`` → GroupSpec. + """ + try: + raw = yaml.safe_load(text) + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML: {e}") from e + if not isinstance(raw, dict): + raise ValueError("YAML document must be a mapping") + if "group" in raw: + return self._parse_group(text, inputs) + if "room" in raw: + return self._parse_room(text, inputs) + raise ValueError("YAML must have a top-level 'room:' or 'group:' mapping") + + def _parse_room(self, text: str, inputs: dict[str, Any] | None = None) -> RoomSpec: + data = self._load_and_resolve(text, inputs, {"room", "params", "version"}) try: - return RoomSpec.model_validate(room_data) + return RoomSpec.model_validate(data["room"]) except ValidationError as e: raise ValueError(f"Invalid room spec: {e}") from e + def _parse_group( + self, text: str, inputs: dict[str, Any] | None = None + ) -> GroupSpec: + data = self._load_and_resolve( + text, inputs, {"group", "rooms", "links", "params", "version"} + ) + if "rooms" not in data: + raise ValueError("Group document requires a 'rooms:' list") + try: + group_meta = GroupMeta.model_validate(data["group"]) + except ValidationError as e: + raise ValueError(f"Invalid group spec: {e}") from e + raw_rooms = data["rooms"] + if not isinstance(raw_rooms, list) or not raw_rooms: + raise ValueError("'rooms:' must be a non-empty list") + rooms: list[RoomSpec] = [] + for i, entry in enumerate(raw_rooms): + try: + rooms.append(RoomSpec.model_validate(entry)) + except ValidationError as e: + raise ValueError(f"Invalid room spec at index {i}: {e}") from e + raw_links = data.get("links", []) + if not isinstance(raw_links, list): + raise ValueError("'links:' must be a list") + links: list[GroupLinkSpec] = [] + for i, entry in enumerate(raw_links): + try: + links.append(GroupLinkSpec.model_validate(entry)) + except ValidationError as e: + raise ValueError(f"Invalid link spec at index {i}: {e}") from e + room_names = {r.name for r in rooms} + for link in links: + for end, name in [("from", link.from_), ("to", link.to)]: + if name not in room_names: + raise ValueError( + f"Link {end} {name!r} does not match any room name" + ) + return GroupSpec(group=group_meta, rooms=rooms, links=links) + # ── Provision ─────────────────────────────────────────────────────────── async def provision( @@ -378,6 +487,140 @@ async def provision( failed_attachments=failures, ) + async def provision_group( + self, spec: GroupSpec, *, user_id: str, is_admin: bool + ) -> GroupProvisionResult: + """Provision a room group, its rooms, and resolve intra-document links. + + Order: group row → each room with ``group_id`` → links by name. + Partial failure on a room is reported, not rolled back. + """ + async with self._session_factory() as session: + group = await self._room_groups.create( + session, + name=spec.group.name, + description=spec.group.description, + color=spec.group.color, + parent_group_id=None, + ) + await session.commit() + group_id = group.id + + room_results: list[ProvisionResult] = [] + errors: list[dict[str, Any]] = [] + name_to_room_id: dict[str, str] = {} + + for i, room_spec in enumerate(spec.rooms): + try: + result = await self._provision_room_in_group( + room_spec, group_id=group_id, user_id=user_id, is_admin=is_admin + ) + room_results.append(result) + name_to_room_id[room_spec.name] = result.room_id + except Exception as e: + errors.append( + {"room_index": i, "room_name": room_spec.name, "error": str(e)} + ) + + for link in spec.links: + from_id = name_to_room_id.get(link.from_) + to_id = name_to_room_id.get(link.to) + if from_id is None or to_id is None: + errors.append( + { + "kind": "link", + "from": link.from_, + "to": link.to, + "error": "one or both rooms were not created", + } + ) + continue + try: + async with self._session_factory() as session: + await self._resources.attach_linked_room( + session, + source_room_id=from_id, + target_room_id=to_id, + label=link.label, + ) + await session.commit() + except Exception as e: + errors.append( + { + "kind": "link", + "from": link.from_, + "to": link.to, + "error": str(e), + } + ) + + return GroupProvisionResult( + group_id=group_id, + group_name=spec.group.name, + rooms=room_results, + errors=errors, + ) + + async def _provision_room_in_group( + self, + spec: RoomSpec, + *, + group_id: str, + user_id: str, + is_admin: bool, + ) -> ProvisionResult: + """Provision a single room with ``group_id`` set.""" + bridge_id = await self._resolve_bridge_id(spec.bridge) + if spec.users and bridge_id is None: + raise ValueError( + "Cannot attach users to a room with no bridge " + "(users live on a collaboration bridge)" + ) + + attached_ref_ids, inline_refs = await self._resolve_references( + spec.references, user_id=user_id, is_admin=is_admin + ) + + config = RoomCreateConfig( + name=spec.name, + description=spec.description, + instructions=spec.instructions, + channel_type=cast(ChannelType, spec.channel_type), + agent_names=spec.agents or None, + user_names=spec.users or None, + bridge_id=bridge_id, + group_id=group_id, + created_by=user_id, + owner_id=user_id, + acting_user_id=user_id, + acting_is_admin=is_admin, + read_visibility=spec.read_visibility, + write_visibility=spec.write_visibility, + roles=spec.roles or None, + reference_ids=attached_ref_ids or None, + aliases=spec.aliases, + ) + result = await self._rooms.create_room(config) + room_id = result.room.id + failures: list[dict[str, Any]] = list(result.failed_attachments) + + created_ref_ids = await self._create_inline_references( + room_id, inline_refs, user_id=user_id, is_admin=is_admin, failures=failures + ) + created_doc_ids = await self._create_inline_docs( + room_id, spec.docs, user_id=user_id, failures=failures + ) + + return ProvisionResult( + room_id=room_id, + room_name=result.room.name, + attached_reference_ids=attached_ref_ids, + created_reference_ids=created_ref_ids, + created_document_ids=created_doc_ids, + role_names=[r.name for r in spec.roles], + failed_attachments=failures, + ) + async def _resolve_bridge_id(self, bridge_name: str | None) -> str | None: if bridge_name is None: return None diff --git a/core/tests/switch_core/test_rooms_yaml.py b/core/tests/switch_core/test_rooms_yaml.py index 064c5f461..355ea94d7 100644 --- a/core/tests/switch_core/test_rooms_yaml.py +++ b/core/tests/switch_core/test_rooms_yaml.py @@ -40,6 +40,7 @@ from switch_core.db.stores.package_store import PackageStore from switch_core.db.stores.reference_store import ReferenceStore from switch_core.db.stores.reference_type_store import ReferenceTypeStore +from switch_core.db.stores.room_group_store import RoomGroupStore from switch_core.db.stores.room_link_store import RoomLinkStore from switch_core.db.stores.room_role_store import RoomRoleStore from switch_core.db.stores.room_store import RoomStore @@ -163,6 +164,7 @@ async def env(session_factory: async_sessionmaker[AsyncSession]): agent_store=agent_store, bridge_store=CollaborationBridgeStore(), external_user_store=ExternalUserStore(), + room_group_store=RoomGroupStore(), room_role_store=RoomRoleStore(), session_factory=session_factory, ) From e2302e918656c903a3369a19a1e36e782759cb42 Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Tue, 8 Sep 2026 22:14:26 +0000 Subject: [PATCH 3/5] feat(rooms-yaml): interpolate dict keys, tests for group templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `interpolate()` to substitute placeholders in dict keys (not just values), so alias maps like `{bot}: greeter` resolve when the agent name comes from a param — the onboarder's actual need. Adds 12 tests covering group parsing (shape, params across rooms, alias key interpolation, validation errors), group provisioning (two-room linked group, params across rooms, partial failure on bad agent), single-room regression, and the /from-yaml endpoint handling a group document. CHOO-2657 --- core/switch_core/rooms_yaml.py | 16 +- core/tests/switch_core/test_rooms_yaml.py | 276 ++++++++++++++++++++++ 2 files changed, 280 insertions(+), 12 deletions(-) diff --git a/core/switch_core/rooms_yaml.py b/core/switch_core/rooms_yaml.py index 9fbbc02d9..b268fcbc8 100644 --- a/core/switch_core/rooms_yaml.py +++ b/core/switch_core/rooms_yaml.py @@ -25,7 +25,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast import yaml -from pydantic import BaseModel, ValidationError, model_validator +from pydantic import BaseModel, Field, ValidationError, model_validator from switch_core.bridges.collaboration.models import ChannelType from switch_core.bridges.resource.registry import validate_reference_value @@ -144,7 +144,7 @@ def _replace(m: re.Match[str]) -> str: return PLACEHOLDER_RE.sub(_replace, node) if isinstance(node, dict): - return {k: interpolate(v, values) for k, v in node.items()} + return {interpolate(k, values): interpolate(v, values) for k, v in node.items()} if isinstance(node, list): return [interpolate(item, values) for item in node] return node @@ -239,19 +239,11 @@ class ProvisionResult(BaseModel): class GroupLinkSpec(BaseModel): - model_config = {"extra": "forbid"} - from_: str # room name within the document + model_config = {"extra": "forbid", "populate_by_name": True} + from_: str = Field(alias="from") to: str label: str - @model_validator(mode="before") - @classmethod - def _rename_from(cls, data: Any) -> Any: - """Accept ``from`` in YAML (a Python keyword) as ``from_``.""" - if isinstance(data, dict) and "from" in data: - data = {**data, "from_": data.pop("from")} - return data - class GroupMeta(BaseModel): model_config = {"extra": "forbid"} diff --git a/core/tests/switch_core/test_rooms_yaml.py b/core/tests/switch_core/test_rooms_yaml.py index 355ea94d7..67a1e7d07 100644 --- a/core/tests/switch_core/test_rooms_yaml.py +++ b/core/tests/switch_core/test_rooms_yaml.py @@ -28,6 +28,8 @@ Reference, ReferenceType, Room, + RoomGroup, + RoomLink, RoomRole, User, room_agents, @@ -47,6 +49,7 @@ from switch_core.room_service import RoomCreateConfig, RoomCreateResult from switch_core.rooms_yaml import ( ExistingReferenceById, + GroupSpec, ParamSpec, RoomYamlService, interpolate, @@ -92,6 +95,7 @@ async def create_room(self, config: RoomCreateConfig) -> RoomCreateResult: owner_id=config.owner_id, read_visibility=config.read_visibility, write_visibility=config.write_visibility, + group_id=config.group_id, ) session.add(room) await session.flush() @@ -1082,3 +1086,275 @@ async def test_endpoint_json_body(env): with pytest.raises(HTTPException) as exc_info: await create_room_from_yaml(request, svc, user) assert exc_info.value.status_code == 400 + + +# ── group parse ───────────────────────────────────────────────────────────── + + +GROUP_TEMPLATE = """\ +version: 0 +params: + newcomer: + type: string +group: + name: "Onboarding" + description: "Lobby + per-person workroom" + color: "#3b82f6" +rooms: + - name: "{newcomer} lobby" + description: "Welcome room for {newcomer}" + agents: ["claude-code.alice"] + aliases: + claude-code.alice: greeter + - name: "{newcomer} workroom" + description: "Work room for {newcomer}" + agents: ["claude-code.bob"] +links: + - from: "{newcomer} lobby" + to: "{newcomer} workroom" + label: workroom +""" + + +def test_parse_group_returns_group_spec(env): + spec = _svc(env).parse(GROUP_TEMPLATE, inputs={"newcomer": "dana"}) + assert isinstance(spec, GroupSpec) + assert spec.group.name == "Onboarding" + assert spec.group.color == "#3b82f6" + assert len(spec.rooms) == 2 + assert spec.rooms[0].name == "dana lobby" + assert spec.rooms[1].name == "dana workroom" + assert len(spec.links) == 1 + assert spec.links[0].from_ == "dana lobby" + assert spec.links[0].to == "dana workroom" + + +def test_parse_group_params_interpolate_aliases(env): + template = """\ +params: + bot: + type: string +group: + name: "G" +rooms: + - name: "R" + description: "d" + agents: ["{bot}"] + aliases: + "{bot}": helper +""" + spec = _svc(env).parse(template, inputs={"bot": "claude-code.alice"}) + assert isinstance(spec, GroupSpec) + assert spec.rooms[0].aliases == {"claude-code.alice": "helper"} + + +def test_parse_group_missing_rooms_key(env): + with pytest.raises(ValueError, match="'rooms:'"): + _svc(env).parse( + """ + group: + name: "G" + """ + ) + + +def test_parse_group_empty_rooms_list(env): + with pytest.raises(ValueError, match="non-empty"): + _svc(env).parse( + """ + group: + name: "G" + rooms: [] + """ + ) + + +def test_parse_group_link_bad_name(env): + with pytest.raises(ValueError, match="does not match"): + _svc(env).parse( + """ + group: + name: "G" + rooms: + - name: "A" + description: "d" + links: + - from: "A" + to: "B" + label: "x" + """ + ) + + +def test_parse_group_unknown_top_level_key(env): + with pytest.raises(ValueError, match="Unknown top-level"): + _svc(env).parse( + """ + group: + name: "G" + rooms: + - name: "A" + description: "d" + extra: bad + """ + ) + + +# ── group provision ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_provision_group_two_rooms_linked(env): + """The onboarder-shaped template imports in one call: both rooms exist, + grouped together, linked to each other.""" + svc = _svc(env) + spec = svc.parse(GROUP_TEMPLATE, inputs={"newcomer": "dana"}) + assert isinstance(spec, GroupSpec) + + result = await svc.provision_group(spec, user_id=env["user_id"], is_admin=False) + + assert result.group_name == "Onboarding" + assert len(result.rooms) == 2 + assert result.rooms[0].room_name == "dana lobby" + assert result.rooms[1].room_name == "dana workroom" + assert result.errors == [] + + # Both rooms filed under the same group. + sf = env["session_factory"] + async with sf() as session: + group = await session.get(RoomGroup, result.group_id) + assert group is not None + assert group.name == "Onboarding" + assert group.color == "#3b82f6" + + for rr in result.rooms: + room = await session.get(Room, rr.room_id) + assert room is not None + assert room.group_id == result.group_id + + # Link exists from lobby → workroom. + link = await session.get( + RoomLink, + (result.rooms[0].room_id, result.rooms[1].room_id), + ) + assert link is not None + assert link.label == "workroom" + + +@pytest.mark.asyncio +async def test_provision_group_params_across_rooms(env): + """Params interpolate across the whole document from one inputs dict.""" + svc = _svc(env) + spec = svc.parse(GROUP_TEMPLATE, inputs={"newcomer": "eve"}) + assert isinstance(spec, GroupSpec) + result = await svc.provision_group(spec, user_id=env["user_id"], is_admin=False) + + assert result.rooms[0].room_name == "eve lobby" + assert result.rooms[1].room_name == "eve workroom" + + +@pytest.mark.asyncio +async def test_provision_group_partial_failure_collision(env): + """When the second room name collides, group + first room exist and the + error names the collision.""" + svc = _svc(env) + + # Provision once to create "dana lobby". + spec1 = svc.parse(GROUP_TEMPLATE, inputs={"newcomer": "dana"}) + assert isinstance(spec1, GroupSpec) + await svc.provision_group(spec1, user_id=env["user_id"], is_admin=False) + + # Build a group whose second room collides with an existing name. + collision_template = """\ +group: + name: "Collider" +rooms: + - name: "safe room" + description: "d" + agents: ["claude-code.alice"] + - name: "dana lobby" + description: "d" + agents: ["claude-code.bob"] +""" + spec2 = svc.parse(collision_template) + assert isinstance(spec2, GroupSpec) + + # FakeRoomService doesn't enforce unique names, so we need to pre-create + # the collision. Instead, make the second room reference an unknown agent. + collision_template_bad_agent = """\ +group: + name: "Collider" +rooms: + - name: "safe room" + description: "d" + agents: ["claude-code.alice"] + - name: "boom room" + description: "d" + agents: ["does-not-exist"] +""" + spec3 = svc.parse(collision_template_bad_agent) + assert isinstance(spec3, GroupSpec) + result = await svc.provision_group(spec3, user_id=env["user_id"], is_admin=False) + + # Group + first room exist, second room errored. + assert result.group_name == "Collider" + assert len(result.rooms) == 1 + assert result.rooms[0].room_name == "safe room" + assert len(result.errors) == 1 + assert result.errors[0]["room_name"] == "boom room" + assert "does-not-exist" in result.errors[0]["error"] + + # Verify the group row was created. + sf = env["session_factory"] + async with sf() as session: + group = await session.get(RoomGroup, result.group_id) + assert group is not None + + +# ── single-room regression ────────────────────────────────────────────────── + + +def test_single_room_parse_still_works(env): + """Single-room documents keep working unchanged after adding group support.""" + spec = _svc(env).parse( + """ + room: + name: "Room A" + description: "desc" + """ + ) + assert spec.name == "Room A" + + +def test_parse_rejects_neither_room_nor_group(env): + with pytest.raises(ValueError, match="'room:' or 'group:'"): + _svc(env).parse("name: oops\ndescription: d\n") + + +@pytest.mark.asyncio +async def test_endpoint_json_body_group(env): + """The /from-yaml endpoint handles a group document.""" + import json + from unittest.mock import AsyncMock + + from switch_core.gateway.rooms import create_room_from_yaml + + svc = _svc(env) + user_id = env["user_id"] + user = User(name="alice", email="alice@example.com", role="member") + object.__setattr__(user, "id", user_id) + + body = json.dumps( + { + "yaml": GROUP_TEMPLATE, + "inputs": {"newcomer": "frank"}, + } + ).encode() + + request = AsyncMock() + request.headers = {"content-type": "application/json"} + request.body.return_value = body + + result = await create_room_from_yaml(request, svc, user) + assert result.group_name == "Onboarding" + assert len(result.rooms) == 2 From 0fe8785b7971b2d852a850b9e82e7315fe3f015e Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Tue, 8 Sep 2026 22:31:12 +0000 Subject: [PATCH 4/5] fix(rooms-yaml): reject duplicate room names in group, remove provision duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch duplicate room names at parse time — a set collision would let links silently point to the wrong room. Also collapses _provision_room_in_group into provision() via an optional group_id parameter, removing ~55 lines of duplication. CHOO-2657 --- core/switch_core/rooms_yaml.py | 79 +++++------------------ core/tests/switch_core/test_rooms_yaml.py | 15 +++++ 2 files changed, 31 insertions(+), 63 deletions(-) diff --git a/core/switch_core/rooms_yaml.py b/core/switch_core/rooms_yaml.py index b268fcbc8..fefe1b8d7 100644 --- a/core/switch_core/rooms_yaml.py +++ b/core/switch_core/rooms_yaml.py @@ -416,6 +416,10 @@ def _parse_group( except ValidationError as e: raise ValueError(f"Invalid link spec at index {i}: {e}") from e room_names = {r.name for r in rooms} + if len(room_names) != len(rooms): + seen: set[str] = set() + dupes = [r.name for r in rooms if r.name in seen or seen.add(r.name)] # type: ignore[func-returns-value] + raise ValueError(f"Duplicate room name(s): {', '.join(dupes)}") for link in links: for end, name in [("from", link.from_), ("to", link.to)]: if name not in room_names: @@ -427,7 +431,12 @@ def _parse_group( # ── Provision ─────────────────────────────────────────────────────────── async def provision( - self, spec: RoomSpec, *, user_id: str, is_admin: bool + self, + spec: RoomSpec, + *, + user_id: str, + is_admin: bool, + group_id: str | None = None, ) -> ProvisionResult: bridge_id = await self._resolve_bridge_id(spec.bridge) if spec.users and bridge_id is None: @@ -448,6 +457,7 @@ async def provision( agent_names=spec.agents or None, user_names=spec.users or None, bridge_id=bridge_id, + group_id=group_id, created_by=user_id, owner_id=user_id, acting_user_id=user_id, @@ -504,8 +514,11 @@ async def provision_group( for i, room_spec in enumerate(spec.rooms): try: - result = await self._provision_room_in_group( - room_spec, group_id=group_id, user_id=user_id, is_admin=is_admin + result = await self.provision( + room_spec, + user_id=user_id, + is_admin=is_admin, + group_id=group_id, ) room_results.append(result) name_to_room_id[room_spec.name] = result.room_id @@ -553,66 +566,6 @@ async def provision_group( errors=errors, ) - async def _provision_room_in_group( - self, - spec: RoomSpec, - *, - group_id: str, - user_id: str, - is_admin: bool, - ) -> ProvisionResult: - """Provision a single room with ``group_id`` set.""" - bridge_id = await self._resolve_bridge_id(spec.bridge) - if spec.users and bridge_id is None: - raise ValueError( - "Cannot attach users to a room with no bridge " - "(users live on a collaboration bridge)" - ) - - attached_ref_ids, inline_refs = await self._resolve_references( - spec.references, user_id=user_id, is_admin=is_admin - ) - - config = RoomCreateConfig( - name=spec.name, - description=spec.description, - instructions=spec.instructions, - channel_type=cast(ChannelType, spec.channel_type), - agent_names=spec.agents or None, - user_names=spec.users or None, - bridge_id=bridge_id, - group_id=group_id, - created_by=user_id, - owner_id=user_id, - acting_user_id=user_id, - acting_is_admin=is_admin, - read_visibility=spec.read_visibility, - write_visibility=spec.write_visibility, - roles=spec.roles or None, - reference_ids=attached_ref_ids or None, - aliases=spec.aliases, - ) - result = await self._rooms.create_room(config) - room_id = result.room.id - failures: list[dict[str, Any]] = list(result.failed_attachments) - - created_ref_ids = await self._create_inline_references( - room_id, inline_refs, user_id=user_id, is_admin=is_admin, failures=failures - ) - created_doc_ids = await self._create_inline_docs( - room_id, spec.docs, user_id=user_id, failures=failures - ) - - return ProvisionResult( - room_id=room_id, - room_name=result.room.name, - attached_reference_ids=attached_ref_ids, - created_reference_ids=created_ref_ids, - created_document_ids=created_doc_ids, - role_names=[r.name for r in spec.roles], - failed_attachments=failures, - ) - async def _resolve_bridge_id(self, bridge_name: str | None) -> str | None: if bridge_name is None: return None diff --git a/core/tests/switch_core/test_rooms_yaml.py b/core/tests/switch_core/test_rooms_yaml.py index 67a1e7d07..dcf0c1fff 100644 --- a/core/tests/switch_core/test_rooms_yaml.py +++ b/core/tests/switch_core/test_rooms_yaml.py @@ -1169,6 +1169,21 @@ def test_parse_group_empty_rooms_list(env): ) +def test_parse_group_duplicate_room_names(env): + with pytest.raises(ValueError, match="Duplicate room name"): + _svc(env).parse( + """ + group: + name: "G" + rooms: + - name: "lobby" + description: "d" + - name: "lobby" + description: "d2" + """ + ) + + def test_parse_group_link_bad_name(env): with pytest.raises(ValueError, match="does not match"): _svc(env).parse( From 02e2e0e484f32c7f23194ac949586c4e207ded55 Mon Sep 17 00:00:00 2001 From: Abel Dantas Date: Fri, 11 Sep 2026 15:22:46 +0100 Subject: [PATCH 5/5] feat(agent-ops): add create_room_from_yaml operation (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rooms-yaml): add typed params block and {var} interpolation Add ParamSpec model, resolve_params() and interpolate() helpers to rooms_yaml.py. parse() now accepts an optional `inputs` dict and an optional top-level `params:` sibling of `room:`. Declared placeholders are substituted throughout the room tree before Pydantic validation; whole-field placeholders preserve the typed value so enum/boolean params fill non-string fields correctly. Undeclared {word} patterns are left intact. Also accepts an optional top-level `version:` key (default 0). 26 new tests covering resolve_params, interpolate, and parse-with-params (defaults, overrides, missing required, undeclared input, unknown placeholder passthrough, whole-field typed substitution, nested docs/references interpolation, version key). * feat(gateway): accept JSON body with inputs on /rooms/from-yaml The endpoint now checks Content-Type: when application/json, it expects {"yaml": "