From b0ce424e737ee19e9449465ce2e5cd1f1f2f6828 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Sat, 29 Aug 2026 19:05:22 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20bind=20workspace=20actions=20to=20rende?= =?UTF-8?q?red=20state=20=E2=80=94=200.9.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 4 +- CHANGELOG.md | 15 ++ README.md | 12 +- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/forms/SKILL.md | 10 +- pyproject.toml | 4 +- src/attune_forms/__init__.py | 10 + src/attune_forms/mcp_server.py | 268 ++++++++++++++++++++- src/attune_forms/workspace.py | 388 +++++++++++++++++++++++++++++- tests/test_mcp_server.py | 197 ++++++++++++++- tests/test_workspace.py | 280 +++++++++++++++++++++ 11 files changed, 1174 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2c8cd89..a24562b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,14 +7,14 @@ }, "metadata": { "description": "Structured agent-user communication: validated forms, decision cards with recommendations, structured pushback, and progress reports \u2014 batch questions instead of asking one at a time.", - "version": "0.9.0" + "version": "0.9.1" }, "plugins": [ { "name": "attune-forms", "description": "The communication grammar for AI agents: batch independent questions into ONE validated form; offer recommendations as decision cards with rationales and per-option tradeoffs; disagree constructively via pushback cards; report multi-step progress with a blocked-item picker. Renders rich HTML where the host supports widgets and degrades cleanly to plain questions everywhere else. Powered by the attune-forms PyPI package via a bundled MCP server.", "source": "./plugin", - "version": "0.9.0", + "version": "0.9.1", "author": { "name": "Smart AI Memory", "email": "admin@smartaimemory.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f400ad..e14b979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +## [0.9.1] — 2026-08-29 + +### Added +- **Bound workspace action round trips** — optional workspace id, + revision, contract hash, and one-render nonce context is preserved by + widget and Markdown renderers. The strict collector accepts only an + action defined by the rendered view, requires explicit confirmation + where declared, and rejects stale or fabricated context without + authorizing or executing the action. +- **Workspace dictionary and MCP boundaries** — + `workspace_from_dict` rejects unknown keys throughout the closed view + grammar. `elicitation_render_workspace` and + `elicitation_collect_workspace_action` expose the same render/collect + pattern as forms, with a non-mocked stdio round-trip receipt. + ## [0.9.0] — 2026-08-29 ### Added diff --git a/README.md b/README.md index 57c4c0f..792fd1b 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,10 @@ claude plugin install attune-forms@attune-forms ``` The plugin teaches the session the forms discipline (the `forms` skill) -and serves four MCP tools — `elicitation_render_form`, +and serves six MCP tools — `elicitation_render_form`, `elicitation_render_widget`, `elicitation_collect_response`, -`elicitation_ask` — from this package via `uvx`. Decision cards, +`elicitation_ask`, `elicitation_render_workspace`, and +`elicitation_collect_workspace_action` — from this package via `uvx`. Decision cards, pushback cards, progress forms, deliberation cards, triage boards, confirm gates, ranking lists, and assumption reviews work out of the box; rich HTML renders where the host @@ -120,6 +121,13 @@ if select_form_surface(form) == "widget": - **Validation** — `form_from_dict` refuses malformed definitions; `collect_form_response` refuses malformed answers (required fields, option membership) with field-level problems. +- **Command workspaces** — `workspace_from_dict` validates a closed, + non-executable document grammar for intake, preview, execution, and + receipt views. `workspace_to_widget_html` and + `workspace_to_markdown` render the same action contract; + `collect_workspace_action` rejects unknown, stale, or mismatched + action envelopes. Optional revision/hash/nonce bindings are echoed + for the host to authorize and consume — the UI never grants authority. - **Intake templates** — `FormTemplate` + `FieldSlot` generate a workflow's intake form at ask-time from named candidate providers (`PROVIDERS`): tools describe what they need once, and the form diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index dccbdf8..e14fc73 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "attune-forms", - "version": "0.9.0", + "version": "0.9.1", "description": "Structured agent-user communication \u2014 validated forms, decision cards, pushback, progress reports, deliberation, triage boards, confirm gates, rankings, and assumption reviews via the attune-forms MCP server.", "author": { "name": "Smart AI Memory", diff --git a/plugin/skills/forms/SKILL.md b/plugin/skills/forms/SKILL.md index 36e90b2..a6d00f7 100644 --- a/plugin/skills/forms/SKILL.md +++ b/plugin/skills/forms/SKILL.md @@ -11,12 +11,20 @@ argument-hint: " **Forms** — Gathering the independent dimensions of this decision as > one validated form instead of asking one question at a time. -This skill drives the four `attune-forms` MCP tools: +This skill drives the six `attune-forms` MCP tools: - `elicitation_render_widget` — form dict → interactive HTML (rich surface). - `elicitation_render_form` — form dict → batched plain-question payloads. - `elicitation_collect_response` — form + answers → validated response. - `elicitation_ask` — native MCP elicitation dialog, where supported. +- `elicitation_render_workspace` — validated workspace view → widget + markdown. +- `elicitation_collect_workspace_action` — view + action envelope → validated action. + +Command workspaces compose forms with typed evidence, progress, receipt, +and action blocks. For consequential actions, render a host-generated +revision/hash/nonce binding and validate the returned envelope before +the host authorizes anything. A successful collection proves only that +the action belongs to that rendered view; it never executes the action. ## When to use a multi-field form (the batching rule) diff --git a/pyproject.toml b/pyproject.toml index 00164ec..df7e5cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "attune-forms" -version = "0.9.0" -description = "Dynamic forms library: declarative FormSchema, multi-surface renderers (widget HTML, AskUserQuestion, MCP elicitation), and template-driven intake generation" +version = "0.9.1" +description = "Dynamic forms and command workspaces: validated multi-surface interaction documents for AI agents" readme = "README.md" requires-python = ">=3.10" license = {file = "LICENSE"} diff --git a/src/attune_forms/__init__.py b/src/attune_forms/__init__.py index b7dd948..6652935 100644 --- a/src/attune_forms/__init__.py +++ b/src/attune_forms/__init__.py @@ -87,14 +87,19 @@ from attune_forms.widget import WIDGET_RESPONSE_MARKER, form_to_widget_html from attune_forms.workspace import ( WorkspaceAction, + WorkspaceActionBinding, WorkspaceActionIntent, + WorkspaceActionResponse, WorkspaceBlock, WorkspaceBlockKind, WorkspaceItem, WorkspaceSection, WorkspaceTone, + WorkspaceValidationError, WorkspaceView, WorkspaceViewId, + collect_workspace_action, + workspace_from_dict, workspace_to_markdown, workspace_to_widget_html, ) @@ -139,7 +144,9 @@ "set_keyboard_mode", "token", "WorkspaceAction", + "WorkspaceActionBinding", "WorkspaceActionIntent", + "WorkspaceActionResponse", "WorkspaceBlock", "WorkspaceBlockKind", "WorkspaceItem", @@ -147,6 +154,9 @@ "WorkspaceTone", "WorkspaceView", "WorkspaceViewId", + "WorkspaceValidationError", + "collect_workspace_action", + "workspace_from_dict", "workspace_to_markdown", "workspace_to_widget_html", "ASSUMPTION_RULINGS", diff --git a/src/attune_forms/mcp_server.py b/src/attune_forms/mcp_server.py index 2f56d61..62e0979 100644 --- a/src/attune_forms/mcp_server.py +++ b/src/attune_forms/mcp_server.py @@ -1,6 +1,6 @@ """Standalone MCP server for the attune-forms substrate (spec Phase 2). -Mirrors attune-ai\'s four elicitation tools — same names, same schemas, +Mirrors attune-ai\'s elicitation tools — same names, same schemas, same result shapes (chair-ruled D3: identical surface makes the later convergence a pure swap and lets docs/skills transfer verbatim): @@ -8,6 +8,8 @@ - ``elicitation_render_widget`` — form dict -> self-contained HTML - ``elicitation_collect_response``— form + answers -> validated response - ``elicitation_ask`` — native MCP elicitation round-trip +- ``elicitation_render_workspace``— workspace dict -> widget + markdown +- ``elicitation_collect_workspace_action`` — bound action validation Launch: ``attune-forms-mcp`` (console script) or ``uvx --from 'attune-forms[mcp]' attune-forms-mcp`` — the exact command @@ -39,6 +41,14 @@ from attune_forms.elicitation_schema import form_to_elicitation_schema from attune_forms.form_events import log_submission, maybe_keyboard_hint from attune_forms.widget import form_to_widget_html +from attune_forms.workspace import ( + WorkspaceActionBinding, + WorkspaceValidationError, + collect_workspace_action, + workspace_from_dict, + workspace_to_markdown, + workspace_to_widget_html, +) logger = logging.getLogger(__name__) @@ -188,9 +198,152 @@ def _form_schema() -> dict[str, Any]: } +def _workspace_schema() -> dict[str, Any]: + """Closed serializable workspace grammar mirrored by workspace_from_dict.""" + item = { + "type": "object", + "properties": { + "label": {"type": "string"}, + "value": {"type": "string"}, + "detail": {"type": "string"}, + "status": {"type": "string"}, + }, + "required": ["label"], + "additionalProperties": False, + } + block = { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "key_value", + "code", + "timeline", + "change_summary", + "evidence", + "disclosure", + "action_list", + ], + }, + "title": {"type": "string"}, + "body": {"type": "string"}, + "items": {"type": "array", "items": item}, + "language": {"type": "string"}, + }, + "required": ["kind"], + "additionalProperties": False, + } + section = { + "type": "object", + "properties": { + "heading": {"type": "string"}, + "tone": { + "type": "string", + "enum": [ + "neutral", + "action", + "recommendation", + "success", + "warning", + "danger", + ], + }, + "blocks": {"type": "array", "items": block}, + }, + "required": ["blocks"], + "additionalProperties": False, + } + action = { + "type": "object", + "properties": { + "id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]{0,63}$"}, + "label": {"type": "string"}, + "intent": { + "type": "string", + "enum": ["primary", "secondary", "danger"], + }, + "consequence": {"type": "string"}, + "requires_explicit_choice": {"type": "boolean"}, + }, + "required": ["id", "label"], + "additionalProperties": False, + } + return { + "type": "object", + "description": "Validated command-workspace view document.", + "properties": { + "id": { + "type": "string", + "enum": ["intake", "preview", "execution", "receipt"], + }, + "title": {"type": "string"}, + "summary": {"type": "string"}, + "sections": {"type": "array", "items": section}, + "actions": {"type": "array", "items": action}, + "form": _form_schema(), + }, + "required": ["id", "title"], + "additionalProperties": False, + } + + +def _workspace_binding_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "workspace_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", + }, + "revision": {"type": "integer", "minimum": 0}, + "action_nonce": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{16,128}$", + }, + "contract_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + }, + "required": ["workspace_id", "revision", "action_nonce", "contract_hash"], + "additionalProperties": False, + } + + +def _workspace_response_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "__elicitation_response__": {"type": "boolean", "const": True}, + "title": {"type": "string"}, + "workspace_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", + }, + "revision": {"type": "integer", "minimum": 0}, + "view": { + "type": "string", + "enum": ["intake", "preview", "execution", "receipt"], + }, + "action": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]{0,63}$", + }, + "action_nonce": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{16,128}$", + }, + "contract_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "confirmed": {"type": "boolean"}, + }, + "required": ["__elicitation_response__", "title", "view", "action", "confirmed"], + "additionalProperties": False, + } + + def tool_definitions() -> list[types.Tool]: - """The four mirrored tools (names/schemas match attune-ai\'s server).""" + """The mirrored tools (names/schemas match attune-ai\'s server).""" form = _form_schema() + workspace = _workspace_schema() + binding = _workspace_binding_schema() return [ types.Tool( name="elicitation_render_form", @@ -258,6 +411,44 @@ def tool_definitions() -> list[types.Tool]: "required": ["form"], }, ), + types.Tool( + name="elicitation_render_workspace", + description=( + "Validate one command-workspace view and render equivalent " + "widget HTML and portable Markdown. An optional action binding " + "is copied into display-action responses; it never grants " + "execution authority." + ), + inputSchema={ + "type": "object", + "properties": { + "workspace": workspace, + "binding": binding, + "instance_id": {"type": "string"}, + }, + "required": ["workspace"], + "additionalProperties": False, + }, + ), + types.Tool( + name="elicitation_collect_workspace_action", + description=( + "Validate a workspace action against the exact rendered view " + "and optional revision/hash/nonce binding. Returns only a " + "validated action envelope; the host still authorizes and " + "dispatches it." + ), + inputSchema={ + "type": "object", + "properties": { + "workspace": workspace, + "response": _workspace_response_schema(), + "binding": binding, + }, + "required": ["workspace", "response"], + "additionalProperties": False, + }, + ), ] @@ -344,6 +535,77 @@ async def handle_collect_response(args: dict[str, Any]) -> dict[str, Any]: return result +def _parse_workspace_binding( + raw: Any, +) -> tuple[WorkspaceActionBinding | None, dict[str, Any] | None]: + if raw is None: + return None, None + if not isinstance(raw, dict): + return None, {"success": False, "problems": ["'binding' must be an object"]} + expected = {"workspace_id", "revision", "action_nonce", "contract_hash"} + problems = [f"binding has unknown key {key!r}" for key in raw if key not in expected] + problems.extend(f"binding requires {key!r}" for key in sorted(expected - set(raw))) + if problems: + return None, {"success": False, "problems": problems} + try: + return WorkspaceActionBinding(**raw), None + except (TypeError, ValueError) as exc: + return None, {"success": False, "problems": [str(exc)]} + + +async def handle_render_workspace(args: dict[str, Any]) -> dict[str, Any]: + """Render a strict workspace document through both portable surfaces.""" + try: + view = workspace_from_dict(args.get("workspace", {})) + except WorkspaceValidationError as exc: + return {"success": False, "problems": exc.problems} + binding, failure = _parse_workspace_binding(args.get("binding")) + if failure: + return failure + instance_id = args.get("instance_id") + if instance_id is not None and not isinstance(instance_id, str): + return {"success": False, "problems": ["'instance_id' must be a string"]} + try: + html = workspace_to_widget_html(view, instance_id, binding=binding) + markdown = workspace_to_markdown(view, binding=binding) + except ValueError as exc: + return {"success": False, "problems": [str(exc)]} + return { + "success": True, + "html": html, + "markdown": markdown, + "title": view.title, + "view": view.id.value, + "action_ids": [action.id for action in view.actions], + "bound": binding is not None, + } + + +async def handle_collect_workspace_action(args: dict[str, Any]) -> dict[str, Any]: + """Validate one returned action without authorizing or executing it.""" + try: + view = workspace_from_dict(args.get("workspace", {})) + except WorkspaceValidationError as exc: + return {"success": False, "problems": exc.problems} + binding, failure = _parse_workspace_binding(args.get("binding")) + if failure: + return failure + try: + response = collect_workspace_action(view, args.get("response", {}), binding) + except WorkspaceValidationError as exc: + return {"success": False, "problems": exc.problems} + return { + "success": True, + "view": response.view.value, + "action": response.action, + "confirmed": response.confirmed, + "workspace_id": response.workspace_id, + "revision": response.revision, + "action_nonce": response.action_nonce, + "contract_hash": response.contract_hash, + } + + async def handle_ask(args: dict[str, Any]) -> dict[str, Any]: form, problems = _parse_form(args) if problems: @@ -394,6 +656,8 @@ async def handle_ask(args: dict[str, Any]) -> dict[str, Any]: "elicitation_render_widget": handle_render_widget, "elicitation_collect_response": handle_collect_response, "elicitation_ask": handle_ask, + "elicitation_render_workspace": handle_render_workspace, + "elicitation_collect_workspace_action": handle_collect_workspace_action, } diff --git a/src/attune_forms/workspace.py b/src/attune_forms/workspace.py index ab11a41..77a75a5 100644 --- a/src/attune_forms/workspace.py +++ b/src/attune_forms/workspace.py @@ -7,13 +7,17 @@ from __future__ import annotations +import hmac import json import re import uuid +from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum from html import escape +from typing import Any +from attune_forms.bridge import FormValidationError, form_from_dict from attune_forms.markdown_surface import form_to_markdown from attune_forms.models import FormSchema from attune_forms.theme import CSS_WORKSPACE @@ -21,6 +25,41 @@ _ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") _LANG_RE = re.compile(r"^[A-Za-z0-9_+-]{1,32}$") +_WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +_ACTION_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,128}$") +_CONTRACT_HASH_RE = re.compile(r"^[0-9a-f]{64}$") + +_WORKSPACE_KEYS = frozenset({"id", "title", "sections", "actions", "summary", "form"}) +_SECTION_KEYS = frozenset({"blocks", "heading", "tone"}) +_BLOCK_KEYS = frozenset({"kind", "title", "body", "items", "language"}) +_ITEM_KEYS = frozenset({"label", "value", "detail", "status"}) +_ACTION_KEYS = frozenset({"id", "label", "intent", "consequence", "requires_explicit_choice"}) +_ACTION_RESPONSE_KEYS = frozenset( + { + WIDGET_RESPONSE_MARKER, + "title", + "workspace_id", + "revision", + "view", + "action", + "action_nonce", + "contract_hash", + "confirmed", + } +) + + +class WorkspaceValidationError(ValueError): + """A malformed workspace definition or action response. + + ``problems`` is deliberately compatible with + :class:`~attune_forms.bridge.FormValidationError` so MCP callers can + repair the exact invalid fields instead of handling a raw exception. + """ + + def __init__(self, problems: list[str]) -> None: + self.problems = problems + super().__init__("; ".join(problems)) class WorkspaceViewId(str, Enum): @@ -145,6 +184,55 @@ def __post_init__(self) -> None: raise ValueError("explicit workspace action requires a consequence") +@dataclass(frozen=True) +class WorkspaceActionBinding: + """Opaque host authority context copied into an action response. + + The renderer never interprets or grants this authority. The host + supplies a binding for one rendered state revision and validates the + returned values before dispatching the stable action id. + """ + + workspace_id: str + revision: int + action_nonce: str + contract_hash: str + + def __post_init__(self) -> None: + if not _WORKSPACE_ID_RE.fullmatch(self.workspace_id): + raise ValueError("workspace id must be a 1-128 char stable identifier") + if isinstance(self.revision, bool) or not isinstance(self.revision, int): + raise TypeError("workspace revision must be an integer") + if self.revision < 0: + raise ValueError("workspace revision must not be negative") + if not _ACTION_NONCE_RE.fullmatch(self.action_nonce): + raise ValueError("workspace action nonce must be a 16-128 char URL-safe token") + if not _CONTRACT_HASH_RE.fullmatch(self.contract_hash): + raise ValueError("workspace contract hash must be a lowercase SHA-256 digest") + + def to_payload(self) -> dict[str, str | int]: + """Return the serializable response fields for this binding.""" + return { + "workspace_id": self.workspace_id, + "revision": self.revision, + "action_nonce": self.action_nonce, + "contract_hash": self.contract_hash, + } + + +@dataclass(frozen=True) +class WorkspaceActionResponse: + """One structurally validated action returned by a workspace view.""" + + view: WorkspaceViewId + action: str + confirmed: bool + workspace_id: str = "" + revision: int | None = None + action_nonce: str = "" + contract_hash: str = "" + + @dataclass(frozen=True) class WorkspaceView: """One portable state view of a command workspace.""" @@ -173,6 +261,271 @@ def __post_init__(self) -> None: raise ValueError("a form workspace view requires exactly one submit action") +def _unknown_keys(where: str, raw: Mapping[str, Any], allowed: frozenset[str]) -> list[str]: + return [f"{where} has unknown definition key {key!r}" for key in raw if key not in allowed] + + +def _string_value( + where: str, + raw: Mapping[str, Any], + key: str, + problems: list[str], + *, + required: bool = False, + default: str = "", +) -> str: + value = raw.get(key, default) + if not isinstance(value, str): + problems.append(f"{where} '{key}' must be a string") + return default + if required and not value.strip(): + problems.append(f"{where} '{key}' is required and must not be empty") + return value + + +def _enum_value( + where: str, + raw: Mapping[str, Any], + key: str, + enum_type: type[Enum], + problems: list[str], + *, + default: str | None = None, +) -> Any: + value = raw.get(key, default) + try: + return enum_type(value) + except (TypeError, ValueError): + allowed = ", ".join(member.value for member in enum_type) + problems.append(f"{where} '{key}' must be one of: {allowed}") + return None + + +def _items_from_data(raw_items: Any, where: str, problems: list[str]) -> list[WorkspaceItem]: + if not isinstance(raw_items, list): + problems.append(f"{where} 'items' must be a list") + return [] + items: list[WorkspaceItem] = [] + for index, raw_item in enumerate(raw_items): + item_where = f"{where}.items[{index}]" + if not isinstance(raw_item, Mapping): + problems.append(f"{item_where} must be a mapping") + continue + item_problems = _unknown_keys(item_where, raw_item, _ITEM_KEYS) + label = _string_value(item_where, raw_item, "label", item_problems, required=True) + value = _string_value(item_where, raw_item, "value", item_problems) + detail = _string_value(item_where, raw_item, "detail", item_problems) + status = _string_value(item_where, raw_item, "status", item_problems) + if not item_problems: + items.append(WorkspaceItem(label, value, detail, status)) + problems.extend(item_problems) + return items + + +def _blocks_from_data(raw_blocks: Any, where: str, problems: list[str]) -> list[WorkspaceBlock]: + if not isinstance(raw_blocks, list): + problems.append(f"{where} 'blocks' must be a list") + return [] + blocks: list[WorkspaceBlock] = [] + for index, raw_block in enumerate(raw_blocks): + block_where = f"{where}.blocks[{index}]" + if not isinstance(raw_block, Mapping): + problems.append(f"{block_where} must be a mapping") + continue + block_problems = _unknown_keys(block_where, raw_block, _BLOCK_KEYS) + kind = _enum_value(block_where, raw_block, "kind", WorkspaceBlockKind, block_problems) + title = _string_value(block_where, raw_block, "title", block_problems) + body = _string_value(block_where, raw_block, "body", block_problems) + language = _string_value(block_where, raw_block, "language", block_problems, default="text") + items = _items_from_data(raw_block.get("items", []), block_where, block_problems) + if kind is not None and not block_problems: + try: + blocks.append(WorkspaceBlock(kind, title, body, tuple(items), language)) + except (TypeError, ValueError) as exc: + block_problems.append(f"{block_where}: {exc}") + problems.extend(block_problems) + return blocks + + +def _sections_from_data( + raw_sections: Any, where: str, problems: list[str] +) -> list[WorkspaceSection]: + if not isinstance(raw_sections, list): + problems.append(f"{where} 'sections' must be a list") + return [] + sections: list[WorkspaceSection] = [] + for index, raw_section in enumerate(raw_sections): + section_where = f"{where}.sections[{index}]" + if not isinstance(raw_section, Mapping): + problems.append(f"{section_where} must be a mapping") + continue + section_problems = _unknown_keys(section_where, raw_section, _SECTION_KEYS) + heading = _string_value(section_where, raw_section, "heading", section_problems) + tone = _enum_value( + section_where, + raw_section, + "tone", + WorkspaceTone, + section_problems, + default=WorkspaceTone.NEUTRAL.value, + ) + blocks = _blocks_from_data(raw_section.get("blocks"), section_where, section_problems) + if tone is not None and not section_problems: + try: + sections.append(WorkspaceSection(tuple(blocks), heading, tone)) + except (TypeError, ValueError) as exc: + section_problems.append(f"{section_where}: {exc}") + problems.extend(section_problems) + return sections + + +def _actions_from_data(raw_actions: Any, where: str, problems: list[str]) -> list[WorkspaceAction]: + if not isinstance(raw_actions, list): + problems.append(f"{where} 'actions' must be a list") + return [] + actions: list[WorkspaceAction] = [] + for index, raw_action in enumerate(raw_actions): + action_where = f"{where}.actions[{index}]" + if not isinstance(raw_action, Mapping): + problems.append(f"{action_where} must be a mapping") + continue + action_problems = _unknown_keys(action_where, raw_action, _ACTION_KEYS) + action_id = _string_value(action_where, raw_action, "id", action_problems, required=True) + label = _string_value(action_where, raw_action, "label", action_problems, required=True) + intent = _enum_value( + action_where, + raw_action, + "intent", + WorkspaceActionIntent, + action_problems, + default=WorkspaceActionIntent.SECONDARY.value, + ) + consequence = _string_value(action_where, raw_action, "consequence", action_problems) + explicit = raw_action.get("requires_explicit_choice", False) + if not isinstance(explicit, bool): + action_problems.append(f"{action_where} 'requires_explicit_choice' must be a boolean") + if intent is not None and not action_problems: + try: + actions.append(WorkspaceAction(action_id, label, intent, consequence, explicit)) + except (TypeError, ValueError) as exc: + action_problems.append(f"{action_where}: {exc}") + problems.extend(action_problems) + return actions + + +def workspace_from_dict(data: dict[str, Any]) -> WorkspaceView: + """Build a strict :class:`WorkspaceView` from serializable data. + + Every definition key is consumed or rejected. This is the workspace + twin of :func:`attune_forms.form_from_dict` and is the safe boundary + for MCP/tool-authored view documents. + """ + if not isinstance(data, dict): + raise WorkspaceValidationError(["workspace must be a mapping"]) + + where = "workspace" + problems = _unknown_keys(where, data, _WORKSPACE_KEYS) + view_id = _enum_value(where, data, "id", WorkspaceViewId, problems) + title = _string_value(where, data, "title", problems, required=True) + summary = _string_value(where, data, "summary", problems) + sections = _sections_from_data(data.get("sections", []), where, problems) + actions = _actions_from_data(data.get("actions", []), where, problems) + + form: FormSchema | None = None + raw_form = data.get("form") + if raw_form is not None: + if not isinstance(raw_form, dict): + problems.append("workspace 'form' must be a mapping") + else: + try: + form = form_from_dict(raw_form, source="workspace") + except FormValidationError as exc: + problems.extend(f"workspace form: {problem}" for problem in exc.problems) + + if view_id is not None and not problems: + try: + return WorkspaceView(view_id, title, tuple(sections), tuple(actions), summary, form) + except (TypeError, ValueError) as exc: + problems.append(f"workspace: {exc}") + raise WorkspaceValidationError(problems) + + +def collect_workspace_action( + view: WorkspaceView, + payload: Mapping[str, Any], + binding: WorkspaceActionBinding | None = None, +) -> WorkspaceActionResponse: + """Validate a returned action against its exact rendered view. + + This validates structure and an optional host-supplied binding. It + does not authorize or execute the action; the host must still compare + the binding with canonical state and consume the nonce once. + """ + if not isinstance(payload, Mapping): + raise WorkspaceValidationError(["workspace action response must be a mapping"]) + + problems = [ + f"workspace action response has unknown key {key!r}" + for key in payload + if key not in _ACTION_RESPONSE_KEYS + ] + if payload.get(WIDGET_RESPONSE_MARKER) is not True: + problems.append(f"workspace action response requires {WIDGET_RESPONSE_MARKER}=true") + if payload.get("title") != view.title: + problems.append("workspace action response title does not match the rendered view") + if payload.get("view") != view.id.value: + problems.append("workspace action response view does not match the rendered view") + + action_id = payload.get("action") + action = next((candidate for candidate in view.actions if candidate.id == action_id), None) + if not isinstance(action_id, str): + problems.append("workspace action response 'action' must be a string") + elif action is None: + problems.append(f"workspace action {action_id!r} is not allowed by the rendered view") + + confirmed = payload.get("confirmed") + if not isinstance(confirmed, bool): + problems.append("workspace action response 'confirmed' must be a boolean") + elif action is not None and action.requires_explicit_choice and not confirmed: + problems.append(f"workspace action {action.id!r} requires explicit confirmation") + + binding_keys = ("workspace_id", "revision", "action_nonce", "contract_hash") + if binding is None: + unexpected = [key for key in binding_keys if key in payload] + if unexpected: + problems.append( + "workspace action response supplied an unexpected binding: " + ", ".join(unexpected) + ) + else: + if payload.get("workspace_id") != binding.workspace_id: + problems.append("workspace action response workspace id does not match") + revision = payload.get("revision") + if isinstance(revision, bool) or not isinstance(revision, int): + problems.append("workspace action response revision must be an integer") + elif revision != binding.revision: + problems.append("workspace action response revision does not match") + nonce = payload.get("action_nonce") + if not isinstance(nonce, str) or not hmac.compare_digest(nonce, binding.action_nonce): + problems.append("workspace action response nonce does not match") + contract_hash = payload.get("contract_hash") + if not isinstance(contract_hash, str) or not hmac.compare_digest( + contract_hash, binding.contract_hash + ): + problems.append("workspace action response contract hash does not match") + + if problems: + raise WorkspaceValidationError(problems) + return WorkspaceActionResponse( + view=view.id, + action=action_id, + confirmed=confirmed, + workspace_id=binding.workspace_id if binding else "", + revision=binding.revision if binding else None, + action_nonce=binding.action_nonce if binding else "", + contract_hash=binding.contract_hash if binding else "", + ) + + def _item_text(item: WorkspaceItem) -> str: suffix = f" — {escape(item.detail)}" if item.detail else "" status = f'{escape(item.status)} ' if item.status else "" @@ -225,8 +578,20 @@ def _sections_html(sections: tuple[WorkspaceSection, ...]) -> str: return "".join(rendered) -def workspace_to_widget_html(view: WorkspaceView, instance_id: str | None = None) -> str: - """Render one workspace view as self-contained widget HTML.""" +def workspace_to_widget_html( + view: WorkspaceView, + instance_id: str | None = None, + *, + binding: WorkspaceActionBinding | None = None, +) -> str: + """Render one workspace view as self-contained widget HTML. + + ``binding`` is allowed only on display/action views. Intake answers + continue through the existing form collector; consequential preview + actions use this separate, state-bound response path. + """ + if view.form is not None and binding is not None: + raise ValueError("workspace action binding is not valid on a form view") suffix = re.sub(r"[^A-Za-z0-9]+", "-", instance_id or "").strip("-") suffix = suffix or uuid.uuid4().hex[:8] root_id = f"attune-workspace-{suffix}" @@ -278,17 +643,24 @@ def action_button(action: WorkspaceAction) -> str: content = "" script = "" if view.form is None and view.actions: + binding_json = json.dumps( + binding.to_payload() if binding else {}, + ensure_ascii=True, + separators=(",", ":"), + ) script = f"""