feat(webui): request preview, authorization, and workflow initiation (Closes #643)
Operators had to paste a role prompt into a terminal to start work, and
nothing enforced that the allocator had been consulted first, so two sessions
could reach for the same issue and each believe it was theirs. This adds a
request surface: a desired role, an issue or PR, and a stated intent, answered
by an authorization decision and - on confirmation - an exclusive assignment
from the allocator.
Preview (POST /api/v1/requests/preview, and the /requests form) runs five
checks and reports authorize/deny with a reason for each: console
authorization, capability resolution for the desired role, lease availability,
whether the allocator would independently select this work unit, and head
pinning for PR work. It is read-only - it calls the allocator with apply=false
and writes only an audit line. An unauthorized principal never reaches the
allocator or the control-plane DB, so a denial cannot enumerate the queue.
Initiation (POST /api/v1/requests/apply) never assigns the requested item
directly. It runs a dry-run first and proceeds only when the allocator would
independently pick that exact work unit, carrying the dry-run's
candidate_set_fingerprint as a CAS pin; otherwise it returns wait or blocked
and mutates nothing. An active claim on the work unit rejects a duplicate
assign before one is attempted. A returned assignment carries a handoff block
naming the required profile, namespace, and the actions that stay forbidden.
Authorization reuses the #633 model rather than adding a second one. The new
initiate_workflow action is operator-class because its outcome is a claim, not
a Gitea verdict: requesting reviewer or merger work reserves that work but
grants no right to approve or merge. Execution is gated by a new per-action
execution_env_flag (WEBUI_REQUESTS_EXECUTION), deliberately in place of raising
ACTIVE_PHASE - a phase bump would enable execution for every phase-2 action at
once, including ones whose execution path is not implemented. Actions that
declare no flag are unchanged and still report execution_enabled false.
Every preview and apply emits a console audit record correlated to the
resulting assignment by correlation.request_id.
Fail-closed throughout: an unreadable control-plane DB, an incomplete queue
inventory (#758), an allocator that raises, an unpinned PR head, a moved PR
head, and an unconfirmed apply all deny without mutating.
Files:
- webui/request_service.py (new) - request model, preview, initiation
- webui/request_views.py (new) - form and preview rendering, escaped
- tests/test_webui_request_initiation.py (new) - 52 tests
- webui/console_authz.py - initiate_workflow action, execution_wired()
- webui/app.py - /requests, /api/v1/requests/preview, /api/v1/requests/apply
- webui/nav.py - Requests nav entry
- webui/traffic_loader.py - public candidates_from_queue_snapshot alias
- docs/webui-requests.md (new), docs/webui-authz-audit.md
Validation: full suite on this branch 5242 passed, 6 skipped, 899 subtests, 23
failed. Clean master baseline at 2f4dec83 in an equivalent branches/ worktree:
5190 passed, 6 skipped, 867 subtests, the same 23 tests failed. The branch adds
52 passing tests and introduces no new full-suite failure signature.
Closes #643
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+116
@@ -67,6 +67,8 @@ from webui.system_health import (
|
||||
snapshot_to_dict as system_health_to_dict,
|
||||
)
|
||||
from webui.system_health_views import render_system_health_page
|
||||
from webui import request_service
|
||||
from webui.request_views import render_requests_page
|
||||
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
||||
@@ -722,6 +724,109 @@ async def api_v1_analytics_ingest(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
def _default_request_scope() -> dict[str, str]:
|
||||
"""Resolve remote/org/repo from the project registry for request forms.
|
||||
|
||||
Returns an empty mapping when the registry cannot be read, which makes
|
||||
``parse_request`` reject a request that did not name its own scope rather
|
||||
than letting it default to some other repository.
|
||||
"""
|
||||
from webui.queue_loader import _host_from_url # host normalisation helper
|
||||
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None or not registry.projects:
|
||||
return {}
|
||||
project = registry.projects[0]
|
||||
host = _host_from_url(project.remote_host)
|
||||
return {
|
||||
"remote": _derive_remote(host),
|
||||
"org": project.gitea_owner or "",
|
||||
"repo": project.repo_name or "",
|
||||
}
|
||||
|
||||
|
||||
async def _request_payload(request: Request) -> dict[str, object]:
|
||||
"""Read a request body as JSON or form-encoded. Never raises."""
|
||||
content_type = (request.headers.get("content-type") or "").lower()
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return dict(body) if isinstance(body, dict) else {}
|
||||
try:
|
||||
form = await request.form()
|
||||
except Exception:
|
||||
return {}
|
||||
return {key: form[key] for key in form}
|
||||
|
||||
|
||||
async def requests_page(request: Request) -> HTMLResponse:
|
||||
"""Operator request form and intent preview (#643).
|
||||
|
||||
POST here only ever *previews*. Initiation is a separate confirmed call to
|
||||
``/api/v1/requests/apply`` so that submitting this form cannot reserve
|
||||
work as a side effect.
|
||||
"""
|
||||
submitted: dict[str, object] = {}
|
||||
preview = None
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
submitted = await _request_payload(request)
|
||||
work_request, error = request_service.parse_request(
|
||||
submitted, default_scope=_default_request_scope()
|
||||
)
|
||||
if work_request is not None:
|
||||
preview = request_service.preview_request(
|
||||
work_request,
|
||||
principal=resolve_principal(headers=dict(request.headers)),
|
||||
)
|
||||
return HTMLResponse(
|
||||
render_requests_page(
|
||||
preview=preview, error=error, submitted=submitted
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def api_v1_request_preview(request: Request) -> JSONResponse:
|
||||
"""Dry-run authorization and intent preview for a work request (#643)."""
|
||||
payload = await _request_payload(request)
|
||||
work_request, error = request_service.parse_request(
|
||||
payload, default_scope=_default_request_scope()
|
||||
)
|
||||
if work_request is None:
|
||||
return JSONResponse(error.to_dict(), status_code=400)
|
||||
preview = request_service.preview_request(
|
||||
work_request,
|
||||
principal=resolve_principal(headers=dict(request.headers)),
|
||||
)
|
||||
return JSONResponse(
|
||||
preview.to_dict(), status_code=200 if preview.authorized else 403
|
||||
)
|
||||
|
||||
|
||||
async def api_v1_request_apply(request: Request) -> JSONResponse:
|
||||
"""Initiate a previewed work request through the allocator (#643).
|
||||
|
||||
Fail-closed at every step: unauthorized, unconfirmed, not-next-safe, and
|
||||
already-claimed all return without attempting an assignment.
|
||||
"""
|
||||
payload = await _request_payload(request)
|
||||
work_request, error = request_service.parse_request(
|
||||
payload, default_scope=_default_request_scope()
|
||||
)
|
||||
if work_request is None:
|
||||
return JSONResponse(error.to_dict(), status_code=400)
|
||||
confirm = _truthy_flag(str(payload.get("confirm") or ""))
|
||||
result = request_service.apply_request(
|
||||
work_request,
|
||||
principal=resolve_principal(headers=dict(request.headers)),
|
||||
confirm=confirm,
|
||||
)
|
||||
status = int(result.pop("status_code", 403))
|
||||
return JSONResponse(result, status_code=status)
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
path = request.url.path
|
||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||
@@ -786,6 +891,17 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
api_action_attempt,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/requests", requests_page, methods=["GET", "POST"]),
|
||||
Route(
|
||||
"/api/v1/requests/preview",
|
||||
api_v1_request_preview,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/v1/requests/apply",
|
||||
api_v1_request_apply,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route("/api/leases", api_leases, methods=["GET"]),
|
||||
Route("/api/v1/inventory", api_inventory, methods=["GET"]),
|
||||
Route(
|
||||
|
||||
+71
-8
@@ -115,6 +115,12 @@ class ConsoleAction:
|
||||
break_glass: bool
|
||||
phase: int
|
||||
summary: str
|
||||
# Opt-in switch for an action whose execution path is genuinely wired
|
||||
# ahead of its phase becoming globally active (#643). Naming a variable
|
||||
# here enables nothing on its own: the variable must also be set in the
|
||||
# environment. An action that leaves this ``None`` can only execute once
|
||||
# ACTIVE_PHASE reaches its phase, exactly as before.
|
||||
execution_env_flag: str | None = None
|
||||
|
||||
@property
|
||||
def mcp_permission(self) -> str:
|
||||
@@ -277,6 +283,27 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
|
||||
phase=2,
|
||||
summary="Restart one MCP namespace via the host supervisor.",
|
||||
),
|
||||
# #643: submit a work request — desired role, issue/PR, intent — and let
|
||||
# the allocator reserve it. This is the one Phase 2 action whose execution
|
||||
# path is actually implemented (``webui.request_service``), so it carries
|
||||
# the opt-in flag; it stays denied until an operator sets that variable.
|
||||
# Authority is operator-class because the outcome is a claim, not a Gitea
|
||||
# verdict: initiating reviewer or merger *work* does not grant the right
|
||||
# to approve or merge, which stays with the MCP role profile.
|
||||
ConsoleAction(
|
||||
action_id="initiate_workflow",
|
||||
task_key="allocate_next_work",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=2,
|
||||
summary=(
|
||||
"Preview and initiate allocator-owned workflow work for a role."
|
||||
),
|
||||
execution_env_flag="WEBUI_REQUESTS_EXECUTION",
|
||||
),
|
||||
)
|
||||
|
||||
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
||||
@@ -430,6 +457,33 @@ ALLOW_PREVIEW = "allowed_preview_only"
|
||||
# gated on this model landing; nothing here enables it.
|
||||
ACTIVE_PHASE = 1
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def execution_wired(
|
||||
action: ConsoleAction | None, env: dict[str, str] | None = None
|
||||
) -> bool:
|
||||
"""Whether *action* has a live execution path right now.
|
||||
|
||||
Two ways to be wired, and only two. The action's phase is active, or the
|
||||
action declares an opt-in environment variable *and* that variable is set.
|
||||
Everything else — including every action that never declares a flag — is
|
||||
unwired, so the default across the registry stays deny.
|
||||
|
||||
Bumping ``ACTIVE_PHASE`` would enable execution for every action of that
|
||||
phase at once. The per-action flag exists so a single implemented action
|
||||
can go live without dragging its unimplemented phase-mates with it.
|
||||
"""
|
||||
if action is None:
|
||||
return False
|
||||
if action.phase <= ACTIVE_PHASE:
|
||||
return True
|
||||
flag = (action.execution_env_flag or "").strip()
|
||||
if not flag:
|
||||
return False
|
||||
source = env if env is not None else os.environ
|
||||
return (source.get(flag) or "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationDecision:
|
||||
@@ -469,16 +523,19 @@ def authorize(
|
||||
principal: Principal | None = None,
|
||||
*,
|
||||
for_execution: bool = False,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> AuthorizationDecision:
|
||||
"""Decide whether *principal* may invoke *action_id*. Deny by default.
|
||||
|
||||
``for_execution`` distinguishes a read-only preview from a real invocation.
|
||||
Even an allowed decision reports ``execution_enabled=False`` while the
|
||||
console is in Phase 1, so no caller can read an allow as permission to
|
||||
mutate.
|
||||
``execution_enabled`` reports whether the action has a live execution path
|
||||
at all (:func:`execution_wired`) — for every action without an explicit
|
||||
opt-in flag that stays ``False`` while the console is in Phase 1, so no
|
||||
caller can read an allow as permission to mutate.
|
||||
"""
|
||||
who = principal if principal is not None else ANONYMOUS
|
||||
action = get_action(action_id)
|
||||
wired = execution_wired(action, env)
|
||||
|
||||
if action is None:
|
||||
return AuthorizationDecision(
|
||||
@@ -497,7 +554,7 @@ def authorize(
|
||||
"requires_confirmation": action.requires_confirmation,
|
||||
"dual_control": action.dual_control,
|
||||
"break_glass": action.break_glass,
|
||||
"execution_enabled": False,
|
||||
"execution_enabled": wired,
|
||||
}
|
||||
|
||||
if not who.authenticated:
|
||||
@@ -530,13 +587,19 @@ def authorize(
|
||||
**base,
|
||||
)
|
||||
|
||||
if for_execution and action.phase > ACTIVE_PHASE:
|
||||
if for_execution and not wired:
|
||||
return AuthorizationDecision(
|
||||
allowed=False,
|
||||
reason_code=DENY_PHASE_NOT_ACTIVE,
|
||||
detail=(
|
||||
f"Action {action_id!r} belongs to phase {action.phase}; the "
|
||||
f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
|
||||
f"console is in phase {ACTIVE_PHASE}"
|
||||
+ (
|
||||
f" and {action.execution_env_flag} is not set"
|
||||
if action.execution_env_flag
|
||||
else ""
|
||||
)
|
||||
+ ". Execution is not wired."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
@@ -545,8 +608,8 @@ def authorize(
|
||||
allowed=True,
|
||||
reason_code=ALLOW_PREVIEW,
|
||||
detail=(
|
||||
"Principal holds the required role. Preview only — execution "
|
||||
"remains disabled until the Phase 2 action framework ships."
|
||||
"Principal holds the required role. Execution proceeds only for an "
|
||||
"action with a wired execution path; everything else is preview."
|
||||
),
|
||||
**base,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
||||
NavItem("/queue", "Queue"),
|
||||
NavItem("/leases", "Leases"),
|
||||
NavItem("/actions", "Actions"),
|
||||
NavItem("/requests", "Requests"),
|
||||
)),
|
||||
NavGroup("Runtime/Sessions", (
|
||||
NavItem("/runtime", "Runtime health"),
|
||||
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Operator work-request preview and initiation (#643, Phase 2).
|
||||
|
||||
An operator's alternative to pasting a role prompt into a terminal. A
|
||||
*request* names three things — the role to run as, the issue or PR to run
|
||||
against, and what the operator intends — and this module answers two questions
|
||||
about it:
|
||||
|
||||
* **Preview** (:func:`preview_request`) — would that request be authorized,
|
||||
is the work unit actually free, is it the next safe thing that role should
|
||||
touch, and which actions stay prohibited? Read-only, always. It creates no
|
||||
assignment and never mutates.
|
||||
* **Initiate** (:func:`apply_request`) — turn an authorized request into an
|
||||
*exclusive assignment*, and only ever through the allocator.
|
||||
|
||||
Three invariants hold and are the reason this module exists rather than a
|
||||
direct call to :func:`allocator_service.allocate_next_work` from a route:
|
||||
|
||||
1. **The allocator remains the only source of exclusive ownership** (#600 /
|
||||
#613). ``apply`` never assigns the requested item directly. It runs a
|
||||
dry-run first and proceeds only when the allocator would independently pick
|
||||
that exact item; otherwise it reports ``wait`` and mutates nothing. A
|
||||
request is therefore a *confirmation* of the allocator's decision, never an
|
||||
override of it.
|
||||
2. **Duplicate assignment is rejected before it is attempted.** An active
|
||||
claim on the work unit — held by any session, this one included — blocks.
|
||||
3. **Fail closed at every unknown.** An unparseable request, an unavailable
|
||||
control-plane DB, an incomplete queue inventory, or an unresolved
|
||||
authorization all deny. There is no branch that proceeds on missing
|
||||
evidence.
|
||||
|
||||
Authorization comes from :mod:`webui.console_authz` (``initiate_workflow``)
|
||||
and every outcome is audited through :mod:`webui.console_audit`, correlated to
|
||||
the resulting assignment by ``correlation_id``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
import allocator_service
|
||||
from task_capability_map import required_permission, required_role
|
||||
from webui import console_audit, console_authz
|
||||
|
||||
# The console action this module is gated by. Registered in console_authz.
|
||||
ACTION_ID = "initiate_workflow"
|
||||
|
||||
KIND_ISSUE = "issue"
|
||||
KIND_PR = "pr"
|
||||
WORK_KINDS: tuple[str, ...] = (KIND_ISSUE, KIND_PR)
|
||||
|
||||
REQUESTABLE_ROLES: tuple[str, ...] = (
|
||||
allocator_service.ROLE_AUTHOR,
|
||||
allocator_service.ROLE_REVIEWER,
|
||||
allocator_service.ROLE_MERGER,
|
||||
allocator_service.ROLE_RECONCILER,
|
||||
allocator_service.ROLE_CONTROLLER,
|
||||
)
|
||||
|
||||
# Intent is operator prose echoed back into an audit record. Bounded so a
|
||||
# pasted transcript cannot bloat the append-only log.
|
||||
MAX_INTENT_CHARS = 500
|
||||
|
||||
# --- Outcomes ---------------------------------------------------------------
|
||||
OUTCOME_ASSIGNED = allocator_service.OUTCOME_ASSIGNED
|
||||
OUTCOME_WAIT = allocator_service.OUTCOME_WAIT
|
||||
OUTCOME_BLOCKED = "blocked"
|
||||
OUTCOME_DENIED = "denied"
|
||||
OUTCOME_INVALID = "invalid_request"
|
||||
OUTCOME_PREVIEW = allocator_service.OUTCOME_PREVIEW
|
||||
|
||||
# --- Reason codes -----------------------------------------------------------
|
||||
REASON_AUTHORIZED = "request_authorized"
|
||||
REASON_PREVIEW_OK = "preview_authorized"
|
||||
REASON_UNAUTHORIZED = "unauthorized"
|
||||
REASON_NOT_NEXT_SAFE = "not_next_safe_work"
|
||||
REASON_DUPLICATE_ASSIGNMENT = "duplicate_assignment"
|
||||
REASON_CONFIRMATION_REQUIRED = "confirmation_required"
|
||||
REASON_EVIDENCE_UNAVAILABLE = "evidence_unavailable"
|
||||
REASON_ALLOCATOR_OUTCOME = "allocator_declined"
|
||||
|
||||
# --- Check names ------------------------------------------------------------
|
||||
CHECK_AUTHORIZATION = "authorization"
|
||||
CHECK_CAPABILITY = "capability"
|
||||
CHECK_LEASE_AVAILABILITY = "lease_availability"
|
||||
CHECK_NEXT_SAFE_ACTION = "next_safe_action"
|
||||
CHECK_HEAD_PIN = "head_pin"
|
||||
|
||||
|
||||
# --- Request model ----------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkRequest:
|
||||
"""One operator request: a role, a work unit, and a stated intent."""
|
||||
|
||||
desired_role: str
|
||||
work_kind: str
|
||||
work_number: int
|
||||
intent_summary: str
|
||||
remote: str
|
||||
org: str
|
||||
repo: str
|
||||
expected_head_sha: str | None = None
|
||||
|
||||
@property
|
||||
def work_key(self) -> tuple[str, int]:
|
||||
return (self.work_kind, self.work_number)
|
||||
|
||||
@property
|
||||
def display_ref(self) -> str:
|
||||
return f"#{self.work_number}"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"desired_role": self.desired_role,
|
||||
"work_kind": self.work_kind,
|
||||
"work_number": self.work_number,
|
||||
"intent_summary": self.intent_summary,
|
||||
"remote": self.remote,
|
||||
"org": self.org,
|
||||
"repo": self.repo,
|
||||
"expected_head_sha": self.expected_head_sha,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestError:
|
||||
"""A rejected request, with the field that caused the rejection."""
|
||||
|
||||
reason_code: str
|
||||
detail: str
|
||||
field_name: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"outcome": OUTCOME_INVALID,
|
||||
"reason_code": self.reason_code,
|
||||
"detail": self.detail,
|
||||
"field": self.field_name,
|
||||
}
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def parse_request(
|
||||
payload: Mapping[str, Any] | None,
|
||||
*,
|
||||
default_scope: Mapping[str, str] | None = None,
|
||||
) -> tuple[WorkRequest | None, RequestError | None]:
|
||||
"""Validate an operator payload into a :class:`WorkRequest`.
|
||||
|
||||
Returns ``(request, None)`` or ``(None, error)``. Never raises and never
|
||||
guesses: an unknown role, an unknown work kind, or a non-positive number is
|
||||
an error rather than a silently corrected value.
|
||||
"""
|
||||
body = dict(payload or {})
|
||||
scope = dict(default_scope or {})
|
||||
|
||||
role = _clean(body.get("desired_role") or body.get("role")).lower()
|
||||
if role not in REQUESTABLE_ROLES:
|
||||
return None, RequestError(
|
||||
reason_code="unknown_role",
|
||||
detail=(
|
||||
f"desired_role must be one of {', '.join(REQUESTABLE_ROLES)}; "
|
||||
f"got {role or '(empty)'!r}."
|
||||
),
|
||||
field_name="desired_role",
|
||||
)
|
||||
|
||||
kind = _clean(body.get("work_kind") or body.get("kind")).lower()
|
||||
if kind not in WORK_KINDS:
|
||||
return None, RequestError(
|
||||
reason_code="unknown_work_kind",
|
||||
detail=(
|
||||
f"work_kind must be 'issue' or 'pr'; got {kind or '(empty)'!r}."
|
||||
),
|
||||
field_name="work_kind",
|
||||
)
|
||||
|
||||
raw_number = body.get("work_number")
|
||||
if raw_number is None:
|
||||
raw_number = (
|
||||
body.get("pr_number") if kind == KIND_PR else body.get("issue_number")
|
||||
)
|
||||
if raw_number is None:
|
||||
raw_number = body.get("number")
|
||||
try:
|
||||
number = int(str(raw_number).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None, RequestError(
|
||||
reason_code="invalid_work_number",
|
||||
detail=f"work_number must be an integer; got {raw_number!r}.",
|
||||
field_name="work_number",
|
||||
)
|
||||
if number <= 0:
|
||||
return None, RequestError(
|
||||
reason_code="invalid_work_number",
|
||||
detail="work_number must be a positive issue or PR number.",
|
||||
field_name="work_number",
|
||||
)
|
||||
|
||||
intent = _clean(body.get("intent_summary") or body.get("intent"))
|
||||
if not intent:
|
||||
return None, RequestError(
|
||||
reason_code="missing_intent",
|
||||
detail="intent_summary is required so the audit record states why.",
|
||||
field_name="intent_summary",
|
||||
)
|
||||
intent = intent[:MAX_INTENT_CHARS]
|
||||
|
||||
remote = _clean(body.get("remote")) or _clean(scope.get("remote"))
|
||||
org = _clean(body.get("org")) or _clean(scope.get("org"))
|
||||
repo = _clean(body.get("repo")) or _clean(scope.get("repo"))
|
||||
if not (remote and org and repo):
|
||||
return None, RequestError(
|
||||
reason_code="scope_unresolved",
|
||||
detail=(
|
||||
"remote, org, and repo could not be resolved from the request "
|
||||
"or the project registry."
|
||||
),
|
||||
field_name="repo",
|
||||
)
|
||||
|
||||
head = _clean(body.get("expected_head_sha")) or None
|
||||
|
||||
return (
|
||||
WorkRequest(
|
||||
desired_role=role,
|
||||
work_kind=kind,
|
||||
work_number=number,
|
||||
intent_summary=intent,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
expected_head_sha=head,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# --- Preview ----------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestCheck:
|
||||
"""One named precondition and its verdict."""
|
||||
|
||||
name: str
|
||||
ok: bool
|
||||
reason_code: str
|
||||
detail: str
|
||||
evidence: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"ok": self.ok,
|
||||
"reason_code": self.reason_code,
|
||||
"detail": self.detail,
|
||||
"evidence": dict(self.evidence),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestPreview:
|
||||
"""The full intent preview for one request. Read-only in every field."""
|
||||
|
||||
request: WorkRequest
|
||||
authorized: bool
|
||||
reason_code: str
|
||||
detail: str
|
||||
authorization: dict[str, Any]
|
||||
checks: tuple[RequestCheck, ...]
|
||||
prohibited_actions: tuple[str, ...]
|
||||
allowed_actions: tuple[str, ...]
|
||||
next_safe_action: str
|
||||
required_profile: str
|
||||
required_namespace: str
|
||||
required_permission: str
|
||||
correlation_id: str
|
||||
allocator_evidence: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def failed_checks(self) -> tuple[RequestCheck, ...]:
|
||||
return tuple(c for c in self.checks if not c.ok)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": self.authorized,
|
||||
"outcome": OUTCOME_PREVIEW,
|
||||
"dry_run": True,
|
||||
"mutation_performed": False,
|
||||
"authorized": self.authorized,
|
||||
"reason_code": self.reason_code,
|
||||
"detail": self.detail,
|
||||
"request": self.request.to_dict(),
|
||||
"authorization": dict(self.authorization),
|
||||
"checks": [c.to_dict() for c in self.checks],
|
||||
"failed_checks": [c.name for c in self.failed_checks],
|
||||
"prohibited_actions": list(self.prohibited_actions),
|
||||
"allowed_actions": list(self.allowed_actions),
|
||||
"next_safe_action": self.next_safe_action,
|
||||
"required_profile": self.required_profile,
|
||||
"required_namespace": self.required_namespace,
|
||||
"required_permission": self.required_permission,
|
||||
"correlation_id": self.correlation_id,
|
||||
"allocator_evidence": dict(self.allocator_evidence),
|
||||
}
|
||||
|
||||
|
||||
AllocatorFn = Callable[..., dict[str, Any] | None]
|
||||
ClaimsFn = Callable[["WorkRequest"], Mapping[tuple[str, int], dict[str, Any]]]
|
||||
|
||||
|
||||
def _correlation_id() -> str:
|
||||
return f"req-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _selection_matches(
|
||||
selection: Mapping[str, Any] | None, request: WorkRequest
|
||||
) -> bool:
|
||||
if not selection:
|
||||
return False
|
||||
kind = _clean(selection.get("kind")).lower()
|
||||
try:
|
||||
number_int = int(selection.get("number"))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return (kind, number_int) == request.work_key
|
||||
|
||||
|
||||
def _authorization_check(
|
||||
decision: console_authz.AuthorizationDecision,
|
||||
) -> RequestCheck:
|
||||
return RequestCheck(
|
||||
name=CHECK_AUTHORIZATION,
|
||||
ok=bool(decision.allowed),
|
||||
reason_code=decision.reason_code,
|
||||
detail=decision.detail,
|
||||
evidence={
|
||||
"subject": decision.principal.subject,
|
||||
"role": decision.principal.role,
|
||||
"required_role": decision.required_role,
|
||||
"identity_source": decision.principal.identity_source,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _capability_check(request: WorkRequest) -> RequestCheck:
|
||||
"""Whether the requested role maps to a declared MCP capability.
|
||||
|
||||
The console never invents an authority: the permission and role come from
|
||||
``task_capability_map`` via the same ``allocate_next_work`` task the MCP
|
||||
allocator gates on.
|
||||
"""
|
||||
# The remote-prefixed hint keeps a dadeschools request from being told to
|
||||
# run under a prgs profile; ``required_profile_for_role`` preserves the
|
||||
# prefix when one is present and falls back to its own default otherwise.
|
||||
profile_hint = f"{request.remote}-{request.desired_role}"
|
||||
try:
|
||||
profile = allocator_service.required_profile_for_role(
|
||||
request.desired_role, profile_name=profile_hint
|
||||
)
|
||||
namespace = allocator_service.required_namespace_for_role(
|
||||
request.desired_role, profile_name=profile_hint
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — an unresolved role is a denial
|
||||
return RequestCheck(
|
||||
name=CHECK_CAPABILITY,
|
||||
ok=False,
|
||||
reason_code="capability_unresolved",
|
||||
detail=(
|
||||
f"no profile/namespace maps to role {request.desired_role!r}: "
|
||||
f"{exc}"
|
||||
),
|
||||
)
|
||||
resolved = bool(profile and namespace)
|
||||
return RequestCheck(
|
||||
name=CHECK_CAPABILITY,
|
||||
ok=resolved,
|
||||
reason_code="capability_resolved" if resolved else "capability_unresolved",
|
||||
detail=(
|
||||
f"role {request.desired_role!r} runs under profile {profile!r} in "
|
||||
f"MCP namespace {namespace!r}."
|
||||
),
|
||||
evidence={
|
||||
"required_profile": profile,
|
||||
"required_namespace": namespace,
|
||||
"required_permission": required_permission("allocate_next_work"),
|
||||
"capability_role": required_role("allocate_next_work"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _lease_check(
|
||||
request: WorkRequest,
|
||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None,
|
||||
) -> RequestCheck:
|
||||
"""Whether the work unit is free of an active claim.
|
||||
|
||||
``claims is None`` means the control-plane DB could not be read. That is a
|
||||
failure, not an absence of claims: an unreadable substrate must never read
|
||||
as "nothing holds this".
|
||||
"""
|
||||
if claims is None:
|
||||
return RequestCheck(
|
||||
name=CHECK_LEASE_AVAILABILITY,
|
||||
ok=False,
|
||||
reason_code=REASON_EVIDENCE_UNAVAILABLE,
|
||||
detail=(
|
||||
"active-claim inventory is unavailable; refusing to treat an "
|
||||
"unreadable control-plane DB as an unclaimed work unit."
|
||||
),
|
||||
)
|
||||
claim = claims.get(request.work_key)
|
||||
if claim:
|
||||
return RequestCheck(
|
||||
name=CHECK_LEASE_AVAILABILITY,
|
||||
ok=False,
|
||||
reason_code=REASON_DUPLICATE_ASSIGNMENT,
|
||||
detail=(
|
||||
f"{request.work_kind} {request.display_ref} already carries an "
|
||||
f"active {claim.get('role') or 'unknown'} lease."
|
||||
),
|
||||
evidence={
|
||||
"lease_id": claim.get("lease_id"),
|
||||
"session_id": claim.get("session_id"),
|
||||
"role": claim.get("role"),
|
||||
"expires_at": claim.get("expires_at"),
|
||||
},
|
||||
)
|
||||
return RequestCheck(
|
||||
name=CHECK_LEASE_AVAILABILITY,
|
||||
ok=True,
|
||||
reason_code="lease_available",
|
||||
detail=f"no active lease holds {request.work_kind} {request.display_ref}.",
|
||||
)
|
||||
|
||||
|
||||
def _next_safe_action_check(
|
||||
request: WorkRequest, allocation: Mapping[str, Any] | None
|
||||
) -> RequestCheck:
|
||||
"""Whether the allocator would independently select this exact work unit."""
|
||||
if not allocation:
|
||||
return RequestCheck(
|
||||
name=CHECK_NEXT_SAFE_ACTION,
|
||||
ok=False,
|
||||
reason_code=REASON_EVIDENCE_UNAVAILABLE,
|
||||
detail="allocator dry-run produced no result; refusing to proceed.",
|
||||
)
|
||||
selection = allocation.get("selected") or {}
|
||||
outcome = _clean(allocation.get("outcome"))
|
||||
if not _selection_matches(selection, request):
|
||||
chosen = (
|
||||
f"{_clean(selection.get('kind')) or 'unknown'} #{selection.get('number')}"
|
||||
if selection
|
||||
else "nothing"
|
||||
)
|
||||
return RequestCheck(
|
||||
name=CHECK_NEXT_SAFE_ACTION,
|
||||
ok=False,
|
||||
reason_code=REASON_NOT_NEXT_SAFE,
|
||||
detail=(
|
||||
f"the allocator would select {chosen} for role "
|
||||
f"{request.desired_role!r}, not {request.work_kind} "
|
||||
f"{request.display_ref}. Requests confirm the allocator's "
|
||||
"decision; they never override it."
|
||||
),
|
||||
evidence={
|
||||
"allocator_outcome": outcome,
|
||||
"allocator_selection": dict(selection),
|
||||
"reasons": list(allocation.get("reasons") or ()),
|
||||
},
|
||||
)
|
||||
return RequestCheck(
|
||||
name=CHECK_NEXT_SAFE_ACTION,
|
||||
ok=True,
|
||||
reason_code="next_safe_work",
|
||||
detail=(
|
||||
f"the allocator selects {request.work_kind} {request.display_ref} "
|
||||
f"for role {request.desired_role!r}."
|
||||
),
|
||||
evidence={
|
||||
"allocator_outcome": outcome,
|
||||
"selected_action": _clean(selection.get("selected_action")),
|
||||
"expected_role_next": _clean(selection.get("expected_role_next")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _head_pin_check(
|
||||
request: WorkRequest, allocation: Mapping[str, Any] | None
|
||||
) -> RequestCheck:
|
||||
"""PR work must be pinned to a head SHA; issue work has nothing to pin."""
|
||||
if request.work_kind != KIND_PR:
|
||||
return RequestCheck(
|
||||
name=CHECK_HEAD_PIN,
|
||||
ok=True,
|
||||
reason_code="head_pin_not_applicable",
|
||||
detail="issue work carries no head SHA to pin.",
|
||||
)
|
||||
selection = (allocation or {}).get("selected") or {}
|
||||
allocator_head = _clean(selection.get("head_sha")) or None
|
||||
if not allocator_head:
|
||||
return RequestCheck(
|
||||
name=CHECK_HEAD_PIN,
|
||||
ok=False,
|
||||
reason_code=REASON_EVIDENCE_UNAVAILABLE,
|
||||
detail=(
|
||||
"the allocator reported no head SHA for this PR; PR work "
|
||||
"cannot be initiated unpinned."
|
||||
),
|
||||
)
|
||||
if request.expected_head_sha and request.expected_head_sha != allocator_head:
|
||||
return RequestCheck(
|
||||
name=CHECK_HEAD_PIN,
|
||||
ok=False,
|
||||
reason_code="head_moved",
|
||||
detail=(
|
||||
"the requested head SHA does not match the PR's current head; "
|
||||
"re-preview against the live head before initiating."
|
||||
),
|
||||
evidence={
|
||||
"requested_head_sha": request.expected_head_sha,
|
||||
"current_head_sha": allocator_head,
|
||||
},
|
||||
)
|
||||
return RequestCheck(
|
||||
name=CHECK_HEAD_PIN,
|
||||
ok=True,
|
||||
reason_code="head_pinned",
|
||||
detail=f"PR {request.display_ref} is pinned at {allocator_head}.",
|
||||
evidence={"head_sha": allocator_head},
|
||||
)
|
||||
|
||||
|
||||
def _next_safe_action_text(
|
||||
request: WorkRequest, checks: Sequence[RequestCheck], authorized: bool
|
||||
) -> str:
|
||||
if authorized:
|
||||
return (
|
||||
f"Confirm and initiate {request.desired_role} work on "
|
||||
f"{request.work_kind} {request.display_ref} via the allocator."
|
||||
)
|
||||
for check in checks:
|
||||
if not check.ok:
|
||||
return f"Resolve {check.name}: {check.detail}"
|
||||
return "No safe action; the request is not authorized."
|
||||
|
||||
|
||||
def preview_request(
|
||||
request: WorkRequest,
|
||||
*,
|
||||
principal: console_authz.Principal | None = None,
|
||||
allocator: AllocatorFn | None = None,
|
||||
claims_source: ClaimsFn | None = None,
|
||||
correlation_id: str | None = None,
|
||||
audit: bool = True,
|
||||
) -> RequestPreview:
|
||||
"""Build the read-only intent preview for *request*. Never mutates."""
|
||||
who = principal or console_authz.ANONYMOUS
|
||||
corr = correlation_id or _correlation_id()
|
||||
decision = console_authz.authorize(ACTION_ID, who, for_execution=False)
|
||||
|
||||
allocation: dict[str, Any] | None = None
|
||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None
|
||||
checks: list[RequestCheck] = [_authorization_check(decision)]
|
||||
if decision.allowed:
|
||||
# An unauthorized principal never reaches the allocator or the
|
||||
# control-plane DB: a denial must not double as a queue oracle.
|
||||
allocation = _run_allocator(request, allocator, apply=False)
|
||||
claims = _load_claims(request, claims_source)
|
||||
checks.append(_capability_check(request))
|
||||
checks.append(_lease_check(request, claims))
|
||||
checks.append(_next_safe_action_check(request, allocation))
|
||||
checks.append(_head_pin_check(request, allocation))
|
||||
|
||||
authorized = all(c.ok for c in checks)
|
||||
allowed_actions, prohibited_actions = allocator_service.role_actions(
|
||||
request.desired_role
|
||||
)
|
||||
capability = next((c for c in checks if c.name == CHECK_CAPABILITY), None)
|
||||
evidence = capability.evidence if capability else {}
|
||||
|
||||
if authorized:
|
||||
reason_code = REASON_PREVIEW_OK
|
||||
detail = (
|
||||
"Request is authorized. Preview only — nothing has been assigned."
|
||||
)
|
||||
else:
|
||||
first_failure = next(c for c in checks if not c.ok)
|
||||
reason_code, detail = first_failure.reason_code, first_failure.detail
|
||||
|
||||
preview = RequestPreview(
|
||||
request=request,
|
||||
authorized=authorized,
|
||||
reason_code=reason_code,
|
||||
detail=detail,
|
||||
authorization=decision.to_dict(),
|
||||
checks=tuple(checks),
|
||||
prohibited_actions=tuple(prohibited_actions),
|
||||
allowed_actions=tuple(allowed_actions),
|
||||
next_safe_action=_next_safe_action_text(request, checks, authorized),
|
||||
required_profile=str(evidence.get("required_profile") or ""),
|
||||
required_namespace=str(evidence.get("required_namespace") or ""),
|
||||
required_permission=str(evidence.get("required_permission") or ""),
|
||||
correlation_id=corr,
|
||||
allocator_evidence=_allocator_evidence(allocation),
|
||||
)
|
||||
|
||||
if audit:
|
||||
_audit(
|
||||
request,
|
||||
result=console_audit.RESULT_PREVIEWED,
|
||||
decision=decision,
|
||||
principal=who,
|
||||
reason_code=reason_code,
|
||||
detail=detail,
|
||||
correlation_id=corr,
|
||||
metadata={
|
||||
"intent_summary": request.intent_summary,
|
||||
"desired_role": request.desired_role,
|
||||
"authorized": authorized,
|
||||
"failed_checks": [c.name for c in preview.failed_checks],
|
||||
"phase": "preview",
|
||||
},
|
||||
)
|
||||
return preview
|
||||
|
||||
|
||||
# --- Initiation -------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_request(
|
||||
request: WorkRequest,
|
||||
*,
|
||||
principal: console_authz.Principal | None = None,
|
||||
confirm: bool = False,
|
||||
allocator: AllocatorFn | None = None,
|
||||
claims_source: ClaimsFn | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Initiate *request* as an exclusive assignment, or refuse.
|
||||
|
||||
The only path to an assignment is the allocator agreeing, on a dry-run,
|
||||
that this work unit is what the requested role should take next. Every
|
||||
refusal returns before any mutation is attempted.
|
||||
"""
|
||||
who = principal or console_authz.ANONYMOUS
|
||||
corr = correlation_id or _correlation_id()
|
||||
|
||||
execution_decision = console_authz.authorize(ACTION_ID, who, for_execution=True)
|
||||
authorization = execution_decision.to_dict()
|
||||
|
||||
def _refuse(
|
||||
outcome: str,
|
||||
reason_code: str,
|
||||
detail: str,
|
||||
*,
|
||||
status: int,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
_audit(
|
||||
request,
|
||||
result=console_audit.RESULT_DENIED,
|
||||
decision=execution_decision,
|
||||
principal=who,
|
||||
reason_code=reason_code,
|
||||
detail=detail,
|
||||
correlation_id=corr,
|
||||
metadata={
|
||||
"intent_summary": request.intent_summary,
|
||||
"desired_role": request.desired_role,
|
||||
"phase": "apply",
|
||||
"outcome": outcome,
|
||||
},
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"outcome": outcome,
|
||||
"reason_code": reason_code,
|
||||
"detail": detail,
|
||||
"request": request.to_dict(),
|
||||
"authorization": authorization,
|
||||
"assignment": None,
|
||||
"correlation_id": corr,
|
||||
"mutation_performed": False,
|
||||
"status_code": status,
|
||||
}
|
||||
payload.update(extra or {})
|
||||
return payload
|
||||
|
||||
if not (execution_decision.allowed and execution_decision.execution_enabled):
|
||||
return _refuse(
|
||||
OUTCOME_DENIED,
|
||||
REASON_UNAUTHORIZED,
|
||||
execution_decision.detail,
|
||||
status=403,
|
||||
)
|
||||
|
||||
# Confirmation is a property of the action in the RBAC model, so it is read
|
||||
# from there rather than assumed here.
|
||||
action = console_authz.get_action(ACTION_ID)
|
||||
if action is not None and action.requires_confirmation and not confirm:
|
||||
return _refuse(
|
||||
OUTCOME_DENIED,
|
||||
REASON_CONFIRMATION_REQUIRED,
|
||||
(
|
||||
"This action requires explicit confirmation. Re-submit with "
|
||||
"confirm=true after reviewing the preview."
|
||||
),
|
||||
status=409,
|
||||
)
|
||||
|
||||
preview = preview_request(
|
||||
request,
|
||||
principal=who,
|
||||
allocator=allocator,
|
||||
claims_source=claims_source,
|
||||
correlation_id=corr,
|
||||
audit=False,
|
||||
)
|
||||
if not preview.authorized:
|
||||
outcome = (
|
||||
OUTCOME_BLOCKED
|
||||
if preview.reason_code == REASON_DUPLICATE_ASSIGNMENT
|
||||
else OUTCOME_WAIT
|
||||
)
|
||||
return _refuse(
|
||||
outcome,
|
||||
preview.reason_code,
|
||||
preview.detail,
|
||||
status=409,
|
||||
extra={"preview": preview.to_dict()},
|
||||
)
|
||||
|
||||
fingerprint = (
|
||||
_clean(preview.allocator_evidence.get("candidate_set_fingerprint")) or None
|
||||
)
|
||||
allocation = _run_allocator(
|
||||
request,
|
||||
allocator,
|
||||
apply=True,
|
||||
expected_candidate_set_fingerprint=fingerprint,
|
||||
)
|
||||
if not allocation:
|
||||
return _refuse(
|
||||
OUTCOME_WAIT,
|
||||
REASON_EVIDENCE_UNAVAILABLE,
|
||||
"the allocator returned no result; nothing was assigned.",
|
||||
status=503,
|
||||
)
|
||||
|
||||
assignment = allocation.get("assignment") or None
|
||||
outcome = _clean(allocation.get("outcome"))
|
||||
assigned = bool(
|
||||
outcome == allocator_service.OUTCOME_ASSIGNED
|
||||
and assignment
|
||||
and _selection_matches(allocation.get("selected"), request)
|
||||
)
|
||||
if not assigned:
|
||||
blocked = outcome in {
|
||||
allocator_service.OUTCOME_BLOCKED_LEASE,
|
||||
allocator_service.OUTCOME_BLOCKED_TERMINAL,
|
||||
allocator_service.OUTCOME_BLOCKED_EXCLUDED_OWN_LEASE,
|
||||
}
|
||||
return _refuse(
|
||||
OUTCOME_BLOCKED if blocked else OUTCOME_WAIT,
|
||||
REASON_ALLOCATOR_OUTCOME,
|
||||
(
|
||||
f"the allocator returned {outcome or 'no outcome'} rather than "
|
||||
"an assignment for this work unit; nothing was assigned."
|
||||
),
|
||||
status=409,
|
||||
extra={"allocator_evidence": _allocator_evidence(allocation)},
|
||||
)
|
||||
|
||||
_audit(
|
||||
request,
|
||||
result=console_audit.RESULT_SUCCEEDED,
|
||||
decision=execution_decision,
|
||||
principal=who,
|
||||
reason_code=REASON_AUTHORIZED,
|
||||
detail=(
|
||||
f"assigned {request.work_kind} {request.display_ref} to role "
|
||||
f"{request.desired_role}."
|
||||
),
|
||||
correlation_id=corr,
|
||||
metadata={
|
||||
"intent_summary": request.intent_summary,
|
||||
"desired_role": request.desired_role,
|
||||
"phase": "apply",
|
||||
"outcome": OUTCOME_ASSIGNED,
|
||||
"assignment_id": assignment.get("assignment_id"),
|
||||
"lease_id": assignment.get("lease_id"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"outcome": OUTCOME_ASSIGNED,
|
||||
"reason_code": REASON_AUTHORIZED,
|
||||
"detail": (
|
||||
"Exclusive assignment created via the allocator. Continue in the "
|
||||
f"{preview.required_namespace or 'assigned'} MCP namespace."
|
||||
),
|
||||
"request": request.to_dict(),
|
||||
"authorization": authorization,
|
||||
"assignment": dict(assignment),
|
||||
"handoff": {
|
||||
"assignment_id": assignment.get("assignment_id"),
|
||||
"lease_id": assignment.get("lease_id"),
|
||||
"session_id": assignment.get("session_id"),
|
||||
"required_profile": preview.required_profile,
|
||||
"required_namespace": preview.required_namespace,
|
||||
"allowed_actions": list(preview.allowed_actions),
|
||||
"forbidden_actions": list(preview.prohibited_actions),
|
||||
"expected_head_sha": assignment.get("expected_head_sha"),
|
||||
},
|
||||
"correlation_id": corr,
|
||||
"mutation_performed": True,
|
||||
"status_code": 201,
|
||||
"allocator_evidence": _allocator_evidence(allocation),
|
||||
}
|
||||
|
||||
|
||||
# --- Adapters ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _allocator_evidence(allocation: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""Reduce an allocator result to the non-secret fields worth surfacing."""
|
||||
if not allocation:
|
||||
return {}
|
||||
return {
|
||||
"outcome": allocation.get("outcome"),
|
||||
"selected": allocation.get("selected"),
|
||||
"reasons": list(allocation.get("reasons") or ()),
|
||||
"candidate_set_fingerprint": allocation.get("candidate_set_fingerprint"),
|
||||
"candidate_count": allocation.get("candidate_count"),
|
||||
"inventory_complete": allocation.get("inventory_complete"),
|
||||
"selection_policy": allocation.get("selection_policy"),
|
||||
"substrate": allocation.get("substrate"),
|
||||
}
|
||||
|
||||
|
||||
def _run_allocator(
|
||||
request: WorkRequest,
|
||||
allocator: AllocatorFn | None,
|
||||
*,
|
||||
apply: bool,
|
||||
expected_candidate_set_fingerprint: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
fn = allocator or default_allocator
|
||||
try:
|
||||
result = fn(
|
||||
request=request,
|
||||
apply=apply,
|
||||
expected_candidate_set_fingerprint=expected_candidate_set_fingerprint,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — an allocator failure denies, never proceeds
|
||||
return None
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
|
||||
def _load_claims(
|
||||
request: WorkRequest, claims_source: ClaimsFn | None
|
||||
) -> Mapping[tuple[str, int], dict[str, Any]] | None:
|
||||
fn = claims_source or default_claims_source
|
||||
try:
|
||||
claims = fn(request)
|
||||
except Exception: # noqa: BLE001 — an unreadable substrate is a denial
|
||||
return None
|
||||
return claims if isinstance(claims, Mapping) else None
|
||||
|
||||
|
||||
def default_claims_source(
|
||||
request: WorkRequest,
|
||||
) -> Mapping[tuple[str, int], dict[str, Any]]:
|
||||
"""Live active-claim inventory from the #613 control-plane DB."""
|
||||
import control_plane_db
|
||||
|
||||
db = control_plane_db.ControlPlaneDB()
|
||||
return db.list_active_claims(
|
||||
remote=request.remote, org=request.org, repo=request.repo
|
||||
)
|
||||
|
||||
|
||||
def default_allocator(
|
||||
*,
|
||||
request: WorkRequest,
|
||||
apply: bool,
|
||||
expected_candidate_set_fingerprint: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Run the real allocator over the live queue for *request*'s scope.
|
||||
|
||||
An incomplete candidate inventory returns ``None`` rather than a ranking
|
||||
over a partial set (#758): selecting from a short list can pick the wrong
|
||||
work unit, so the request denies instead.
|
||||
"""
|
||||
import control_plane_db
|
||||
|
||||
from webui.queue_loader import load_queue_snapshot
|
||||
from webui.traffic_loader import candidates_from_queue_snapshot
|
||||
|
||||
snapshot = load_queue_snapshot()
|
||||
if snapshot.fetch_error:
|
||||
return None
|
||||
for pagination in (snapshot.pr_pagination, snapshot.issue_pagination):
|
||||
if pagination is not None and not pagination.inventory_complete:
|
||||
return None
|
||||
|
||||
candidates = candidates_from_queue_snapshot(snapshot)
|
||||
db = control_plane_db.ControlPlaneDB()
|
||||
result = allocator_service.allocate_next_work(
|
||||
db,
|
||||
session_id=f"webui-request-{uuid.uuid4().hex[:12]}",
|
||||
role=request.desired_role,
|
||||
remote=request.remote,
|
||||
org=request.org,
|
||||
repo=request.repo,
|
||||
candidates=candidates,
|
||||
apply=bool(apply),
|
||||
allocation_mode="role_scoped",
|
||||
expected_candidate_set_fingerprint=expected_candidate_set_fingerprint,
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
result.setdefault("candidate_count", len(candidates))
|
||||
result.setdefault("inventory_complete", True)
|
||||
result.setdefault("selection_policy", allocator_service.SELECTION_POLICY)
|
||||
return result
|
||||
|
||||
|
||||
# --- Audit ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _audit(
|
||||
request: WorkRequest,
|
||||
*,
|
||||
result: str,
|
||||
decision: console_authz.AuthorizationDecision,
|
||||
principal: console_authz.Principal,
|
||||
reason_code: str,
|
||||
detail: str,
|
||||
correlation_id: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return console_audit.record_event(
|
||||
action_id=ACTION_ID,
|
||||
result=result,
|
||||
decision=decision,
|
||||
principal=principal,
|
||||
target={
|
||||
"kind": request.work_kind,
|
||||
"ref": request.display_ref,
|
||||
"remote": request.remote,
|
||||
"org": request.org,
|
||||
"repo": request.repo,
|
||||
},
|
||||
reason_code=reason_code,
|
||||
request_id=correlation_id,
|
||||
detail=detail,
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""HTML views for the operator request surface (#643).
|
||||
|
||||
The form is deliberately a *preview* form. It has no initiate button, because
|
||||
initiating requires a confirmed POST to ``/api/v1/requests/apply`` and a stray
|
||||
form submission must not be able to produce one by accident.
|
||||
|
||||
Nothing rendered here is trusted input: every interpolated value is escaped,
|
||||
and the page renders only values the service already produced rather than
|
||||
echoing a raw request body back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from webui.layout import render_page
|
||||
from webui.request_service import (
|
||||
REQUESTABLE_ROLES,
|
||||
WORK_KINDS,
|
||||
RequestError,
|
||||
RequestPreview,
|
||||
)
|
||||
|
||||
REQUESTS_PATH = "/requests"
|
||||
PREVIEW_API_PATH = "/api/v1/requests/preview"
|
||||
APPLY_API_PATH = "/api/v1/requests/apply"
|
||||
|
||||
|
||||
def _escape(text: Any) -> str:
|
||||
return html.escape(str(text if text is not None else ""), quote=True)
|
||||
|
||||
|
||||
REQUEST_PAGE_STYLES = """
|
||||
<style>
|
||||
.request-form { display: grid; gap: 0.75rem; max-width: 44rem; }
|
||||
.request-form label { display: grid; gap: 0.25rem; font-size: 0.9rem; }
|
||||
.request-check { margin: 0.35rem 0; }
|
||||
.request-check .verdict-ok { color: var(--accent); }
|
||||
.request-check .verdict-fail { color: #d14; }
|
||||
.request-prohibited code { margin-right: 0.4rem; }
|
||||
</style>
|
||||
"""
|
||||
|
||||
|
||||
def _options(values: tuple[str, ...], selected: Any) -> str:
|
||||
return "".join(
|
||||
f"<option value='{_escape(value)}'"
|
||||
+ (" selected" if selected == value else "")
|
||||
+ f">{_escape(value)}</option>"
|
||||
for value in values
|
||||
)
|
||||
|
||||
|
||||
def _form(values: dict[str, Any] | None = None) -> str:
|
||||
current = dict(values or {})
|
||||
number = current.get("work_number")
|
||||
return (
|
||||
f"<form class='request-form' method='post' action='{REQUESTS_PATH}'>"
|
||||
"<label>Desired role<select name='desired_role'>"
|
||||
f"{_options(REQUESTABLE_ROLES, current.get('desired_role'))}"
|
||||
"</select></label>"
|
||||
"<label>Work kind<select name='work_kind'>"
|
||||
f"{_options(WORK_KINDS, current.get('work_kind'))}"
|
||||
"</select></label>"
|
||||
"<label>Issue or PR number"
|
||||
"<input type='number' name='work_number' min='1' required "
|
||||
f"value='{_escape(number) if number else ''}'></label>"
|
||||
"<label>Intent summary"
|
||||
"<input type='text' name='intent_summary' maxlength='500' required "
|
||||
f"value='{_escape(current.get('intent_summary'))}'></label>"
|
||||
"<label>Expected head SHA <span class='muted'>(PR work only)</span>"
|
||||
"<input type='text' name='expected_head_sha' "
|
||||
f"value='{_escape(current.get('expected_head_sha'))}'></label>"
|
||||
"<button type='submit' class='copy-btn'>Preview request</button>"
|
||||
"<p class='muted meta'>Preview is read-only and creates no assignment. "
|
||||
f"Initiating requires a confirmed POST to <code>{APPLY_API_PATH}</code>."
|
||||
"</p>"
|
||||
"</form>"
|
||||
)
|
||||
|
||||
|
||||
def _checks_block(preview: RequestPreview) -> str:
|
||||
rows = []
|
||||
for check in preview.checks:
|
||||
verdict = "PASS" if check.ok else "FAIL"
|
||||
css = "verdict-ok" if check.ok else "verdict-fail"
|
||||
rows.append(
|
||||
"<li class='request-check'>"
|
||||
f"<span class='{css}'><strong>{verdict}</strong></span> "
|
||||
f"<code>{_escape(check.name)}</code> — {_escape(check.detail)} "
|
||||
f"<span class='muted meta'>({_escape(check.reason_code)})</span>"
|
||||
"</li>"
|
||||
)
|
||||
return "<ul>" + "".join(rows) + "</ul>"
|
||||
|
||||
|
||||
def _preview_block(preview: RequestPreview) -> str:
|
||||
verdict = "AUTHORIZED" if preview.authorized else "DENIED"
|
||||
prohibited = "".join(
|
||||
f"<code>{_escape(action)}</code>" for action in preview.prohibited_actions
|
||||
)
|
||||
request = preview.request
|
||||
evidence = json.dumps(preview.allocator_evidence, indent=2, default=str)
|
||||
return (
|
||||
"<h3>Intent preview</h3>"
|
||||
f"<p><strong>{verdict}</strong> — {_escape(preview.detail)}</p>"
|
||||
"<p class='meta'>"
|
||||
f"Role <code>{_escape(request.desired_role)}</code> · "
|
||||
f"{_escape(request.work_kind)} <code>{_escape(request.display_ref)}</code>"
|
||||
f" · profile <code>{_escape(preview.required_profile)}</code> · "
|
||||
f"namespace <code>{_escape(preview.required_namespace)}</code> · "
|
||||
f"permission <code>{_escape(preview.required_permission)}</code>"
|
||||
"</p>"
|
||||
f"<p>Intent: {_escape(request.intent_summary)}</p>"
|
||||
f"{_checks_block(preview)}"
|
||||
f"<p><strong>Next safe action:</strong> "
|
||||
f"{_escape(preview.next_safe_action)}</p>"
|
||||
"<p class='request-prohibited'><strong>Prohibited for this role:</strong> "
|
||||
+ (prohibited or "<span class='muted'>none declared</span>")
|
||||
+ "</p>"
|
||||
"<p class='muted meta'>Correlation id "
|
||||
f"<code>{_escape(preview.correlation_id)}</code></p>"
|
||||
"<details><summary>Allocator evidence</summary>"
|
||||
f"<pre class='prompt-text'>{_escape(evidence)}</pre>"
|
||||
"</details>"
|
||||
)
|
||||
|
||||
|
||||
def _error_block(error: RequestError) -> str:
|
||||
field = (
|
||||
f"<p class='meta'>Field: <code>{_escape(error.field_name)}</code></p>"
|
||||
if error.field_name
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
"<h3>Request rejected</h3>"
|
||||
f"<p><strong>{_escape(error.reason_code)}</strong> — "
|
||||
f"{_escape(error.detail)}</p>{field}"
|
||||
)
|
||||
|
||||
|
||||
def render_requests_page(
|
||||
*,
|
||||
preview: RequestPreview | None = None,
|
||||
error: RequestError | None = None,
|
||||
submitted: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Render the request form, plus a preview or rejection when one exists."""
|
||||
body = (
|
||||
"<h2>Requests</h2>"
|
||||
"<p>Submit a work request — desired role, issue or PR, and intent — "
|
||||
"and see whether it would be authorized before anything is reserved. "
|
||||
"Initiation goes through the allocator (#600/#613); this console never "
|
||||
"self-selects work, never approves, and never merges.</p>"
|
||||
+ _form(submitted)
|
||||
+ (_error_block(error) if error is not None else "")
|
||||
+ (_preview_block(preview) if preview is not None else "")
|
||||
+ f"<p class='meta'><a href='{PREVIEW_API_PATH}'>Preview API</a> · "
|
||||
"<a href='/api/console/security-model'>RBAC model</a></p>"
|
||||
+ REQUEST_PAGE_STYLES
|
||||
)
|
||||
return render_page(title="Requests", body_html=body)
|
||||
@@ -201,6 +201,16 @@ def _candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate
|
||||
return candidates
|
||||
|
||||
|
||||
def candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate]:
|
||||
"""Public alias for :func:`_candidates_from_queue_snapshot` (#643).
|
||||
|
||||
The request-initiation service ranks the same candidate set this view
|
||||
renders, so both must agree on how a queue row becomes a candidate. One
|
||||
construction, two callers — not two that can drift apart.
|
||||
"""
|
||||
return _candidates_from_queue_snapshot(q_snap)
|
||||
|
||||
|
||||
def _claim_lease_records(inventory: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Normalize ``build_claim_inventory`` entries into lease records.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user