feat(webui): gated action framework (#434)
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
This commit is contained in:
@@ -14,6 +14,8 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
|
||||
from webui.project_views import render_project_detail, render_projects_list
|
||||
from webui.prompt_library import find_prompt, library_to_dict
|
||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
||||
from webui.gated_action_views import render_actions_page
|
||||
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
|
||||
from webui.queue_views import render_queue_page
|
||||
|
||||
@@ -41,6 +43,7 @@ async def home(_request: Request) -> HTMLResponse:
|
||||
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
||||
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
||||
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
||||
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
|
||||
"</ul>"
|
||||
)
|
||||
return HTMLResponse(render_page(title="Home", body_html=body))
|
||||
@@ -147,6 +150,41 @@ async def leases(_request: Request) -> HTMLResponse:
|
||||
)
|
||||
|
||||
|
||||
async def actions(_request: Request) -> HTMLResponse:
|
||||
registry = load_action_registry()
|
||||
return HTMLResponse(render_actions_page(registry))
|
||||
|
||||
|
||||
async def api_actions(_request: Request) -> JSONResponse:
|
||||
return JSONResponse(load_action_registry().to_dict())
|
||||
|
||||
|
||||
async def api_action_preview(request: Request) -> JSONResponse:
|
||||
action_id = request.path_params["action_id"]
|
||||
params = dict(request.query_params)
|
||||
for key in ("issue_number", "pr_number"):
|
||||
if key in params:
|
||||
params[key] = int(params[key])
|
||||
result = preview_action(action_id, **params)
|
||||
if "error" in result:
|
||||
return JSONResponse(result, status_code=404)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
async def api_action_attempt(request: Request) -> JSONResponse:
|
||||
"""POST is rejected by read-only guard; kept for explicit fail-closed tests."""
|
||||
action_id = request.path_params["action_id"]
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
result = attempt_action(action_id, **body)
|
||||
status = 403 if not result.get("success") else 200
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
if request.method not in _READ_ONLY_METHODS:
|
||||
return JSONResponse(
|
||||
@@ -175,6 +213,18 @@ def create_app() -> Starlette:
|
||||
Route("/audit", audit, methods=["GET"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
Route("/leases", leases, methods=["GET"]),
|
||||
Route("/actions", actions, methods=["GET"]),
|
||||
Route("/api/actions", api_actions, methods=["GET"]),
|
||||
Route(
|
||||
"/api/actions/{action_id}/preview",
|
||||
api_action_preview,
|
||||
methods=["GET"],
|
||||
),
|
||||
Route(
|
||||
"/api/actions/{action_id}/attempt",
|
||||
api_action_attempt,
|
||||
methods=["POST"],
|
||||
),
|
||||
],
|
||||
exception_handlers={405: method_not_allowed},
|
||||
)
|
||||
Reference in New Issue
Block a user