Register future write actions with task_capability_map metadata, mutation-ledger previews, and fail-closed execution in the read-only MVP. Adds /actions routes, disabled UI buttons, and tests proving ungated attempts cannot invoke mutations. Closes #434
101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
"""HTML views for gated action previews (#434)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
import json
|
|
|
|
from webui.gated_actions import ActionRegistry, GatedAction, preview_action
|
|
from webui.layout import render_page
|
|
|
|
|
|
def _escape(text: str) -> str:
|
|
return html.escape(text, quote=True)
|
|
|
|
|
|
def _ledger_block(action: GatedAction) -> str:
|
|
sample_params: dict[str, object]
|
|
if action.action_id == "create_issue":
|
|
sample_params = {"title": "Example issue"}
|
|
elif action.action_id == "create_pr":
|
|
sample_params = {"head": "feat/issue-99-example", "base": "master"}
|
|
elif action.action_id == "delete_branch":
|
|
sample_params = {"branch_name": "feat/issue-99-example"}
|
|
elif action.action_id in {"review_pr", "merge_pr", "comment_pr", "close_pr"}:
|
|
sample_params = {"pr_number": 99}
|
|
else:
|
|
sample_params = {"issue_number": 99}
|
|
|
|
preview = preview_action(action.action_id, **sample_params)
|
|
ledger_json = json.dumps(preview.get("mutation_ledger", []), indent=2)
|
|
return (
|
|
"<details class='action-preview'>"
|
|
"<summary>Preview mutation ledger</summary>"
|
|
f"<pre class='prompt-text'>{_escape(ledger_json)}</pre>"
|
|
f"<p class='muted meta'>Sample params: "
|
|
f"<code>{_escape(json.dumps(sample_params))}</code></p>"
|
|
f"<p><a href=\"/api/actions/{_escape(action.action_id)}/preview?"
|
|
f"{_preview_query(sample_params)}\">JSON preview</a></p>"
|
|
"</details>"
|
|
)
|
|
|
|
|
|
def _preview_query(params: dict[str, object]) -> str:
|
|
parts = []
|
|
for key, value in params.items():
|
|
parts.append(f"{key}={_escape(str(value))}")
|
|
return "&".join(parts)
|
|
|
|
|
|
def _action_card(action: GatedAction) -> str:
|
|
disabled_attr = " disabled" if not action.enabled else ""
|
|
return (
|
|
"<article class='prompt-card action-card'>"
|
|
f"<h3>{_escape(action.label)}</h3>"
|
|
f"<p class='muted'>{_escape(action.description)}</p>"
|
|
"<p class='meta'>"
|
|
f"Task <code>{_escape(action.task_key)}</code> · "
|
|
f"MCP <code>{_escape(action.mcp_tool)}</code><br>"
|
|
f"Requires <code>{_escape(action.required_permission)}</code> "
|
|
f"({_escape(action.required_role)} profile)</p>"
|
|
f"<button type='button' class='copy-btn action-btn'{disabled_attr} "
|
|
f"aria-disabled='true' title='Disabled in read-only MVP'>"
|
|
f"{_escape(action.label)}</button>"
|
|
f"{_ledger_block(action)}"
|
|
"</article>"
|
|
)
|
|
|
|
|
|
ACTION_PAGE_STYLES = """
|
|
<style>
|
|
.action-card .action-btn[disabled] {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
filter: none;
|
|
}
|
|
.action-preview { margin-top: 0.75rem; }
|
|
.action-preview summary {
|
|
cursor: pointer;
|
|
color: var(--accent);
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|
|
"""
|
|
|
|
|
|
def render_actions_page(registry: ActionRegistry) -> str:
|
|
cards = "".join(_action_card(action) for action in registry.actions)
|
|
body = (
|
|
"<h2>Gated actions</h2>"
|
|
"<p>Future write actions are registered here but remain "
|
|
"<strong>disabled</strong> in the read-only MVP. Each action "
|
|
"declares the MCP capability and profile role from "
|
|
"<code>task_capability_map</code>; previews show the mutation "
|
|
"ledger before any execution path ships.</p>"
|
|
f"<p class='meta'>{len(registry.actions)} registered actions · "
|
|
"execution: fail-closed</p>"
|
|
f"{cards}"
|
|
"<p><a href=\"/api/actions\">JSON registry</a></p>"
|
|
f"{ACTION_PAGE_STYLES}"
|
|
)
|
|
return render_page(title="Actions", body_html=body) |