Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6868b345ee | ||
|
|
4f3a464a90 | ||
|
|
da6a864463 | ||
|
|
9468dd624d | ||
|
|
648d9464ba | ||
|
|
caaae9b6ee | ||
|
|
689c60fc7c | ||
|
|
66a89a46bb | ||
|
|
53c2c92782 | ||
|
|
14c9c4d702 | ||
|
|
95a5eb254f | ||
|
|
910b6edbdc | ||
|
|
99fda93bcc | ||
|
|
5494696227 | ||
|
|
b2f6e9a6dc | ||
|
|
479e434f92 | ||
|
|
9eb0f29cef | ||
|
|
0b29404031 | ||
|
|
5463f58933 | ||
|
|
5032965e3a | ||
|
|
e33b8d3712 |
+293
-39
@@ -78,6 +78,33 @@ VALID_ROLES = frozenset(
|
|||||||
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
|
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Allocation modes (#840).
|
||||||
|
# role_scoped: only candidates whose expected role matches the caller role.
|
||||||
|
# cross_role: controller-owned generic queue selection — inspect full queue,
|
||||||
|
# rank/eligibility canonically, return one selection naming the required
|
||||||
|
# downstream role/profile. Controller routes; it does not perform mutations.
|
||||||
|
ALLOCATION_MODE_ROLE_SCOPED = "role_scoped"
|
||||||
|
ALLOCATION_MODE_CROSS_ROLE = "cross_role"
|
||||||
|
VALID_ALLOCATION_MODES = frozenset(
|
||||||
|
{ALLOCATION_MODE_ROLE_SCOPED, ALLOCATION_MODE_CROSS_ROLE}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default execution-profile / MCP-namespace names for each role.
|
||||||
|
DEFAULT_ROLE_PROFILES: dict[str, str] = {
|
||||||
|
ROLE_AUTHOR: "prgs-author",
|
||||||
|
ROLE_REVIEWER: "prgs-reviewer",
|
||||||
|
ROLE_MERGER: "prgs-merger",
|
||||||
|
ROLE_RECONCILER: "prgs-reconciler",
|
||||||
|
ROLE_CONTROLLER: "prgs-controller",
|
||||||
|
}
|
||||||
|
DEFAULT_ROLE_NAMESPACES: dict[str, str] = {
|
||||||
|
ROLE_AUTHOR: "gitea-author",
|
||||||
|
ROLE_REVIEWER: "gitea-reviewer",
|
||||||
|
ROLE_MERGER: "gitea-merger",
|
||||||
|
ROLE_RECONCILER: "gitea-reconciler",
|
||||||
|
ROLE_CONTROLLER: "gitea-controller",
|
||||||
|
}
|
||||||
|
|
||||||
# Default action matrices by role (mutation gate will re-check).
|
# Default action matrices by role (mutation gate will re-check).
|
||||||
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||||
ROLE_AUTHOR: (
|
ROLE_AUTHOR: (
|
||||||
@@ -259,6 +286,126 @@ def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_allocation_mode(
|
||||||
|
role: str,
|
||||||
|
allocation_mode: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve allocation mode; controller defaults to cross_role (#840)."""
|
||||||
|
raw = (allocation_mode or "").strip().lower()
|
||||||
|
if raw:
|
||||||
|
if raw not in VALID_ALLOCATION_MODES:
|
||||||
|
raise ControlPlaneError(
|
||||||
|
f"unknown allocation_mode {allocation_mode!r}; expected one of "
|
||||||
|
f"{sorted(VALID_ALLOCATION_MODES)}"
|
||||||
|
)
|
||||||
|
return raw
|
||||||
|
if role == ROLE_CONTROLLER:
|
||||||
|
return ALLOCATION_MODE_CROSS_ROLE
|
||||||
|
return ALLOCATION_MODE_ROLE_SCOPED
|
||||||
|
|
||||||
|
|
||||||
|
def required_profile_for_role(
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Map a required role to the canonical execution profile name."""
|
||||||
|
role_norm = (role or "").strip().lower()
|
||||||
|
# Preserve remote/env prefix from the active profile when present
|
||||||
|
# (e.g. dadeschools-author → dadeschools-reviewer).
|
||||||
|
active = (profile_name or "").strip()
|
||||||
|
if active:
|
||||||
|
lower = active.lower()
|
||||||
|
for token in ("author", "reviewer", "merger", "reconciler", "controller"):
|
||||||
|
if lower.endswith(f"-{token}") or lower == token:
|
||||||
|
prefix = active[: -len(token)].rstrip("-")
|
||||||
|
if prefix:
|
||||||
|
return f"{prefix}-{role_norm}"
|
||||||
|
return role_norm
|
||||||
|
return DEFAULT_ROLE_PROFILES.get(role_norm, f"prgs-{role_norm}")
|
||||||
|
|
||||||
|
|
||||||
|
def required_namespace_for_role(
|
||||||
|
role: str,
|
||||||
|
*,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Map a required role to the canonical MCP namespace name."""
|
||||||
|
role_norm = (role or "").strip().lower()
|
||||||
|
profile = required_profile_for_role(role_norm, profile_name=profile_name)
|
||||||
|
# Namespace is typically gitea-<role>; keep stable mapping when profile is
|
||||||
|
# non-prgs (still gitea-<role> for isolation).
|
||||||
|
return DEFAULT_ROLE_NAMESPACES.get(role_norm, f"gitea-{role_norm}")
|
||||||
|
|
||||||
|
|
||||||
|
def selected_action_for_candidate(c: WorkCandidate, required_role: str) -> str:
|
||||||
|
"""Canonical next action for the selected work under *required_role*."""
|
||||||
|
role = (required_role or "").strip().lower()
|
||||||
|
if role == ROLE_AUTHOR:
|
||||||
|
if c.kind == "pr" and c.request_changes_current_head:
|
||||||
|
return "address_pr_change_requests"
|
||||||
|
if c.kind == "pr":
|
||||||
|
return "update_pr"
|
||||||
|
return "implement"
|
||||||
|
if role == ROLE_REVIEWER:
|
||||||
|
if c.approval_stale:
|
||||||
|
return "re_review"
|
||||||
|
return "review"
|
||||||
|
if role == ROLE_MERGER:
|
||||||
|
return "merge"
|
||||||
|
if role == ROLE_RECONCILER:
|
||||||
|
if c.approval_contaminated:
|
||||||
|
return "reconcile_contaminated_approval"
|
||||||
|
return "reconcile"
|
||||||
|
if role == ROLE_CONTROLLER:
|
||||||
|
return "diagnose"
|
||||||
|
return "process"
|
||||||
|
|
||||||
|
|
||||||
|
def build_selection_dict(
|
||||||
|
selected: WorkCandidate,
|
||||||
|
*,
|
||||||
|
active_role: str,
|
||||||
|
required_role: str,
|
||||||
|
profile_name: str | None = None,
|
||||||
|
allocation_mode: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Authoritative single selection payload for allocator results (#840)."""
|
||||||
|
action = selected_action_for_candidate(selected, required_role)
|
||||||
|
req_profile = required_profile_for_role(
|
||||||
|
required_role, profile_name=profile_name
|
||||||
|
)
|
||||||
|
req_ns = required_namespace_for_role(
|
||||||
|
required_role, profile_name=profile_name
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"kind": selected.kind,
|
||||||
|
"number": selected.number,
|
||||||
|
"title": selected.title,
|
||||||
|
"labels": list(selected.labels),
|
||||||
|
"head_sha": selected.head_sha,
|
||||||
|
"priority": selected.priority,
|
||||||
|
"expected_role_next": required_role,
|
||||||
|
"required_role": required_role,
|
||||||
|
"selected_action": action,
|
||||||
|
"action": action,
|
||||||
|
"required_profile": req_profile,
|
||||||
|
"required_namespace": req_ns,
|
||||||
|
"pinned": {
|
||||||
|
"kind": selected.kind,
|
||||||
|
"number": selected.number,
|
||||||
|
"head_sha": selected.head_sha,
|
||||||
|
"issue_number": selected.number if selected.kind == "issue" else None,
|
||||||
|
"pr_number": selected.number if selected.kind == "pr" else None,
|
||||||
|
},
|
||||||
|
"reason_selected": (
|
||||||
|
f"highest-priority eligible candidate under allocation_mode="
|
||||||
|
f"'{allocation_mode}' (active_role={active_role}, "
|
||||||
|
f"required_role={required_role}, action={action})"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def expected_role_for_candidate(c: WorkCandidate) -> str:
|
def expected_role_for_candidate(c: WorkCandidate) -> str:
|
||||||
"""ADR §5.3 routing: which role should take this work next."""
|
"""ADR §5.3 routing: which role should take this work next."""
|
||||||
if c.kind == "pr":
|
if c.kind == "pr":
|
||||||
@@ -289,6 +436,7 @@ def classify_skip(
|
|||||||
role: str,
|
role: str,
|
||||||
terminal_pr: int | None,
|
terminal_pr: int | None,
|
||||||
claim_ownership: str | None = None,
|
claim_ownership: str | None = None,
|
||||||
|
allocation_mode: str | None = None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Return skip reason, or None if candidate is selectable for *role*.
|
"""Return skip reason, or None if candidate is selectable for *role*.
|
||||||
|
|
||||||
@@ -297,7 +445,12 @@ def classify_skip(
|
|||||||
and unknown claims are excluded so one session's in-progress task can never
|
and unknown claims are excluded so one session's in-progress task can never
|
||||||
blockade the queue for a different controller; ``own`` stays selectable so
|
blockade the queue for a different controller; ``own`` stays selectable so
|
||||||
a controller can resume its own work.
|
a controller can resume its own work.
|
||||||
|
|
||||||
|
*allocation_mode* (#840): ``cross_role`` (controller default) ranks the full
|
||||||
|
queue and selects the highest-priority eligible item for any downstream
|
||||||
|
role. ``role_scoped`` retains prior role-match filtering.
|
||||||
"""
|
"""
|
||||||
|
mode = resolve_allocation_mode(role, allocation_mode)
|
||||||
if c.state in ("merged", "closed"):
|
if c.state in ("merged", "closed"):
|
||||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||||
if c.blocked or "status:blocked" in c.labels:
|
if c.blocked or "status:blocked" in c.labels:
|
||||||
@@ -322,34 +475,58 @@ def classify_skip(
|
|||||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||||
return f"pr#{c.number} missing head_sha pin"
|
return f"pr#{c.number} missing head_sha pin"
|
||||||
|
|
||||||
|
expected = expected_role_for_candidate(c)
|
||||||
|
|
||||||
# Terminal path first: when an active terminal PR exists, only that PR
|
# Terminal path first: when an active terminal PR exists, only that PR
|
||||||
# (or controller diagnosis) is assignable for review-path roles.
|
# is assignable for review-path roles (or for work whose expected role is
|
||||||
|
# review/merge under cross_role selection).
|
||||||
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
|
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
|
||||||
if role in (ROLE_REVIEWER, ROLE_MERGER):
|
terminal_roles = (ROLE_REVIEWER, ROLE_MERGER)
|
||||||
|
if mode == ALLOCATION_MODE_CROSS_ROLE:
|
||||||
|
if expected in terminal_roles:
|
||||||
|
return (
|
||||||
|
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||||
|
f"PR #{terminal_pr} must be resolved first"
|
||||||
|
)
|
||||||
|
elif role in terminal_roles:
|
||||||
return (
|
return (
|
||||||
f"pr#{c.number} skipped: active terminal-review lock on "
|
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||||
f"PR #{terminal_pr} must be resolved first"
|
f"PR #{terminal_pr} must be resolved first"
|
||||||
)
|
)
|
||||||
|
|
||||||
expected = expected_role_for_candidate(c)
|
if mode == ALLOCATION_MODE_CROSS_ROLE:
|
||||||
if role == ROLE_CONTROLLER:
|
# Cross-role controller selection: eligibility only — no active-role
|
||||||
# Controller may inspect anything but only assigns diagnosis targets
|
# match filter. The selection payload names required_role.
|
||||||
# when contaminated / blocked.
|
pass
|
||||||
|
elif role == ROLE_CONTROLLER:
|
||||||
|
# Legacy diagnosis-only controller path (role_scoped): only reconciler-
|
||||||
|
# needed targets. Prefer cross_role for generic queue allocation.
|
||||||
if expected == ROLE_RECONCILER or c.blocked:
|
if expected == ROLE_RECONCILER or c.blocked:
|
||||||
return None
|
return None
|
||||||
return f"{c.kind}#{c.number} does not require controller (expected {expected})"
|
return (
|
||||||
|
f"{c.kind}#{c.number} does not require controller "
|
||||||
if role != expected:
|
f"(expected {expected})"
|
||||||
|
)
|
||||||
|
elif role != expected:
|
||||||
return (
|
return (
|
||||||
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
|
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ready-gate for issues: prefer status:ready when labels present.
|
# Ready-gate for issues: prefer status:ready when labels present.
|
||||||
|
# Applies for author-bound work in both modes (cross_role only gates
|
||||||
|
# author-expected issues so reconciler/reviewer PRs stay selectable).
|
||||||
if c.kind == "issue" and c.labels:
|
if c.kind == "issue" and c.labels:
|
||||||
if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
|
gate_role = expected if mode == ALLOCATION_MODE_CROSS_ROLE else role
|
||||||
# Allow unlabeled open issues; only skip explicit non-ready states.
|
if gate_role in (ROLE_AUTHOR, ROLE_CONTROLLER):
|
||||||
|
if (
|
||||||
|
"status:ready" not in c.labels
|
||||||
|
and "status:in-progress" not in c.labels
|
||||||
|
):
|
||||||
if any(l.startswith("status:") for l in c.labels):
|
if any(l.startswith("status:") for l in c.labels):
|
||||||
return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
|
return (
|
||||||
|
f"issue#{c.number} not status:ready "
|
||||||
|
f"({','.join(c.labels)})"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -501,12 +678,19 @@ def allocate_next_work(
|
|||||||
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
claims: Mapping[tuple[str, int], dict[str, Any]] | None = None,
|
||||||
exclude_issue_numbers: Sequence[int] | None = None,
|
exclude_issue_numbers: Sequence[int] | None = None,
|
||||||
expected_candidate_set_fingerprint: str | None = None,
|
expected_candidate_set_fingerprint: str | None = None,
|
||||||
|
allocation_mode: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||||
|
|
||||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||||
|
|
||||||
|
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
|
||||||
|
the complete queue and returns one authoritative selection naming the
|
||||||
|
required downstream role/profile/action. ``role_scoped`` keeps prior
|
||||||
|
per-role filtering. Controller routes only — never grants author/reviewer/
|
||||||
|
merger/reconciler mutation rights to the controller session.
|
||||||
|
|
||||||
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
*exclude_issue_numbers* (#776): numbers removed before ranking. Omitted /
|
||||||
empty preserves prior behavior.
|
empty preserves prior behavior.
|
||||||
|
|
||||||
@@ -541,6 +725,19 @@ def allocate_next_work(
|
|||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
mode = resolve_allocation_mode(role_norm, allocation_mode)
|
||||||
|
except ControlPlaneError as exc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_ROLE_INELIGIBLE,
|
||||||
|
"reasons": [str(exc)],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
"allocation_mode": (allocation_mode or "").strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
||||||
try:
|
try:
|
||||||
db.upsert_session(
|
db.upsert_session(
|
||||||
@@ -748,6 +945,7 @@ def allocate_next_work(
|
|||||||
role=role_norm,
|
role=role_norm,
|
||||||
terminal_pr=terminal_pr,
|
terminal_pr=terminal_pr,
|
||||||
claim_ownership=ownership,
|
claim_ownership=ownership,
|
||||||
|
allocation_mode=mode,
|
||||||
)
|
)
|
||||||
if reason:
|
if reason:
|
||||||
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
|
is_claim_skip = SKIP_CLAIMED_BY_OTHER_SESSION in reason
|
||||||
@@ -828,6 +1026,10 @@ def allocate_next_work(
|
|||||||
"outcome": outcome,
|
"outcome": outcome,
|
||||||
"apply": bool(apply),
|
"apply": bool(apply),
|
||||||
"role": role_norm,
|
"role": role_norm,
|
||||||
|
"allocation_mode": mode,
|
||||||
|
"routing_role": role_norm,
|
||||||
|
"required_role": None,
|
||||||
|
"selected_action": None,
|
||||||
"profile_name": profile_name,
|
"profile_name": profile_name,
|
||||||
"username": username,
|
"username": username,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
@@ -840,6 +1042,12 @@ def allocate_next_work(
|
|||||||
"skipped": [s.as_dict() for s in skipped],
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
"terminal_pr": terminal_pr,
|
"terminal_pr": terminal_pr,
|
||||||
"assignment": None,
|
"assignment": None,
|
||||||
|
"allocation_evidence": {
|
||||||
|
"mode": "empty",
|
||||||
|
"allocation_mode": mode,
|
||||||
|
"lease_created": False,
|
||||||
|
"selection_policy": SELECTION_POLICY,
|
||||||
|
},
|
||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
"file_lock_only": False,
|
"file_lock_only": False,
|
||||||
"comment_lease_only": False,
|
"comment_lease_only": False,
|
||||||
@@ -852,25 +1060,37 @@ def allocate_next_work(
|
|||||||
"owner_session_id": owner_session_id,
|
"owner_session_id": owner_session_id,
|
||||||
"downstream_note": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents; "
|
||||||
|
"controller routes only under cross_role (#840)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_role = expected_role_for_candidate(selected)
|
expected_role = expected_role_for_candidate(selected)
|
||||||
allowed, forbidden = role_actions(role_norm)
|
# Cross-role: lease/action matrix follows the required downstream role so
|
||||||
selection = {
|
# evidence names the worker that must act. Controller session still owns
|
||||||
"kind": selected.kind,
|
# the routing decision; mutation isolation is enforced by role gates on
|
||||||
"number": selected.number,
|
# mutation tools (controller profile lacks author/review/merge ops).
|
||||||
"title": selected.title,
|
lease_role = (
|
||||||
"labels": list(selected.labels),
|
expected_role if mode == ALLOCATION_MODE_CROSS_ROLE else role_norm
|
||||||
"head_sha": selected.head_sha,
|
)
|
||||||
"priority": selected.priority,
|
allowed, forbidden = role_actions(lease_role)
|
||||||
"expected_role_next": expected_role,
|
# Controller must never receive mutation-class rights via cross-role apply.
|
||||||
"reason_selected": (
|
if role_norm == ROLE_CONTROLLER:
|
||||||
f"highest-priority candidate for role '{role_norm}' "
|
ctrl_allowed, ctrl_forbidden = role_actions(ROLE_CONTROLLER)
|
||||||
f"(expected_role={expected_role})"
|
# Keep controller session capability evidence separate from lease_role.
|
||||||
),
|
controller_allowed_actions = ctrl_allowed
|
||||||
}
|
controller_forbidden_actions = ctrl_forbidden
|
||||||
|
else:
|
||||||
|
controller_allowed_actions = allowed
|
||||||
|
controller_forbidden_actions = forbidden
|
||||||
|
|
||||||
|
selection = build_selection_dict(
|
||||||
|
selected,
|
||||||
|
active_role=role_norm,
|
||||||
|
required_role=expected_role,
|
||||||
|
profile_name=profile_name,
|
||||||
|
allocation_mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
if not apply:
|
if not apply:
|
||||||
return {
|
return {
|
||||||
@@ -878,6 +1098,12 @@ def allocate_next_work(
|
|||||||
"outcome": OUTCOME_PREVIEW,
|
"outcome": OUTCOME_PREVIEW,
|
||||||
"apply": False,
|
"apply": False,
|
||||||
"role": role_norm,
|
"role": role_norm,
|
||||||
|
"allocation_mode": mode,
|
||||||
|
"routing_role": role_norm,
|
||||||
|
"required_role": expected_role,
|
||||||
|
"selected_action": selection["selected_action"],
|
||||||
|
"required_profile": selection["required_profile"],
|
||||||
|
"required_namespace": selection["required_namespace"],
|
||||||
"profile_name": profile_name,
|
"profile_name": profile_name,
|
||||||
"username": username,
|
"username": username,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
@@ -893,6 +1119,12 @@ def allocate_next_work(
|
|||||||
"skipped": [s.as_dict() for s in skipped],
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
"terminal_pr": terminal_pr,
|
"terminal_pr": terminal_pr,
|
||||||
"assignment": None,
|
"assignment": None,
|
||||||
|
"allocation_evidence": {
|
||||||
|
"mode": "preview",
|
||||||
|
"allocation_mode": mode,
|
||||||
|
"lease_created": False,
|
||||||
|
"selection_policy": SELECTION_POLICY,
|
||||||
|
},
|
||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
"file_lock_only": False,
|
"file_lock_only": False,
|
||||||
"comment_lease_only": False,
|
"comment_lease_only": False,
|
||||||
@@ -902,9 +1134,12 @@ def allocate_next_work(
|
|||||||
"controller_excluded": list(controller_excluded),
|
"controller_excluded": list(controller_excluded),
|
||||||
"exclude_issue_numbers": list(exclude_nums),
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
"candidate_set_fingerprint": cas_fp,
|
"candidate_set_fingerprint": cas_fp,
|
||||||
|
"controller_allowed_actions": list(controller_allowed_actions),
|
||||||
|
"controller_forbidden_actions": list(controller_forbidden_actions),
|
||||||
"downstream_note": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents; "
|
||||||
|
"controller routes only under cross_role (#840)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +1148,7 @@ def allocate_next_work(
|
|||||||
try:
|
try:
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"role": role_norm,
|
"role": lease_role,
|
||||||
"remote": remote,
|
"remote": remote,
|
||||||
"org": org,
|
"org": org,
|
||||||
"repo": repo,
|
"repo": repo,
|
||||||
@@ -992,11 +1227,27 @@ def allocate_next_work(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# assigned
|
# assigned
|
||||||
|
lease_proof = {
|
||||||
|
"assignment_id": result.assignment_id,
|
||||||
|
"lease_id": result.lease_id,
|
||||||
|
"expires_at": result.expires_at,
|
||||||
|
"expected_head_sha": result.expected_head_sha,
|
||||||
|
"allowed_actions": list(result.allowed_actions),
|
||||||
|
"forbidden_actions": list(result.forbidden_actions),
|
||||||
|
"lease_role": lease_role,
|
||||||
|
"source": "control_plane_db.assign_and_lease",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"outcome": OUTCOME_ASSIGNED,
|
"outcome": OUTCOME_ASSIGNED,
|
||||||
"apply": True,
|
"apply": True,
|
||||||
"role": role_norm,
|
"role": role_norm,
|
||||||
|
"allocation_mode": mode,
|
||||||
|
"routing_role": role_norm,
|
||||||
|
"required_role": expected_role,
|
||||||
|
"selected_action": selection["selected_action"],
|
||||||
|
"required_profile": selection["required_profile"],
|
||||||
|
"required_namespace": selection["required_namespace"],
|
||||||
"profile_name": profile_name,
|
"profile_name": profile_name,
|
||||||
"username": username,
|
"username": username,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
@@ -1012,16 +1263,16 @@ def allocate_next_work(
|
|||||||
"skipped": [s.as_dict() for s in skipped],
|
"skipped": [s.as_dict() for s in skipped],
|
||||||
"terminal_pr": terminal_pr,
|
"terminal_pr": terminal_pr,
|
||||||
"assignment": result.as_dict(),
|
"assignment": result.as_dict(),
|
||||||
"lease_proof": {
|
"lease_proof": lease_proof,
|
||||||
"assignment_id": result.assignment_id,
|
"allocation_evidence": {
|
||||||
"lease_id": result.lease_id,
|
"mode": "assigned",
|
||||||
"expires_at": result.expires_at,
|
"allocation_mode": mode,
|
||||||
"expected_head_sha": result.expected_head_sha,
|
"lease_created": True,
|
||||||
"allowed_actions": list(result.allowed_actions),
|
"lease_role": lease_role,
|
||||||
"forbidden_actions": list(result.forbidden_actions),
|
"lease_proof": lease_proof,
|
||||||
"source": "control_plane_db.assign_and_lease",
|
"selection_policy": SELECTION_POLICY,
|
||||||
},
|
},
|
||||||
"next_valid_command": _next_command(role_norm, selected),
|
"next_valid_command": _next_command(lease_role, selected),
|
||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
"file_lock_only": False,
|
"file_lock_only": False,
|
||||||
"comment_lease_only": False,
|
"comment_lease_only": False,
|
||||||
@@ -1031,9 +1282,12 @@ def allocate_next_work(
|
|||||||
"controller_excluded": list(controller_excluded),
|
"controller_excluded": list(controller_excluded),
|
||||||
"exclude_issue_numbers": list(exclude_nums),
|
"exclude_issue_numbers": list(exclude_nums),
|
||||||
"candidate_set_fingerprint": cas_fp,
|
"candidate_set_fingerprint": cas_fp,
|
||||||
|
"controller_allowed_actions": list(controller_allowed_actions),
|
||||||
|
"controller_forbidden_actions": list(controller_forbidden_actions),
|
||||||
"downstream_note": (
|
"downstream_note": (
|
||||||
"#612 incident bridge remains downstream of #600; "
|
"#612 incident bridge remains downstream of #600; "
|
||||||
"allocator never assigns raw monitoring incidents"
|
"allocator never assigns raw monitoring incidents; "
|
||||||
|
"controller routes only under cross_role (#840)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ MUTATION_TASKS = frozenset({
|
|||||||
"edit_pr",
|
"edit_pr",
|
||||||
"commit_files",
|
"commit_files",
|
||||||
"gitea_commit_files",
|
"gitea_commit_files",
|
||||||
|
"publish_unpublished_branch",
|
||||||
"delete_branch",
|
"delete_branch",
|
||||||
"cleanup_merged_pr_branch",
|
"cleanup_merged_pr_branch",
|
||||||
"cleanup_stale_claims",
|
"cleanup_stale_claims",
|
||||||
|
|||||||
@@ -0,0 +1,892 @@
|
|||||||
|
"""ARCH-01 Foundation Slice A — atomic platform installation + authority kernel (#822).
|
||||||
|
|
||||||
|
Parents: #820, #821. **First implementation leaf of the ARCH-01 program.**
|
||||||
|
|
||||||
|
This module implements the smallest executable ARCH-01 foundation:
|
||||||
|
|
||||||
|
* a connection-bound authenticated actor context (``cp_actor_*`` /
|
||||||
|
``cp_operation_mode`` / ``cp_context_epoch`` SQLite scalar functions that SQL
|
||||||
|
may *read* but can never *set* — ``[TRUSTED-SERVICE]`` authenticity);
|
||||||
|
* an immutable authority-dominance lattice with an exact seeded tuple set
|
||||||
|
(``[SCHEMA]``);
|
||||||
|
* the principal-equivalence root (a class exists *before* its first principal;
|
||||||
|
``principals.current_class_id`` is ``NOT NULL``; ``[SCHEMA]``);
|
||||||
|
* a single-transaction platform installation that seeds the initial
|
||||||
|
``platform.bootstrap`` grant and an immutable ``installed`` marker, validated
|
||||||
|
by a fail-closed ``install_state`` ``BEFORE INSERT`` trigger (``[SCHEMA]``).
|
||||||
|
|
||||||
|
Everything else in the ARCH-01/02/04 program (evidence stores, repository
|
||||||
|
bindings, workspaces, PostgreSQL parity, full grant succession, full principal
|
||||||
|
merge) is out of scope here and tracked in its own issue — see #822 §5/§17.
|
||||||
|
|
||||||
|
**Readiness / production posture.** This subsystem is *disabled by default*.
|
||||||
|
Nothing in the running MCP server imports or enables it. It becomes a security
|
||||||
|
boundary only once its readiness checks (the ACs in #822) pass in the target
|
||||||
|
environment. Instantiating :class:`PlatformKernel` creates an isolated SQLite
|
||||||
|
database and never touches the operational control-plane store.
|
||||||
|
|
||||||
|
Enforcement classification (per #820 vocabulary):
|
||||||
|
|
||||||
|
* ``[TRUSTED-SERVICE]`` — actor-context authenticity: the scalar functions are
|
||||||
|
registered by the trusted Python process; SQL cannot define or redefine them.
|
||||||
|
* ``[SCHEMA]`` — fail-closed aborts, the dominance/immutability/NOT-NULL-class/
|
||||||
|
last-active-grant invariants, enforced by CHECK/FK/trigger.
|
||||||
|
* ``[RUNTIME-ADAPTER]`` — *none* in this slice.
|
||||||
|
|
||||||
|
SQLite-first. ``BEGIN IMMEDIATE`` serializes concurrent installs and concurrent
|
||||||
|
grant/revoke on the singleton invariant row. PostgreSQL parity is a distinct
|
||||||
|
issue (#827); this module does **not** claim it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Closed enumerations (#822 §4).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
ACTOR_KINDS = ("operator", "supervisor", "service", "installer")
|
||||||
|
OPERATION_MODES = ("normal", "install", "merge", "internal_service")
|
||||||
|
|
||||||
|
# Exact seeded authority-dominance tuple set (#822 §4). This set is normative:
|
||||||
|
# the install-state trigger rejects any missing, additional, or malformed tuple.
|
||||||
|
DOMINANCE_TUPLES = (
|
||||||
|
("platform.bootstrap", "platform.bootstrap"),
|
||||||
|
("platform.bootstrap", "project.admin"),
|
||||||
|
("platform.bootstrap", "supervisor.root.establish"),
|
||||||
|
("supervisor.root", "supervisor.register"),
|
||||||
|
("supervisor.root", "supervisor.verify"),
|
||||||
|
("supervisor.root", "supervisor.recover"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The distinguished operator-key issuer seeded during install.
|
||||||
|
DISTINGUISHED_ISSUER_KIND = "operator-key"
|
||||||
|
DISTINGUISHED_ISSUER_ID = "platform.bootstrap.operator-key"
|
||||||
|
|
||||||
|
# Structured result codes (#822 §10).
|
||||||
|
INSTALLED = "INSTALLED"
|
||||||
|
ALREADY_INSTALLED = "ALREADY_INSTALLED"
|
||||||
|
INVALID_ACTOR_CONTEXT = "INVALID_ACTOR_CONTEXT"
|
||||||
|
INVALID_BOOTSTRAP_STATE = "INVALID_BOOTSTRAP_STATE"
|
||||||
|
DOMINANCE_SET_MISMATCH = "DOMINANCE_SET_MISMATCH"
|
||||||
|
AUTHORIZATION_DENIED = "AUTHORIZATION_DENIED"
|
||||||
|
CONCURRENT_INSTALLATION_LOST = "CONCURRENT_INSTALLATION_LOST"
|
||||||
|
|
||||||
|
# Required audit events (#822 §14).
|
||||||
|
EVT_PLATFORM_INSTALLED = "platform_installed"
|
||||||
|
EVT_GRANT_CREATED = "platform_grant_created"
|
||||||
|
EVT_GRANT_REVOKED = "platform_grant_revoked"
|
||||||
|
EVT_PRINCIPAL_REGISTERED = "principal_registered"
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
DB_PATH_ENV = "ARCH01_PLATFORM_DB"
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformKernelError(RuntimeError):
|
||||||
|
"""Base class for structured, code-bearing kernel failures."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str = "") -> None:
|
||||||
|
super().__init__(message or code)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
class ActorContextError(PlatformKernelError):
|
||||||
|
"""Raised when a mutation is attempted without a valid actor context."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Schema (#822 §6). Tables + fail-closed triggers.
|
||||||
|
#
|
||||||
|
# Every *mutating* trigger opens with the actor protocol: read the context
|
||||||
|
# epoch, read the actor fields, and abort unless the context is present,
|
||||||
|
# non-null, mode/kind well-formed, and epoch-consistent with the active
|
||||||
|
# transaction. The scalar functions ``cp_*`` are registered from Python only;
|
||||||
|
# SQL has no statement that can set them, which is the trusted-service boundary.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_ACTOR_KINDS_SQL = ", ".join("'%s'" % k for k in ACTOR_KINDS)
|
||||||
|
_OP_MODES_SQL = ", ".join("'%s'" % m for m in OPERATION_MODES)
|
||||||
|
|
||||||
|
# Actor-protocol predicate: TRUE when the context is INVALID and the trigger
|
||||||
|
# must abort. ``cp_actor_context_valid()`` folds "present + non-expired +
|
||||||
|
# live-epoch == bound-epoch" (the read/re-read epoch equality of #822 §4) into
|
||||||
|
# one trusted-service answer; the remaining reads assert field well-formedness.
|
||||||
|
_INVALID_ACTOR = (
|
||||||
|
"cp_actor_context_valid() IS NOT 1 "
|
||||||
|
"OR cp_context_epoch() IS NULL "
|
||||||
|
"OR cp_actor_principal() IS NULL "
|
||||||
|
"OR cp_actor_kind() NOT IN (%s) "
|
||||||
|
"OR cp_operation_mode() NOT IN (%s)" % (_ACTOR_KINDS_SQL, _OP_MODES_SQL)
|
||||||
|
)
|
||||||
|
|
||||||
|
_ACTOR_GUARD = (
|
||||||
|
"SELECT CASE WHEN (%s) "
|
||||||
|
"THEN RAISE(ABORT, 'INVALID_ACTOR_CONTEXT') END;" % _INVALID_ACTOR
|
||||||
|
)
|
||||||
|
|
||||||
|
# require_installed: abort a privileged mutation when there is no install
|
||||||
|
# marker and we are not currently installing (#822 §4).
|
||||||
|
_REQUIRE_INSTALLED = (
|
||||||
|
"SELECT CASE WHEN ((SELECT COUNT(*) FROM install_state) = 0 "
|
||||||
|
"AND cp_operation_mode() <> 'install') "
|
||||||
|
"THEN RAISE(ABORT, 'NOT_INSTALLED') END;"
|
||||||
|
)
|
||||||
|
|
||||||
|
_SCHEMA_SQL = f"""
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arch01_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Equivalence classes are created BEFORE their first principal (#822 §4).
|
||||||
|
CREATE TABLE IF NOT EXISTS principal_equivalence_classes (
|
||||||
|
class_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS authoritative_issuers (
|
||||||
|
issuer_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
issuer_kind TEXT NOT NULL,
|
||||||
|
issuer_ref TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE (issuer_kind, issuer_ref)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- current_class_id is NOT NULL: a principal cannot exist without a class
|
||||||
|
-- (#822 AC6). issuer_id is nullable ONLY for the installer during install
|
||||||
|
-- (#822 AC7), enforced by trg_principals_null_issuer below.
|
||||||
|
CREATE TABLE IF NOT EXISTS principals (
|
||||||
|
principal_id TEXT PRIMARY KEY,
|
||||||
|
actor_kind TEXT NOT NULL CHECK (actor_kind IN ({_ACTOR_KINDS_SQL})),
|
||||||
|
current_class_id INTEGER NOT NULL REFERENCES principal_equivalence_classes(class_id),
|
||||||
|
issuer_id INTEGER REFERENCES authoritative_issuers(issuer_id),
|
||||||
|
registered_by TEXT REFERENCES principals(principal_id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS authority_dominance (
|
||||||
|
dominant TEXT NOT NULL,
|
||||||
|
subordinate TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (dominant, subordinate)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_bootstrap_seed (
|
||||||
|
seed_id INTEGER PRIMARY KEY CHECK (seed_id = 1),
|
||||||
|
installer_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_bootstrap_grants (
|
||||||
|
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
grantee_principal_id TEXT NOT NULL REFERENCES principals(principal_id),
|
||||||
|
granted_by TEXT REFERENCES principals(principal_id),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Singleton row; active_count floored at 1 by CHECK so the last active grant
|
||||||
|
-- can never be revoked (#822 AC11).
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_active_invariant (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
active_count INTEGER NOT NULL CHECK (active_count >= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The immutable install marker; inserted LAST in the install transaction.
|
||||||
|
CREATE TABLE IF NOT EXISTS install_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
marker TEXT NOT NULL CHECK (marker = 'installed'),
|
||||||
|
installed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Append-only (#822 AC14).
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_records (
|
||||||
|
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
principal_id TEXT,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
-- Actor protocol on every mutating trigger (#822 §4, [SCHEMA] fail-closed).
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_classes_actor
|
||||||
|
BEFORE INSERT ON principal_equivalence_classes
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_issuers_actor
|
||||||
|
BEFORE INSERT ON authoritative_issuers
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_principals_actor
|
||||||
|
BEFORE INSERT ON principals
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_dominance_actor
|
||||||
|
BEFORE INSERT ON authority_dominance
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_seed_actor
|
||||||
|
BEFORE INSERT ON platform_bootstrap_seed
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_insert
|
||||||
|
BEFORE INSERT ON platform_bootstrap_grants
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
{_REQUIRE_INSTALLED}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_grants_actor_update
|
||||||
|
BEFORE UPDATE ON platform_bootstrap_grants
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_insert
|
||||||
|
BEFORE INSERT ON platform_active_invariant
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_invariant_actor_update
|
||||||
|
BEFORE UPDATE ON platform_active_invariant
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_audit_actor
|
||||||
|
BEFORE INSERT ON audit_records
|
||||||
|
BEGIN
|
||||||
|
{_ACTOR_GUARD}
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
-- NOT-NULL-issuer exception for the installer only (#822 AC7).
|
||||||
|
-- A NULL issuer_id is accepted solely for an installer principal during
|
||||||
|
-- install mode, before the marker exists; any other NULL-issuer principal is
|
||||||
|
-- rejected. install-time issuer linkage (installer -> distinguished issuer)
|
||||||
|
-- is applied by a later UPDATE, permitted while no marker exists.
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_principals_null_issuer
|
||||||
|
BEFORE INSERT ON principals
|
||||||
|
WHEN NEW.issuer_id IS NULL
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE WHEN NOT (
|
||||||
|
NEW.actor_kind = 'installer'
|
||||||
|
AND cp_operation_mode() = 'install'
|
||||||
|
AND (SELECT COUNT(*) FROM install_state) = 0
|
||||||
|
AND (SELECT COUNT(*) FROM principals WHERE issuer_id IS NULL) = 0
|
||||||
|
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
-- Post-install immutability of the authority root (#822 §4, AC9).
|
||||||
|
-- Registration fields freeze only AFTER the marker exists, so the install
|
||||||
|
-- transaction's own installer issuer-linkage UPDATE is permitted.
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_update
|
||||||
|
BEFORE UPDATE ON principals
|
||||||
|
WHEN (SELECT COUNT(*) FROM install_state) > 0
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_principals_frozen_delete
|
||||||
|
BEFORE DELETE ON principals
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_PRINCIPAL');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Distinguished issuer identity is immutable once written.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_update
|
||||||
|
BEFORE UPDATE ON authoritative_issuers
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_issuers_immutable_delete
|
||||||
|
BEFORE DELETE ON authoritative_issuers
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_ISSUER');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The dominance lattice is immutable once seeded.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_update
|
||||||
|
BEFORE UPDATE ON authority_dominance
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_dominance_immutable_delete
|
||||||
|
BEFORE DELETE ON authority_dominance
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_DOMINANCE');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The bootstrap seed is immutable once written.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_update
|
||||||
|
BEFORE UPDATE ON platform_bootstrap_seed
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_seed_immutable_delete
|
||||||
|
BEFORE DELETE ON platform_bootstrap_seed
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_SEED');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- The install marker is immutable once written.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_update
|
||||||
|
BEFORE UPDATE ON install_state
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_install_state_immutable_delete
|
||||||
|
BEFORE DELETE ON install_state
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_INSTALL_STATE');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Grants: identity is immutable; the ONLY permitted mutation is a single
|
||||||
|
-- active 1 -> 0 revocation (#822 §4 initial-grant identity immutability +
|
||||||
|
-- grant/revoke). Reactivation and identity edits are rejected.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_grants_identity_frozen
|
||||||
|
BEFORE UPDATE ON platform_bootstrap_grants
|
||||||
|
WHEN NOT (
|
||||||
|
NEW.grant_id = OLD.grant_id
|
||||||
|
AND NEW.grantee_principal_id = OLD.grantee_principal_id
|
||||||
|
AND NEW.granted_by IS OLD.granted_by
|
||||||
|
AND NEW.created_at = OLD.created_at
|
||||||
|
AND OLD.active = 1
|
||||||
|
AND NEW.active = 0
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_grants_no_delete
|
||||||
|
BEFORE DELETE ON platform_bootstrap_grants
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_GRANT');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- audit_records is append-only.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_update
|
||||||
|
BEFORE UPDATE ON audit_records
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_audit_immutable_delete
|
||||||
|
BEFORE DELETE ON audit_records
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'IMMUTABLE_AUDIT');
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
-- install_state BEFORE INSERT: validate the whole bootstrap atomically
|
||||||
|
-- (#822 §4, AC4). Each dominance tuple is checked individually; a missing,
|
||||||
|
-- additional, or malformed tuple -> DOMINANCE_SET_MISMATCH. The seed<->installer
|
||||||
|
-- link, the single active NULL-grantor installer grant, the installer's
|
||||||
|
-- non-NULL issuer, the active invariant, and "no extra principal created under
|
||||||
|
-- the NULL-issuer exception" -> INVALID_BOOTSTRAP_STATE.
|
||||||
|
-- ------------------------------------------------------------------------- --
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS trg_install_state_validate
|
||||||
|
BEFORE INSERT ON install_state
|
||||||
|
BEGIN
|
||||||
|
SELECT CASE WHEN NOT (
|
||||||
|
(SELECT COUNT(*) FROM authority_dominance) = {len(DOMINANCE_TUPLES)}
|
||||||
|
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='platform.bootstrap')
|
||||||
|
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='project.admin')
|
||||||
|
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='platform.bootstrap' AND subordinate='supervisor.root.establish')
|
||||||
|
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.register')
|
||||||
|
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.verify')
|
||||||
|
AND EXISTS (SELECT 1 FROM authority_dominance WHERE dominant='supervisor.root' AND subordinate='supervisor.recover')
|
||||||
|
) THEN RAISE(ABORT, 'DOMINANCE_SET_MISMATCH') END;
|
||||||
|
|
||||||
|
SELECT CASE WHEN NOT (
|
||||||
|
(SELECT COUNT(*) FROM platform_bootstrap_seed) = 1
|
||||||
|
AND (SELECT COUNT(*) FROM principals) = 1
|
||||||
|
AND (SELECT actor_kind FROM principals
|
||||||
|
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
|
||||||
|
) = 'installer'
|
||||||
|
AND (SELECT issuer_id FROM principals
|
||||||
|
WHERE principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
|
||||||
|
) IS NOT NULL
|
||||||
|
AND (SELECT COUNT(*) FROM platform_bootstrap_grants
|
||||||
|
WHERE granted_by IS NULL AND active = 1
|
||||||
|
AND grantee_principal_id = (SELECT installer_principal_id FROM platform_bootstrap_seed WHERE seed_id = 1)
|
||||||
|
) = 1
|
||||||
|
AND (SELECT COUNT(*) FROM platform_bootstrap_grants) = 1
|
||||||
|
AND (SELECT active_count FROM platform_active_invariant WHERE id = 1) = 1
|
||||||
|
) THEN RAISE(ABORT, 'INVALID_BOOTSTRAP_STATE') END;
|
||||||
|
END;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def default_db_path() -> str:
|
||||||
|
return os.environ.get(
|
||||||
|
DB_PATH_ENV,
|
||||||
|
os.path.expanduser("~/.cache/gitea-tools/arch01/platform.sqlite3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OperationResult:
|
||||||
|
"""Structured result of a kernel operation (#822 §10)."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.code in (INSTALLED, ALREADY_INSTALLED)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ActorContext:
|
||||||
|
principal: str
|
||||||
|
kind: str
|
||||||
|
mode: str
|
||||||
|
session: Optional[str]
|
||||||
|
bound_epoch: int
|
||||||
|
live_epoch: int
|
||||||
|
expired: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformKernel:
|
||||||
|
"""ARCH-01 authority kernel over a single SQLite connection.
|
||||||
|
|
||||||
|
The connection carries the trusted-service actor context: the ``cp_*``
|
||||||
|
scalar functions read the context this object holds. Only Python code here
|
||||||
|
can bind or clear it, so no SQL statement can assert an actor identity — the
|
||||||
|
trusted-service authenticity boundary of #822 §4.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Optional[str] = None, *, busy_timeout_ms: int = 5000) -> None:
|
||||||
|
self.db_path = db_path or default_db_path()
|
||||||
|
if self.db_path != ":memory:":
|
||||||
|
parent = os.path.dirname(self.db_path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
self._ctx: Optional[_ActorContext] = None
|
||||||
|
self._epoch_seq = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
# check_same_thread=False is safe: every mutation path is serialized
|
||||||
|
# by self._lock, so the connection is never used concurrently even when
|
||||||
|
# callers drive the kernel from different threads (concurrency tests).
|
||||||
|
self._conn = sqlite3.connect(
|
||||||
|
self.db_path, isolation_level=None, check_same_thread=False
|
||||||
|
)
|
||||||
|
self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
self._conn.execute(f"PRAGMA busy_timeout = {int(busy_timeout_ms)}")
|
||||||
|
self._register_actor_functions()
|
||||||
|
self._migrate()
|
||||||
|
|
||||||
|
# -- trusted-service actor functions ---------------------------------- #
|
||||||
|
|
||||||
|
def _register_actor_functions(self) -> None:
|
||||||
|
c = self._conn
|
||||||
|
c.create_function("cp_actor_principal", 0, lambda: self._ctx.principal if self._ctx else None)
|
||||||
|
c.create_function("cp_actor_kind", 0, lambda: self._ctx.kind if self._ctx else None)
|
||||||
|
c.create_function("cp_operation_mode", 0, lambda: self._ctx.mode if self._ctx else None)
|
||||||
|
c.create_function("cp_service_session", 0, lambda: self._ctx.session if self._ctx else None)
|
||||||
|
c.create_function("cp_context_epoch", 0, self._fn_context_epoch)
|
||||||
|
# Trusted-service helper: folds present + non-expired + epoch-consistent
|
||||||
|
# into the read/re-read epoch equality of #822 §4.
|
||||||
|
c.create_function("cp_actor_context_valid", 0, self._fn_context_valid)
|
||||||
|
|
||||||
|
def _fn_context_epoch(self) -> Optional[int]:
|
||||||
|
if self._ctx is None or self._ctx.expired:
|
||||||
|
return None
|
||||||
|
return self._ctx.live_epoch
|
||||||
|
|
||||||
|
def _fn_context_valid(self) -> int:
|
||||||
|
ctx = self._ctx
|
||||||
|
if ctx is None or ctx.expired:
|
||||||
|
return 0
|
||||||
|
# read/re-read epoch equality: a context whose live epoch has drifted
|
||||||
|
# from the epoch it was bound to (a stale/replaced connection context)
|
||||||
|
# is not bound to the active transaction and fails closed.
|
||||||
|
if ctx.live_epoch != ctx.bound_epoch:
|
||||||
|
return 0
|
||||||
|
if ctx.principal is None:
|
||||||
|
return 0
|
||||||
|
if ctx.kind not in ACTOR_KINDS or ctx.mode not in OPERATION_MODES:
|
||||||
|
return 0
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# -- context lifecycle ------------------------------------------------ #
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def actor_context(
|
||||||
|
self, principal: str, kind: str, mode: str, session: Optional[str] = None
|
||||||
|
) -> Iterator[None]:
|
||||||
|
"""Bind a trusted actor context for the duration of the block."""
|
||||||
|
prev = self._ctx
|
||||||
|
self._epoch_seq += 1
|
||||||
|
epoch = self._epoch_seq
|
||||||
|
self._ctx = _ActorContext(
|
||||||
|
principal=principal, kind=kind, mode=mode, session=session,
|
||||||
|
bound_epoch=epoch, live_epoch=epoch,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self._ctx = prev
|
||||||
|
|
||||||
|
def _clear_context(self) -> None:
|
||||||
|
self._ctx = None
|
||||||
|
|
||||||
|
# -- migration -------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
self._conn.executescript(_SCHEMA_SQL)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES ('schema_version', ?)",
|
||||||
|
(str(SCHEMA_VERSION),),
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO arch01_meta(key, value) VALUES "
|
||||||
|
"('architecture', 'ARCH-01 Slice A: atomic install + authority kernel (#822); "
|
||||||
|
"disabled by default until readiness checks pass')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- introspection ---------------------------------------------------- #
|
||||||
|
|
||||||
|
def is_installed(self) -> bool:
|
||||||
|
row = self._conn.execute("SELECT COUNT(*) FROM install_state").fetchone()
|
||||||
|
return bool(row[0])
|
||||||
|
|
||||||
|
def active_grant_count(self) -> int:
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT active_count FROM platform_active_invariant WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
|
def audit_events(self) -> list[str]:
|
||||||
|
return [
|
||||||
|
r[0]
|
||||||
|
for r in self._conn.execute(
|
||||||
|
"SELECT event FROM audit_records ORDER BY audit_id"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
# -- operations ------------------------------------------------------- #
|
||||||
|
|
||||||
|
def install_platform(
|
||||||
|
self,
|
||||||
|
installer_principal_id: str = "platform.installer",
|
||||||
|
*,
|
||||||
|
session: Optional[str] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""Single atomic install transaction (#822 §4/§7).
|
||||||
|
|
||||||
|
``BEGIN IMMEDIATE`` serializes concurrent installs; the loser rechecks
|
||||||
|
the marker and returns ``ALREADY_INSTALLED``, or — if it never acquires
|
||||||
|
the write lock — ``CONCURRENT_INSTALLATION_LOST``. On any stage failure
|
||||||
|
the whole transaction rolls back leaving no partial rows (AC3/AC5).
|
||||||
|
"""
|
||||||
|
now = _utc_now_iso()
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self._conn.execute("BEGIN IMMEDIATE")
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||||
|
return OperationResult(CONCURRENT_INSTALLATION_LOST, str(exc))
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
if self.is_installed():
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
return OperationResult(ALREADY_INSTALLED, "install marker already present")
|
||||||
|
|
||||||
|
with self.actor_context(installer_principal_id, "installer", "install", session):
|
||||||
|
c = self._conn
|
||||||
|
# class -> installer principal (temporary NULL issuer)
|
||||||
|
cur = c.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
class_id = cur.lastrowid
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO principals"
|
||||||
|
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES (?, 'installer', ?, NULL, ?, ?)",
|
||||||
|
(installer_principal_id, class_id, installer_principal_id, now),
|
||||||
|
)
|
||||||
|
# distinguished operator-key issuer
|
||||||
|
cur = c.execute(
|
||||||
|
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
(DISTINGUISHED_ISSUER_KIND, DISTINGUISHED_ISSUER_ID, now),
|
||||||
|
)
|
||||||
|
issuer_id = cur.lastrowid
|
||||||
|
# link installer -> issuer (permitted pre-marker)
|
||||||
|
c.execute(
|
||||||
|
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
|
||||||
|
(issuer_id, installer_principal_id),
|
||||||
|
)
|
||||||
|
# dominance tuples
|
||||||
|
c.executemany(
|
||||||
|
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
|
||||||
|
DOMINANCE_TUPLES,
|
||||||
|
)
|
||||||
|
# seed
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) "
|
||||||
|
"VALUES (1, ?, ?)",
|
||||||
|
(installer_principal_id, now),
|
||||||
|
)
|
||||||
|
# initial grant (granted_by NULL, active)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_grants"
|
||||||
|
"(grantee_principal_id, granted_by, active, created_at) "
|
||||||
|
"VALUES (?, NULL, 1, ?)",
|
||||||
|
(installer_principal_id, now),
|
||||||
|
)
|
||||||
|
# active invariant
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)"
|
||||||
|
)
|
||||||
|
# audit rows for the security-sensitive operation
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(EVT_PRINCIPAL_REGISTERED, installer_principal_id, "installer", now),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(EVT_GRANT_CREATED, installer_principal_id, "initial platform.bootstrap grant", now),
|
||||||
|
)
|
||||||
|
# install marker LAST -> fires the whole-bootstrap validator
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(EVT_PLATFORM_INSTALLED, installer_principal_id, "platform installed", now),
|
||||||
|
)
|
||||||
|
self._conn.execute("COMMIT")
|
||||||
|
return OperationResult(INSTALLED, "platform installed")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
self._safe_rollback()
|
||||||
|
return OperationResult(self._classify(exc), str(exc))
|
||||||
|
|
||||||
|
def register_principal(
|
||||||
|
self,
|
||||||
|
principal_id: str,
|
||||||
|
actor_kind: str,
|
||||||
|
issuer_ref: str,
|
||||||
|
*,
|
||||||
|
actor_principal: str,
|
||||||
|
actor_kind_ctx: str = "operator",
|
||||||
|
session: Optional[str] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""Atomically create an equivalence class and its first principal.
|
||||||
|
|
||||||
|
The class is inserted *before* the principal, and ``current_class_id``
|
||||||
|
is ``NOT NULL`` (#822 AC6): a principal can never exist classless.
|
||||||
|
The principal references an existing issuer (non-NULL); the temporary
|
||||||
|
NULL-issuer exception is reserved for the installer during install
|
||||||
|
(AC7).
|
||||||
|
"""
|
||||||
|
if actor_kind not in ACTOR_KINDS:
|
||||||
|
return OperationResult(INVALID_BOOTSTRAP_STATE, f"bad actor_kind {actor_kind!r}")
|
||||||
|
now = _utc_now_iso()
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self._conn.execute("BEGIN IMMEDIATE")
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
return OperationResult(AUTHORIZATION_DENIED, str(exc))
|
||||||
|
try:
|
||||||
|
if not self.is_installed():
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT issuer_id FROM authoritative_issuers WHERE issuer_ref = ?",
|
||||||
|
(issuer_ref,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
return OperationResult(INVALID_BOOTSTRAP_STATE, f"unknown issuer {issuer_ref!r}")
|
||||||
|
issuer_id = row[0]
|
||||||
|
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
class_id = cur.lastrowid
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO principals"
|
||||||
|
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(principal_id, actor_kind, class_id, issuer_id, actor_principal, now),
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(EVT_PRINCIPAL_REGISTERED, principal_id, actor_kind, now),
|
||||||
|
)
|
||||||
|
self._conn.execute("COMMIT")
|
||||||
|
return OperationResult(INSTALLED, f"registered {principal_id}")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
self._safe_rollback()
|
||||||
|
return OperationResult(self._classify(exc), str(exc))
|
||||||
|
|
||||||
|
def grant_platform_bootstrap(
|
||||||
|
self,
|
||||||
|
grantee_principal_id: str,
|
||||||
|
granted_by: str,
|
||||||
|
*,
|
||||||
|
actor_kind_ctx: str = "operator",
|
||||||
|
session: Optional[str] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""Create an additional active platform.bootstrap grant.
|
||||||
|
|
||||||
|
Serialized on the singleton invariant row via ``BEGIN IMMEDIATE``.
|
||||||
|
"""
|
||||||
|
now = _utc_now_iso()
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self._conn.execute("BEGIN IMMEDIATE")
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
return OperationResult(AUTHORIZATION_DENIED, str(exc))
|
||||||
|
try:
|
||||||
|
if not self.is_installed():
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
|
||||||
|
with self.actor_context(granted_by, actor_kind_ctx, "normal", session):
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_grants"
|
||||||
|
"(grantee_principal_id, granted_by, active, created_at) "
|
||||||
|
"VALUES (?, ?, 1, ?)",
|
||||||
|
(grantee_principal_id, granted_by, now),
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE platform_active_invariant SET active_count = active_count + 1 WHERE id = 1"
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(EVT_GRANT_CREATED, grantee_principal_id, f"granted_by={granted_by}", now),
|
||||||
|
)
|
||||||
|
self._conn.execute("COMMIT")
|
||||||
|
return OperationResult(INSTALLED, f"granted to {grantee_principal_id}")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
self._safe_rollback()
|
||||||
|
return OperationResult(self._classify(exc), str(exc))
|
||||||
|
|
||||||
|
def revoke_platform_bootstrap(
|
||||||
|
self,
|
||||||
|
grant_id: int,
|
||||||
|
*,
|
||||||
|
actor_principal: str,
|
||||||
|
actor_kind_ctx: str = "operator",
|
||||||
|
session: Optional[str] = None,
|
||||||
|
) -> OperationResult:
|
||||||
|
"""Revoke an active grant, floored so the last one can never drop.
|
||||||
|
|
||||||
|
The ``active_count >= 1`` CHECK plus ``BEGIN IMMEDIATE`` serialization
|
||||||
|
make two concurrent revocations unable to remove the final active grant
|
||||||
|
(#822 AC11): the decrement that would reach zero fails and rolls back.
|
||||||
|
"""
|
||||||
|
now = _utc_now_iso()
|
||||||
|
with self._lock:
|
||||||
|
try:
|
||||||
|
self._conn.execute("BEGIN IMMEDIATE")
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
return OperationResult(AUTHORIZATION_DENIED, str(exc))
|
||||||
|
try:
|
||||||
|
if not self.is_installed():
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
return OperationResult(INVALID_BOOTSTRAP_STATE, "platform not installed")
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT active, grantee_principal_id FROM platform_bootstrap_grants WHERE grant_id = ?",
|
||||||
|
(grant_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None or row[0] != 1:
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
return OperationResult(AUTHORIZATION_DENIED, "grant absent or already inactive")
|
||||||
|
grantee = row[1]
|
||||||
|
with self.actor_context(actor_principal, actor_kind_ctx, "normal", session):
|
||||||
|
# Decrement first: the CHECK floor rejects dropping below 1,
|
||||||
|
# aborting the whole revoke before the grant flips inactive.
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE platform_active_invariant SET active_count = active_count - 1 WHERE id = 1"
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"UPDATE platform_bootstrap_grants SET active = 0, revoked_at = ? WHERE grant_id = ?",
|
||||||
|
(now, grant_id),
|
||||||
|
)
|
||||||
|
self._conn.execute(
|
||||||
|
"INSERT INTO audit_records(event, principal_id, detail, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(EVT_GRANT_REVOKED, grantee, f"grant_id={grant_id}", now),
|
||||||
|
)
|
||||||
|
self._conn.execute("COMMIT")
|
||||||
|
return OperationResult(INSTALLED, f"revoked grant {grant_id}")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
self._safe_rollback()
|
||||||
|
return OperationResult(self._classify(exc), str(exc))
|
||||||
|
|
||||||
|
# -- helpers ---------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _safe_rollback(self) -> None:
|
||||||
|
try:
|
||||||
|
self._conn.execute("ROLLBACK")
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify(exc: sqlite3.Error) -> str:
|
||||||
|
msg = str(exc)
|
||||||
|
if "INVALID_ACTOR_CONTEXT" in msg:
|
||||||
|
return INVALID_ACTOR_CONTEXT
|
||||||
|
if "DOMINANCE_SET_MISMATCH" in msg:
|
||||||
|
return DOMINANCE_SET_MISMATCH
|
||||||
|
if "active_count" in msg or "CHECK constraint failed: platform_active_invariant" in msg:
|
||||||
|
# last-active-grant floor tripped
|
||||||
|
return AUTHORIZATION_DENIED
|
||||||
|
if any(tag in msg for tag in (
|
||||||
|
"INVALID_BOOTSTRAP_STATE", "IMMUTABLE_", "NOT_INSTALLED",
|
||||||
|
)):
|
||||||
|
return INVALID_BOOTSTRAP_STATE
|
||||||
|
return INVALID_BOOTSTRAP_STATE
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
"""Publish an unpublished local commit on a registered issue worktree (#812 AC20).
|
||||||
|
|
||||||
|
Entry point B of #812 is the state where an author's work has already advanced
|
||||||
|
to a local commit: the worktree is registered, clean, on the issue branch, and
|
||||||
|
carries the only copy of the implementation, but the branch has never been
|
||||||
|
published. That state deadlocks, because two individually correct predicates
|
||||||
|
close a cycle:
|
||||||
|
|
||||||
|
* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an
|
||||||
|
expired lease without an observable remote head — an unpublished branch has
|
||||||
|
none.
|
||||||
|
* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives
|
||||||
|
its workspace from the author issue lock under #618, so nothing can create
|
||||||
|
that remote head without first holding the lock.
|
||||||
|
|
||||||
|
This module supplies the missing operation: it publishes an *already committed*
|
||||||
|
local head to the remote branch, so exact-owner renewal has the evidence it
|
||||||
|
requires. It deliberately does **not** renew, reclaim, rebind, or clear any
|
||||||
|
lock. Publication is the whole of its authority.
|
||||||
|
|
||||||
|
Why this is not a lock bypass
|
||||||
|
-----------------------------
|
||||||
|
The operation can only publish a branch whose **durable issue-lock record
|
||||||
|
already names the caller as claimant**. Ownership is read from the lock file on
|
||||||
|
disk (``issue_lock_store``), never from a caller-supplied flag, so the tool
|
||||||
|
cannot manufacture a claim it does not already hold. Nothing here weakens the
|
||||||
|
#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign
|
||||||
|
claimant, a changed HEAD, or a divergent remote head each refuse, exactly as
|
||||||
|
they do today. The only thing this adds is the ability to make an existing,
|
||||||
|
owned, committed, clean branch observable on the remote.
|
||||||
|
|
||||||
|
Separation of records (#812 AC23)
|
||||||
|
---------------------------------
|
||||||
|
The durable **issue-lock file** and the control-plane **workflow lease** are
|
||||||
|
distinct records. This module reads the former as ownership evidence and writes
|
||||||
|
neither. Publishing changes remote git state only; no lock is renewed,
|
||||||
|
abandoned, reclaimed, or generation-bumped here.
|
||||||
|
|
||||||
|
Process evidence (#812 AC24)
|
||||||
|
----------------------------
|
||||||
|
Liveness of the lock's recorded pid is **not consulted**. That is deliberate:
|
||||||
|
the recorded pid routinely belongs to the long-running MCP daemon rather than to
|
||||||
|
an active author client, and the existing reclaim predicate
|
||||||
|
(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs.
|
||||||
|
Publication does not require the recording process to be dead, so this module
|
||||||
|
never asserts, infers, or depends on a process being dead. Ownership is proven
|
||||||
|
by identity and profile match against the recorded claimant instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from reviewer_worktree import parse_dirty_tracked_files
|
||||||
|
from stable_branch_push_guard import is_stable_ref, redact_command
|
||||||
|
|
||||||
|
# Assessment outcomes.
|
||||||
|
PUBLISH_SANCTIONED = "publish_sanctioned"
|
||||||
|
ALREADY_PUBLISHED = "already_published"
|
||||||
|
REFUSED = "refused"
|
||||||
|
|
||||||
|
#: Implementation branches must stay traceable to their issue (#713 lineage).
|
||||||
|
ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$")
|
||||||
|
|
||||||
|
_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object) -> str:
|
||||||
|
return value.strip() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _realpath(value: str | None) -> str | None:
|
||||||
|
path = _text(value)
|
||||||
|
return os.path.realpath(path) if path else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_untracked_files(porcelain_status: str) -> list[str]:
|
||||||
|
"""Return untracked paths from ``git status --porcelain`` output.
|
||||||
|
|
||||||
|
``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??``
|
||||||
|
entries. Publication needs both halves: an untracked file in the worktree is
|
||||||
|
unpublished content that the commit does not carry, so publishing would
|
||||||
|
silently leave it behind.
|
||||||
|
"""
|
||||||
|
untracked: list[str] = []
|
||||||
|
for line in (porcelain_status or "").splitlines():
|
||||||
|
if not line.startswith("??"):
|
||||||
|
continue
|
||||||
|
path = line[2:].strip()
|
||||||
|
if path:
|
||||||
|
untracked.append(path)
|
||||||
|
return untracked
|
||||||
|
|
||||||
|
|
||||||
|
def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]:
|
||||||
|
"""SHA-256 each path under *worktree_path*; ``None`` when unreadable."""
|
||||||
|
root = _text(worktree_path)
|
||||||
|
hashes: dict[str, str | None] = {}
|
||||||
|
for rel in paths or ():
|
||||||
|
rel_text = _text(rel)
|
||||||
|
if not rel_text:
|
||||||
|
continue
|
||||||
|
full = os.path.join(root, rel_text)
|
||||||
|
try:
|
||||||
|
with open(full, "rb") as handle:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for chunk in iter(lambda: handle.read(65536), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
hashes[rel_text] = digest.hexdigest()
|
||||||
|
except OSError:
|
||||||
|
hashes[rel_text] = None
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def read_remote_branch_head(
|
||||||
|
worktree_path: str, remote_name: str, branch_name: str
|
||||||
|
) -> dict:
|
||||||
|
"""Observe the remote head for *branch_name*, read-only.
|
||||||
|
|
||||||
|
``probe_ok`` False means git could not answer at all. That is kept distinct
|
||||||
|
from "the branch does not exist": an unobservable remote must fail closed
|
||||||
|
rather than be mistaken for an absent branch, because the two lead to
|
||||||
|
opposite dispositions.
|
||||||
|
"""
|
||||||
|
path = _text(worktree_path)
|
||||||
|
remote = _text(remote_name)
|
||||||
|
branch = _text(branch_name)
|
||||||
|
result: dict = {
|
||||||
|
"probe_ok": False,
|
||||||
|
"remote_branch_exists": False,
|
||||||
|
"remote_head_sha": None,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not (path and remote and branch):
|
||||||
|
result["reasons"].append(
|
||||||
|
"remote head probe requires a worktree path, remote name, and branch"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc: # git unavailable — fail closed, never assume absent
|
||||||
|
result["reasons"].append(f"remote head probe could not run: {exc}")
|
||||||
|
return result
|
||||||
|
if res.returncode != 0:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"remote head probe failed for '{branch}' on remote '{remote}'"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["probe_ok"] = True
|
||||||
|
for line in (res.stdout or "").splitlines():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
|
||||||
|
result["remote_branch_exists"] = True
|
||||||
|
result["remote_head_sha"] = parts[0].strip()
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_is_ancestor(
|
||||||
|
worktree_path: str, ancestor_sha: str, descendant_sha: str
|
||||||
|
) -> dict:
|
||||||
|
"""Observe whether *ancestor_sha* is an ancestor of *descendant_sha*."""
|
||||||
|
path = _text(worktree_path)
|
||||||
|
ancestor = _text(ancestor_sha)
|
||||||
|
descendant = _text(descendant_sha)
|
||||||
|
result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []}
|
||||||
|
if not (path and ancestor and descendant):
|
||||||
|
result["reasons"].append(
|
||||||
|
"ancestry probe requires a worktree path and both commit SHAs"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
present = subprocess.run(
|
||||||
|
["git", "-C", path, "rev-parse", "--verify", "--quiet",
|
||||||
|
f"{ancestor}^{{commit}}"],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if present.returncode != 0:
|
||||||
|
result["reasons"].append(
|
||||||
|
f"remote head {ancestor} is not present locally, so it cannot be "
|
||||||
|
"proven an ancestor of the commit being published"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||||
|
return result
|
||||||
|
result["probe_ok"] = res.returncode in (0, 1)
|
||||||
|
result["is_ancestor"] = res.returncode == 0
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def assess_unpublished_commit_publication(
|
||||||
|
existing_lock,
|
||||||
|
*,
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
expected_head: str,
|
||||||
|
remote: str,
|
||||||
|
org: str,
|
||||||
|
repo: str,
|
||||||
|
identity: str | None,
|
||||||
|
profile: str | None,
|
||||||
|
worktree_state,
|
||||||
|
worktree_registered: bool | None = None,
|
||||||
|
remote_probe=None,
|
||||||
|
ancestry=None,
|
||||||
|
competing_open_prs=(),
|
||||||
|
expected_file_hashes=None,
|
||||||
|
observed_file_hashes=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Decide whether an unpublished local commit may be published (#812 AC20).
|
||||||
|
|
||||||
|
Pure predicate. Every input is either a caller-declared expectation that
|
||||||
|
must be *matched* against observation, or a server-side observation. No
|
||||||
|
caller-supplied boolean is accepted as proof of ownership, liveness, or
|
||||||
|
eligibility: ``existing_lock`` comes from the durable lock file and the
|
||||||
|
git/PR state is observed by the server.
|
||||||
|
|
||||||
|
The single mutating disposition it can return is "publish this exact commit
|
||||||
|
to this exact branch". It never sanctions renewal, reclamation, force
|
||||||
|
updates, history rewriting, or publication of uncommitted content.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
branch = _text(branch_name)
|
||||||
|
head = _text(expected_head).lower()
|
||||||
|
workspace = _realpath(worktree_path)
|
||||||
|
state = worktree_state if isinstance(worktree_state, dict) else {}
|
||||||
|
lock = existing_lock if isinstance(existing_lock, dict) else None
|
||||||
|
|
||||||
|
evidence: dict = {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": branch or None,
|
||||||
|
"worktree_path": workspace,
|
||||||
|
"expected_head": head or None,
|
||||||
|
"remote": _text(remote) or None,
|
||||||
|
"org": _text(org) or None,
|
||||||
|
"repo": _text(repo) or None,
|
||||||
|
"identity": _text(identity) or None,
|
||||||
|
"profile": _text(profile) or None,
|
||||||
|
"lock_record_present": lock is not None,
|
||||||
|
"recorded_claimant": None,
|
||||||
|
"recorded_branch": None,
|
||||||
|
"recorded_worktree": None,
|
||||||
|
"lock_generation": None,
|
||||||
|
"local_head_sha": _text(state.get("head_sha")) or None,
|
||||||
|
"current_branch": _text(state.get("current_branch")) or None,
|
||||||
|
"dirty_tracked_files": [],
|
||||||
|
"untracked_files": [],
|
||||||
|
"worktree_registered": worktree_registered,
|
||||||
|
"remote_branch_exists": None,
|
||||||
|
"remote_head_sha": None,
|
||||||
|
"fast_forward_from_remote": None,
|
||||||
|
"competing_open_prs": [],
|
||||||
|
"file_hashes_verified": None,
|
||||||
|
"hash_mismatches": [],
|
||||||
|
# Recorded explicitly so no reader mistakes silence for a liveness
|
||||||
|
# claim, and so the audit shows which records were left alone (AC23/AC24).
|
||||||
|
"owner_pid_liveness_consulted": False,
|
||||||
|
"workflow_lease_touched": False,
|
||||||
|
"issue_lock_record_mutated": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── declared shape ────────────────────────────────────────────────────
|
||||||
|
if not branch:
|
||||||
|
reasons.append("branch name not declared; fail closed")
|
||||||
|
if not head:
|
||||||
|
reasons.append(
|
||||||
|
"expected_head not declared; publication must name the exact commit"
|
||||||
|
)
|
||||||
|
elif not _SHA_RE.match(head):
|
||||||
|
reasons.append(
|
||||||
|
f"expected_head '{head}' is not a full 40-character commit SHA; "
|
||||||
|
"abbreviated or symbolic revisions are refused"
|
||||||
|
)
|
||||||
|
if not workspace:
|
||||||
|
reasons.append("worktree path not declared; fail closed")
|
||||||
|
|
||||||
|
if branch:
|
||||||
|
match = ISSUE_BRANCH_RE.match(branch)
|
||||||
|
if not match:
|
||||||
|
reasons.append(
|
||||||
|
f"branch '{branch}' is not an issue-linked implementation branch "
|
||||||
|
"((fix|feat|docs|chore)/issue-<number>-<description>); fail closed"
|
||||||
|
)
|
||||||
|
elif int(match.group(2)) != int(issue_number):
|
||||||
|
reasons.append(
|
||||||
|
f"branch '{branch}' does not carry issue number {issue_number}; "
|
||||||
|
"fail closed"
|
||||||
|
)
|
||||||
|
if is_stable_ref(branch):
|
||||||
|
reasons.append(
|
||||||
|
f"refusing to publish stable branch '{branch}'; this operation "
|
||||||
|
"publishes issue branches only"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── ownership: durable issue-lock record only (AC8, AC20, AC24) ───────
|
||||||
|
if lock is None:
|
||||||
|
reasons.append(
|
||||||
|
"no durable issue-lock record for this issue; publication requires an "
|
||||||
|
"existing recorded claim naming the caller, so this operation cannot "
|
||||||
|
"be used to bypass the author lock"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lease = lock.get("work_lease")
|
||||||
|
lease = lease if isinstance(lease, dict) else {}
|
||||||
|
claimant = lease.get("claimant")
|
||||||
|
claimant = claimant if isinstance(claimant, dict) else {}
|
||||||
|
recorded_user = _text(claimant.get("username"))
|
||||||
|
recorded_profile = _text(claimant.get("profile"))
|
||||||
|
recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch"))
|
||||||
|
recorded_worktree = _realpath(
|
||||||
|
_text(lock.get("worktree_path")) or _text(lease.get("worktree_path"))
|
||||||
|
)
|
||||||
|
evidence["recorded_claimant"] = {
|
||||||
|
"username": recorded_user or None,
|
||||||
|
"profile": recorded_profile or None,
|
||||||
|
}
|
||||||
|
evidence["recorded_branch"] = recorded_branch or None
|
||||||
|
evidence["recorded_worktree"] = recorded_worktree
|
||||||
|
try:
|
||||||
|
evidence["lock_generation"] = int(lock.get("lock_generation") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
evidence["lock_generation"] = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
recorded_issue = int(lock.get("issue_number") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
recorded_issue = 0
|
||||||
|
if recorded_issue != int(issue_number):
|
||||||
|
reasons.append(
|
||||||
|
f"durable lock records issue {lock.get('issue_number')}, not "
|
||||||
|
f"{issue_number}; ambiguous ownership, fail closed"
|
||||||
|
)
|
||||||
|
for field, declared in (
|
||||||
|
("remote", _text(remote)),
|
||||||
|
("org", _text(org)),
|
||||||
|
("repo", _text(repo)),
|
||||||
|
):
|
||||||
|
recorded = _text(lock.get(field))
|
||||||
|
if recorded and declared and recorded != declared:
|
||||||
|
reasons.append(
|
||||||
|
f"durable lock records {field} '{recorded}' but the request "
|
||||||
|
f"declares '{declared}'; repository mismatch, fail closed"
|
||||||
|
)
|
||||||
|
if recorded_branch and branch and recorded_branch != branch:
|
||||||
|
reasons.append(
|
||||||
|
f"durable lock records branch '{recorded_branch}' but the request "
|
||||||
|
f"declares '{branch}'; fail closed"
|
||||||
|
)
|
||||||
|
if recorded_worktree and workspace and recorded_worktree != workspace:
|
||||||
|
reasons.append(
|
||||||
|
f"durable lock records worktree '{recorded_worktree}' but the "
|
||||||
|
f"request declares '{workspace}'; fail closed"
|
||||||
|
)
|
||||||
|
if not recorded_user or not recorded_profile:
|
||||||
|
reasons.append(
|
||||||
|
"durable lock does not record a claimant username and profile; "
|
||||||
|
"ownership cannot be proven, fail closed"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if recorded_user != _text(identity):
|
||||||
|
reasons.append(
|
||||||
|
f"durable lock claimant '{recorded_user}' is not the acting "
|
||||||
|
f"identity '{_text(identity) or '(unknown)'}'; foreign claim, "
|
||||||
|
"fail closed"
|
||||||
|
)
|
||||||
|
if recorded_profile != _text(profile):
|
||||||
|
reasons.append(
|
||||||
|
f"durable lock claimant profile '{recorded_profile}' is not "
|
||||||
|
f"the active profile '{_text(profile) or '(unknown)'}'; "
|
||||||
|
"fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── worktree: registered, on-branch, clean, at the expected commit ────
|
||||||
|
if worktree_registered is False:
|
||||||
|
reasons.append(
|
||||||
|
f"worktree '{workspace}' is not listed in git worktree list; #713 "
|
||||||
|
"requires a genuinely registered worktree, fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
current_branch = _text(state.get("current_branch"))
|
||||||
|
if not current_branch:
|
||||||
|
reasons.append("worktree branch could not be observed; fail closed")
|
||||||
|
elif branch and current_branch != branch:
|
||||||
|
reasons.append(
|
||||||
|
f"worktree is on branch '{current_branch}', not '{branch}'; fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
porcelain = state.get("porcelain_status") or ""
|
||||||
|
dirty_tracked = parse_dirty_tracked_files(porcelain)
|
||||||
|
untracked = parse_untracked_files(porcelain)
|
||||||
|
evidence["dirty_tracked_files"] = dirty_tracked
|
||||||
|
evidence["untracked_files"] = untracked
|
||||||
|
if dirty_tracked:
|
||||||
|
reasons.append(
|
||||||
|
"worktree has dirty tracked files, so the commit is not the whole of "
|
||||||
|
f"the work: {', '.join(dirty_tracked)}. This operation publishes an "
|
||||||
|
"existing clean commit only; uncommitted content is out of scope"
|
||||||
|
)
|
||||||
|
if untracked:
|
||||||
|
reasons.append(
|
||||||
|
"worktree has untracked files that the commit does not carry: "
|
||||||
|
f"{', '.join(untracked)}. Publishing would silently leave them "
|
||||||
|
"behind; fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
local_head = _text(state.get("head_sha")).lower()
|
||||||
|
if not local_head:
|
||||||
|
reasons.append("local HEAD could not be observed; fail closed")
|
||||||
|
elif head and local_head != head:
|
||||||
|
reasons.append(
|
||||||
|
f"worktree HEAD is {local_head} but the request declares {head}; the "
|
||||||
|
"local commit changed since it was recorded, fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── remote state ──────────────────────────────────────────────────────
|
||||||
|
probe = remote_probe if isinstance(remote_probe, dict) else {}
|
||||||
|
already_published = False
|
||||||
|
if not probe.get("probe_ok"):
|
||||||
|
reasons.append(
|
||||||
|
"remote branch head could not be observed; publication must not "
|
||||||
|
"proceed against an unknown remote state, fail closed"
|
||||||
|
)
|
||||||
|
reasons.extend(probe.get("reasons") or [])
|
||||||
|
else:
|
||||||
|
remote_exists = bool(probe.get("remote_branch_exists"))
|
||||||
|
remote_head = _text(probe.get("remote_head_sha")).lower() or None
|
||||||
|
evidence["remote_branch_exists"] = remote_exists
|
||||||
|
evidence["remote_head_sha"] = remote_head
|
||||||
|
if remote_exists and remote_head and head:
|
||||||
|
if remote_head == head:
|
||||||
|
already_published = True
|
||||||
|
evidence["fast_forward_from_remote"] = True
|
||||||
|
else:
|
||||||
|
anc = ancestry if isinstance(ancestry, dict) else {}
|
||||||
|
is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor"))
|
||||||
|
evidence["fast_forward_from_remote"] = is_anc
|
||||||
|
if not is_anc:
|
||||||
|
reasons.append(
|
||||||
|
f"remote branch '{branch}' already exists at {remote_head}, "
|
||||||
|
f"which is not an ancestor of {head}; publishing would "
|
||||||
|
"discard or rewrite published history, fail closed"
|
||||||
|
)
|
||||||
|
reasons.extend(anc.get("reasons") or [])
|
||||||
|
elif remote_exists and not remote_head:
|
||||||
|
reasons.append(
|
||||||
|
f"remote branch '{branch}' exists but its head could not be read; "
|
||||||
|
"fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── competing claims ──────────────────────────────────────────────────
|
||||||
|
competing = [p for p in (competing_open_prs or ()) if p]
|
||||||
|
evidence["competing_open_prs"] = list(competing)
|
||||||
|
if competing:
|
||||||
|
reasons.append(
|
||||||
|
f"open pull request(s) {competing} already claim issue {issue_number} "
|
||||||
|
"or this branch; ambiguous ownership, fail closed"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── content verification before publication ───────────────────────────
|
||||||
|
if expected_file_hashes:
|
||||||
|
observed = (
|
||||||
|
observed_file_hashes if isinstance(observed_file_hashes, dict) else {}
|
||||||
|
)
|
||||||
|
mismatches: list[str] = []
|
||||||
|
for path, expected_digest in dict(expected_file_hashes).items():
|
||||||
|
actual = observed.get(path)
|
||||||
|
if actual is None:
|
||||||
|
mismatches.append(f"{path}: missing or unreadable in the worktree")
|
||||||
|
elif _text(actual).lower() != _text(expected_digest).lower():
|
||||||
|
mismatches.append(
|
||||||
|
f"{path}: expected {expected_digest}, observed {actual}"
|
||||||
|
)
|
||||||
|
evidence["hash_mismatches"] = mismatches
|
||||||
|
evidence["file_hashes_verified"] = not mismatches
|
||||||
|
if mismatches:
|
||||||
|
reasons.append(
|
||||||
|
"declared content hashes do not match the worktree: "
|
||||||
|
+ "; ".join(mismatches)
|
||||||
|
+ ". Refusing to publish content that is not what was recorded"
|
||||||
|
)
|
||||||
|
|
||||||
|
if reasons:
|
||||||
|
return {
|
||||||
|
"outcome": REFUSED,
|
||||||
|
"publish_sanctioned": False,
|
||||||
|
"already_published": False,
|
||||||
|
"reasons": reasons,
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED,
|
||||||
|
# Idempotent retry: a remote head that already equals the assessed commit
|
||||||
|
# needs no second push, so the caller verifies instead of acting.
|
||||||
|
"publish_sanctioned": not already_published,
|
||||||
|
"already_published": already_published,
|
||||||
|
"reasons": [],
|
||||||
|
"evidence": evidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def publish_commit_to_remote_branch(
|
||||||
|
*,
|
||||||
|
worktree_path: str,
|
||||||
|
remote_name: str,
|
||||||
|
branch_name: str,
|
||||||
|
expected_head: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Send exactly *expected_head* to ``refs/heads/<branch_name>``.
|
||||||
|
|
||||||
|
The refspec names the commit SHA explicitly rather than ``HEAD`` or the
|
||||||
|
local branch, so what lands is the commit that was assessed and nothing
|
||||||
|
else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by
|
||||||
|
git itself, the last of several independent guards against overwriting
|
||||||
|
published history.
|
||||||
|
"""
|
||||||
|
path = _text(worktree_path)
|
||||||
|
remote = _text(remote_name)
|
||||||
|
branch = _text(branch_name)
|
||||||
|
head = _text(expected_head)
|
||||||
|
result: dict = {
|
||||||
|
"success": False,
|
||||||
|
"pushed_ref": f"refs/heads/{branch}" if branch else None,
|
||||||
|
"pushed_sha": head or None,
|
||||||
|
"stderr": None,
|
||||||
|
"reasons": [],
|
||||||
|
}
|
||||||
|
if not (path and remote and branch and head):
|
||||||
|
result["reasons"].append(
|
||||||
|
"publication requires a worktree path, remote, branch, and commit SHA"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
refspec = f"{head}:refs/heads/{branch}"
|
||||||
|
try:
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", path, "push", remote, refspec],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
result["reasons"].append(f"publication could not run: {exc}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if res.returncode != 0:
|
||||||
|
# Redact before surfacing: failures can echo credentialed remote URLs.
|
||||||
|
result["stderr"] = redact_command(res.stderr or "")
|
||||||
|
result["reasons"].append(
|
||||||
|
f"publication of {head} to '{branch}' on remote '{remote}' failed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_published_head(
|
||||||
|
*, worktree_path: str, remote_name: str, branch_name: str, expected_head: str
|
||||||
|
) -> dict:
|
||||||
|
"""Read-after-write: confirm the remote head equals *expected_head* (AC20)."""
|
||||||
|
probe = read_remote_branch_head(worktree_path, remote_name, branch_name)
|
||||||
|
head = _text(expected_head).lower()
|
||||||
|
observed = _text(probe.get("remote_head_sha")).lower() or None
|
||||||
|
verified = bool(head) and bool(probe.get("probe_ok")) and observed == head
|
||||||
|
reasons: list[str] = list(probe.get("reasons") or [])
|
||||||
|
if probe.get("probe_ok") and not verified:
|
||||||
|
reasons.append(
|
||||||
|
"read-after-write verification failed: remote head is "
|
||||||
|
f"{observed or '(absent)'}, expected {head}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"verified": verified,
|
||||||
|
"remote_head_sha": observed,
|
||||||
|
"expected_head": head or None,
|
||||||
|
"reasons": reasons,
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# ADR: MCP Control Plane Web Console architecture and information architecture
|
||||||
|
|
||||||
|
- **Status:** Proposed (documentation only; blocks no code, gates every #631 child)
|
||||||
|
- **Date:** 2026-07-22
|
||||||
|
- **Tracking issue:** [#632](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/632) — architecture and information architecture (Phase 1)
|
||||||
|
- **Parent epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631) — MCP Control Plane Web Console
|
||||||
|
- **Foundation (closed, extend — do not recreate):** #425 tracker and children #426 skeleton, #427 projects, #428 prompts, #429 queue, #430 runtime, #431 audit paste, #432 worktrees, #433 leases, #434 gated actions, #435 auth/deployment boundary, #436 tests/CI
|
||||||
|
- **Related:** `mcp-allocator-control-plane-observability-adr.md`, `mcp-stable-control-runtime-policy-adr.md`, `control-plane-db-substrate.md`, `../safety-model.md`, `../tool-boundaries.md`, `../credential-isolation.md`, `../webui-local-dev.md`, `../webui-deployment.md`
|
||||||
|
|
||||||
|
## 1. Context
|
||||||
|
|
||||||
|
The MVP web UI shipped under `webui/` as a read-only Starlette application with ten operator routes and a JSON export beside most of them. It is a working foundation, not the console product described by epic #631, and it carries no durable architecture record: no layer contract, no authority boundary, no API versioning rule, no page map, and no statement of which phase may open a write path.
|
||||||
|
|
||||||
|
Twenty children (#632–#651) hang off #631. Without one architecture document each implementer re-derives boundaries, and the most likely failure is not a bad view — it is a privileged action wired into the browser before the authorization and audit model of #633 exists.
|
||||||
|
|
||||||
|
This ADR is the single retrievable design source for the console. It decides structure only. It implements no UI, no API, and no change to deployment topology.
|
||||||
|
|
||||||
|
## 2. Decision summary (core)
|
||||||
|
|
||||||
|
| Layer | Owns | Must not |
|
||||||
|
|-------|------|----------|
|
||||||
|
| **Browser UI** | Rendering, navigation, operator affordances | Hold tokens, call Gitea/providers directly, or execute an action the server did not gate |
|
||||||
|
| **HTTP route layer** (`webui/app.py`) | Versioned routing, authentication, authorization, redaction boundary, audit emission | Contain domain logic or reach past a loader to a raw credential |
|
||||||
|
| **Domain loaders** (`webui/*_loader.py`, `*_scanner.py`, `runtime_health.py`, `project_registry.py`) | Assembling read models from authoritative sources | Mutate anything, or emit unredacted secrets across the boundary |
|
||||||
|
| **Gitea** | Durable work record: issues, PRs, comments, reviews, labels, merges | Be the concurrency lock under multi-session load |
|
||||||
|
| **Control-plane DB** | Sessions, assignment, leases, heartbeats, events | Replace Gitea history |
|
||||||
|
| **MCP tools / capability gates** | Mutation authorization | Be re-implemented, mirrored, or bypassed by console code |
|
||||||
|
| **External providers** (Sentry/GlitchTip, AI providers) | Incident and usage data | Assign work or mutate Gitea outside the #612 bridge |
|
||||||
|
|
||||||
|
**One-liner:** **Gitea records. The DB coordinates. MCP tools authorize. The console projects state and executes only capability-checked, audited actions. Providers observe.**
|
||||||
|
|
||||||
|
## 3. Console surface today versus target
|
||||||
|
|
||||||
|
`webui/app.py` currently registers these routes (see `../webui-local-dev.md` for the operator-facing table): `/`, `/health`, `/queue`, `/projects`, `/projects/{id}`, `/prompts`, `/runtime`, `/audit`, `/worktrees`, `/leases`, `/actions`, and the unversioned exports `/api/queue`, `/api/projects`, `/api/prompts`, `/api/runtime`, `/api/audit`, `/api/worktrees`, `/api/leases`, `/api/actions`, `/api/actions/{id}/preview`, `/api/actions/{id}/attempt`.
|
||||||
|
|
||||||
|
Every one of these is **retained and evolved**. No child issue may recreate a route from scratch; each states in its PR which MVP surface it extends and what it changes.
|
||||||
|
|
||||||
|
## 4. Authority boundaries
|
||||||
|
|
||||||
|
### 4.1 Gitea (durable record)
|
||||||
|
|
||||||
|
Authoritative for issue and PR identity and state, comments, reviews and verdicts, labels, merges, and branch refs. When the console and Gitea disagree about durable state, Gitea wins and the console view is refreshed — never the reverse.
|
||||||
|
|
||||||
|
### 4.2 Control-plane DB (coordination)
|
||||||
|
|
||||||
|
Authoritative for live coordination: which session holds which assignment or lease, heartbeat freshness, expiry, and the allocation event log. The console reads it; only allocator and lease tools write it.
|
||||||
|
|
||||||
|
### 4.3 MCP capability gates (authorization)
|
||||||
|
|
||||||
|
`task_capability_map.py` and `gitea_resolve_task_capability` remain the only authority that decides whether a mutation may run. The console asks; it never answers. A console action that cannot name the MCP tool it delegates to is not an action — it is a defect.
|
||||||
|
|
||||||
|
### 4.4 Filesystem and git (local state)
|
||||||
|
|
||||||
|
Issue lock files, `branches/` worktrees, and registered git worktrees are read through existing scanners. The console never deletes, rebinds, or force-clears local state outside a Phase 2 gated action.
|
||||||
|
|
||||||
|
### 4.5 Providers (observe only)
|
||||||
|
|
||||||
|
Sentry/GlitchTip and AI providers are read surfaces. The #612 incident bridge is the only path that turns an observation into Gitea work.
|
||||||
|
|
||||||
|
## 5. Request flow and the redaction boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
browser ──HTTP──> route layer ──> domain loader ──> Gitea REST
|
||||||
|
│ ├──> control-plane DB
|
||||||
|
│ ├──> filesystem / git
|
||||||
|
│ └──> providers
|
||||||
|
│
|
||||||
|
[redaction boundary]
|
||||||
|
│
|
||||||
|
audit event
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | May hold credentials | Emits |
|
||||||
|
|-------|----------------------|-------|
|
||||||
|
| Loader → route layer | yes (server-side, via `gitea_auth`) | domain objects |
|
||||||
|
| Route layer → browser | **no** | redacted DTOs, HTML |
|
||||||
|
|
||||||
|
Two invariants govern the boundary and are non-negotiable for every child:
|
||||||
|
|
||||||
|
1. **No secrets to the browser.** Tokens, keychain identifiers, Authorization headers, raw provider endpoints, and credential-bearing URLs are redacted by default, consistent with `../safety-model.md` §3 and `../credential-isolation.md`. Serializers redact; templates do not sanitize after the fact.
|
||||||
|
2. **No ungated mutations.** A write reaches an authoritative system only by delegating to an MCP tool that passed its own capability gate. HTML forms and JSON endpoints are transport, never authority.
|
||||||
|
|
||||||
|
## 6. API naming and versioning
|
||||||
|
|
||||||
|
**Decision:** all console APIs added from Phase 1 onward are served under `/api/v1/...`.
|
||||||
|
|
||||||
|
- Nouns are plural and hierarchical: `/api/v1/inventory/leases`, `/api/v1/system/health`.
|
||||||
|
- Read endpoints are `GET` and side-effect free.
|
||||||
|
- Phase 2 action endpoints are `POST /api/v1/actions/{action_id}/preview` and `POST /api/v1/actions/{action_id}/execute`; `preview` stays side-effect free and returns a mutation ledger.
|
||||||
|
- The existing unversioned MVP exports remain as **compatibility aliases** for the whole of Phase 1 so the current operator flow never breaks. They may be retired no earlier than Phase 2, and only after the replacing `v1` route ships and `../webui-local-dev.md` records the swap.
|
||||||
|
- A breaking change to a `v1` payload requires `/api/v2/...`, not an in-place edit.
|
||||||
|
- Every JSON payload carries enough provenance for an auditor to tell where the data came from — at minimum the source system and whether the inventory was complete, matching the pagination-proof habit the MVP queue export already established.
|
||||||
|
|
||||||
|
## 7. Page map
|
||||||
|
|
||||||
|
| Page | Purpose | Owning child | Evolves |
|
||||||
|
|------|---------|--------------|---------|
|
||||||
|
| `/` | Console shell, navigation, next-safe-action summary | #638 | MVP `/` (#426) |
|
||||||
|
| `/system` | System-health dashboard | #639 | new, backed by #634 |
|
||||||
|
| `/traffic` | Workflow traffic control, queues, blockers | #640 | MVP `/queue` (#429) |
|
||||||
|
| `/runtime` | Runtime and session view | #641 | MVP `/runtime` (#430) |
|
||||||
|
| `/projects`, `/projects/{id}` | Project registry and onboarding | #635 | MVP `/projects` (#427) |
|
||||||
|
| `/inventory` | Sessions, leases, locks, worktrees in one surface | #636 | MVP `/leases` (#433) + `/worktrees` (#432) |
|
||||||
|
| `/timeline` | Workflow events and conversation timeline | #637 | new |
|
||||||
|
| `/actions` | Gated action registry, preview, execution | #642, #643, #644 | MVP `/actions` (#434) |
|
||||||
|
| `/gitea` | Issue and PR linkage console | #645 | new |
|
||||||
|
| `/policy` | Guardrail visibility, then versioned editing | #646, #647 | new |
|
||||||
|
| `/notifications` | Human-attention routing | #648 | new |
|
||||||
|
| `/observability` | Sentry/GlitchTip correlation and durable issue creation | #649 | new |
|
||||||
|
| `/providers` | AI-provider connections and insights | #650 | new |
|
||||||
|
| `/analytics` | Usage, token cost, latency, workflow performance | #651 | new |
|
||||||
|
| `/audit` | Final-report validator preview and audit log | #431 foundation, extended by #633 | MVP `/audit` (#431) |
|
||||||
|
| `/prompts`, `/prompts/{id}` | Canonical prompt library | #638 | MVP `/prompts` (#428) |
|
||||||
|
| `/health` | Liveness and deployment metadata | #634 | MVP `/health` (#435) |
|
||||||
|
|
||||||
|
## 8. Component ownership for every epic child
|
||||||
|
|
||||||
|
Each #631 child maps to at least one architectural component defined above.
|
||||||
|
|
||||||
|
| Child | Capability area | Primary component | Phase |
|
||||||
|
|-------|-----------------|-------------------|-------|
|
||||||
|
| #632 | Architecture and information architecture | this ADR | 1 |
|
||||||
|
| #633 | Authorization, RBAC, secret redaction, audit and retention | route layer + redaction boundary (§5) | 1 |
|
||||||
|
| #634 | Read-only system-health API | `/api/v1/system/health` + health loader | 1 |
|
||||||
|
| #635 | Project registry API evolution | `/api/v1/projects` + `project_registry.py` | 1 |
|
||||||
|
| #636 | Session, lease, lock, worktree inventory API | `/api/v1/inventory/*` + `lease_loader.py`, `worktree_scanner.py` | 1 |
|
||||||
|
| #637 | Workflow-event and conversation timeline model | `/api/v1/events` + control-plane DB event log | 1 |
|
||||||
|
| #638 | Application shell evolution | browser UI layer + `layout.py` | 1 |
|
||||||
|
| #639 | System-health dashboard | `/system` page over #634 | 1 |
|
||||||
|
| #640 | Workflow traffic-control view | `/traffic` page over the queue loader | 1 |
|
||||||
|
| #641 | Runtime and session view | `/runtime` page over `runtime_health.py` | 1 |
|
||||||
|
| #642 | Sanctioned restart and graceful reload controls | gated action framework, restart class | 2 |
|
||||||
|
| #643 | Requests, intent preview, authorization, workflow initiation | `/api/v1/actions/*` execute path | 2 |
|
||||||
|
| #644 | Stale-runtime recovery, worktree rebinding, reconciliation controls | gated actions over filesystem/git authority | 2 |
|
||||||
|
| #645 | Gitea issue and PR linkage console | `/gitea` page over Gitea authority | 3 |
|
||||||
|
| #646 | Workflow policy and guardrail visibility | `/policy` read view over the capability map | 3 |
|
||||||
|
| #647 | Versioned policy editing, validation, simulation, approval, rollback | `/policy` write path, gated | 3 |
|
||||||
|
| #648 | Notifications and human-attention routing | notification component over the event model | 3 |
|
||||||
|
| #649 | Sentry/GlitchTip connections, correlation, durable issue creation | provider layer + #612 incident bridge | 4 |
|
||||||
|
| #650 | AI-provider connections and operational insights | provider layer | 4 |
|
||||||
|
| #651 | Model usage, token cost, latency, workflow analytics | analytics component over the event model | 4 |
|
||||||
|
|
||||||
|
Related but **outside** this epic: #667 (restart status, impact preview, and approval controls) belongs to the #655 restart-governance umbrella and must reuse the #642 action class rather than adding a second restart surface.
|
||||||
|
|
||||||
|
## 9. Phase gates
|
||||||
|
|
||||||
|
| Phase | May ship | Entry condition |
|
||||||
|
|-------|----------|-----------------|
|
||||||
|
| **1 — read-only visibility** | `GET` pages and `GET /api/v1/...` | this ADR accepted |
|
||||||
|
| **2 — controlled actions** | gated `POST` action execution | #633 authorization, RBAC, and audit model landed |
|
||||||
|
| **3 — orchestration and policy** | linkage, policy visibility, versioned policy editing | Phase 1 inventory plus the Phase 2 action framework |
|
||||||
|
| **4 — insights** | provider correlation, analytics | evidence-backed sources from Phases 1–3 |
|
||||||
|
|
||||||
|
Phase 1 must not open a mutation endpoint, and the read-only guard that returns `405 read-only-mvp` stays in force until the Phase 2 entry condition is met. A phase is not entered by exception; if a control is urgent, the entry condition is what gets prioritized.
|
||||||
|
|
||||||
|
## 10. Security and workflow safety
|
||||||
|
|
||||||
|
- **Fail closed** on unknown authentication, missing RBAC mapping, or ambiguous lease ownership. An unknown state renders as blocked, never as permitted.
|
||||||
|
- **Redact by default**, per §5.
|
||||||
|
- **Every privileged action** requires a resolved capability, an explicit operator confirmation, and a durable audit event naming actor, action, target, and outcome.
|
||||||
|
- **Contamination surfaces.** Session contamination — including a manually killed MCP daemon (#630) — must be shown and must block clean claims rather than being silently repaired.
|
||||||
|
- **Deployment boundary unchanged.** Loopback by default, with the existing refusal of public binds (#435). This ADR documents that target; it does not widen it.
|
||||||
|
|
||||||
|
## 11. Forbidden paths
|
||||||
|
|
||||||
|
These are rejected designs, not preferences:
|
||||||
|
|
||||||
|
1. **Raw provider incidents as work.** The allocator never receives an unclassified Sentry/GlitchTip incident; only the #612 bridge turns an observation into a Gitea issue.
|
||||||
|
2. **Browser-held tokens.** No credential, keychain identifier, or Authorization header is ever sent to the browser or embedded in a client bundle.
|
||||||
|
3. **Process-kill recovery.** The console must not expose `pkill`, process-identifier termination, or any host process kill as a recovery affordance (#630). Restart is the sanctioned, operator-owned path of #642 and the #655 umbrella.
|
||||||
|
4. **Ungated browser mutations.** No review, approval, merge, close, or comment may originate from the browser without passing an MCP capability gate.
|
||||||
|
5. **Policy invented in the console.** The console projects policy from the capability map and canonical workflows; it never encodes a second copy.
|
||||||
|
6. **Recreating MVP scope.** Re-implementing a #426–#436 surface without an explicit evolve-or-extend statement is out of bounds.
|
||||||
|
|
||||||
|
## 12. Approval checklist (readable without chat history)
|
||||||
|
|
||||||
|
A controller can accept or reject this ADR against these six points alone:
|
||||||
|
|
||||||
|
1. Layers and their owners are defined (§2) and each authority is named (§4).
|
||||||
|
2. The redaction boundary and the two invariants are stated (§5).
|
||||||
|
3. API versioning is decided, including what happens to the existing unversioned routes (§6).
|
||||||
|
4. A page map exists and names an owning child for every page (§7).
|
||||||
|
5. Every #631 child maps to at least one component and one phase (§8).
|
||||||
|
6. Phase gates and forbidden paths are explicit (§9, §11).
|
||||||
|
|
||||||
|
## 13. Open questions and follow-ups
|
||||||
|
|
||||||
|
Unresolved choices are recorded here rather than settled by implication. Each needs its own durable issue before the phase that depends on it:
|
||||||
|
|
||||||
|
- **Authentication mechanism.** Whether the console authenticates via an access proxy (Cloudflare Access or equivalent) or an application-level session is deferred to #633. This ADR requires only that it fail closed.
|
||||||
|
- **Event model substrate.** Whether the #637 timeline reads the control-plane event log directly or through a projection is deferred to #637.
|
||||||
|
- **CI path filter coverage.** `webui/ci_paths.py` triggers the web UI suite on `webui/`, `tests/test_webui_*`, and `docs/webui*`. This ADR lives under `docs/architecture/`, so editing it alone does not trigger that gate; the accompanying `tests/test_webui_architecture_docs.py` does run in the full suite. Widening the filter is a small follow-up, deliberately not bundled into a documentation-only change.
|
||||||
|
- **Retention.** Audit-event retention duration is owned by #633.
|
||||||
|
|
||||||
|
## 14. Acceptance
|
||||||
|
|
||||||
|
Accepting this ADR means:
|
||||||
|
|
||||||
|
- Phase 1 children may proceed against the layers, page map, and API rules above.
|
||||||
|
- Phase 2 children may not open a write path until #633 lands.
|
||||||
|
- Any deviation is recorded as an amendment to this file with its own issue reference, not as an undocumented divergence in code.
|
||||||
@@ -120,6 +120,7 @@ that gates each call, not which tools exist.
|
|||||||
- `gitea_observability_list_projects`
|
- `gitea_observability_list_projects`
|
||||||
- `gitea_observability_reconcile_incident`
|
- `gitea_observability_reconcile_incident`
|
||||||
- `gitea_post_heartbeat`
|
- `gitea_post_heartbeat`
|
||||||
|
- `gitea_publish_unpublished_issue_branch`
|
||||||
- `gitea_quarantine_contaminated_review`
|
- `gitea_quarantine_contaminated_review`
|
||||||
- `gitea_reclaim_expired_workflow_lease`
|
- `gitea_reclaim_expired_workflow_lease`
|
||||||
- `gitea_reconcile_already_landed_pr`
|
- `gitea_reconcile_already_landed_pr`
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# Web console authorization, RBAC, redaction, and audit model (#633)
|
||||||
|
|
||||||
|
**Phase 1. Read-only. This document defines the model that future console
|
||||||
|
writes must pass through; it enables none of them.**
|
||||||
|
|
||||||
|
The MVP deployment boundary ([`webui-deployment.md`](webui-deployment.md), #435)
|
||||||
|
documents internal-only serving and states plainly that MVP authentication is
|
||||||
|
*none* — protection comes from network placement. That is adequate while every
|
||||||
|
route is a GET, and inadequate the moment a gated write ships. This document
|
||||||
|
and the three modules it describes land **before** any write exists, so no
|
||||||
|
Phase 2 action can be added without an authority to check it against.
|
||||||
|
|
||||||
|
| Concern | Module |
|
||||||
|
|---------|--------|
|
||||||
|
| Identity, roles, authorization decision | `webui/console_authz.py` |
|
||||||
|
| Secret redaction for every surface | `webui/console_redaction.py` |
|
||||||
|
| Audit event schema, retention, sink | `webui/console_audit.py` |
|
||||||
|
| Machine-readable publication | `GET /api/console/security-model` |
|
||||||
|
|
||||||
|
Two invariants hold everywhere and are non-negotiable for every child of #631:
|
||||||
|
|
||||||
|
1. **No secrets reach the browser.** Credentials are resolved server-side and
|
||||||
|
redacted before any payload, page, log line, or audit record leaves.
|
||||||
|
2. **No ungated mutations.** Authorization is necessary but never sufficient;
|
||||||
|
execution stays disabled until the Phase 2 framework ships.
|
||||||
|
|
||||||
|
## Identity sources
|
||||||
|
|
||||||
|
The console performs *authorization*. Authentication is delegated, because a
|
||||||
|
console that mints its own sessions is a credential store, and this one must
|
||||||
|
not be.
|
||||||
|
|
||||||
|
| Source | Mode value | Authenticated | Shared host | Phase |
|
||||||
|
|--------|-----------|---------------|-------------|-------|
|
||||||
|
| None | `none` (default) | No — anonymous, capped at `viewer` | No | 1 |
|
||||||
|
| Local dev | `local-dev` / `local_dev` | Yes, **asserted not verified** | No | 1 |
|
||||||
|
| Access proxy | `access-proxy` / `access_proxy` | Yes, asserted by trusted proxy | Yes | 2 |
|
||||||
|
|
||||||
|
Selected by `WEBUI_AUTH_MODE`. An unrecognised value falls back to `none`
|
||||||
|
rather than erroring open.
|
||||||
|
|
||||||
|
**Access-proxy mode** reads the subject from the
|
||||||
|
`Cf-Access-Authenticated-User-Email` header, set by Cloudflare Access, WARP, or
|
||||||
|
an equivalent org portal that terminates authentication in front of the
|
||||||
|
console. If the header is absent the request did not traverse the proxy, so the
|
||||||
|
principal degrades to anonymous — it is never trusted by default.
|
||||||
|
|
||||||
|
The **role is always server-side configuration**, never a client assertion. It
|
||||||
|
comes from `WEBUI_ROLE_MAP`, a JSON object of subject → role:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"[email protected]": "operator", "[email protected]": "controller"}
|
||||||
|
```
|
||||||
|
|
||||||
|
An unmapped subject gets `viewer`. Malformed JSON yields an empty map, so
|
||||||
|
everyone gets `viewer` — a parse failure loses authority rather than granting
|
||||||
|
it.
|
||||||
|
|
||||||
|
Full SSO is explicitly a non-goal of this issue.
|
||||||
|
|
||||||
|
## Role matrix
|
||||||
|
|
||||||
|
Four roles, ordered least to most authority. Each role inherits every lower
|
||||||
|
role's actions; the table states the *minimum* rank required.
|
||||||
|
|
||||||
|
| Role | Authority |
|
||||||
|
|------|-----------|
|
||||||
|
| `viewer` | Read every console view. No write, ever, in any phase. |
|
||||||
|
| `operator` | Viewer, plus author-class work: claim, comment, open a PR. |
|
||||||
|
| `controller` | Operator, plus reviewer/merger-class decisions on a PR. |
|
||||||
|
| `admin` | Controller, plus destructive and policy-editing actions. |
|
||||||
|
|
||||||
|
`viewer` holds the empty write set by construction, and a test asserts it stays
|
||||||
|
empty.
|
||||||
|
|
||||||
|
## Privileged actions
|
||||||
|
|
||||||
|
Every console action maps to a `task_key` in `task_capability_map.py`, the same
|
||||||
|
single source of truth `gitea_resolve_task_capability` and the MCP tool gates
|
||||||
|
use. The console therefore cannot invent an authority the MCP layer does not
|
||||||
|
already define, and a regression test asserts each mapping matches.
|
||||||
|
|
||||||
|
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|
||||||
|
|--------|--------------|-------|----------------|---------|--------------|-------------|-------|
|
||||||
|
| `claim_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||||
|
| `comment_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||||
|
| `create_issue` | operator | gated_write | `gitea.issue.create` | Yes | No | No | 2 |
|
||||||
|
| `comment_pr` | operator | gated_write | `gitea.pr.comment` | Yes | No | No | 2 |
|
||||||
|
| `create_pr` | operator | gated_write | `gitea.pr.create` | Yes | No | No | 2 |
|
||||||
|
| `review_pr` | controller | privileged | `gitea.pr.review` | Yes | No | No | 3 |
|
||||||
|
| `close_pr` | controller | privileged | `gitea.pr.close` | Yes | No | No | 3 |
|
||||||
|
| `merge_pr` | controller | privileged | `gitea.pr.merge` | Yes | **Yes** | **Yes** | 3 |
|
||||||
|
| `delete_branch` | admin | destructive | `gitea.branch.delete` | Yes | **Yes** | **Yes** | 3 |
|
||||||
|
|
||||||
|
**Dual control** means the acting principal may not be the sole authority: a
|
||||||
|
second distinct principal must confirm. **Break-glass** means the action is
|
||||||
|
expected to be unavailable in normal operation and its use is retained for two
|
||||||
|
years. Both are declared here and enforced by the Phase 2 framework; Phase 1
|
||||||
|
records the requirement on every decision so the framework cannot ship without
|
||||||
|
honouring it.
|
||||||
|
|
||||||
|
`delete_branch` is admin-only rather than controller because it is the one
|
||||||
|
irreversible action in the set.
|
||||||
|
|
||||||
|
### Authorization decision
|
||||||
|
|
||||||
|
`authorize(action_id, principal, for_execution=False)` returns a decision
|
||||||
|
record and **denies by default**. The deny reasons are closed and enumerated:
|
||||||
|
|
||||||
|
| Reason code | Meaning |
|
||||||
|
|-------------|---------|
|
||||||
|
| `unknown_action` | No such console action is registered. |
|
||||||
|
| `unauthenticated` | The principal is anonymous. |
|
||||||
|
| `unknown_role` | The role is not in the matrix. |
|
||||||
|
| `insufficient_role` | The role ranks below the action's minimum. |
|
||||||
|
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
||||||
|
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
||||||
|
|
||||||
|
There is no implicit allow branch. Even the allow result reports
|
||||||
|
`execution_enabled: false` while the console is in Phase 1, so no caller can
|
||||||
|
read an allow as permission to mutate.
|
||||||
|
|
||||||
|
## Secret redaction
|
||||||
|
|
||||||
|
One pass applies to **API payloads, rendered HTML, server logs, and audit
|
||||||
|
records** — the four surfaces where a credential could escape.
|
||||||
|
|
||||||
|
Redaction reuses `gitea_audit.redact` rather than forking it: that remains the
|
||||||
|
authority for secret-looking dict keys, `Authorization` material, and raw URLs.
|
||||||
|
The console layer then applies its own patterns:
|
||||||
|
|
||||||
|
Each rule below matches an *assignment form*: the named key, followed by `=` or
|
||||||
|
`:`, followed by the value. The keys are listed bare rather than spelled out as
|
||||||
|
complete assignments, because this document is itself scanned by
|
||||||
|
`scan_for_secrets` — writing the examples in full assignment form would make the
|
||||||
|
documentation trip the very detectors it documents.
|
||||||
|
|
||||||
|
| Rule | Catches (as an assignment) |
|
||||||
|
|------|----------------------------|
|
||||||
|
| `credential_assignment` | `token`, `password`, `passwd`, `secret`, `api_key`, `access_key`, `client_secret`, `private_key` |
|
||||||
|
| `credential_env_assignment` | `GITEA_TOKEN`, `GITEA_PASS`, `GITEA_PASSWORD` and suffixed variants |
|
||||||
|
| `keychain_reference` | `keychain:` entry references |
|
||||||
|
| `keychain_command` | macOS `security` keychain lookups (`find-generic-password`, `find-internet-password`) |
|
||||||
|
| `private_key_block` | PEM `BEGIN ... PRIVATE KEY` blocks |
|
||||||
|
| `json_web_token` | Three-segment `eyJ...` JWTs |
|
||||||
|
| `bearer_credential` | `Bearer` / `Basic` credentials |
|
||||||
|
|
||||||
|
Assignments keep the key and replace only the value, so an operator can still
|
||||||
|
see *what* was removed. Two behaviours are deliberate:
|
||||||
|
|
||||||
|
- **Fail closed.** A value that cannot be redacted becomes `[REDACTED]`
|
||||||
|
outright rather than being emitted raw. Redaction never raises.
|
||||||
|
- **Redact before persist.** `console_audit.build_event` redacts before
|
||||||
|
serialization, and `write_event` re-scans and **drops** any record that still
|
||||||
|
trips a detector. An unredacted record is never durable.
|
||||||
|
|
||||||
|
`scan_for_secrets` is the assertion helper: it returns the detector names still
|
||||||
|
matching a payload, and already-redacted hits are not findings. Tests use it to
|
||||||
|
prove the published policy, the security-model endpoint, and this document
|
||||||
|
itself carry no secret material.
|
||||||
|
|
||||||
|
## Audit event schema
|
||||||
|
|
||||||
|
`gitea_audit` records MCP-side *mutations* — which profile and Gitea user
|
||||||
|
performed which tool call. It has no console actor, no identity source, no
|
||||||
|
correlation identifier, and no retention class, and an authorization **denial**
|
||||||
|
is not a mutation, so it would never appear there at all. The console record is
|
||||||
|
additive, not a replacement: a Phase 2 action emits both, joined on
|
||||||
|
`correlation.request_id`.
|
||||||
|
|
||||||
|
Required fields, all asserted by tests so an edit cannot quietly drop one:
|
||||||
|
|
||||||
|
| Field | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `schema_version` | Currently `1`. |
|
||||||
|
| `event_id` | Unique per record. |
|
||||||
|
| `timestamp` | Timezone-aware ISO-8601, UTC. |
|
||||||
|
| `actor` | `subject`, `role`, `identity_source`, `authenticated`. |
|
||||||
|
| `action` | Console action id. |
|
||||||
|
| `action_class` | `gated_write`, `privileged`, `destructive`, or `unknown`. |
|
||||||
|
| `target` | `{kind, ref}`, e.g. `{"kind": "pr", "ref": "#123"}`. |
|
||||||
|
| `result` | `allowed`, `denied`, `previewed`, `failed`, `succeeded`. |
|
||||||
|
| `reason_code` | The authorization reason code above. |
|
||||||
|
| `correlation` | `request_id`, `session_id`, `mcp_task`, `mcp_permission`. |
|
||||||
|
| `retention` | `class`, `days`, `expires_at`. |
|
||||||
|
| `redacted` | Always `true`; records are redacted at build time. |
|
||||||
|
|
||||||
|
An unrecognised `result` degrades to `failed` rather than being stored
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
The sink is an append-only JSON Lines file named by
|
||||||
|
`WEBUI_CONSOLE_AUDIT_LOG`. It is **off by default**: with the variable unset,
|
||||||
|
events are still built — so callers and tests exercise the schema — but nothing
|
||||||
|
is written. Auditing never raises; a failed write returns `False` rather than
|
||||||
|
breaking the request it describes.
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
| Class | Applies to | Default |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| `standard` | Routine gated writes | 90 days |
|
||||||
|
| `privileged` | `review_pr`, `close_pr`, and any unclassifiable action | 365 days |
|
||||||
|
| `break_glass` | `merge_pr`, `delete_branch` | 730 days |
|
||||||
|
|
||||||
|
Each record carries its own class, day count, and computed `expires_at`, so
|
||||||
|
retention is auditable per record rather than inferred from file age. An
|
||||||
|
**unknown action is retained as privileged, not standard** — for a safety
|
||||||
|
control the conservative direction is to keep the record longer.
|
||||||
|
|
||||||
|
Nothing in this module updates or deletes. Expiry is enforced by an
|
||||||
|
operator-run policy against `expires_at`, never by the console silently
|
||||||
|
rewriting its own history.
|
||||||
|
|
||||||
|
## Phase 2 integration
|
||||||
|
|
||||||
|
Phase 2 opens gated writes. It must reuse this model rather than introduce a
|
||||||
|
second one. The integration points are already wired and observable:
|
||||||
|
|
||||||
|
- **`GET /api/actions/{action_id}/preview`** attaches an `authorization` block
|
||||||
|
to the existing preview payload and records a `previewed` audit event.
|
||||||
|
- **`POST /api/actions/{action_id}/attempt`** attaches the same block and
|
||||||
|
records a `denied` event. The terminal outcome is unchanged — the MVP
|
||||||
|
registry in `webui/gated_actions.py` still fails closed for every action — so
|
||||||
|
Phase 1 cannot loosen anything. Phase 2 enforces on this same decision
|
||||||
|
instead of adding a parallel check.
|
||||||
|
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
||||||
|
policy, and audit policy as JSON for operators and tests.
|
||||||
|
|
||||||
|
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
|
||||||
|
confirmation and dual-control flow the matrix already declares, emit a
|
||||||
|
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
|
||||||
|
keep `viewer` unable to reach any of it. Turning on execution without the
|
||||||
|
confirmation flow contradicts a declared requirement and is a review failure,
|
||||||
|
not a shortcut.
|
||||||
|
|
||||||
|
## Local-dev mode
|
||||||
|
|
||||||
|
`WEBUI_AUTH_MODE=local-dev` reads the principal straight from the environment:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `WEBUI_DEV_SUBJECT` | Subject string; absent ⇒ anonymous |
|
||||||
|
| `WEBUI_DEV_ROLE` | One of `viewer`, `operator`, `controller`, `admin`; unrecognised ⇒ `viewer` |
|
||||||
|
|
||||||
|
**INSECURE — this mode is for loopback development only.** The subject and role
|
||||||
|
are *asserted by the developer running the process and verified by nothing*.
|
||||||
|
Anyone able to set an environment variable on the host is an `admin`, and
|
||||||
|
anyone able to reach the port inherits that principal. It provides no
|
||||||
|
authentication whatsoever; it exists so Phase 2 authorization paths can be
|
||||||
|
exercised without standing up a proxy.
|
||||||
|
|
||||||
|
Never enable local-dev mode on a non-loopback bind. Combining it with
|
||||||
|
`WEBUI_ALLOW_PUBLIC_BIND=1` or `WEBUI_ALLOW_REMOTE_BIND=1` publishes an
|
||||||
|
unauthenticated admin console.
|
||||||
|
|
||||||
|
For anything beyond a laptop use `access-proxy` mode behind Cloudflare Access,
|
||||||
|
WARP, or a VPN, as [`webui-deployment.md`](webui-deployment.md) requires.
|
||||||
|
|
||||||
|
### Probe authentication
|
||||||
|
|
||||||
|
`WEBUI_REQUIRE_PROBE_AUTH=1` declares that non-public probes should require an
|
||||||
|
authenticated principal. It is **opt-in**: the default is off so the MVP
|
||||||
|
`/health` contract is unchanged.
|
||||||
|
|
||||||
|
**This flag is declarative in Phase 1 and enforces nothing today.**
|
||||||
|
`console_authz.probe_auth_required()` reports the operator's intent, and no
|
||||||
|
route consults it — setting the variable does not currently change the
|
||||||
|
behaviour of `/health` or any other endpoint. It is published here so the Phase
|
||||||
|
2 action framework has a declared policy to honour rather than inventing a
|
||||||
|
second one, exactly as `ACTIVE_PHASE` gates execution while the matrix is
|
||||||
|
already declared. A regression test pins this "declared, not enforced" status,
|
||||||
|
so wiring it later is a deliberate change rather than a silent one.
|
||||||
|
|
||||||
|
Until Phase 2 wires it, probe protection rests on network placement alone, as
|
||||||
|
[`webui-deployment.md`](webui-deployment.md) (#435) states.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `WEBUI_AUTH_MODE` | `none` | Identity source selection |
|
||||||
|
| `WEBUI_DEV_SUBJECT` | unset | Local-dev subject (insecure) |
|
||||||
|
| `WEBUI_DEV_ROLE` | `viewer` | Local-dev role (insecure) |
|
||||||
|
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
||||||
|
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
||||||
|
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
||||||
|
|
||||||
|
All are read server-side only. None is ever rendered into a page or returned by
|
||||||
|
an API.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No full SSO product; authentication stays delegated to the proxy.
|
||||||
|
- No browser-initiated merges or approvals in any phase covered here.
|
||||||
|
- No tokens in the frontend, in browser storage, or in committed config.
|
||||||
@@ -7,7 +7,10 @@ only.
|
|||||||
## MVP deployment model
|
## MVP deployment model
|
||||||
|
|
||||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||||
- **Authentication:** none in MVP — protection comes from network placement
|
- **Authentication:** none in MVP — protection comes from network placement.
|
||||||
|
The authorization, RBAC, redaction, and audit model that future gated writes
|
||||||
|
must pass through is defined in
|
||||||
|
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
|
||||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||||
embedded in HTML, JavaScript, or browser storage
|
embedded in HTML, JavaScript, or browser storage
|
||||||
|
|||||||
+95
-3
@@ -37,17 +37,30 @@ Optional environment variables:
|
|||||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||||
|
|
||||||
|
See
|
||||||
|
[architecture/webui-control-plane-console-architecture-adr.md](architecture/webui-control-plane-console-architecture-adr.md)
|
||||||
|
for the console architecture: layer and authority boundaries, the redaction
|
||||||
|
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
|
||||||
|
that govern when a write path may open (#632, epic #631).
|
||||||
|
|
||||||
|
See [webui-project-registry-api.md](webui-project-registry-api.md) for the
|
||||||
|
versioned project registry contract: registry schema versions 1 and 2, project
|
||||||
|
status, onboarding checklist state, and the fail-closed error payloads (#635).
|
||||||
|
|
||||||
## Routes (MVP)
|
## Routes (MVP)
|
||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `/` | Home / operator overview |
|
| `/` | Home / operator overview |
|
||||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`, `uptime_seconds`) |
|
||||||
|
| `/api/v1/system/health` | Structured read-only system health (#634) |
|
||||||
| `/queue` | Live PR and issue queue dashboard (#429) |
|
| `/queue` | Live PR and issue queue dashboard (#429) |
|
||||||
| `/api/queue` | JSON queue export with pagination metadata |
|
| `/api/queue` | JSON queue export with pagination metadata |
|
||||||
| `/projects` | Project registry list (#427) |
|
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
|
||||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||||
| `/api/projects` | JSON registry export |
|
| `/api/v1/projects` | Versioned JSON registry export (#635) |
|
||||||
|
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
|
||||||
|
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
|
||||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||||
@@ -66,6 +79,85 @@ Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
|||||||
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||||
local validator preview only (no Gitea mutations, no server-side storage).
|
local validator preview only (no Gitea mutations, no server-side storage).
|
||||||
|
|
||||||
|
## System health API (#634)
|
||||||
|
|
||||||
|
`GET /api/v1/system/health` is the structured, read-only health surface for
|
||||||
|
automated readiness checks. It is the first console API under the `/api/v1`
|
||||||
|
prefix; the unversioned MVP exports remain as compatibility aliases.
|
||||||
|
|
||||||
|
`/health` is unchanged for existing consumers — every MVP key is still present
|
||||||
|
— and now also carries `started_at`, `uptime_seconds`, and a
|
||||||
|
`system_health_api` pointer. It stays deliberately cheap and runs no dependency
|
||||||
|
probe, because answering readiness costs real work.
|
||||||
|
|
||||||
|
**Status codes.** `200` when ready, `503` when a required dependency failed or
|
||||||
|
was never probed. Automation can branch on the code without parsing the body.
|
||||||
|
|
||||||
|
**Query flags.** The Gitea check is a network call, so it is opt-in:
|
||||||
|
`GET /api/v1/system/health?deep=1` runs it and caches the result for
|
||||||
|
`WEBUI_HEALTH_PROBE_TTL_SECONDS` (default 15s) so dashboard polling does not
|
||||||
|
amplify into remote load. Without the flag that probe reports `skipped`.
|
||||||
|
|
||||||
|
**Dependencies.** `control_plane_db` and `repository` are required and drive
|
||||||
|
readiness. `gitea` is optional: when it fails the overall `status` degrades but
|
||||||
|
`readiness.ready` stays true, because local inventory is still serveable. Each
|
||||||
|
entry carries `status`, `detail`, `required`, and `latency_ms`.
|
||||||
|
|
||||||
|
Two honesty rules are worth knowing before reading the payload:
|
||||||
|
|
||||||
|
* `stale_runtime.mutation_safe` is true only when the runtime, checkout, and
|
||||||
|
remote-tracking commits are all known and equal. An unfetched remote is
|
||||||
|
reported as indeterminate, never as safe.
|
||||||
|
* `mcp_namespaces` entries are always `unproven`. A web process runs outside
|
||||||
|
the IDE-managed MCP client and cannot invoke a namespace tool, so per #543
|
||||||
|
only a `client_namespace` probe can prove that path.
|
||||||
|
|
||||||
|
Sample response (abridged, healthy):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "mcp-control-plane-webui",
|
||||||
|
"mode": "read-only",
|
||||||
|
"api": "/api/v1/system/health",
|
||||||
|
"timestamp": "2026-07-22T11:04:18.512034+00:00",
|
||||||
|
"readiness": { "ready": true, "complete": true, "reasons": [] },
|
||||||
|
"version": {
|
||||||
|
"git_sha": "620ed6e9a9550b8da2ceb82d9ab8744e8920490f",
|
||||||
|
"git_describe": "v1.1.0-898-g620ed6e",
|
||||||
|
"control_plane_schema_version": 4,
|
||||||
|
"python_version": "3.14.5",
|
||||||
|
"known": true
|
||||||
|
},
|
||||||
|
"process": { "started_at": "2026-07-22T10:58:02.114+00:00", "uptime_seconds": 376.4 },
|
||||||
|
"deep_probes_requested": false,
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"name": "control_plane_db",
|
||||||
|
"kind": "sqlite",
|
||||||
|
"status": "ok",
|
||||||
|
"detail": "schema v4 readable",
|
||||||
|
"required": true,
|
||||||
|
"healthy": true,
|
||||||
|
"latency_ms": 1.482,
|
||||||
|
"metadata": { "schema_version": 4, "active_leases": 3 }
|
||||||
|
},
|
||||||
|
{ "name": "repository", "kind": "git", "status": "ok", "required": true, "healthy": true },
|
||||||
|
{ "name": "gitea", "kind": "http", "status": "skipped", "required": false, "healthy": false }
|
||||||
|
],
|
||||||
|
"mcp_namespaces": [
|
||||||
|
{ "namespace": "gitea-author", "required_tool": "gitea_whoami", "status": "unproven" }
|
||||||
|
],
|
||||||
|
"stale_runtime": { "stale": false, "determinable": true, "mutation_safe": true, "reasons": [] },
|
||||||
|
"probe_errors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No restart, reload, or process-kill control is exposed here: those are Phase 2
|
||||||
|
at the earliest, and #630 forbids process-kill recovery outright. Every probe
|
||||||
|
opens its subject read-only — the control-plane database is opened through a
|
||||||
|
`mode=ro` URI so a health check can never create or migrate a schema.
|
||||||
|
|
||||||
## Report audit (#431)
|
## Report audit (#431)
|
||||||
|
|
||||||
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Project registry API (#635)
|
||||||
|
|
||||||
|
Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
|
||||||
|
gives the project registry a versioned, read-only API. This document is the
|
||||||
|
field-by-field contract for that API and for the registry file behind it.
|
||||||
|
|
||||||
|
Everything here is **read-only**. The console never writes the registry; an
|
||||||
|
operator edits the JSON file, and an invalid file fails closed rather than
|
||||||
|
rendering a partial inventory.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Method | Description |
|
||||||
|
|-------|--------|-------------|
|
||||||
|
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
|
||||||
|
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
|
||||||
|
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
|
||||||
|
| `/projects` | GET | HTML list — status and onboarding progress per project |
|
||||||
|
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |
|
||||||
|
|
||||||
|
Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
|
||||||
|
and only after this document and `webui-local-dev.md` record the swap. The alias
|
||||||
|
returns the same payload as `/api/v1/projects`, including the legacy `version`
|
||||||
|
and `source_path` keys #427 consumers already read.
|
||||||
|
|
||||||
|
The HTML views render from the same DTO the JSON routes serialize
|
||||||
|
(`project_to_dict`), so the console and the API cannot disagree about a
|
||||||
|
project's status or onboarding progress.
|
||||||
|
|
||||||
|
## Registry file
|
||||||
|
|
||||||
|
Default location: `webui/data/projects.registry.json`. Override with the
|
||||||
|
`WEBUI_PROJECT_REGISTRY` environment variable.
|
||||||
|
|
||||||
|
Schema versions: **1** and **2** are accepted; **2** is current. A version 1
|
||||||
|
file loads unchanged and is normalized with the documented defaults, so an
|
||||||
|
existing operator registry keeps working without edits.
|
||||||
|
|
||||||
|
### Root
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `version` | int | yes | `1` or `2`. Anything else fails closed |
|
||||||
|
| `projects` | array | yes | Must be non-empty |
|
||||||
|
|
||||||
|
### Project
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Notes |
|
||||||
|
|-------|------|----------|---------|-------|
|
||||||
|
| `id` | string | yes | — | Stable registry id used in URLs |
|
||||||
|
| `repo_name` | string | yes | — | Gitea repository name |
|
||||||
|
| `gitea_owner` | string | yes | — | Owning org or user |
|
||||||
|
| `remote_host` | string | yes | — | Instance base URL, no credentials |
|
||||||
|
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
|
||||||
|
| `default_branch` | string | yes | — | Stable branch name |
|
||||||
|
| `local_checkout_path` | string | yes | — | Control checkout path |
|
||||||
|
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
|
||||||
|
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
|
||||||
|
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
|
||||||
|
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
|
||||||
|
| `onboarding_checklist` | array | no | `[]` | See below |
|
||||||
|
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |
|
||||||
|
|
||||||
|
### Onboarding step
|
||||||
|
|
||||||
|
| Field | Type | Required | Default | Notes |
|
||||||
|
|-------|------|----------|---------|-------|
|
||||||
|
| `id` | string | yes | — | Stable step id |
|
||||||
|
| `title` | string | yes | — | Short operator-facing label |
|
||||||
|
| `description` | string | yes | — | Self-contained; assumes no chat history |
|
||||||
|
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
|
||||||
|
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |
|
||||||
|
|
||||||
|
### Last-seen health
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
|
||||||
|
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
|
||||||
|
| `detail` | string | no | Short redacted note |
|
||||||
|
|
||||||
|
Health is recorded metadata, not a live probe: Phase 1 performs no outbound
|
||||||
|
health checks. Endpoints, tokens, and keychain identifiers must never appear
|
||||||
|
here.
|
||||||
|
|
||||||
|
## Response shape
|
||||||
|
|
||||||
|
`GET /api/v1/projects`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_version": "v1",
|
||||||
|
"schema_version": 2,
|
||||||
|
"version": 2,
|
||||||
|
"source_path": "/path/to/webui/data/projects.registry.json",
|
||||||
|
"source": {
|
||||||
|
"kind": "file",
|
||||||
|
"path": "/path/to/webui/data/projects.registry.json",
|
||||||
|
"inventory_complete": true
|
||||||
|
},
|
||||||
|
"project_count": 1,
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": "example",
|
||||||
|
"repo_name": "Example",
|
||||||
|
"gitea_owner": "Org",
|
||||||
|
"repo_full_name": "Org/Example",
|
||||||
|
"remote_host": "https://gitea.example.invalid",
|
||||||
|
"remote_name": "example-remote",
|
||||||
|
"default_branch": "main",
|
||||||
|
"local_checkout_path": ".",
|
||||||
|
"status": "active",
|
||||||
|
"profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
|
||||||
|
"workflow_paths": {"skill": "skills/..."},
|
||||||
|
"schema_paths": {},
|
||||||
|
"onboarding_checklist": [
|
||||||
|
{
|
||||||
|
"id": "profiles",
|
||||||
|
"title": "Configure execution profiles",
|
||||||
|
"description": "...",
|
||||||
|
"state": "complete",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"onboarding_summary": {
|
||||||
|
"total": 1,
|
||||||
|
"complete": 1,
|
||||||
|
"pending": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"not_applicable": 0,
|
||||||
|
"required_outstanding": 0,
|
||||||
|
"onboarding_complete": true
|
||||||
|
},
|
||||||
|
"last_seen_health": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
|
||||||
|
`source`, and a single `project` object with the same fields.
|
||||||
|
|
||||||
|
The `source` block satisfies the ADR section 6 provenance rule: every payload
|
||||||
|
states where the data came from and whether the inventory is complete. A
|
||||||
|
file-backed registry is always complete — there is no pagination to truncate it.
|
||||||
|
|
||||||
|
`onboarding_summary` is derived, never stored. `required_outstanding` counts
|
||||||
|
steps that are `required` **and** in state `pending` or `blocked`;
|
||||||
|
`onboarding_complete` is true when that count is zero.
|
||||||
|
|
||||||
|
## Fail-closed errors
|
||||||
|
|
||||||
|
Validation failures raise `RegistryError`, which routes render instead of a
|
||||||
|
traceback.
|
||||||
|
|
||||||
|
`404` — unknown project id on `/api/v1/projects/{project_id}`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "project_not_found",
|
||||||
|
"project_id": "not-registered",
|
||||||
|
"known_project_ids": ["example"],
|
||||||
|
"remediation": "Request one of the known project ids, or add the project ...",
|
||||||
|
"source": {"kind": "file", "path": "...", "inventory_complete": true}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`500` — invalid registry, on both the versioned route and the alias:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "registry_invalid",
|
||||||
|
"detail": "unsupported registry version: 42",
|
||||||
|
"remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
|
||||||
|
"field_path": "version",
|
||||||
|
"source_path": "/path/to/registry.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`field_path` points at the offending location (`projects[0].profiles.reconciler`,
|
||||||
|
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
|
||||||
|
the same detail, field, source, and remediation on a "Project registry
|
||||||
|
unavailable" page.
|
||||||
|
|
||||||
|
Conditions that fail closed:
|
||||||
|
|
||||||
|
* file missing or unreadable;
|
||||||
|
* invalid JSON (the remediation names line and column);
|
||||||
|
* root not an object, or `projects` missing/empty;
|
||||||
|
* unsupported `version`;
|
||||||
|
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
|
||||||
|
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
|
||||||
|
* an unknown `status`, onboarding `state`, or health `status`.
|
||||||
|
|
||||||
|
## Credential rule
|
||||||
|
|
||||||
|
The registry stores redacted metadata only. Credential-shaped keys are
|
||||||
|
rejected at load time, before any DTO is built, consistent with
|
||||||
|
[safety-model.md](safety-model.md) and
|
||||||
|
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
|
||||||
|
and are resolved server-side by `gitea_auth`.
|
||||||
|
|
||||||
|
## Migrating a version 1 registry
|
||||||
|
|
||||||
|
1. Set `"version": 2`.
|
||||||
|
2. Optionally add `"status"` per project (omitted means `active`).
|
||||||
|
3. Optionally add `"remote_name"` per project.
|
||||||
|
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
|
||||||
|
means `pending` and `true`).
|
||||||
|
5. Optionally add `"last_seen_health"`.
|
||||||
|
|
||||||
|
No step is mandatory: a version 1 file keeps loading. Bumping the version only
|
||||||
|
declares that the file may use the v2 fields.
|
||||||
+289
-6
@@ -234,12 +234,25 @@ def _effective_workspace_role() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _profile_role_kind(profile: dict) -> str:
|
def _profile_role_kind(profile: dict) -> str:
|
||||||
"""Resolve a profile's declared role before inferring from permissions."""
|
"""Resolve a profile's declared role before inferring from permissions.
|
||||||
role = (profile.get("role") or profile.get("role_kind") or "").strip()
|
|
||||||
|
Declared ``role`` / ``role_kind`` always wins so a controller profile is
|
||||||
|
never reclassified as reconciler from permission inference (#840).
|
||||||
|
"""
|
||||||
|
role = (profile.get("role") or profile.get("role_kind") or "").strip().lower()
|
||||||
if role:
|
if role:
|
||||||
|
# Normalize aliases / case.
|
||||||
|
if "control" in role:
|
||||||
|
return "controller"
|
||||||
return role
|
return role
|
||||||
profile_name = (profile.get("profile_name") or "").strip().lower()
|
profile_name = (profile.get("profile_name") or "").strip().lower()
|
||||||
for candidate in ("reconciler", "merger", "reviewer", "author"):
|
for candidate in (
|
||||||
|
"controller",
|
||||||
|
"reconciler",
|
||||||
|
"merger",
|
||||||
|
"reviewer",
|
||||||
|
"author",
|
||||||
|
):
|
||||||
if candidate in profile_name:
|
if candidate in profile_name:
|
||||||
return candidate
|
return candidate
|
||||||
return _role_kind(
|
return _role_kind(
|
||||||
@@ -2024,6 +2037,7 @@ import review_quarantine # noqa: E402 # #695 contaminated formal-review quaran
|
|||||||
import mcp_daemon_guard # noqa: E402 # #695 native transport provenance
|
import mcp_daemon_guard # noqa: E402 # #695 native transport provenance
|
||||||
import already_landed_reconcile # noqa: E402
|
import already_landed_reconcile # noqa: E402
|
||||||
import author_mutation_worktree # noqa: E402
|
import author_mutation_worktree # noqa: E402
|
||||||
|
import branch_publish # noqa: E402 # #812 AC20 unpublished-commit publication
|
||||||
import root_checkout_guard # noqa: E402
|
import root_checkout_guard # noqa: E402
|
||||||
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
|
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
|
||||||
import stable_branch_push_guard # noqa: E402
|
import stable_branch_push_guard # noqa: E402
|
||||||
@@ -9082,6 +9096,253 @@ def gitea_commit_files(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _publication_block(reasons: list[str], **extra) -> dict:
|
||||||
|
"""Uniform fail-closed shape for publication refusals (#812 AC20)."""
|
||||||
|
payload = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"published": False,
|
||||||
|
"verified": False,
|
||||||
|
"outcome": branch_publish.REFUSED,
|
||||||
|
"reasons": reasons,
|
||||||
|
# State every record this operation left alone, so a refusal can never
|
||||||
|
# be misread as a lock mutation (#812 AC23).
|
||||||
|
"issue_lock_record_mutated": False,
|
||||||
|
"workflow_lease_touched": False,
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_publish_unpublished_issue_branch(
|
||||||
|
issue_number: int,
|
||||||
|
branch_name: str,
|
||||||
|
worktree_path: str,
|
||||||
|
expected_head: str,
|
||||||
|
remote: str = "dadeschools",
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
git_remote_name: str | None = None,
|
||||||
|
expected_file_hashes: dict | None = None,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Publish an already-committed, unpublished issue branch (#812 AC20).
|
||||||
|
|
||||||
|
Creates the remote head for a branch whose work is *already* a local commit
|
||||||
|
on a registered, clean worktree, so exact-owner lease renewal
|
||||||
|
(``issue_lock_renewal``) has the published head its evidence model requires.
|
||||||
|
This is the one step of the entry point B deadlock that no existing tool can
|
||||||
|
perform: publication is otherwise lock-derived under #618, and the lock
|
||||||
|
itself is withheld until a remote head exists.
|
||||||
|
|
||||||
|
Not a lock bypass. The branch's **durable issue-lock record must already
|
||||||
|
name the caller as claimant** — ownership is read from the lock file, never
|
||||||
|
asserted by the caller — so this can only publish work the caller already
|
||||||
|
owns. It refuses a dirty or untracked-carrying worktree, an unregistered
|
||||||
|
worktree, a changed local HEAD, a remote head that is not an ancestor of the
|
||||||
|
commit, a competing open PR on another branch for the same issue, and any
|
||||||
|
declared-hash mismatch. It renews, reclaims, and clears nothing: the durable
|
||||||
|
issue-lock file and the control-plane workflow lease are both left untouched
|
||||||
|
(#812 AC23), and the recorded owner pid's liveness is never consulted or
|
||||||
|
asserted (#812 AC24).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_number: The issue whose recorded claim authorizes publication.
|
||||||
|
branch_name: Issue branch to publish, ``(fix|feat|docs|chore)/issue-N-…``.
|
||||||
|
worktree_path: Registered worktree holding the commit.
|
||||||
|
expected_head: Full 40-character SHA of the commit to publish. Required:
|
||||||
|
publication names the exact commit, and a mismatch fails closed.
|
||||||
|
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||||
|
host: Override the Gitea host.
|
||||||
|
org: Override the owner/organization.
|
||||||
|
repo: Override the repository name.
|
||||||
|
git_remote_name: Git remote to publish to; defaults to *remote*.
|
||||||
|
expected_file_hashes: Optional ``{path: sha256}`` verified against the
|
||||||
|
worktree before publication. Any mismatch or missing file refuses.
|
||||||
|
dry_run: Report the decision and evidence, mutate nothing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with 'success', 'performed', 'published', 'verified', 'outcome',
|
||||||
|
'remote_head_sha', 'reasons', and 'evidence'.
|
||||||
|
"""
|
||||||
|
task = "publish_unpublished_branch"
|
||||||
|
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||||||
|
task
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return _publication_block(block_reasons)
|
||||||
|
|
||||||
|
blocked = _namespace_mutation_block(task, remote=remote)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
blocked = _profile_permission_block(
|
||||||
|
task_capability_map.required_permission(task),
|
||||||
|
remote=remote, host=host, org=org, repo=repo,
|
||||||
|
org_explicit=org is not None,
|
||||||
|
repo_explicit=repo is not None,
|
||||||
|
)
|
||||||
|
if blocked:
|
||||||
|
return blocked
|
||||||
|
|
||||||
|
# #815: resolve the caller's worktree *before* preflight and forward it, so
|
||||||
|
# every workspace-resolution layer behind verify_preflight_purity — including
|
||||||
|
# the #618 branches-only guard — judges the registered issue worktree this
|
||||||
|
# publication actually operates on. Resolving it afterwards let preflight
|
||||||
|
# fall back to the MCP process root, so a daemon rooted at the stable control
|
||||||
|
# checkout refused a valid explicit worktree before the assessor ever ran.
|
||||||
|
# A caller supplying nothing usable forwards None and keeps the ordinary
|
||||||
|
# fail-closed fallback.
|
||||||
|
explicit_worktree = (worktree_path or "").strip()
|
||||||
|
workspace = os.path.realpath(os.path.abspath(explicit_worktree or "."))
|
||||||
|
|
||||||
|
verify_preflight_purity(
|
||||||
|
remote,
|
||||||
|
worktree_path=workspace if explicit_worktree else None,
|
||||||
|
task=task,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
|
git_remote = (git_remote_name or remote or "").strip()
|
||||||
|
|
||||||
|
existing_lock = issue_lock_store.load_issue_lock(
|
||||||
|
remote=remote, org=o, repo=r, issue_number=int(issue_number)
|
||||||
|
)
|
||||||
|
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||||
|
registered = author_mutation_worktree.path_in_git_worktree_list(
|
||||||
|
workspace, PROJECT_ROOT
|
||||||
|
)
|
||||||
|
|
||||||
|
remote_probe = branch_publish.read_remote_branch_head(
|
||||||
|
workspace, git_remote, branch_name
|
||||||
|
)
|
||||||
|
ancestry = None
|
||||||
|
probe_head = (remote_probe.get("remote_head_sha") or "").strip()
|
||||||
|
if probe_head and probe_head.lower() != (expected_head or "").strip().lower():
|
||||||
|
ancestry = branch_publish.read_is_ancestor(workspace, probe_head, expected_head)
|
||||||
|
|
||||||
|
# A PR on this very branch is this work's own PR, not a rival claim. Only an
|
||||||
|
# open PR for the same issue on a *different* branch is a competing claim.
|
||||||
|
competing: list = []
|
||||||
|
for pull in _list_open_pulls(h, o, r, _auth(h)):
|
||||||
|
ref = str((pull.get("head") or {}).get("ref") or "")
|
||||||
|
if not ref or ref == branch_name:
|
||||||
|
continue
|
||||||
|
if issue_lock_adoption.branch_carries_issue_marker(ref, int(issue_number)):
|
||||||
|
competing.append(pull.get("number"))
|
||||||
|
|
||||||
|
observed_hashes = None
|
||||||
|
if expected_file_hashes:
|
||||||
|
observed_hashes = branch_publish.hash_worktree_files(
|
||||||
|
workspace, list(expected_file_hashes.keys())
|
||||||
|
)
|
||||||
|
|
||||||
|
claimant = _work_lease_claimant(h)
|
||||||
|
assessment = branch_publish.assess_unpublished_commit_publication(
|
||||||
|
existing_lock,
|
||||||
|
issue_number=int(issue_number),
|
||||||
|
branch_name=branch_name,
|
||||||
|
worktree_path=workspace,
|
||||||
|
expected_head=expected_head,
|
||||||
|
remote=remote,
|
||||||
|
org=o,
|
||||||
|
repo=r,
|
||||||
|
identity=claimant.get("username"),
|
||||||
|
profile=claimant.get("profile"),
|
||||||
|
worktree_state=git_state,
|
||||||
|
worktree_registered=registered,
|
||||||
|
remote_probe=remote_probe,
|
||||||
|
ancestry=ancestry,
|
||||||
|
competing_open_prs=competing,
|
||||||
|
expected_file_hashes=expected_file_hashes,
|
||||||
|
observed_file_hashes=observed_hashes,
|
||||||
|
)
|
||||||
|
|
||||||
|
if assessment["outcome"] == branch_publish.REFUSED:
|
||||||
|
return _publication_block(
|
||||||
|
assessment["reasons"], evidence=assessment["evidence"]
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"performed": False,
|
||||||
|
"published": False,
|
||||||
|
"verified": False,
|
||||||
|
"dry_run": True,
|
||||||
|
"outcome": assessment["outcome"],
|
||||||
|
"would_publish": assessment["publish_sanctioned"],
|
||||||
|
"remote_head_sha": assessment["evidence"].get("remote_head_sha"),
|
||||||
|
"reasons": [],
|
||||||
|
"evidence": assessment["evidence"],
|
||||||
|
"issue_lock_record_mutated": False,
|
||||||
|
"workflow_lease_touched": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
performed = False
|
||||||
|
if assessment["publish_sanctioned"]:
|
||||||
|
with _audited(
|
||||||
|
task, host=h, remote=remote, org=o, repo=r,
|
||||||
|
target_branch=branch_name,
|
||||||
|
request_metadata={
|
||||||
|
"issue_number": int(issue_number),
|
||||||
|
"expected_head": expected_head,
|
||||||
|
"git_remote": git_remote,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
push = branch_publish.publish_commit_to_remote_branch(
|
||||||
|
worktree_path=workspace,
|
||||||
|
remote_name=git_remote,
|
||||||
|
branch_name=branch_name,
|
||||||
|
expected_head=expected_head,
|
||||||
|
)
|
||||||
|
if not push.get("success"):
|
||||||
|
return _publication_block(
|
||||||
|
push.get("reasons") or ["publication failed"],
|
||||||
|
evidence=assessment["evidence"],
|
||||||
|
stderr=push.get("stderr"),
|
||||||
|
)
|
||||||
|
performed = True
|
||||||
|
|
||||||
|
verification = branch_publish.verify_published_head(
|
||||||
|
worktree_path=workspace,
|
||||||
|
remote_name=git_remote,
|
||||||
|
branch_name=branch_name,
|
||||||
|
expected_head=expected_head,
|
||||||
|
)
|
||||||
|
if not verification.get("verified"):
|
||||||
|
return _publication_block(
|
||||||
|
verification.get("reasons") or ["read-after-write verification failed"],
|
||||||
|
evidence=assessment["evidence"],
|
||||||
|
performed=performed,
|
||||||
|
published=performed,
|
||||||
|
remote_head_sha=verification.get("remote_head_sha"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"performed": performed,
|
||||||
|
"published": True,
|
||||||
|
"verified": True,
|
||||||
|
"outcome": assessment["outcome"],
|
||||||
|
"remote_head_sha": verification.get("remote_head_sha"),
|
||||||
|
"reasons": [],
|
||||||
|
"evidence": assessment["evidence"],
|
||||||
|
# Publication is the whole of this operation's authority (#812 AC23).
|
||||||
|
"issue_lock_record_mutated": False,
|
||||||
|
"workflow_lease_touched": False,
|
||||||
|
"exact_next_action": (
|
||||||
|
f"Remote head for '{branch_name}' is now observable. Call "
|
||||||
|
f"gitea_lock_issue(issue_number={int(issue_number)}, "
|
||||||
|
f"branch_name='{branch_name}', worktree_path='{workspace}') to renew "
|
||||||
|
"the exact-owner lease, then gitea_create_pr."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Merge methods supported by the Gitea merge API.
|
# Merge methods supported by the Gitea merge API.
|
||||||
_MERGE_METHODS = ("merge", "squash", "rebase")
|
_MERGE_METHODS = ("merge", "squash", "rebase")
|
||||||
|
|
||||||
@@ -15290,7 +15551,8 @@ def mcp_get_control_plane_guide(
|
|||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
allowed = profile["allowed_operations"]
|
allowed = profile["allowed_operations"]
|
||||||
forbidden = profile["forbidden_operations"]
|
forbidden = profile["forbidden_operations"]
|
||||||
role = _role_kind(allowed, forbidden)
|
# Prefer declared profile role so controller is not mislabeled reconciler (#840).
|
||||||
|
role = _profile_role_kind(profile)
|
||||||
username = _authenticated_username(h)
|
username = _authenticated_username(h)
|
||||||
|
|
||||||
identity = {
|
identity = {
|
||||||
@@ -15349,6 +15611,16 @@ def mcp_get_control_plane_guide(
|
|||||||
"user, and merging requires explicit operator authorization plus the "
|
"user, and merging requires explicit operator authorization plus the "
|
||||||
"'MERGE PR <n>' confirmation. "
|
"'MERGE PR <n>' confirmation. "
|
||||||
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
|
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
|
||||||
|
elif role == "controller":
|
||||||
|
guidance.append(
|
||||||
|
"Controller profile: route work via "
|
||||||
|
"gitea_route_task_session(task_type='process_work_queue') then "
|
||||||
|
"gitea_allocate_next_work (allocation_mode=cross_role by default). "
|
||||||
|
"The allocator returns exactly one authoritative selection with "
|
||||||
|
"required_role / required_profile / selected_action. Do not "
|
||||||
|
"implement, review, approve, or merge in this session — schedule "
|
||||||
|
"the matching role namespace instead. Dashboard output is "
|
||||||
|
"explanatory only and never replaces allocator selection.")
|
||||||
elif role == "mixed":
|
elif role == "mixed":
|
||||||
guidance.append(
|
guidance.append(
|
||||||
"WARNING: this profile allows both authoring and "
|
"WARNING: this profile allows both authoring and "
|
||||||
@@ -15558,7 +15830,8 @@ def gitea_whoami(
|
|||||||
"environment": profile.get("environment"),
|
"environment": profile.get("environment"),
|
||||||
"service": profile.get("service"),
|
"service": profile.get("service"),
|
||||||
"identity": profile.get("identity"),
|
"identity": profile.get("identity"),
|
||||||
"role": profile.get("role"),
|
"role": profile.get("role") or _profile_role_kind(profile),
|
||||||
|
"role_kind": _profile_role_kind(profile),
|
||||||
"profile_address": profile.get("profile_path"),
|
"profile_address": profile.get("profile_path"),
|
||||||
"execution_profile": profile.get("execution_profile"),
|
"execution_profile": profile.get("execution_profile"),
|
||||||
"audit_label": profile.get("audit_label"),
|
"audit_label": profile.get("audit_label"),
|
||||||
@@ -20342,9 +20615,10 @@ def gitea_allocate_next_work(
|
|||||||
candidates_json: Any = None,
|
candidates_json: Any = None,
|
||||||
exclude_issue_numbers: list[int] | None = None,
|
exclude_issue_numbers: list[int] | None = None,
|
||||||
expected_candidate_set_fingerprint: str | None = None,
|
expected_candidate_set_fingerprint: str | None = None,
|
||||||
|
allocation_mode: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Controller-owned next-work allocator using the #613 control-plane DB (#600).
|
"""Controller-owned next-work allocator using the #613 control-plane DB (#600/#840).
|
||||||
|
|
||||||
Workers must not self-select exclusive work under the standard multi-LLM
|
Workers must not self-select exclusive work under the standard multi-LLM
|
||||||
workflow. Call this tool instead.
|
workflow. Call this tool instead.
|
||||||
@@ -20354,6 +20628,14 @@ def gitea_allocate_next_work(
|
|||||||
``ControlPlaneDB.assign_and_lease`` (never file locks or comment-only
|
``ControlPlaneDB.assign_and_lease`` (never file locks or comment-only
|
||||||
leases as the coordination source).
|
leases as the coordination source).
|
||||||
|
|
||||||
|
*allocation_mode* (#840): when the active role is controller (or mode is
|
||||||
|
``cross_role``), inspect the complete queue and return exactly one
|
||||||
|
authoritative selection with selected item, action, required_role,
|
||||||
|
required_profile/namespace, pins, and allocation/lease evidence.
|
||||||
|
Role-scoped workers pass ``role=author|reviewer|merger|reconciler`` (or
|
||||||
|
omit for profile role) for single-role filtering. Controller routes only
|
||||||
|
and does not perform downstream mutations.
|
||||||
|
|
||||||
Outcomes include: ``assigned_work``, ``preview``, ``wait``,
|
Outcomes include: ``assigned_work``, ``preview``, ``wait``,
|
||||||
``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``,
|
``blocked_by_terminal_path``, ``no_safe_work``, ``role_ineligible``,
|
||||||
``blocked_by_excluded_own_lease``, ``candidate_set_drift``.
|
``blocked_by_excluded_own_lease``, ``candidate_set_drift``.
|
||||||
@@ -20488,6 +20770,7 @@ def gitea_allocate_next_work(
|
|||||||
controller_instance_id=allocator_service.resolve_controller_instance_id(),
|
controller_instance_id=allocator_service.resolve_controller_instance_id(),
|
||||||
exclude_issue_numbers=exclude_issue_numbers,
|
exclude_issue_numbers=exclude_issue_numbers,
|
||||||
expected_candidate_set_fingerprint=expected_candidate_set_fingerprint,
|
expected_candidate_set_fingerprint=expected_candidate_set_fingerprint,
|
||||||
|
allocation_mode=allocation_mode,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ ROLE_WORKTREE_ENVS: dict[str, str] = {
|
|||||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||||
}
|
}
|
||||||
|
|
||||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
# Controller has no task worktree env — it routes only (#840).
|
||||||
|
KNOWN_ROLE_KINDS = frozenset(
|
||||||
|
{"author", "reviewer", "merger", "reconciler", "controller"}
|
||||||
|
)
|
||||||
|
|
||||||
|
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler", "controller"})
|
||||||
|
|
||||||
|
|
||||||
def normalize_role_kind(
|
def normalize_role_kind(
|
||||||
@@ -37,8 +42,12 @@ def normalize_role_kind(
|
|||||||
profile = (profile_name or "").strip().lower()
|
profile = (profile_name or "").strip().lower()
|
||||||
if role == "reviewer" and "merger" in profile:
|
if role == "reviewer" and "merger" in profile:
|
||||||
return "merger"
|
return "merger"
|
||||||
|
if "controller" in profile or role == "controller":
|
||||||
|
return "controller"
|
||||||
if role in ROLE_WORKTREE_ENVS:
|
if role in ROLE_WORKTREE_ENVS:
|
||||||
return role
|
return role
|
||||||
|
if role in KNOWN_ROLE_KINDS:
|
||||||
|
return role
|
||||||
return "author"
|
return "author"
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +89,7 @@ def resolve_namespace_workspace(
|
|||||||
"""
|
"""
|
||||||
env_map = env if env is not None else os.environ
|
env_map = env if env is not None else os.environ
|
||||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
role_env_key = ROLE_WORKTREE_ENVS.get(role)
|
||||||
|
|
||||||
# #618: durable author resolution — no silent control/master fallback.
|
# #618: durable author resolution — no silent control/master fallback.
|
||||||
if role == "author" and verify_paths:
|
if role == "author" and verify_paths:
|
||||||
@@ -108,13 +117,17 @@ def resolve_namespace_workspace(
|
|||||||
)
|
)
|
||||||
return workspace, source
|
return workspace, source
|
||||||
|
|
||||||
|
role_env_candidate = (
|
||||||
|
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable", True)
|
||||||
|
if role_env_key
|
||||||
|
else (None, "no role worktree env", True)
|
||||||
|
)
|
||||||
for candidate, source, env_sourced in (
|
for candidate, source, env_sourced in (
|
||||||
(worktree_path, "worktree_path argument", False),
|
(worktree_path, "worktree_path argument", False),
|
||||||
(worktree, "worktree argument", False),
|
(worktree, "worktree argument", False),
|
||||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
(_env_value(env_map, ACTIVE_WORKTREE_ENV),
|
||||||
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
f"{ACTIVE_WORKTREE_ENV} environment variable", True),
|
||||||
(_env_value(env_map, role_env_key),
|
role_env_candidate,
|
||||||
f"{role_env_key} environment variable", True),
|
|
||||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||||
"reviewer PR lease worktree", False),
|
"reviewer PR lease worktree", False),
|
||||||
# Author lock derivation is handled by the durable path above when
|
# Author lock derivation is handled by the durable path above when
|
||||||
@@ -433,7 +446,8 @@ def assess_namespace_mutation_workspace(
|
|||||||
reasons.append(
|
reasons.append(
|
||||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||||
f"create or reconnect to a session-owned worktree under branches/ "
|
f"create or reconnect to a session-owned worktree under branches/ "
|
||||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
f"or set {ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} / "
|
||||||
|
f"{ACTIVE_WORKTREE_ENV}"
|
||||||
)
|
)
|
||||||
elif (
|
elif (
|
||||||
role in {"reviewer", "merger"}
|
role in {"reviewer", "merger"}
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ AUTHOR_TASKS = frozenset({
|
|||||||
"reconcile_landed_pr",
|
"reconcile_landed_pr",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
CONTROLLER_TASKS = frozenset({
|
||||||
|
"process_work_queue",
|
||||||
|
"process-work-queue",
|
||||||
|
"cross_role_allocate",
|
||||||
|
})
|
||||||
|
|
||||||
RECONCILER_TASKS = frozenset({
|
RECONCILER_TASKS = frozenset({
|
||||||
"cleanup_merged_pr_branch",
|
"cleanup_merged_pr_branch",
|
||||||
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
||||||
@@ -132,6 +138,10 @@ TASK_REQUIRED_ROLE = {
|
|||||||
"reconcile_close_superseded_pr": "reconciler",
|
"reconcile_close_superseded_pr": "reconciler",
|
||||||
"reconcile_close_satisfied_issue": "reconciler",
|
"reconcile_close_satisfied_issue": "reconciler",
|
||||||
"reconcile_create_followup_issue": "reconciler",
|
"reconcile_create_followup_issue": "reconciler",
|
||||||
|
# #840: controller-owned generic queue allocation / routing.
|
||||||
|
"process_work_queue": "controller",
|
||||||
|
"process-work-queue": "controller",
|
||||||
|
"cross_role_allocate": "controller",
|
||||||
}
|
}
|
||||||
|
|
||||||
WRONG_ROLE_REVIEWER_MSG = (
|
WRONG_ROLE_REVIEWER_MSG = (
|
||||||
@@ -147,6 +157,10 @@ WRONG_ROLE_MERGER_MSG = (
|
|||||||
"Wrong role/session for merger task. Launch merger MCP namespace."
|
"Wrong role/session for merger task. Launch merger MCP namespace."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WRONG_ROLE_CONTROLLER_MSG = (
|
||||||
|
"Wrong role/session for controller task. Launch controller MCP namespace."
|
||||||
|
)
|
||||||
|
|
||||||
_session_last_route: dict | None = None
|
_session_last_route: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -281,6 +295,27 @@ def route_task_session(
|
|||||||
_record_route(result)
|
_record_route(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if required_role == "controller":
|
||||||
|
result = {
|
||||||
|
"task_type": task_type,
|
||||||
|
"required_role": required_role,
|
||||||
|
"active_role": active_role_kind,
|
||||||
|
"active_profile": active_profile,
|
||||||
|
"route_result": ROUTE_WRONG_ROLE,
|
||||||
|
"downstream_allowed": False,
|
||||||
|
"reasons": [
|
||||||
|
WRONG_ROLE_CONTROLLER_MSG,
|
||||||
|
"Controller tasks (process_work_queue / cross-role allocate) "
|
||||||
|
"cannot run in author, reviewer, merger, or reconciler "
|
||||||
|
"worker sessions.",
|
||||||
|
],
|
||||||
|
"message": WRONG_ROLE_CONTROLLER_MSG,
|
||||||
|
"runtime_switching_supported": runtime_switching_supported,
|
||||||
|
"profile_switch_blocked": not runtime_switching_supported,
|
||||||
|
}
|
||||||
|
_record_route(result)
|
||||||
|
return result
|
||||||
|
|
||||||
if required_role == "author":
|
if required_role == "author":
|
||||||
route = ROUTE_TO_AUTHOR
|
route = ROUTE_TO_AUTHOR
|
||||||
message = (
|
message = (
|
||||||
|
|||||||
+26
-2
@@ -62,6 +62,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.branch.push",
|
"permission": "gitea.branch.push",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #812 AC20: publish an already-committed, unpublished local head so
|
||||||
|
# exact-owner lease renewal has an observable remote head to reason about.
|
||||||
|
# Same authority as any other author push — deliberately not a new
|
||||||
|
# operation name, so it cannot widen an already-configured author profile.
|
||||||
|
"publish_unpublished_branch": {
|
||||||
|
"permission": "gitea.branch.push",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"create_pr": {
|
"create_pr": {
|
||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -301,8 +309,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.create",
|
"permission": "gitea.pr.create",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
# #600: controller-owned allocator — any authenticated profile may call;
|
# #600: workers and controller may call with gitea.read; role-scoped workers
|
||||||
# routing enforces role match to selected work. Uses control-plane DB (#613).
|
# pass role=author|reviewer|merger|reconciler. Cross-role routing is the
|
||||||
|
# controller default (#840). The canonical generic queue *task type* is
|
||||||
|
# process_work_queue (controller-only below).
|
||||||
"allocate_next_work": {
|
"allocate_next_work": {
|
||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -311,6 +321,19 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #840: documented generic queue task — controller routes only.
|
||||||
|
"process_work_queue": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "controller",
|
||||||
|
},
|
||||||
|
"process-work-queue": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "controller",
|
||||||
|
},
|
||||||
|
"cross_role_allocate": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "controller",
|
||||||
|
},
|
||||||
|
|
||||||
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
|
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
|
||||||
# ownership in the control-plane DB (not a separate Gitea write permission).
|
# ownership in the control-plane DB (not a separate Gitea write permission).
|
||||||
@@ -500,6 +523,7 @@ ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset(
|
|||||||
"gitea_release_merger_pr_lease",
|
"gitea_release_merger_pr_lease",
|
||||||
"create_branch",
|
"create_branch",
|
||||||
"push_branch",
|
"push_branch",
|
||||||
|
"publish_unpublished_branch",
|
||||||
"create_pr",
|
"create_pr",
|
||||||
"commit_files",
|
"commit_files",
|
||||||
"gitea_commit_files",
|
"gitea_commit_files",
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""Executable acceptance tests for ARCH-01 Slice A (#822).
|
||||||
|
|
||||||
|
Each acceptance criterion (#822 §12) and named test (#822 §13) is exercised
|
||||||
|
against a real SQLite database. The migration runs on a fresh DB in ``setUp``;
|
||||||
|
the test-run output is the durable evidence the issue requires (§14).
|
||||||
|
|
||||||
|
Enforcement being proven:
|
||||||
|
|
||||||
|
* ``[TRUSTED-SERVICE]`` — the ``cp_*`` actor functions exist only on the
|
||||||
|
trusted kernel connection; a raw connection cannot satisfy the triggers.
|
||||||
|
* ``[SCHEMA]`` — fail-closed aborts, exact dominance set, NOT-NULL class,
|
||||||
|
immutability, and the last-active-grant floor are enforced by
|
||||||
|
CHECK/FK/trigger, verified here including raw-write bypass and concurrency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import arch01_platform as ap
|
||||||
|
from arch01_platform import (
|
||||||
|
ALREADY_INSTALLED,
|
||||||
|
AUTHORIZATION_DENIED,
|
||||||
|
CONCURRENT_INSTALLATION_LOST,
|
||||||
|
DISTINGUISHED_ISSUER_ID,
|
||||||
|
DOMINANCE_SET_MISMATCH,
|
||||||
|
DOMINANCE_TUPLES,
|
||||||
|
INSTALLED,
|
||||||
|
INVALID_ACTOR_CONTEXT,
|
||||||
|
INVALID_BOOTSTRAP_STATE,
|
||||||
|
PlatformKernel,
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTALLER = "platform.installer"
|
||||||
|
|
||||||
|
_BOOTSTRAP_TABLES = (
|
||||||
|
"principal_equivalence_classes",
|
||||||
|
"principals",
|
||||||
|
"authoritative_issuers",
|
||||||
|
"authority_dominance",
|
||||||
|
"platform_bootstrap_seed",
|
||||||
|
"platform_bootstrap_grants",
|
||||||
|
"platform_active_invariant",
|
||||||
|
"install_state",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _count(kernel: PlatformKernel, table: str) -> int:
|
||||||
|
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _count_where(kernel: PlatformKernel, table: str, where: str) -> int:
|
||||||
|
return kernel._conn.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_bootstrap_empty(kernel: PlatformKernel) -> bool:
|
||||||
|
return all(_count(kernel, t) == 0 for t in _BOOTSTRAP_TABLES)
|
||||||
|
|
||||||
|
|
||||||
|
class Arch01MemoryTest(unittest.TestCase):
|
||||||
|
"""Single-connection behavior on an in-memory database."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.kernel = PlatformKernel(":memory:")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.kernel.close()
|
||||||
|
|
||||||
|
# -- AC1 -------------------------------------------------------------- #
|
||||||
|
def test_install_clean(self) -> None: # t_install_clean(+)
|
||||||
|
res = self.kernel.install_platform(INSTALLER)
|
||||||
|
self.assertEqual(res.code, INSTALLED)
|
||||||
|
self.assertTrue(self.kernel.is_installed())
|
||||||
|
self.assertEqual(_count(self.kernel, "install_state"), 1)
|
||||||
|
self.assertEqual(self.kernel.active_grant_count(), 1)
|
||||||
|
self.assertIn(ap.EVT_PLATFORM_INSTALLED, self.kernel.audit_events())
|
||||||
|
rows = set(
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"SELECT dominant, subordinate FROM authority_dominance"
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
|
self.assertEqual(rows, set(DOMINANCE_TUPLES))
|
||||||
|
issuer_ref = self.kernel._conn.execute(
|
||||||
|
"SELECT i.issuer_ref FROM principals p JOIN authoritative_issuers i "
|
||||||
|
"ON p.issuer_id = i.issuer_id WHERE p.principal_id = ?",
|
||||||
|
(INSTALLER,),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(issuer_ref[0], DISTINGUISHED_ISSUER_ID)
|
||||||
|
|
||||||
|
# -- AC2 -------------------------------------------------------------- #
|
||||||
|
def test_install_twice(self) -> None: # t_install_twice(-)
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
res2 = self.kernel.install_platform(INSTALLER)
|
||||||
|
self.assertEqual(res2.code, ALREADY_INSTALLED)
|
||||||
|
self.assertEqual(_count(self.kernel, "principals"), 1)
|
||||||
|
self.assertEqual(_count(self.kernel, "platform_bootstrap_grants"), 1)
|
||||||
|
self.assertEqual(_count(self.kernel, "install_state"), 1)
|
||||||
|
|
||||||
|
# -- AC3 / AC5 -------------------------------------------------------- #
|
||||||
|
def test_install_stage_rollback(self) -> None: # t_install_stage_rollback
|
||||||
|
for stop in range(1, 9):
|
||||||
|
with self.subTest(stages=stop):
|
||||||
|
k = PlatformKernel(":memory:")
|
||||||
|
try:
|
||||||
|
self._partial_bootstrap_then_rollback(k, stop)
|
||||||
|
self.assertTrue(
|
||||||
|
_all_bootstrap_empty(k),
|
||||||
|
f"partial rows survived rollback at stage {stop}",
|
||||||
|
)
|
||||||
|
self.assertFalse(k.is_installed())
|
||||||
|
finally:
|
||||||
|
k.close()
|
||||||
|
|
||||||
|
def test_no_partial_after_rollback(self) -> None: # t_no_partial_after_rollback
|
||||||
|
k = PlatformKernel(":memory:")
|
||||||
|
try:
|
||||||
|
code = self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1])
|
||||||
|
self.assertEqual(code, DOMINANCE_SET_MISMATCH)
|
||||||
|
self.assertTrue(_all_bootstrap_empty(k))
|
||||||
|
self.assertFalse(k.is_installed())
|
||||||
|
finally:
|
||||||
|
k.close()
|
||||||
|
|
||||||
|
# -- AC4 -------------------------------------------------------------- #
|
||||||
|
def test_dominance_missing(self) -> None: # t_dominance_missing(-)
|
||||||
|
k = PlatformKernel(":memory:")
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
self._seed_bootstrap_and_mark(k, dominance=DOMINANCE_TUPLES[:-1]),
|
||||||
|
DOMINANCE_SET_MISMATCH,
|
||||||
|
)
|
||||||
|
self.assertFalse(k.is_installed())
|
||||||
|
finally:
|
||||||
|
k.close()
|
||||||
|
|
||||||
|
def test_dominance_extra(self) -> None: # t_dominance_extra(-)
|
||||||
|
k = PlatformKernel(":memory:")
|
||||||
|
try:
|
||||||
|
extra = DOMINANCE_TUPLES + (("platform.bootstrap", "rogue.extra"),)
|
||||||
|
self.assertEqual(
|
||||||
|
self._seed_bootstrap_and_mark(k, dominance=extra),
|
||||||
|
DOMINANCE_SET_MISMATCH,
|
||||||
|
)
|
||||||
|
self.assertFalse(k.is_installed())
|
||||||
|
finally:
|
||||||
|
k.close()
|
||||||
|
|
||||||
|
def test_dominance_malformed(self) -> None: # t_dominance_malformed(-)
|
||||||
|
k = PlatformKernel(":memory:")
|
||||||
|
try:
|
||||||
|
malformed = DOMINANCE_TUPLES[:-1] + (("supervisor.root", "WRONG.subordinate"),)
|
||||||
|
self.assertEqual(
|
||||||
|
self._seed_bootstrap_and_mark(k, dominance=malformed),
|
||||||
|
DOMINANCE_SET_MISMATCH,
|
||||||
|
)
|
||||||
|
self.assertFalse(k.is_installed())
|
||||||
|
finally:
|
||||||
|
k.close()
|
||||||
|
|
||||||
|
# -- AC6 -------------------------------------------------------------- #
|
||||||
|
def test_principal_no_class(self) -> None: # t_principal_no_class(-)
|
||||||
|
with self.kernel.actor_context("op", "operator", "install"):
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principals"
|
||||||
|
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES ('x', 'operator', NULL, NULL, NULL, '2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- AC7 -------------------------------------------------------------- #
|
||||||
|
def test_noninstaller_null_issuer(self) -> None: # t_nonobstaller_null_issuer(-)
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
cur = self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
class_id = cur.lastrowid
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principals"
|
||||||
|
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES ('rogue', 'operator', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
|
||||||
|
(class_id,),
|
||||||
|
)
|
||||||
|
self.assertIn("INVALID_BOOTSTRAP_STATE", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_installer_null_issuer_only_during_install(self) -> None:
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
with self.kernel.actor_context("i2", "installer", "install"):
|
||||||
|
cur = self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
class_id = cur.lastrowid
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principals"
|
||||||
|
"(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES ('i2', 'installer', ?, NULL, NULL, '2026-01-01T00:00:00Z')",
|
||||||
|
(class_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- AC8 -------------------------------------------------------------- #
|
||||||
|
def test_context_missing(self) -> None: # t_context_missing(-)
|
||||||
|
self.assertIsNone(self.kernel._ctx)
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_context_stale(self) -> None: # t_context_stale(-)
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
self.kernel._ctx.expired = True
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_context_epoch_shift(self) -> None: # t_context_epoch_shift(-)
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
self.kernel._ctx.live_epoch = self.kernel._ctx.bound_epoch + 99
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES ('2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
self.assertIn("INVALID_ACTOR_CONTEXT", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_bad_actor_kind_or_mode_rejected(self) -> None:
|
||||||
|
for kind, mode in (("intruder", "normal"), ("operator", "sabotage")):
|
||||||
|
with self.subTest(kind=kind, mode=mode):
|
||||||
|
with self.kernel.actor_context("op", kind, mode):
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError):
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) "
|
||||||
|
"VALUES ('2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- AC9 -------------------------------------------------------------- #
|
||||||
|
def test_bootstrap_immutable_update(self) -> None: # t_bootstrap_immutable_{update}
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
cases = [
|
||||||
|
("UPDATE install_state SET installed_at = 'x' WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
|
||||||
|
("UPDATE platform_bootstrap_seed SET created_at = 'x' WHERE seed_id = 1", "IMMUTABLE_SEED"),
|
||||||
|
("UPDATE authority_dominance SET subordinate = 'x' WHERE dominant = 'supervisor.root'", "IMMUTABLE_DOMINANCE"),
|
||||||
|
(f"UPDATE authoritative_issuers SET issuer_ref = 'x' WHERE issuer_ref = '{DISTINGUISHED_ISSUER_ID}'", "IMMUTABLE_ISSUER"),
|
||||||
|
(f"UPDATE principals SET actor_kind = 'operator' WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
|
||||||
|
]
|
||||||
|
for sql, tag in cases:
|
||||||
|
with self.subTest(sql=sql):
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(sql)
|
||||||
|
self.assertIn(tag, str(ctx.exception))
|
||||||
|
|
||||||
|
def test_bootstrap_immutable_delete(self) -> None: # t_bootstrap_immutable_{delete}
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
cases = [
|
||||||
|
("DELETE FROM install_state WHERE id = 1", "IMMUTABLE_INSTALL_STATE"),
|
||||||
|
("DELETE FROM platform_bootstrap_seed WHERE seed_id = 1", "IMMUTABLE_SEED"),
|
||||||
|
("DELETE FROM authority_dominance", "IMMUTABLE_DOMINANCE"),
|
||||||
|
("DELETE FROM authoritative_issuers", "IMMUTABLE_ISSUER"),
|
||||||
|
(f"DELETE FROM principals WHERE principal_id = '{INSTALLER}'", "IMMUTABLE_PRINCIPAL"),
|
||||||
|
("DELETE FROM platform_bootstrap_grants", "IMMUTABLE_GRANT"),
|
||||||
|
]
|
||||||
|
for sql, tag in cases:
|
||||||
|
with self.subTest(sql=sql):
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(sql)
|
||||||
|
self.assertIn(tag, str(ctx.exception))
|
||||||
|
|
||||||
|
def test_grant_reactivation_rejected(self) -> None:
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
self.kernel.register_principal(
|
||||||
|
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
|
||||||
|
)
|
||||||
|
gid = self.kernel._conn.execute(
|
||||||
|
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
|
||||||
|
).fetchone()[0]
|
||||||
|
self.assertEqual(
|
||||||
|
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
|
||||||
|
INSTALLED,
|
||||||
|
)
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as ctx:
|
||||||
|
self.kernel._conn.execute(
|
||||||
|
"UPDATE platform_bootstrap_grants SET active = 1 WHERE grant_id = ?",
|
||||||
|
(gid,),
|
||||||
|
)
|
||||||
|
self.assertIn("IMMUTABLE_GRANT", str(ctx.exception))
|
||||||
|
|
||||||
|
# -- AC12 ------------------------------------------------------------- #
|
||||||
|
def test_raw_write_bypass(self) -> None: # t_raw_write_bypass(raw-bypass)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "p.sqlite3")
|
||||||
|
k = PlatformKernel(path)
|
||||||
|
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
k.close()
|
||||||
|
raw = sqlite3.connect(path)
|
||||||
|
raw.execute("PRAGMA foreign_keys = ON")
|
||||||
|
try:
|
||||||
|
with self.assertRaises(sqlite3.Error):
|
||||||
|
raw.execute(
|
||||||
|
"INSERT INTO audit_records(event, created_at) "
|
||||||
|
"VALUES ('forged', '2026-01-01T00:00:00Z')"
|
||||||
|
)
|
||||||
|
raw.commit()
|
||||||
|
with self.assertRaises(sqlite3.Error):
|
||||||
|
raw.execute("UPDATE install_state SET installed_at = 'x' WHERE id = 1")
|
||||||
|
raw.commit()
|
||||||
|
with self.assertRaises(sqlite3.Error):
|
||||||
|
raw.execute("DELETE FROM platform_bootstrap_grants")
|
||||||
|
raw.commit()
|
||||||
|
finally:
|
||||||
|
raw.close()
|
||||||
|
|
||||||
|
# -- AC13 ------------------------------------------------------------- #
|
||||||
|
def test_audit_created(self) -> None: # t_audit_created(+)
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
self.kernel.register_principal(
|
||||||
|
"op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.kernel.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED
|
||||||
|
)
|
||||||
|
gid = self.kernel._conn.execute(
|
||||||
|
"SELECT grant_id FROM platform_bootstrap_grants WHERE grantee_principal_id = 'op1'"
|
||||||
|
).fetchone()[0]
|
||||||
|
self.assertEqual(
|
||||||
|
self.kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code,
|
||||||
|
INSTALLED,
|
||||||
|
)
|
||||||
|
events = self.kernel.audit_events()
|
||||||
|
for evt in (
|
||||||
|
ap.EVT_PLATFORM_INSTALLED,
|
||||||
|
ap.EVT_GRANT_CREATED,
|
||||||
|
ap.EVT_GRANT_REVOKED,
|
||||||
|
ap.EVT_PRINCIPAL_REGISTERED,
|
||||||
|
):
|
||||||
|
self.assertIn(evt, events)
|
||||||
|
|
||||||
|
# -- AC14 ------------------------------------------------------------- #
|
||||||
|
def test_audit_immutable(self) -> None: # t_audit_immutable(raw-bypass)
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
with self.kernel.actor_context("op", "operator", "normal"):
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as up:
|
||||||
|
self.kernel._conn.execute("UPDATE audit_records SET event = 'x' WHERE audit_id = 1")
|
||||||
|
self.assertIn("IMMUTABLE_AUDIT", str(up.exception))
|
||||||
|
with self.assertRaises(sqlite3.IntegrityError) as dl:
|
||||||
|
self.kernel._conn.execute("DELETE FROM audit_records WHERE audit_id = 1")
|
||||||
|
self.assertIn("IMMUTABLE_AUDIT", str(dl.exception))
|
||||||
|
|
||||||
|
# -- meta ------------------------------------------------------------- #
|
||||||
|
def test_schema_meta(self) -> None:
|
||||||
|
rows = dict(self.kernel._conn.execute("SELECT key, value FROM arch01_meta").fetchall())
|
||||||
|
self.assertEqual(rows["schema_version"], str(ap.SCHEMA_VERSION))
|
||||||
|
self.assertIn("disabled by default", rows["architecture"])
|
||||||
|
|
||||||
|
def test_register_principal_creates_class_first(self) -> None:
|
||||||
|
self.assertEqual(self.kernel.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
res = self.kernel.register_principal(
|
||||||
|
"svc1", "service", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER
|
||||||
|
)
|
||||||
|
self.assertEqual(res.code, INSTALLED)
|
||||||
|
row = self.kernel._conn.execute(
|
||||||
|
"SELECT current_class_id FROM principals WHERE principal_id = 'svc1'"
|
||||||
|
).fetchone()
|
||||||
|
self.assertIsNotNone(row[0])
|
||||||
|
|
||||||
|
# -- helpers ---------------------------------------------------------- #
|
||||||
|
def _partial_bootstrap_then_rollback(self, k: PlatformKernel, stop: int) -> None:
|
||||||
|
"""Execute the first ``stop`` bootstrap statements, then ROLLBACK."""
|
||||||
|
now = "2026-01-01T00:00:00Z"
|
||||||
|
k._conn.execute("BEGIN IMMEDIATE")
|
||||||
|
class_id = None
|
||||||
|
issuer_id = None
|
||||||
|
try:
|
||||||
|
with k.actor_context(INSTALLER, "installer", "install"):
|
||||||
|
c = k._conn
|
||||||
|
if stop >= 1:
|
||||||
|
class_id = c.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
|
||||||
|
).lastrowid
|
||||||
|
if stop >= 2:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES (?, 'installer', ?, NULL, ?, ?)",
|
||||||
|
(INSTALLER, class_id, INSTALLER, now),
|
||||||
|
)
|
||||||
|
if stop >= 3:
|
||||||
|
issuer_id = c.execute(
|
||||||
|
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
|
||||||
|
(DISTINGUISHED_ISSUER_ID, now),
|
||||||
|
).lastrowid
|
||||||
|
if stop >= 4:
|
||||||
|
c.execute(
|
||||||
|
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
|
||||||
|
(issuer_id, INSTALLER),
|
||||||
|
)
|
||||||
|
if stop >= 5:
|
||||||
|
c.executemany(
|
||||||
|
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
|
||||||
|
DOMINANCE_TUPLES,
|
||||||
|
)
|
||||||
|
if stop >= 6:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
|
||||||
|
(INSTALLER, now),
|
||||||
|
)
|
||||||
|
if stop >= 7:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
|
||||||
|
(INSTALLER, now),
|
||||||
|
)
|
||||||
|
if stop >= 8:
|
||||||
|
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
|
||||||
|
finally:
|
||||||
|
k._conn.execute("ROLLBACK")
|
||||||
|
|
||||||
|
def _seed_bootstrap_and_mark(self, k: PlatformKernel, dominance) -> str:
|
||||||
|
"""Seed a full bootstrap with a caller-supplied dominance set, then
|
||||||
|
attempt the marker insert. Returns the classified failure code (or
|
||||||
|
INSTALLED). Rolls back on failure so no partial rows remain."""
|
||||||
|
now = "2026-01-01T00:00:00Z"
|
||||||
|
k._conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
with k.actor_context(INSTALLER, "installer", "install"):
|
||||||
|
c = k._conn
|
||||||
|
class_id = c.execute(
|
||||||
|
"INSERT INTO principal_equivalence_classes(created_at) VALUES (?)", (now,)
|
||||||
|
).lastrowid
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO principals(principal_id, actor_kind, current_class_id, issuer_id, registered_by, created_at) "
|
||||||
|
"VALUES (?, 'installer', ?, NULL, ?, ?)",
|
||||||
|
(INSTALLER, class_id, INSTALLER, now),
|
||||||
|
)
|
||||||
|
issuer_id = c.execute(
|
||||||
|
"INSERT INTO authoritative_issuers(issuer_kind, issuer_ref, created_at) VALUES ('operator-key', ?, ?)",
|
||||||
|
(DISTINGUISHED_ISSUER_ID, now),
|
||||||
|
).lastrowid
|
||||||
|
c.execute(
|
||||||
|
"UPDATE principals SET issuer_id = ? WHERE principal_id = ?",
|
||||||
|
(issuer_id, INSTALLER),
|
||||||
|
)
|
||||||
|
c.executemany(
|
||||||
|
"INSERT INTO authority_dominance(dominant, subordinate) VALUES (?, ?)",
|
||||||
|
dominance,
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_seed(seed_id, installer_principal_id, created_at) VALUES (1, ?, ?)",
|
||||||
|
(INSTALLER, now),
|
||||||
|
)
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO platform_bootstrap_grants(grantee_principal_id, granted_by, active, created_at) VALUES (?, NULL, 1, ?)",
|
||||||
|
(INSTALLER, now),
|
||||||
|
)
|
||||||
|
c.execute("INSERT INTO platform_active_invariant(id, active_count) VALUES (1, 1)")
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO install_state(id, marker, installed_at) VALUES (1, 'installed', ?)",
|
||||||
|
(now,),
|
||||||
|
)
|
||||||
|
k._conn.execute("COMMIT")
|
||||||
|
return INSTALLED
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
k._safe_rollback()
|
||||||
|
return PlatformKernel._classify(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class Arch01ConcurrencyTest(unittest.TestCase):
|
||||||
|
"""Concurrency invariants require file-backed DBs and independent connections."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.path = os.path.join(self._tmp.name, "p.sqlite3")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
# -- AC10 ------------------------------------------------------------- #
|
||||||
|
def test_concurrent_install(self) -> None: # t_concurrent_install(concurrency)
|
||||||
|
k1 = PlatformKernel(self.path, busy_timeout_ms=0)
|
||||||
|
k2 = PlatformKernel(self.path, busy_timeout_ms=0)
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def _install(name, kernel):
|
||||||
|
barrier.wait()
|
||||||
|
results[name] = kernel.install_platform(INSTALLER).code
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||||
|
f1 = ex.submit(_install, "a", k1)
|
||||||
|
f2 = ex.submit(_install, "b", k2)
|
||||||
|
f1.result()
|
||||||
|
f2.result()
|
||||||
|
codes = sorted(results.values())
|
||||||
|
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one install expected: {results}")
|
||||||
|
other = [c for c in results.values() if c != INSTALLED][0]
|
||||||
|
self.assertIn(other, (ALREADY_INSTALLED, CONCURRENT_INSTALLATION_LOST))
|
||||||
|
self.assertTrue(k1.is_installed())
|
||||||
|
self.assertEqual(_count(k1, "install_state"), 1)
|
||||||
|
self.assertEqual(_count(k1, "principals"), 1)
|
||||||
|
finally:
|
||||||
|
k1.close()
|
||||||
|
k2.close()
|
||||||
|
|
||||||
|
# -- AC11 ------------------------------------------------------------- #
|
||||||
|
def test_concurrent_last_grant_revoke(self) -> None: # t_concurrent_last_grant_revoke
|
||||||
|
setup = PlatformKernel(self.path)
|
||||||
|
self.assertEqual(setup.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
setup.register_principal("op1", "operator", DISTINGUISHED_ISSUER_ID, actor_principal=INSTALLER)
|
||||||
|
self.assertEqual(setup.grant_platform_bootstrap("op1", INSTALLER).code, INSTALLED)
|
||||||
|
self.assertEqual(setup.active_grant_count(), 2)
|
||||||
|
gids = [
|
||||||
|
r[0]
|
||||||
|
for r in setup._conn.execute(
|
||||||
|
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1 ORDER BY grant_id"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
setup.close()
|
||||||
|
self.assertEqual(len(gids), 2)
|
||||||
|
|
||||||
|
k1 = PlatformKernel(self.path, busy_timeout_ms=3000)
|
||||||
|
k2 = PlatformKernel(self.path, busy_timeout_ms=3000)
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def _revoke(name, kernel, gid):
|
||||||
|
barrier.wait()
|
||||||
|
results[name] = kernel.revoke_platform_bootstrap(gid, actor_principal=INSTALLER).code
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||||
|
f1 = ex.submit(_revoke, "a", k1, gids[0])
|
||||||
|
f2 = ex.submit(_revoke, "b", k2, gids[1])
|
||||||
|
f1.result()
|
||||||
|
f2.result()
|
||||||
|
codes = list(results.values())
|
||||||
|
self.assertEqual(codes.count(INSTALLED), 1, f"exactly one revoke should win: {results}")
|
||||||
|
self.assertEqual(codes.count(AUTHORIZATION_DENIED), 1, f"one revoke must be denied: {results}")
|
||||||
|
self.assertEqual(k1.active_grant_count(), 1)
|
||||||
|
self.assertEqual(_count_where(k1, "platform_bootstrap_grants", "active = 1"), 1)
|
||||||
|
finally:
|
||||||
|
k1.close()
|
||||||
|
k2.close()
|
||||||
|
|
||||||
|
def test_revoke_final_grant_denied(self) -> None:
|
||||||
|
k = PlatformKernel(self.path)
|
||||||
|
try:
|
||||||
|
self.assertEqual(k.install_platform(INSTALLER).code, INSTALLED)
|
||||||
|
gid = k._conn.execute(
|
||||||
|
"SELECT grant_id FROM platform_bootstrap_grants WHERE active = 1"
|
||||||
|
).fetchone()[0]
|
||||||
|
res = k.revoke_platform_bootstrap(gid, actor_principal=INSTALLER)
|
||||||
|
self.assertEqual(res.code, AUTHORIZATION_DENIED)
|
||||||
|
self.assertEqual(k.active_grant_count(), 1)
|
||||||
|
self.assertEqual(_count_where(k, "platform_bootstrap_grants", "active = 1"), 1)
|
||||||
|
finally:
|
||||||
|
k.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
"""Authoritative controller cross-role generic queue allocation (#840)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from allocator_service import (
|
||||||
|
ALLOCATION_MODE_CROSS_ROLE,
|
||||||
|
ALLOCATION_MODE_ROLE_SCOPED,
|
||||||
|
OUTCOME_NO_SAFE,
|
||||||
|
OUTCOME_PREVIEW,
|
||||||
|
OUTCOME_WAIT,
|
||||||
|
ROLE_AUTHOR,
|
||||||
|
ROLE_CONTROLLER,
|
||||||
|
ROLE_MERGER,
|
||||||
|
ROLE_RECONCILER,
|
||||||
|
ROLE_REVIEWER,
|
||||||
|
WorkCandidate,
|
||||||
|
allocate_next_work,
|
||||||
|
build_selection_dict,
|
||||||
|
classify_skip,
|
||||||
|
required_namespace_for_role,
|
||||||
|
required_profile_for_role,
|
||||||
|
resolve_allocation_mode,
|
||||||
|
selected_action_for_candidate,
|
||||||
|
)
|
||||||
|
from control_plane_db import ControlPlaneDB
|
||||||
|
import role_session_router
|
||||||
|
from role_session_router import (
|
||||||
|
ROUTE_ALLOWED,
|
||||||
|
ROUTE_AMBIGUOUS,
|
||||||
|
ROUTE_WRONG_ROLE,
|
||||||
|
route_task_session,
|
||||||
|
)
|
||||||
|
import namespace_workspace_binding as nwb
|
||||||
|
import task_capability_map
|
||||||
|
|
||||||
|
|
||||||
|
class CrossRoleAllocationModeTest(unittest.TestCase):
|
||||||
|
def test_controller_defaults_to_cross_role(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_allocation_mode(ROLE_CONTROLLER),
|
||||||
|
ALLOCATION_MODE_CROSS_ROLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_worker_defaults_to_role_scoped(self) -> None:
|
||||||
|
for role in (ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER):
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_allocation_mode(role),
|
||||||
|
ALLOCATION_MODE_ROLE_SCOPED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_explicit_modes(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_allocation_mode(ROLE_CONTROLLER, "role_scoped"),
|
||||||
|
ALLOCATION_MODE_ROLE_SCOPED,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_allocation_mode(ROLE_AUTHOR, "cross_role"),
|
||||||
|
ALLOCATION_MODE_CROSS_ROLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CrossRoleSelectionPayloadTest(unittest.TestCase):
|
||||||
|
def test_selection_contains_required_fields(self) -> None:
|
||||||
|
c = WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=840,
|
||||||
|
labels=("status:ready",),
|
||||||
|
title="cross-role",
|
||||||
|
priority=20,
|
||||||
|
)
|
||||||
|
sel = build_selection_dict(
|
||||||
|
c,
|
||||||
|
active_role=ROLE_CONTROLLER,
|
||||||
|
required_role=ROLE_AUTHOR,
|
||||||
|
profile_name="prgs-controller",
|
||||||
|
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||||
|
)
|
||||||
|
self.assertEqual(sel["number"], 840)
|
||||||
|
self.assertEqual(sel["kind"], "issue")
|
||||||
|
self.assertEqual(sel["required_role"], ROLE_AUTHOR)
|
||||||
|
self.assertEqual(sel["selected_action"], "implement")
|
||||||
|
self.assertEqual(sel["action"], "implement")
|
||||||
|
self.assertEqual(sel["required_profile"], "prgs-author")
|
||||||
|
self.assertEqual(sel["required_namespace"], "gitea-author")
|
||||||
|
self.assertEqual(sel["pinned"]["number"], 840)
|
||||||
|
self.assertIsNone(sel["pinned"]["head_sha"])
|
||||||
|
|
||||||
|
def test_profile_prefix_preserved(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
required_profile_for_role(ROLE_REVIEWER, profile_name="dadeschools-controller"),
|
||||||
|
"dadeschools-reviewer",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
required_namespace_for_role(ROLE_MERGER),
|
||||||
|
"gitea-merger",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_selected_actions_per_role(self) -> None:
|
||||||
|
issue = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||||
|
pr_review = WorkCandidate(kind="pr", number=2, head_sha="a" * 40)
|
||||||
|
pr_rc = WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=3,
|
||||||
|
head_sha="b" * 40,
|
||||||
|
request_changes_current_head=True,
|
||||||
|
)
|
||||||
|
pr_merge = WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=4,
|
||||||
|
head_sha="c" * 40,
|
||||||
|
approval_on_current_head=True,
|
||||||
|
mergeable=True,
|
||||||
|
)
|
||||||
|
pr_recon = WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=5,
|
||||||
|
head_sha="d" * 40,
|
||||||
|
approval_contaminated=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(selected_action_for_candidate(issue, ROLE_AUTHOR), "implement")
|
||||||
|
self.assertEqual(
|
||||||
|
selected_action_for_candidate(pr_rc, ROLE_AUTHOR),
|
||||||
|
"address_pr_change_requests",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
selected_action_for_candidate(pr_review, ROLE_REVIEWER), "review"
|
||||||
|
)
|
||||||
|
self.assertEqual(selected_action_for_candidate(pr_merge, ROLE_MERGER), "merge")
|
||||||
|
self.assertEqual(
|
||||||
|
selected_action_for_candidate(pr_recon, ROLE_RECONCILER),
|
||||||
|
"reconcile_contaminated_approval",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CrossRoleAllocateServiceTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _alloc(self, **kwargs):
|
||||||
|
defaults = dict(
|
||||||
|
db=self.db,
|
||||||
|
session_id="ctrl-session",
|
||||||
|
role=ROLE_CONTROLLER,
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
candidates=[],
|
||||||
|
apply=False,
|
||||||
|
profile_name="prgs-controller",
|
||||||
|
username="controller-bot",
|
||||||
|
controller_instance_id="ctrl-1",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return allocate_next_work(**defaults)
|
||||||
|
|
||||||
|
def test_eligible_author_work(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=100,
|
||||||
|
labels=("status:ready",),
|
||||||
|
title="author work",
|
||||||
|
priority=20,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
|
||||||
|
self.assertIsNotNone(res["selected"])
|
||||||
|
self.assertEqual(res["selected"]["number"], 100)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||||
|
self.assertEqual(res["selected_action"], "implement")
|
||||||
|
self.assertEqual(res["required_profile"], "prgs-author")
|
||||||
|
self.assertEqual(res["required_namespace"], "gitea-author")
|
||||||
|
self.assertIn("allocate", res["controller_allowed_actions"])
|
||||||
|
self.assertIn("merge", res["controller_forbidden_actions"])
|
||||||
|
self.assertFalse(res["allocation_evidence"]["lease_created"])
|
||||||
|
|
||||||
|
def test_eligible_reviewer_work(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=200,
|
||||||
|
head_sha="e" * 40,
|
||||||
|
title="needs review",
|
||||||
|
priority=30,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertEqual(res["selected"]["number"], 200)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_REVIEWER)
|
||||||
|
self.assertEqual(res["selected_action"], "review")
|
||||||
|
self.assertEqual(res["required_profile"], "prgs-reviewer")
|
||||||
|
self.assertEqual(res["selected"]["pinned"]["head_sha"], "e" * 40)
|
||||||
|
|
||||||
|
def test_eligible_merger_work(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=300,
|
||||||
|
head_sha="f" * 40,
|
||||||
|
approval_on_current_head=True,
|
||||||
|
mergeable=True,
|
||||||
|
priority=40,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertEqual(res["selected"]["number"], 300)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_MERGER)
|
||||||
|
self.assertEqual(res["selected_action"], "merge")
|
||||||
|
|
||||||
|
def test_eligible_reconciler_work(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=400,
|
||||||
|
head_sha="1" * 40,
|
||||||
|
approval_contaminated=True,
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertEqual(res["selected"]["number"], 400)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_RECONCILER)
|
||||||
|
self.assertIn("reconcile", res["selected_action"])
|
||||||
|
|
||||||
|
def test_no_eligible_work(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=10,
|
||||||
|
labels=("status:blocked",),
|
||||||
|
blocked=True,
|
||||||
|
priority=99,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=11,
|
||||||
|
labels=("status:ready",),
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="blocked by #10",
|
||||||
|
priority=98,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertTrue(res["success"])
|
||||||
|
self.assertEqual(res["outcome"], OUTCOME_NO_SAFE)
|
||||||
|
self.assertIsNone(res["selected"])
|
||||||
|
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_CROSS_ROLE)
|
||||||
|
|
||||||
|
def test_leased_work_skipped(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=50,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=20,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=51,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=10,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# Seed a foreign lease on issue 50 via assign_and_lease under another session.
|
||||||
|
other = allocate_next_work(
|
||||||
|
self.db,
|
||||||
|
session_id="other-worker",
|
||||||
|
role=ROLE_AUTHOR,
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
candidates=cands[:1],
|
||||||
|
apply=True,
|
||||||
|
profile_name="prgs-author",
|
||||||
|
controller_instance_id="other-ctrl",
|
||||||
|
)
|
||||||
|
self.assertEqual(other["outcome"], "assigned_work")
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertIsNotNone(res["selected"])
|
||||||
|
self.assertEqual(res["selected"]["number"], 51)
|
||||||
|
self.assertTrue(any(s["number"] == 50 for s in res["skipped"]))
|
||||||
|
self.assertTrue(res["claims_excluded"])
|
||||||
|
|
||||||
|
def test_dependencies_skipped(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=1,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=99,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason="needs #2",
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=2,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertEqual(res["selected"]["number"], 2)
|
||||||
|
skipped = {s["number"]: s["reason"] for s in res["skipped"]}
|
||||||
|
self.assertIn(1, skipped)
|
||||||
|
self.assertIn("needs #2", skipped[1])
|
||||||
|
|
||||||
|
def test_pagination_limit_only_truncates_skip_report(self) -> None:
|
||||||
|
"""Ranking uses full inventory; reporting limit is MCP-layer only.
|
||||||
|
|
||||||
|
Service ranks all candidates; prove higher-priority eligible item
|
||||||
|
wins even when many skipped precede it.
|
||||||
|
"""
|
||||||
|
cands = []
|
||||||
|
for n in range(1, 30):
|
||||||
|
cands.append(
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=n,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=100 - n,
|
||||||
|
dependency_unmet=True,
|
||||||
|
dependency_reason=f"dep {n}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cands.append(
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=999,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertEqual(res["selected"]["number"], 999)
|
||||||
|
self.assertGreaterEqual(len(res["skipped"]), 29)
|
||||||
|
|
||||||
|
def test_role_scoped_controller_legacy_still_restricts(self) -> None:
|
||||||
|
"""role_scoped controller only takes reconciler-needed items."""
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=1,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=2,
|
||||||
|
head_sha="a" * 40,
|
||||||
|
approval_contaminated=True,
|
||||||
|
priority=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(
|
||||||
|
candidates=cands,
|
||||||
|
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["allocation_mode"], ALLOCATION_MODE_ROLE_SCOPED)
|
||||||
|
self.assertEqual(res["selected"]["number"], 2)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_RECONCILER)
|
||||||
|
|
||||||
|
def test_cross_role_prefers_highest_priority_across_roles(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=10,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=10,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=20,
|
||||||
|
head_sha="b" * 40,
|
||||||
|
priority=50,
|
||||||
|
),
|
||||||
|
WorkCandidate(
|
||||||
|
kind="pr",
|
||||||
|
number=30,
|
||||||
|
head_sha="c" * 40,
|
||||||
|
approval_on_current_head=True,
|
||||||
|
mergeable=True,
|
||||||
|
priority=20,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
# PR #20 highest priority → reviewer
|
||||||
|
self.assertEqual(res["selected"]["number"], 20)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_REVIEWER)
|
||||||
|
|
||||||
|
def test_apply_creates_lease_evidence_for_required_role(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=777,
|
||||||
|
labels=("status:ready",),
|
||||||
|
priority=20,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands, apply=True)
|
||||||
|
self.assertEqual(res["outcome"], "assigned_work")
|
||||||
|
self.assertTrue(res["allocation_evidence"]["lease_created"])
|
||||||
|
self.assertEqual(res["allocation_evidence"]["lease_role"], ROLE_AUTHOR)
|
||||||
|
proof = res["lease_proof"]
|
||||||
|
self.assertIsNotNone(proof["lease_id"])
|
||||||
|
self.assertEqual(proof["lease_role"], ROLE_AUTHOR)
|
||||||
|
self.assertIn("implement", proof["allowed_actions"])
|
||||||
|
# Controller isolation: controller still forbids merge/push/create_pr
|
||||||
|
self.assertIn("merge", res["controller_forbidden_actions"])
|
||||||
|
self.assertIn("push", res["controller_forbidden_actions"])
|
||||||
|
|
||||||
|
def test_metadata_consistency_role_is_controller(self) -> None:
|
||||||
|
cands = [
|
||||||
|
WorkCandidate(
|
||||||
|
kind="issue",
|
||||||
|
number=1,
|
||||||
|
labels=("status:ready",),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
res = self._alloc(candidates=cands)
|
||||||
|
self.assertEqual(res["role"], ROLE_CONTROLLER)
|
||||||
|
self.assertEqual(res["routing_role"], ROLE_CONTROLLER)
|
||||||
|
self.assertEqual(res["required_role"], ROLE_AUTHOR)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessWorkQueueRouterTest(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
role_session_router.clear_route_state()
|
||||||
|
|
||||||
|
def test_process_work_queue_allowed_for_controller(self) -> None:
|
||||||
|
res = route_task_session(
|
||||||
|
"process_work_queue",
|
||||||
|
active_profile="prgs-controller",
|
||||||
|
active_role_kind="controller",
|
||||||
|
allowed_in_current_session=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
|
||||||
|
self.assertEqual(res["required_role"], "controller")
|
||||||
|
self.assertTrue(res["downstream_allowed"])
|
||||||
|
|
||||||
|
def test_process_work_queue_hyphen_alias(self) -> None:
|
||||||
|
res = route_task_session(
|
||||||
|
"process-work-queue",
|
||||||
|
active_profile="prgs-controller",
|
||||||
|
active_role_kind="controller",
|
||||||
|
allowed_in_current_session=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["route_result"], ROUTE_ALLOWED)
|
||||||
|
|
||||||
|
def test_process_work_queue_wrong_role_for_author(self) -> None:
|
||||||
|
res = route_task_session(
|
||||||
|
"process_work_queue",
|
||||||
|
active_profile="prgs-author",
|
||||||
|
active_role_kind="author",
|
||||||
|
allowed_in_current_session=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["route_result"], ROUTE_WRONG_ROLE)
|
||||||
|
self.assertEqual(res["required_role"], "controller")
|
||||||
|
self.assertFalse(res["downstream_allowed"])
|
||||||
|
|
||||||
|
def test_unknown_still_ambiguous(self) -> None:
|
||||||
|
res = route_task_session(
|
||||||
|
"not_a_real_task",
|
||||||
|
active_profile="prgs-controller",
|
||||||
|
active_role_kind="controller",
|
||||||
|
allowed_in_current_session=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(res["route_result"], ROUTE_AMBIGUOUS)
|
||||||
|
|
||||||
|
def test_capability_map_process_work_queue_is_controller(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_role("process_work_queue"),
|
||||||
|
"controller",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
task_capability_map.required_permission("process_work_queue"),
|
||||||
|
"gitea.read",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ControllerRoleMetadataTest(unittest.TestCase):
|
||||||
|
def test_normalize_role_kind_controller(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
nwb.normalize_role_kind("controller"),
|
||||||
|
"controller",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
nwb.normalize_role_kind("author", profile_name="prgs-controller"),
|
||||||
|
"controller",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
nwb.normalize_role_kind("reconciler", profile_name="prgs-controller"),
|
||||||
|
"controller",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_profile_role_kind_prefers_declared_controller(self) -> None:
|
||||||
|
# Import from worktree package path via sys.path already set by pytest.
|
||||||
|
import gitea_mcp_server as mcp
|
||||||
|
|
||||||
|
profile = {
|
||||||
|
"profile_name": "prgs-controller",
|
||||||
|
"role": "controller",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.close",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [
|
||||||
|
"gitea.pr.approve",
|
||||||
|
"gitea.pr.merge",
|
||||||
|
"gitea.pr.create",
|
||||||
|
"gitea.branch.push",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
# Declared role wins even if permissions look reconciler-like.
|
||||||
|
self.assertEqual(mcp._profile_role_kind(profile), "controller")
|
||||||
|
# Name-based fallback.
|
||||||
|
profile_no_role = dict(profile)
|
||||||
|
profile_no_role["role"] = None
|
||||||
|
profile_no_role["role_kind"] = None
|
||||||
|
self.assertEqual(mcp._profile_role_kind(profile_no_role), "controller")
|
||||||
|
|
||||||
|
def test_permission_inference_without_controller_name_stays_reconciler(self) -> None:
|
||||||
|
import gitea_mcp_server as mcp
|
||||||
|
|
||||||
|
# Pure permission inference still may return reconciler when no controller
|
||||||
|
# declaration exists — that is intentional for reconciler profiles.
|
||||||
|
role = mcp._role_kind(
|
||||||
|
["gitea.read", "gitea.pr.close", "gitea.issue.comment"],
|
||||||
|
["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.create", "gitea.branch.push"],
|
||||||
|
)
|
||||||
|
self.assertEqual(role, "reconciler")
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardRemainsExplanatoryTest(unittest.TestCase):
|
||||||
|
def test_dashboard_prompt_points_at_allocator_not_self_select(self) -> None:
|
||||||
|
import workflow_dashboard as wd
|
||||||
|
|
||||||
|
self.assertIn("gitea_allocate_next_work", wd.PROMPT_CONTROLLER)
|
||||||
|
self.assertIn("process_work_queue", wd.PROMPT_CONTROLLER)
|
||||||
|
self.assertIn("never replaces allocator", wd.PROMPT_CONTROLLER.lower())
|
||||||
|
self.assertNotIn("self-select", wd.PROMPT_CONTROLLER.lower())
|
||||||
|
|
||||||
|
|
||||||
|
class ClassifySkipCrossRoleTest(unittest.TestCase):
|
||||||
|
def test_controller_cross_role_accepts_author_issue(self) -> None:
|
||||||
|
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||||
|
self.assertIsNone(
|
||||||
|
classify_skip(
|
||||||
|
c,
|
||||||
|
role=ROLE_CONTROLLER,
|
||||||
|
terminal_pr=None,
|
||||||
|
allocation_mode=ALLOCATION_MODE_CROSS_ROLE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_controller_skips_author_issue(self) -> None:
|
||||||
|
c = WorkCandidate(kind="issue", number=1, labels=("status:ready",))
|
||||||
|
reason = classify_skip(
|
||||||
|
c,
|
||||||
|
role=ROLE_CONTROLLER,
|
||||||
|
terminal_pr=None,
|
||||||
|
allocation_mode=ALLOCATION_MODE_ROLE_SCOPED,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(reason)
|
||||||
|
self.assertIn("does not require controller", reason or "")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
"""Publication of an unpublished local commit (#812 AC20).
|
||||||
|
|
||||||
|
Entry point B of #812: a registered worktree, clean, on its issue branch,
|
||||||
|
holding a local commit that has never been published. Exact-owner lease renewal
|
||||||
|
refuses such a claim for want of an observable remote head, and every existing
|
||||||
|
publication path is lock-derived, so the two predicates close a cycle around
|
||||||
|
work that is otherwise complete.
|
||||||
|
|
||||||
|
These tests exercise the disposition through its *evidence*, never through any
|
||||||
|
particular issue number: every case uses an arbitrary issue number against a
|
||||||
|
synthetic repository, and the same assertions hold for any other. Nothing here
|
||||||
|
reads, writes, or references the live protected worktree named in #812 AC17 —
|
||||||
|
that content is preserved evidence for the duration of this work, so the
|
||||||
|
fixtures below build their own repositories from scratch.
|
||||||
|
|
||||||
|
The remote is a local bare repository, so publication and read-after-write
|
||||||
|
verification are genuinely executed rather than mocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import branch_publish # noqa: E402
|
||||||
|
import issue_lock_provenance # noqa: E402
|
||||||
|
import issue_lock_renewal # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
|
ISSUE = 9812
|
||||||
|
BRANCH = f"feat/issue-{ISSUE}-publish-fixture"
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "test-author-prgs"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
GIT_REMOTE = "prgs"
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(hours: int) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PublishBase(unittest.TestCase):
|
||||||
|
"""Real git repo + real bare remote + durable lock naming the caller.
|
||||||
|
|
||||||
|
The recorded owner pid is deliberately **this live process**. That mirrors
|
||||||
|
the production shape #812 documents, where the pid belongs to a long-running
|
||||||
|
MCP daemon rather than to a dead author client, and it proves publication
|
||||||
|
never depends on a dead process (#812 AC24).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lock_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.lock_dir.cleanup)
|
||||||
|
self.origin = tempfile.mkdtemp(prefix="issue812-origin-")
|
||||||
|
self.repo = tempfile.mkdtemp(prefix="issue812-work-")
|
||||||
|
for path in (self.origin, self.repo):
|
||||||
|
self.addCleanup(
|
||||||
|
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
|
||||||
|
)
|
||||||
|
self._init_repos()
|
||||||
|
self.remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
|
||||||
|
)
|
||||||
|
self.remotes.start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
|
||||||
|
# ── fixture construction ─────────────────────────────────────────────
|
||||||
|
def _git(self, *args, cwd=None):
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", cwd or self.repo, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_repos(self):
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
|
||||||
|
)
|
||||||
|
self._git("init", "-q", "-b", "master")
|
||||||
|
self._git("config", "user.email", "[email protected]")
|
||||||
|
self._git("config", "user.name", "Test")
|
||||||
|
self._git("remote", "add", GIT_REMOTE, self.origin)
|
||||||
|
|
||||||
|
with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
|
||||||
|
fh.write("seed\n")
|
||||||
|
self._git("add", "seed.txt")
|
||||||
|
self._git("commit", "-q", "-m", "seed")
|
||||||
|
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
self._git("push", "-q", GIT_REMOTE, "master")
|
||||||
|
|
||||||
|
self._git("checkout", "-q", "-b", BRANCH)
|
||||||
|
with open(os.path.join(self.repo, "work.txt"), "w") as fh:
|
||||||
|
fh.write("unpublished implementation\n")
|
||||||
|
self._git("add", "work.txt")
|
||||||
|
self._git("commit", "-q", "-m", "unpublished implementation")
|
||||||
|
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
self.worktree = os.path.realpath(self.repo)
|
||||||
|
|
||||||
|
def lock_path(self):
|
||||||
|
return issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_lock(self, **overrides):
|
||||||
|
path = self.lock_path()
|
||||||
|
claimant = overrides.pop(
|
||||||
|
"claimant", {"username": IDENTITY, "profile": PROFILE}
|
||||||
|
)
|
||||||
|
pid = overrides.pop("session_pid", os.getpid())
|
||||||
|
lease = {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"pr_number": None,
|
||||||
|
"branch": overrides.get("branch_name", BRANCH),
|
||||||
|
"worktree_path": overrides.get("worktree_path", self.worktree),
|
||||||
|
"claimant": claimant,
|
||||||
|
"created_at": _ts(-2),
|
||||||
|
"last_heartbeat_at": _ts(-2),
|
||||||
|
# Expired: entry point B's lease has lapsed, which is precisely why
|
||||||
|
# renewal — and therefore a published head — is needed.
|
||||||
|
"expires_at": _ts(-1),
|
||||||
|
}
|
||||||
|
lease.update(overrides.pop("work_lease", {}))
|
||||||
|
data = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
"worktree_path": self.worktree,
|
||||||
|
"session_pid": pid,
|
||||||
|
"pid": pid,
|
||||||
|
"lock_generation": 1,
|
||||||
|
"work_lease": lease,
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool="gitea_lock_issue", claimant=claimant
|
||||||
|
),
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
data["lock_file_path"] = path
|
||||||
|
issue_lock_store.save_lock_file(path, data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _tool_env(self):
|
||||||
|
env = shared_mutation_env(
|
||||||
|
PROFILE, include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
# These tests repoint PROJECT_ROOT at a synthetic repository so the
|
||||||
|
# registered-worktree proof runs for real. Pin the parity gate to the
|
||||||
|
# server's own startup head so that repointing does not read as a stale
|
||||||
|
# daemon; the gate itself stays live and enforced.
|
||||||
|
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
|
||||||
|
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
|
||||||
|
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
|
||||||
|
return env
|
||||||
|
|
||||||
|
# ── tool driver ──────────────────────────────────────────────────────
|
||||||
|
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
|
||||||
|
"""Drive the public publication tool against the synthetic fixture."""
|
||||||
|
env = self._tool_env()
|
||||||
|
with patch(
|
||||||
|
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._work_lease_claimant",
|
||||||
|
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "PROJECT_ROOT", self.repo
|
||||||
|
), patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
return mcp_server.gitea_publish_unpublished_issue_branch(
|
||||||
|
issue_number=kwargs.pop("issue_number", ISSUE),
|
||||||
|
branch_name=kwargs.pop("branch_name", BRANCH),
|
||||||
|
worktree_path=kwargs.pop("worktree_path", self.worktree),
|
||||||
|
expected_head=expected_head or self.head_sha,
|
||||||
|
remote="prgs",
|
||||||
|
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def remote_head(self, branch=BRANCH):
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuccessfulPublication(_PublishBase):
|
||||||
|
"""AC20 — the branch becomes observable and is verified after the write."""
|
||||||
|
|
||||||
|
def test_publishes_clean_unpublished_commit(self):
|
||||||
|
self.write_lock()
|
||||||
|
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
self.assertTrue(result["performed"])
|
||||||
|
self.assertTrue(result["published"])
|
||||||
|
self.assertTrue(result["verified"], "read-after-write must be proven")
|
||||||
|
self.assertEqual(result["remote_head_sha"], self.head_sha)
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
|
||||||
|
def test_publication_does_not_rewrite_the_commit(self):
|
||||||
|
self.write_lock()
|
||||||
|
self.run_publish()
|
||||||
|
# The published object is the same commit, not a copy or a rewrite.
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
self.assertEqual(
|
||||||
|
self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_next_action_names_the_lock_call(self):
|
||||||
|
self.write_lock()
|
||||||
|
result = self.run_publish()
|
||||||
|
self.assertIn("gitea_lock_issue", result["exact_next_action"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailsClosed(_PublishBase):
|
||||||
|
"""AC20/AC9 — each refusal reason, exercised independently."""
|
||||||
|
|
||||||
|
def test_changed_local_head_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
stale = self.base_sha # a real commit, but not the declared head
|
||||||
|
result = self.run_publish(expected_head=stale)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("local commit changed" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.remote_head(), "refusal must not publish")
|
||||||
|
|
||||||
|
def test_abbreviated_sha_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
result = self.run_publish(expected_head=self.head_sha[:8])
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("40-character" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dirty_tracked_worktree_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
with open(os.path.join(self.repo, "work.txt"), "a") as fh:
|
||||||
|
fh.write("uncommitted edit\n")
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("dirty tracked files" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"])
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_untracked_file_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
with open(os.path.join(self.repo, "stray.txt"), "w") as fh:
|
||||||
|
fh.write("not committed\n")
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("untracked files" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
self.assertIn("stray.txt", result["evidence"]["untracked_files"])
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_unexpected_remote_head_refuses(self):
|
||||||
|
"""A remote head that is not an ancestor must never be overwritten."""
|
||||||
|
self.write_lock()
|
||||||
|
# Publish a divergent commit to the branch from a separate line.
|
||||||
|
self._git("checkout", "-q", "-b", "divergent", self.base_sha)
|
||||||
|
with open(os.path.join(self.repo, "other.txt"), "w") as fh:
|
||||||
|
fh.write("someone else's work\n")
|
||||||
|
self._git("add", "other.txt")
|
||||||
|
self._git("commit", "-q", "-m", "divergent")
|
||||||
|
divergent = self._git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}")
|
||||||
|
self._git("checkout", "-q", BRANCH)
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("not an ancestor" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.remote_head(), divergent, "the other head must survive intact"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fast_forward_remote_head_is_allowed(self):
|
||||||
|
"""An ancestor head is an honest fast-forward, not a conflict."""
|
||||||
|
self.write_lock()
|
||||||
|
self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}")
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
self.assertTrue(result["evidence"]["fast_forward_from_remote"])
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
|
||||||
|
def test_content_hash_mismatch_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
wrong = {"work.txt": "0" * 64}
|
||||||
|
|
||||||
|
result = self.run_publish(expected_file_hashes=wrong)
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("declared content hashes" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
self.assertFalse(result["evidence"]["file_hashes_verified"])
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_matching_content_hashes_publish(self):
|
||||||
|
self.write_lock()
|
||||||
|
digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"])
|
||||||
|
|
||||||
|
result = self.run_publish(expected_file_hashes=digests)
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
self.assertTrue(result["evidence"]["file_hashes_verified"])
|
||||||
|
|
||||||
|
def test_missing_declared_file_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64})
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("missing or unreadable" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_foreign_claimant_refuses(self):
|
||||||
|
"""Ownership comes from the durable record, not from the caller."""
|
||||||
|
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("foreign claim" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_foreign_profile_refuses(self):
|
||||||
|
self.write_lock(
|
||||||
|
claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"}
|
||||||
|
)
|
||||||
|
result = self.run_publish()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("claimant profile" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_absent_lock_record_refuses(self):
|
||||||
|
"""No recorded claim means this cannot be used to bypass the lock."""
|
||||||
|
result = self.run_publish() # no write_lock()
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("no durable issue-lock record" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_branch_mismatch_against_lock_refuses(self):
|
||||||
|
self.write_lock(branch_name=f"feat/issue-{ISSUE}-different")
|
||||||
|
result = self.run_publish()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("records branch" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_worktree_mismatch_against_lock_refuses(self):
|
||||||
|
self.write_lock(worktree_path="/tmp/some/other/worktree")
|
||||||
|
result = self.run_publish()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("records worktree" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_competing_open_pr_on_another_branch_refuses(self):
|
||||||
|
self.write_lock()
|
||||||
|
competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
|
||||||
|
|
||||||
|
result = self.run_publish(open_prs=competing)
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("already claim issue" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_open_pr_on_the_same_branch_is_not_competing(self):
|
||||||
|
"""This branch's own PR is not a rival claim against itself."""
|
||||||
|
self.write_lock()
|
||||||
|
own = [{"number": 77, "head": {"ref": BRANCH}}]
|
||||||
|
|
||||||
|
result = self.run_publish(open_prs=own)
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuardStrictnessPreserved(_PublishBase):
|
||||||
|
"""AC15 — publication is an operation, never a weakening of the guards."""
|
||||||
|
|
||||||
|
def test_non_issue_branch_refuses(self):
|
||||||
|
self._git("checkout", "-q", "-b", "scratch/not-issue-linked")
|
||||||
|
self.write_lock(branch_name="scratch/not-issue-linked")
|
||||||
|
|
||||||
|
result = self.run_publish(branch_name="scratch/not-issue-linked")
|
||||||
|
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("issue-linked" in r for r in result["reasons"]), result["reasons"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stable_branch_refuses(self):
|
||||||
|
self.write_lock(branch_name="master")
|
||||||
|
result = self.run_publish(branch_name="master")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("issue-linked" in r or "stable branch" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_branch_number_must_match_the_issue(self):
|
||||||
|
other = "feat/issue-7777-mismatched"
|
||||||
|
self._git("checkout", "-q", "-b", other)
|
||||||
|
self.write_lock(branch_name=other)
|
||||||
|
result = self.run_publish(branch_name=other)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not carry issue number" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unregistered_worktree_refuses(self):
|
||||||
|
"""#713 — an improvised directory is not a registered worktree."""
|
||||||
|
path = self.write_lock()
|
||||||
|
assessment = branch_publish.assess_unpublished_commit_publication(
|
||||||
|
issue_lock_store.read_lock_file(path),
|
||||||
|
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
|
||||||
|
expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO,
|
||||||
|
identity=IDENTITY, profile=PROFILE,
|
||||||
|
worktree_state={
|
||||||
|
"current_branch": BRANCH, "porcelain_status": "",
|
||||||
|
"head_sha": self.head_sha,
|
||||||
|
},
|
||||||
|
worktree_registered=False,
|
||||||
|
remote_probe={"probe_ok": True, "remote_branch_exists": False},
|
||||||
|
)
|
||||||
|
self.assertEqual(assessment["outcome"], branch_publish.REFUSED)
|
||||||
|
self.assertTrue(
|
||||||
|
any("not listed in git worktree list" in r
|
||||||
|
for r in assessment["reasons"]),
|
||||||
|
assessment["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unobservable_remote_refuses(self):
|
||||||
|
"""An unknown remote state must not be mistaken for an absent branch."""
|
||||||
|
self.write_lock()
|
||||||
|
result = self.run_publish(git_remote_name="no-such-remote")
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("could not be observed" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordSeparation(_PublishBase):
|
||||||
|
"""AC23 — the durable issue lock and the workflow lease are distinct."""
|
||||||
|
|
||||||
|
def test_publication_leaves_the_issue_lock_byte_identical(self):
|
||||||
|
path = self.write_lock()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
before = fh.read()
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
after = fh.read()
|
||||||
|
self.assertEqual(before, after, "publication must not mutate the lock record")
|
||||||
|
self.assertFalse(result["issue_lock_record_mutated"])
|
||||||
|
self.assertFalse(result["workflow_lease_touched"])
|
||||||
|
|
||||||
|
def test_refusal_also_reports_untouched_records(self):
|
||||||
|
result = self.run_publish() # refuses: no lock record
|
||||||
|
self.assertFalse(result["issue_lock_record_mutated"])
|
||||||
|
self.assertFalse(result["workflow_lease_touched"])
|
||||||
|
|
||||||
|
def test_lock_generation_is_not_advanced(self):
|
||||||
|
path = self.write_lock()
|
||||||
|
self.run_publish()
|
||||||
|
lock = issue_lock_store.read_lock_file(path)
|
||||||
|
self.assertEqual(lock["lock_generation"], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruthfulProcessEvidence(_PublishBase):
|
||||||
|
"""AC24 — a live daemon pid is never represented as a dead process."""
|
||||||
|
|
||||||
|
def test_live_recorded_pid_does_not_block_publication(self):
|
||||||
|
# The recorded pid is this live process, standing in for the live MCP
|
||||||
|
# daemon. Reclaim would refuse here; publication legitimately does not.
|
||||||
|
path = self.write_lock(session_pid=os.getpid())
|
||||||
|
lock = issue_lock_store.read_lock_file(path)
|
||||||
|
self.assertEqual(lock["pid"], os.getpid())
|
||||||
|
|
||||||
|
result = self.run_publish()
|
||||||
|
|
||||||
|
self.assertTrue(result["success"], result.get("reasons"))
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
|
||||||
|
def test_liveness_is_not_consulted_as_evidence(self):
|
||||||
|
self.write_lock(session_pid=os.getpid())
|
||||||
|
result = self.run_publish()
|
||||||
|
self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"])
|
||||||
|
|
||||||
|
def test_reclaim_still_refuses_for_the_same_live_pid(self):
|
||||||
|
"""Publication does not soften the reclaim predicate it routes around."""
|
||||||
|
path = self.write_lock(session_pid=os.getpid())
|
||||||
|
lock = issue_lock_store.read_lock_file(path)
|
||||||
|
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
|
||||||
|
self.assertFalse(reclaim["reclaim_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdempotentRetry(_PublishBase):
|
||||||
|
"""AC20 — retry is safe and read-after-write is proven every time."""
|
||||||
|
|
||||||
|
def test_second_publication_reports_already_published(self):
|
||||||
|
self.write_lock()
|
||||||
|
first = self.run_publish()
|
||||||
|
self.assertTrue(first["performed"])
|
||||||
|
|
||||||
|
second = self.run_publish()
|
||||||
|
|
||||||
|
self.assertTrue(second["success"], second.get("reasons"))
|
||||||
|
self.assertFalse(second["performed"], "no second push is needed")
|
||||||
|
self.assertTrue(second["published"])
|
||||||
|
self.assertTrue(second["verified"])
|
||||||
|
self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED)
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDryRun(_PublishBase):
|
||||||
|
"""AC12 — dry run reports the decision and mutates nothing."""
|
||||||
|
|
||||||
|
def test_dry_run_reports_intent_without_publishing(self):
|
||||||
|
self.write_lock()
|
||||||
|
|
||||||
|
result = self.run_publish(dry_run=True)
|
||||||
|
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertTrue(result["dry_run"])
|
||||||
|
self.assertTrue(result["would_publish"])
|
||||||
|
self.assertFalse(result["performed"])
|
||||||
|
self.assertIsNone(self.remote_head(), "dry run must not publish")
|
||||||
|
|
||||||
|
def test_dry_run_and_apply_agree_on_a_refusal(self):
|
||||||
|
"""AC11 — the reported decision does not depend on which mode ran."""
|
||||||
|
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
|
||||||
|
|
||||||
|
dry = self.run_publish(dry_run=True)
|
||||||
|
applied = self.run_publish()
|
||||||
|
|
||||||
|
self.assertFalse(dry["success"])
|
||||||
|
self.assertFalse(applied["success"])
|
||||||
|
self.assertEqual(dry["reasons"], applied["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenewalUnblocked(_PublishBase):
|
||||||
|
"""AC20/AC21 — renewal is permitted only after verified publication."""
|
||||||
|
|
||||||
|
def _renewal(self, remote_head):
|
||||||
|
return issue_lock_renewal.assess_exact_owner_lease_renewal(
|
||||||
|
issue_lock_store.read_lock_file(self.lock_path()),
|
||||||
|
issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
|
||||||
|
remote="prgs", org=ORG, repo=REPO,
|
||||||
|
identity=IDENTITY, profile=PROFILE,
|
||||||
|
current_branch=BRANCH, porcelain_status="", worktree_exists=True,
|
||||||
|
head_sha=self.head_sha, remote_head_sha=remote_head,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_renewal_refuses_before_publication(self):
|
||||||
|
self.write_lock()
|
||||||
|
decision = self._renewal(None)
|
||||||
|
self.assertFalse(decision["renewal_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("unpublished branch" in r for r in decision["reasons"]),
|
||||||
|
decision["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_renewal_is_sanctioned_after_publication(self):
|
||||||
|
self.write_lock()
|
||||||
|
result = self.run_publish()
|
||||||
|
self.assertTrue(result["verified"], result.get("reasons"))
|
||||||
|
|
||||||
|
decision = self._renewal(self.remote_head())
|
||||||
|
|
||||||
|
self.assertTrue(decision["renewal_sanctioned"], decision["reasons"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtectedAssetUntouched(unittest.TestCase):
|
||||||
|
"""AC17 — no test or fixture may reference the protected worktree."""
|
||||||
|
|
||||||
|
def test_no_reference_to_the_protected_worktree(self):
|
||||||
|
here = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
root = os.path.dirname(here)
|
||||||
|
needle = "issue-635-project-registry" + "-api"
|
||||||
|
for path in (
|
||||||
|
os.path.join(here, "test_issue_812_publish_unpublished_commit.py"),
|
||||||
|
os.path.join(root, "branch_publish.py"),
|
||||||
|
):
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
body = fh.read()
|
||||||
|
self.assertNotIn(needle, body)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
"""Publication preflight must receive the caller's worktree (#815).
|
||||||
|
|
||||||
|
``gitea_publish_unpublished_issue_branch`` takes a **required** ``worktree_path``
|
||||||
|
but resolved it only *after* ``verify_preflight_purity`` had already run. Every
|
||||||
|
workspace-resolution layer behind that preflight — canonical root, root checkout,
|
||||||
|
create-issue bootstrap, the #618 branches-only guard, issue scope, and anti-stomp
|
||||||
|
— therefore received ``None`` and fell back to the MCP process root. A daemon
|
||||||
|
rooted at the stable control checkout refused a valid registered issue worktree
|
||||||
|
that the caller had explicitly supplied, before the publication assessor ever ran.
|
||||||
|
|
||||||
|
The #812 suite could not see this. Its fixture sets ``self.worktree =
|
||||||
|
os.path.realpath(self.repo)`` and patches ``PROJECT_ROOT`` to that same path, so
|
||||||
|
the fallback resolved to the very worktree the argument named. The production
|
||||||
|
topology — control checkout on a stable branch, issue worktree somewhere else —
|
||||||
|
was never constructed, and preflight additionally no-ops under pytest unless
|
||||||
|
production guards are forced on.
|
||||||
|
|
||||||
|
These tests build that topology honestly:
|
||||||
|
|
||||||
|
* ``PROJECT_ROOT`` is a control checkout sitting on ``master``;
|
||||||
|
* the registered issue worktree is a genuinely separate path under ``branches/``;
|
||||||
|
* ``GITEA_TEST_FORCE_PRODUCTION_GUARDS`` is set so the #618 guard really runs;
|
||||||
|
* no patch makes the issue worktree appear to be ``PROJECT_ROOT``.
|
||||||
|
|
||||||
|
Nothing here reads, writes, or references the protected worktree named in #812
|
||||||
|
AC17 and #815 AC9. Every fixture is built from scratch against a local bare
|
||||||
|
remote, so publication and read-after-write verification genuinely execute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import issue_lock_provenance # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import mcp_server # noqa: E402
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
||||||
|
|
||||||
|
ISSUE = 9815
|
||||||
|
BRANCH = f"feat/issue-{ISSUE}-forwarding-fixture"
|
||||||
|
WORKTREE_DIRNAME = BRANCH.replace("/", "-")
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "test-author-prgs"
|
||||||
|
ORG = "Scaled-Tech-Consulting"
|
||||||
|
REPO = "Gitea-Tools"
|
||||||
|
GIT_REMOTE = "prgs"
|
||||||
|
|
||||||
|
AUTHOR_PROFILE = {
|
||||||
|
"profile_name": "prgs-author",
|
||||||
|
"role": "author",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read", "gitea.issue.create", "gitea.issue.comment",
|
||||||
|
"gitea.pr.create", "gitea.repo.commit", "gitea.branch.push",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
"audit_label": "prgs-author",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ts(hours: int) -> str:
|
||||||
|
return (
|
||||||
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreflightReceivesTheWorktree(unittest.TestCase):
|
||||||
|
"""AC1 — the supplied path reaches ``verify_preflight_purity`` itself.
|
||||||
|
|
||||||
|
Follows the #735 capture pattern: replace preflight with a recorder that
|
||||||
|
raises, so the argument can be proven forwarded without performing the
|
||||||
|
mutation. This is the direct unit-level statement of the defect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _capture_preflight(self, **kwargs):
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _capture(*a, **kw):
|
||||||
|
captured.update(kw)
|
||||||
|
captured["_args"] = a
|
||||||
|
raise RuntimeError("capture-only")
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
mcp_server, "verify_preflight_purity", side_effect=_capture
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "get_profile", return_value=AUTHOR_PROFILE
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_resolve",
|
||||||
|
return_value=("gitea.prgs.cc", ORG, REPO),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_auth", return_value="token fake",
|
||||||
|
), patch.object(
|
||||||
|
mcp_server.role_session_router,
|
||||||
|
"check_author_mutation_after_reviewer_stop",
|
||||||
|
return_value=(True, []),
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_namespace_mutation_block", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "_profile_permission_block", return_value=None
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
mcp_server.gitea_publish_unpublished_issue_branch(**kwargs)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "capture-only" not in str(exc) and not captured:
|
||||||
|
raise
|
||||||
|
self.assertTrue(
|
||||||
|
captured,
|
||||||
|
"gitea_publish_unpublished_issue_branch never called "
|
||||||
|
"verify_preflight_purity",
|
||||||
|
)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
def _base_kwargs(self, **overrides):
|
||||||
|
kwargs = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": "/tmp/issue-815-explicit-worktree",
|
||||||
|
"expected_head": "a" * 40,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
"git_remote_name": GIT_REMOTE,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def test_explicit_worktree_path_reaches_preflight(self):
|
||||||
|
captured = self._capture_preflight(**self._base_kwargs())
|
||||||
|
self.assertEqual(
|
||||||
|
captured.get("worktree_path"),
|
||||||
|
os.path.realpath(os.path.abspath("/tmp/issue-815-explicit-worktree")),
|
||||||
|
"the authoritative worktree_path must be forwarded into preflight",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_forwarded_path_is_the_one_publication_uses(self):
|
||||||
|
"""AC4 — preflight and publication must judge the same resolved path."""
|
||||||
|
raw = "/tmp/issue-815-explicit-worktree/./"
|
||||||
|
captured = self._capture_preflight(**self._base_kwargs(worktree_path=raw))
|
||||||
|
expected = os.path.realpath(os.path.abspath(raw.strip()))
|
||||||
|
self.assertEqual(captured.get("worktree_path"), expected)
|
||||||
|
|
||||||
|
def test_blank_worktree_path_forwards_none(self):
|
||||||
|
"""AC5/AC8 — nothing usable supplied keeps the fail-closed fallback."""
|
||||||
|
for blank in ("", " "):
|
||||||
|
with self.subTest(blank=repr(blank)):
|
||||||
|
captured = self._capture_preflight(
|
||||||
|
**self._base_kwargs(worktree_path=blank)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
captured.get("worktree_path"),
|
||||||
|
"a blank worktree must not resolve to the process cwd",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_org_repo_and_task_forwarding_are_not_regressed(self):
|
||||||
|
"""AC6 — #735's org/repo forwarding and the task name still hold."""
|
||||||
|
captured = self._capture_preflight(**self._base_kwargs())
|
||||||
|
self.assertEqual(captured.get("org"), ORG)
|
||||||
|
self.assertEqual(captured.get("repo"), REPO)
|
||||||
|
self.assertEqual(captured.get("task"), "publish_unpublished_branch")
|
||||||
|
|
||||||
|
|
||||||
|
class _ProductionTopologyBase(unittest.TestCase):
|
||||||
|
"""Control checkout on master + a distinct registered issue worktree.
|
||||||
|
|
||||||
|
This is the shape the production daemon runs in and the shape the #812
|
||||||
|
fixture never built. ``PROJECT_ROOT`` is the control checkout; the issue
|
||||||
|
worktree is a real registered worktree at a different path; production
|
||||||
|
guards are forced on so the #618 branches-only guard genuinely evaluates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lock_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.lock_dir.cleanup)
|
||||||
|
self.origin = tempfile.mkdtemp(prefix="issue815-origin-")
|
||||||
|
self.control = tempfile.mkdtemp(prefix="issue815-control-")
|
||||||
|
for path in (self.origin, self.control):
|
||||||
|
self.addCleanup(
|
||||||
|
lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
|
||||||
|
)
|
||||||
|
self._init_repos()
|
||||||
|
self.remotes = patch.dict(
|
||||||
|
mcp_server.REMOTES,
|
||||||
|
{"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
|
||||||
|
)
|
||||||
|
self.remotes.start()
|
||||||
|
self.addCleanup(patch.stopall)
|
||||||
|
mcp_server._IDENTITY_CACHE.clear()
|
||||||
|
|
||||||
|
def _git(self, *args, cwd=None):
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", cwd or self.control, *args],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_repos(self):
|
||||||
|
subprocess.run(
|
||||||
|
["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
|
||||||
|
)
|
||||||
|
self._git("init", "-q", "-b", "master")
|
||||||
|
self._git("config", "user.email", "[email protected]")
|
||||||
|
self._git("config", "user.name", "Test")
|
||||||
|
self._git("remote", "add", GIT_REMOTE, self.origin)
|
||||||
|
|
||||||
|
with open(os.path.join(self.control, "seed.txt"), "w") as fh:
|
||||||
|
fh.write("seed\n")
|
||||||
|
# The real repository gitignores branches/, so a registered worktree
|
||||||
|
# living there does not dirty the stable control checkout. Mirror that,
|
||||||
|
# or the #615 dirty-runtime block fires on the worktree we just created.
|
||||||
|
with open(os.path.join(self.control, ".gitignore"), "w") as fh:
|
||||||
|
fh.write("branches/\n")
|
||||||
|
self._git("add", "seed.txt", ".gitignore")
|
||||||
|
self._git("commit", "-q", "-m", "seed")
|
||||||
|
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
self._git("push", "-q", GIT_REMOTE, "master")
|
||||||
|
|
||||||
|
# The control checkout STAYS on master. This is the whole point: the
|
||||||
|
# daemon's process root is the stable control checkout, never the
|
||||||
|
# worktree the publication targets.
|
||||||
|
self.worktree = os.path.realpath(
|
||||||
|
os.path.join(self.control, "branches", WORKTREE_DIRNAME)
|
||||||
|
)
|
||||||
|
self._git("worktree", "add", "-q", "-b", BRANCH, self.worktree, "master")
|
||||||
|
|
||||||
|
with open(os.path.join(self.worktree, "work.txt"), "w") as fh:
|
||||||
|
fh.write("unpublished implementation\n")
|
||||||
|
self._git("add", "work.txt", cwd=self.worktree)
|
||||||
|
self._git("commit", "-q", "-m", "unpublished implementation", cwd=self.worktree)
|
||||||
|
self.head_sha = self._git("rev-parse", "HEAD", cwd=self.worktree).stdout.strip()
|
||||||
|
|
||||||
|
self.control_branch = self._git(
|
||||||
|
"rev-parse", "--abbrev-ref", "HEAD"
|
||||||
|
).stdout.strip()
|
||||||
|
|
||||||
|
# ── durable lock naming the caller and the issue worktree ────────────
|
||||||
|
def lock_path(self):
|
||||||
|
return issue_lock_store.lock_file_path(
|
||||||
|
remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
|
||||||
|
lock_dir=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def write_lock(self, *, bind_session=True, **overrides):
|
||||||
|
path = self.lock_path()
|
||||||
|
claimant = overrides.pop(
|
||||||
|
"claimant", {"username": IDENTITY, "profile": PROFILE}
|
||||||
|
)
|
||||||
|
pid = overrides.pop("session_pid", os.getpid())
|
||||||
|
lease = {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"pr_number": None,
|
||||||
|
"branch": overrides.get("branch_name", BRANCH),
|
||||||
|
"worktree_path": overrides.get("worktree_path", self.worktree),
|
||||||
|
"claimant": claimant,
|
||||||
|
"created_at": _ts(-2),
|
||||||
|
"last_heartbeat_at": _ts(-2),
|
||||||
|
"expires_at": _ts(-1),
|
||||||
|
}
|
||||||
|
lease.update(overrides.pop("work_lease", {}))
|
||||||
|
data = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
"worktree_path": self.worktree,
|
||||||
|
"session_pid": pid,
|
||||||
|
"pid": pid,
|
||||||
|
"lock_generation": 1,
|
||||||
|
"work_lease": lease,
|
||||||
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
||||||
|
tool="gitea_lock_issue", claimant=claimant
|
||||||
|
),
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
data["lock_file_path"] = path
|
||||||
|
issue_lock_store.save_lock_file(path, data)
|
||||||
|
# Bind the session pointer so the #683 issue-scope guard resolves an
|
||||||
|
# owning issue for this author session. In real production the publish
|
||||||
|
# task does not require a session lock — require_author_lock is keyed on
|
||||||
|
# the test-only production_guards_forced() flag, which this suite must
|
||||||
|
# set to make preflight run at all — so this pointer is fixture
|
||||||
|
# scaffolding to clear a guard production would not apply here, never a
|
||||||
|
# softening of the worktree-forwarding behaviour under test. The
|
||||||
|
# preflight-negative cases below leave it unbound precisely so the #618
|
||||||
|
# guard is reached with no session fallback to rescue a bad worktree.
|
||||||
|
if bind_session:
|
||||||
|
pointer = {
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"lock_file_path": path,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": data["branch_name"],
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": ORG,
|
||||||
|
"repo": REPO,
|
||||||
|
}
|
||||||
|
issue_lock_store.save_lock_file(
|
||||||
|
issue_lock_store.session_pointer_path(self.lock_dir.name), pointer
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _tool_env(self):
|
||||||
|
env = shared_mutation_env(
|
||||||
|
PROFILE, include_example_repo=True,
|
||||||
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
||||||
|
)
|
||||||
|
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
# The defect only exists where preflight actually runs. Under pytest the
|
||||||
|
# production root/branches/scope guards are skipped unless forced on, so
|
||||||
|
# force them: this test exists to exercise the #618 guard, not to bypass
|
||||||
|
# it. Parity is pinned to the server's own startup head so repointing
|
||||||
|
# PROJECT_ROOT does not read as a stale daemon.
|
||||||
|
env["GITEA_TEST_FORCE_PRODUCTION_GUARDS"] = "1"
|
||||||
|
# Production is a promoted stable-control runtime. The pytest process
|
||||||
|
# itself runs from a branches/ worktree, which the #615 runtime-mode
|
||||||
|
# gate correctly classifies as dev-test; declaring the sanctioned mode
|
||||||
|
# models the production daemon rather than defeating the gate. Without
|
||||||
|
# this, forcing production guards on would trip the *runtime-mode* block
|
||||||
|
# for a reason unrelated to the #815 worktree-forwarding defect.
|
||||||
|
env["GITEA_MCP_RUNTIME_MODE"] = "stable-control"
|
||||||
|
startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
|
||||||
|
env["GITEA_TEST_CURRENT_HEAD"] = startup_head
|
||||||
|
env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
|
||||||
|
return env
|
||||||
|
|
||||||
|
def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
|
||||||
|
"""Drive the public tool with PROJECT_ROOT pinned to the CONTROL checkout."""
|
||||||
|
env = self._tool_env()
|
||||||
|
with patch(
|
||||||
|
"mcp_server._list_open_pulls", return_value=list(open_prs or [])
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._work_lease_claimant",
|
||||||
|
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||||
|
), patch.object(
|
||||||
|
# NOTE: the control checkout — deliberately NOT self.worktree.
|
||||||
|
mcp_server, "PROJECT_ROOT", self.control
|
||||||
|
), patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
return mcp_server.gitea_publish_unpublished_issue_branch(
|
||||||
|
issue_number=kwargs.pop("issue_number", ISSUE),
|
||||||
|
branch_name=kwargs.pop("branch_name", BRANCH),
|
||||||
|
worktree_path=kwargs.pop("worktree_path", self.worktree),
|
||||||
|
expected_head=expected_head or self.head_sha,
|
||||||
|
remote="prgs",
|
||||||
|
git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def remote_head(self, branch=BRANCH):
|
||||||
|
res = subprocess.run(
|
||||||
|
["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
return (res.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardingClearsThe618Guard(_ProductionTopologyBase):
|
||||||
|
"""AC2 — the faithful production reproduction, and the sharpest fix proof.
|
||||||
|
|
||||||
|
The production recovery worker had **no** session issue lock — acquiring one
|
||||||
|
was the very thing the deadlock prevented — so preflight had nothing but the
|
||||||
|
explicit ``worktree_path`` argument to resolve the workspace from. This class
|
||||||
|
reproduces exactly that: no session pointer is bound, so there is no
|
||||||
|
author-lock fallback to rescue a dropped argument.
|
||||||
|
|
||||||
|
With the argument forwarded (fixed source) the #618 branches-only guard
|
||||||
|
accepts the registered issue worktree and the call advances to the next
|
||||||
|
guard. With the argument dropped (the buggy source this issue reports)
|
||||||
|
preflight falls back to ``PROJECT_ROOT`` — the stable control checkout — and
|
||||||
|
the #618 guard traps the call there. The two outcomes are told apart by the
|
||||||
|
guard that fired, on its own error text.
|
||||||
|
|
||||||
|
This test therefore *fails* against the unpatched source (the call is trapped
|
||||||
|
at #618 instead of clearing it), which is what makes it a regression rather
|
||||||
|
than a smoke test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONTROL_CHECKOUT_MARKERS = ("stable control checkout", "#618")
|
||||||
|
|
||||||
|
def test_explicit_worktree_clears_618_without_a_session_lock(self):
|
||||||
|
# No write_lock(): the session is deliberately unbound, as in production.
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.run_publish()
|
||||||
|
message = str(ctx.exception)
|
||||||
|
# The workspace guard is satisfied — the failure is the *later* scope
|
||||||
|
# guard (no owning issue), never the control-checkout refusal. If the
|
||||||
|
# argument were dropped, this call would be trapped at #618 instead.
|
||||||
|
for marker in self._CONTROL_CHECKOUT_MARKERS:
|
||||||
|
self.assertNotIn(
|
||||||
|
marker, message,
|
||||||
|
f"the explicit worktree must clear #618; got a control-checkout "
|
||||||
|
f"refusal instead: {message}",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"owning issue", message,
|
||||||
|
f"expected the downstream scope guard to fire, got: {message}",
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
def test_dropped_argument_would_be_trapped_at_618(self):
|
||||||
|
# Simulate the buggy call shape directly: no session lock, and preflight
|
||||||
|
# given no worktree, exactly as the unpatched source left it. This pins
|
||||||
|
# the control-checkout refusal that the fix eliminates, so the pair of
|
||||||
|
# tests brackets the defect from both sides regardless of which source
|
||||||
|
# version is loaded.
|
||||||
|
env = self._tool_env()
|
||||||
|
with patch(
|
||||||
|
"mcp_server._list_open_pulls", return_value=[]
|
||||||
|
), patch(
|
||||||
|
"mcp_server._auth", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server.get_auth_header", return_value="token x"
|
||||||
|
), patch(
|
||||||
|
"mcp_server._work_lease_claimant",
|
||||||
|
return_value={"username": IDENTITY, "profile": PROFILE},
|
||||||
|
), patch.object(
|
||||||
|
mcp_server, "PROJECT_ROOT", self.control
|
||||||
|
), patch.dict(os.environ, env, clear=True):
|
||||||
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
# Drive verify_preflight_purity the way the buggy body did:
|
||||||
|
# no worktree_path forwarded at all.
|
||||||
|
mcp_server.verify_preflight_purity(
|
||||||
|
"prgs",
|
||||||
|
task="publish_unpublished_branch",
|
||||||
|
org=ORG,
|
||||||
|
repo=REPO,
|
||||||
|
)
|
||||||
|
message = str(ctx.exception)
|
||||||
|
self.assertTrue(
|
||||||
|
any(m in message for m in self._CONTROL_CHECKOUT_MARKERS),
|
||||||
|
f"a dropped worktree must trap at the control checkout: {message}",
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionTopologyPublishes(_ProductionTopologyBase):
|
||||||
|
"""AC2/AC4/AC7 — the explicit registered worktree is what preflight validates."""
|
||||||
|
|
||||||
|
def test_fixture_is_genuinely_the_production_topology(self):
|
||||||
|
"""Guard the guard: if this drifts, the regression stops meaning anything."""
|
||||||
|
self.assertNotEqual(
|
||||||
|
os.path.realpath(self.control), self.worktree,
|
||||||
|
"the issue worktree must not be PROJECT_ROOT",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.control_branch, "master",
|
||||||
|
"the control checkout must sit on a stable branch",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.realpath(self.worktree).startswith(
|
||||||
|
os.path.realpath(os.path.join(self.control, "branches")) + os.sep
|
||||||
|
),
|
||||||
|
"the issue worktree must live under branches/",
|
||||||
|
)
|
||||||
|
listed = subprocess.run(
|
||||||
|
["git", "-C", self.control, "worktree", "list"],
|
||||||
|
capture_output=True, text=True, check=True,
|
||||||
|
).stdout
|
||||||
|
self.assertIn(
|
||||||
|
self.worktree, listed, "the issue worktree must be genuinely registered"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_publishes_from_a_control_rooted_daemon(self):
|
||||||
|
"""The exact production failure: this refused with #618 before the fix."""
|
||||||
|
self.write_lock()
|
||||||
|
self.assertIsNone(self.remote_head(), "fixture must start unpublished")
|
||||||
|
res = self.run_publish()
|
||||||
|
self.assertTrue(res.get("success"), res)
|
||||||
|
self.assertTrue(res.get("performed"), res)
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
|
||||||
|
def test_dry_run_uses_the_explicit_worktree(self):
|
||||||
|
"""AC4 — dry-run reaches the same decision without publishing."""
|
||||||
|
self.write_lock()
|
||||||
|
res = self.run_publish(dry_run=True)
|
||||||
|
self.assertTrue(res.get("success"), res)
|
||||||
|
self.assertFalse(res.get("performed"), res)
|
||||||
|
self.assertTrue(res.get("would_publish"), res)
|
||||||
|
self.assertIsNone(self.remote_head(), "dry-run must not publish")
|
||||||
|
|
||||||
|
def test_dry_run_and_apply_agree_on_the_same_worktree(self):
|
||||||
|
"""AC4 — both paths resolve the same workspace, so both succeed."""
|
||||||
|
self.write_lock()
|
||||||
|
dry = self.run_publish(dry_run=True)
|
||||||
|
self.assertTrue(dry.get("would_publish"), dry)
|
||||||
|
applied = self.run_publish()
|
||||||
|
self.assertTrue(applied.get("performed"), applied)
|
||||||
|
self.assertEqual(self.remote_head(), self.head_sha)
|
||||||
|
|
||||||
|
def test_read_after_write_verification_still_runs(self):
|
||||||
|
"""AC6 — PR #814's post-publication verification is unchanged."""
|
||||||
|
self.write_lock()
|
||||||
|
res = self.run_publish()
|
||||||
|
self.assertTrue(res.get("verified"), res)
|
||||||
|
self.assertEqual(res.get("remote_head_sha"), self.head_sha)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionTopologyFailsClosed(_ProductionTopologyBase):
|
||||||
|
"""AC3/AC5/AC8 — the fix does not weaken any refusal.
|
||||||
|
|
||||||
|
A refusal reaches the caller by one of two mechanisms, and this class holds
|
||||||
|
them apart deliberately. A bad *workspace* is caught by the #618 preflight
|
||||||
|
guard, which raises before the assessor is built. A bad *content/ownership*
|
||||||
|
fact passes preflight (the worktree itself is fine) and is then refused by
|
||||||
|
the publication assessor, which returns ``success: False``. Both are
|
||||||
|
fail-closed; asserting the wrong mechanism would hide a regression.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── #618 preflight refusals: no session lock, so nothing rescues a bad
|
||||||
|
# workspace and the guard fires exactly as it does in production ──────
|
||||||
|
def _assert_preflight_raises(self, **kwargs):
|
||||||
|
with self.assertRaises(RuntimeError) as ctx:
|
||||||
|
self.run_publish(**kwargs)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.remote_head(), "a blocked publication must not reach the remote"
|
||||||
|
)
|
||||||
|
return str(ctx.exception)
|
||||||
|
|
||||||
|
def test_blank_worktree_path_fails_closed_via_618(self):
|
||||||
|
"""AC5 — a blank path forwards None, so preflight sees the control root."""
|
||||||
|
for blank in ("", " "):
|
||||||
|
with self.subTest(blank=repr(blank)):
|
||||||
|
message = self._assert_preflight_raises(worktree_path=blank)
|
||||||
|
self.assertIn("618", message)
|
||||||
|
|
||||||
|
def test_control_checkout_as_worktree_fails_closed_via_618(self):
|
||||||
|
"""AC5 — naming the stable control checkout explicitly is still refused."""
|
||||||
|
message = self._assert_preflight_raises(worktree_path=self.control)
|
||||||
|
self.assertIn("618", message)
|
||||||
|
|
||||||
|
def test_unregistered_directory_fails_closed(self):
|
||||||
|
"""AC3 — a plain directory under branches/ is not a registered worktree."""
|
||||||
|
bogus = os.path.join(self.control, "branches", "not-a-worktree")
|
||||||
|
os.makedirs(bogus, exist_ok=True)
|
||||||
|
self._assert_preflight_raises(worktree_path=bogus)
|
||||||
|
|
||||||
|
def test_missing_worktree_path_fails_closed(self):
|
||||||
|
"""AC3 — a path that does not exist is refused, not silently replaced."""
|
||||||
|
missing = os.path.join(self.control, "branches", "absent-worktree")
|
||||||
|
self._assert_preflight_raises(worktree_path=missing)
|
||||||
|
|
||||||
|
# ── assessor refusals: preflight passes on a valid worktree, then the
|
||||||
|
# publication assessor refuses on content/ownership evidence ──────────
|
||||||
|
def _assert_assessor_refuses(self, **kwargs):
|
||||||
|
res = self.run_publish(**kwargs)
|
||||||
|
self.assertFalse(res.get("success"), res)
|
||||||
|
self.assertFalse(res.get("performed"), res)
|
||||||
|
self.assertIsNone(self.remote_head())
|
||||||
|
return res
|
||||||
|
|
||||||
|
def test_changed_local_head_still_refuses(self):
|
||||||
|
"""AC6 — the declared expected_head remains authoritative."""
|
||||||
|
self.write_lock()
|
||||||
|
self._assert_assessor_refuses(expected_head="b" * 40)
|
||||||
|
|
||||||
|
def test_foreign_claimant_still_refuses(self):
|
||||||
|
"""AC6 — ownership still comes from the durable lock record."""
|
||||||
|
self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})
|
||||||
|
self._assert_assessor_refuses()
|
||||||
|
|
||||||
|
def test_dirty_worktree_still_refuses(self):
|
||||||
|
"""AC6 — cleanliness enforcement survives the forwarding change."""
|
||||||
|
self.write_lock()
|
||||||
|
with open(os.path.join(self.worktree, "work.txt"), "a") as fh:
|
||||||
|
fh.write("uncommitted drift\n")
|
||||||
|
self._assert_assessor_refuses()
|
||||||
|
|
||||||
|
def test_competing_open_pr_still_refuses(self):
|
||||||
|
"""AC6 — a rival claim on another branch still blocks."""
|
||||||
|
self.write_lock()
|
||||||
|
self._assert_assessor_refuses(
|
||||||
|
open_prs=[{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_issue_lock_record_is_not_mutated_by_a_refusal(self):
|
||||||
|
"""AC6 — record separation (#812 AC23) is unaffected by this change."""
|
||||||
|
path = self.write_lock()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
before = fh.read()
|
||||||
|
self._assert_assessor_refuses(expected_head="c" * 40)
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
self.assertEqual(before, fh.read())
|
||||||
|
|
||||||
|
|
||||||
|
class TestProtectedFixtureNotReferenced(unittest.TestCase):
|
||||||
|
"""AC9 — this regression never names the protected #635 fixture.
|
||||||
|
|
||||||
|
The forbidden tokens are reconstructed from fragments so this assertion
|
||||||
|
file does not itself contain them and produce a false positive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_no_reference_to_the_protected_worktree(self):
|
||||||
|
forbidden = [
|
||||||
|
"issue-635-" + "project-registry-api",
|
||||||
|
"b2f6e9a6dc40e9651ef8" + "76f322dd0a68bddebfd8",
|
||||||
|
]
|
||||||
|
here = os.path.abspath(__file__)
|
||||||
|
with open(here, "r", encoding="utf-8") as fh:
|
||||||
|
text = fh.read()
|
||||||
|
for token in forbidden:
|
||||||
|
self.assertNotIn(
|
||||||
|
token, text,
|
||||||
|
f"the protected #635 fixture must not be referenced: {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -139,6 +139,9 @@ EXPECTED_ROLE_EXCLUSIVE_TASKS = frozenset(
|
|||||||
"gitea_release_merger_pr_lease",
|
"gitea_release_merger_pr_lease",
|
||||||
"create_branch",
|
"create_branch",
|
||||||
"push_branch",
|
"push_branch",
|
||||||
|
# #812 AC20: publishing an unpublished local head is author-only for the
|
||||||
|
# same reason every other push is — it writes a branch to the remote.
|
||||||
|
"publish_unpublished_branch",
|
||||||
"create_pr",
|
"create_pr",
|
||||||
"commit_files",
|
"commit_files",
|
||||||
"gitea_commit_files",
|
"gitea_commit_files",
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Documentation acceptance for the web console architecture ADR (#632 / epic #631).
|
||||||
|
|
||||||
|
Enforces the acceptance criteria of issue #632:
|
||||||
|
|
||||||
|
* AC1 — the ADR exists and covers layers, authority, phases, API versioning,
|
||||||
|
and a page map.
|
||||||
|
* AC2 — every #631 child (#632–#651) maps to at least one architectural
|
||||||
|
component.
|
||||||
|
* AC3 — the closed MVP (#425–#436) is stated as foundation, not recreated.
|
||||||
|
* AC4 — forbidden paths are explicit: raw provider incidents as work,
|
||||||
|
browser-held tokens, process-kill recovery.
|
||||||
|
* AC5 — a controller can approve the document without reading chat history.
|
||||||
|
|
||||||
|
Plus the linkage requirement: ``docs/webui-local-dev.md`` cross-links the ADR.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ADR = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "architecture"
|
||||||
|
/ "webui-control-plane-console-architecture-adr.md"
|
||||||
|
)
|
||||||
|
ADR_BASENAME = "webui-control-plane-console-architecture-adr.md"
|
||||||
|
LOCAL_DEV = REPO_ROOT / "docs" / "webui-local-dev.md"
|
||||||
|
|
||||||
|
# Epic #631 children, phases 1-4 (twenty capability areas).
|
||||||
|
EPIC_CHILDREN = tuple(f"#{number}" for number in range(632, 652))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(path: Path) -> str:
|
||||||
|
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_adr_exists_with_required_sections():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
||||||
|
assert "#631" in text and "#632" in text
|
||||||
|
for heading in (
|
||||||
|
"## 2. Decision summary",
|
||||||
|
"## 4. Authority boundaries",
|
||||||
|
"## 5. Request flow and the redaction boundary",
|
||||||
|
"## 6. API naming and versioning",
|
||||||
|
"## 7. Page map",
|
||||||
|
"## 8. Component ownership",
|
||||||
|
"## 9. Phase gates",
|
||||||
|
"## 11. Forbidden paths",
|
||||||
|
):
|
||||||
|
assert heading in text, f"ADR must contain section {heading!r}"
|
||||||
|
assert "browser ui" in lower and "domain loader" in lower
|
||||||
|
assert "control-plane db" in lower and "capability gate" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_api_versioning_is_decided_including_legacy_routes():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert "/api/v1/" in text, "ADR must decide the versioned API prefix"
|
||||||
|
assert "/api/v2/" in text, "ADR must state how breaking changes are handled"
|
||||||
|
lower = text.lower()
|
||||||
|
assert "compatibility alias" in lower, (
|
||||||
|
"ADR must say what happens to the existing unversioned MVP exports"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_page_map_covers_mvp_routes():
|
||||||
|
text = _read(ADR)
|
||||||
|
for route in ("`/`", "`/health`", "`/projects`", "`/prompts`", "`/runtime`",
|
||||||
|
"`/audit`", "`/actions`"):
|
||||||
|
assert route in text, f"page map must account for MVP route {route}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac2_every_epic_child_maps_to_a_component():
|
||||||
|
text = _read(ADR)
|
||||||
|
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
|
||||||
|
missing = [child for child in EPIC_CHILDREN if child not in ownership]
|
||||||
|
assert not missing, (
|
||||||
|
f"epic #631 children without an architectural component: {missing}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac2_every_child_row_declares_a_phase():
|
||||||
|
text = _read(ADR)
|
||||||
|
ownership = text.split("## 8. Component ownership", 1)[-1].split("## 9.", 1)[0]
|
||||||
|
for child in EPIC_CHILDREN:
|
||||||
|
row = next(
|
||||||
|
(line for line in ownership.splitlines() if line.startswith(f"| {child} ")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert row is not None, f"no ownership row for {child}"
|
||||||
|
assert row.rstrip().endswith(("| 1 |", "| 2 |", "| 3 |", "| 4 |")), (
|
||||||
|
f"ownership row for {child} must end with its phase: {row!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_mvp_is_foundation_not_recreated():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert "#425" in text and "#436" in text
|
||||||
|
lower = text.lower()
|
||||||
|
assert "do not recreate" in lower or "recreating mvp scope" in lower
|
||||||
|
assert "retained and evolved" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac4_forbidden_paths_are_explicit():
|
||||||
|
text = _read(ADR)
|
||||||
|
forbidden = text.split("## 11. Forbidden paths", 1)[-1].split("## 12.", 1)[0]
|
||||||
|
lower = forbidden.lower()
|
||||||
|
assert "raw provider incidents" in lower and "#612" in forbidden
|
||||||
|
assert "browser-held tokens" in lower
|
||||||
|
assert "process-kill recovery" in lower and "#630" in forbidden
|
||||||
|
assert "ungated browser mutations" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac5_approval_checklist_is_self_contained():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert "## 12. Approval checklist" in text
|
||||||
|
checklist = text.split("## 12. Approval checklist", 1)[-1].split("## 13.", 1)[0]
|
||||||
|
for marker in ("1.", "2.", "3.", "4.", "5.", "6."):
|
||||||
|
assert marker in checklist, f"approval checklist missing item {marker}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_states_the_two_boundary_invariants():
|
||||||
|
text = _read(ADR)
|
||||||
|
lower = text.lower()
|
||||||
|
assert "no secrets to the browser" in lower
|
||||||
|
assert "no ungated mutations" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_questions_are_recorded_not_implied():
|
||||||
|
text = _read(ADR)
|
||||||
|
assert "## 13. Open questions and follow-ups" in text
|
||||||
|
section = text.split("## 13. Open questions and follow-ups", 1)[-1]
|
||||||
|
assert "#633" in section, "deferred authorization work must name its issue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_dev_doc_cross_links_the_adr():
|
||||||
|
text = _read(LOCAL_DEV)
|
||||||
|
assert ADR_BASENAME in text, (
|
||||||
|
"docs/webui-local-dev.md must cross-link the console architecture ADR "
|
||||||
|
"(issue #632 scope)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_docs_do_not_embed_secrets():
|
||||||
|
for path in (ADR, LOCAL_DEV):
|
||||||
|
text = _read(path)
|
||||||
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
||||||
|
assert marker not in text, f"{path.name} contains {marker!r}"
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
"""Console authorization, redaction, and audit model tests (#633).
|
||||||
|
|
||||||
|
Covers each acceptance criterion and each required test named in the issue:
|
||||||
|
|
||||||
|
* AC1 — RBAC matrix and privileged-action list.
|
||||||
|
* AC2 — redaction rules, unit-tested against sample payloads.
|
||||||
|
* AC3 — audit event schema with required fields and retention defaults.
|
||||||
|
* AC4 — Phase 2 integration points.
|
||||||
|
* AC5 — local-dev mode with explicit insecurity warnings.
|
||||||
|
|
||||||
|
Required tests: redaction units (token, keychain, password patterns),
|
||||||
|
default-deny for unauthenticated write stubs, and audit record creation for a
|
||||||
|
simulated privileged preview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from task_capability_map import TASK_CAPABILITY_MAP # noqa: E402
|
||||||
|
from webui import console_audit, console_authz # noqa: E402
|
||||||
|
from webui.app import create_app # noqa: E402
|
||||||
|
from webui.console_redaction import ( # noqa: E402
|
||||||
|
REDACTED,
|
||||||
|
redact_payload,
|
||||||
|
redact_text,
|
||||||
|
redaction_policy,
|
||||||
|
scan_for_secrets,
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCS = pathlib.Path(__file__).resolve().parents[1] / "docs"
|
||||||
|
AUTHZ_DOC = DOCS / "webui-authz-audit.md"
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(role: str) -> console_authz.Principal:
|
||||||
|
return console_authz.Principal(
|
||||||
|
subject=f"{role}@example.com",
|
||||||
|
role=role,
|
||||||
|
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoleMatrix(unittest.TestCase):
|
||||||
|
"""AC1 — the written RBAC matrix and privileged-action list."""
|
||||||
|
|
||||||
|
def test_roles_are_ordered_least_to_most_authority(self):
|
||||||
|
self.assertEqual(
|
||||||
|
console_authz.ROLE_ORDER,
|
||||||
|
("viewer", "operator", "controller", "admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_role_has_a_description(self):
|
||||||
|
for role in console_authz.ROLE_ORDER:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
self.assertTrue(console_authz.ROLE_DESCRIPTIONS[role].strip())
|
||||||
|
|
||||||
|
def test_higher_roles_inherit_lower_role_actions(self):
|
||||||
|
matrix = {
|
||||||
|
entry["role"]: set(entry["permitted_actions"])
|
||||||
|
for entry in console_authz.rbac_matrix()["roles"]
|
||||||
|
}
|
||||||
|
for lower, higher in zip(
|
||||||
|
console_authz.ROLE_ORDER, console_authz.ROLE_ORDER[1:]
|
||||||
|
):
|
||||||
|
with self.subTest(lower=lower, higher=higher):
|
||||||
|
self.assertTrue(matrix[lower].issubset(matrix[higher]))
|
||||||
|
|
||||||
|
def test_viewer_holds_no_write_action(self):
|
||||||
|
matrix = {
|
||||||
|
entry["role"]: set(entry["permitted_actions"])
|
||||||
|
for entry in console_authz.rbac_matrix()["roles"]
|
||||||
|
}
|
||||||
|
self.assertEqual(matrix["viewer"], set())
|
||||||
|
|
||||||
|
def test_privileged_action_list_is_non_empty_and_classified(self):
|
||||||
|
privileged = console_authz.privileged_actions()
|
||||||
|
self.assertTrue(privileged)
|
||||||
|
ids = {action.action_id for action in privileged}
|
||||||
|
# Merge and branch deletion are the canonical privileged pair.
|
||||||
|
self.assertIn("merge_pr", ids)
|
||||||
|
self.assertIn("delete_branch", ids)
|
||||||
|
|
||||||
|
def test_merge_and_delete_require_dual_control_and_break_glass(self):
|
||||||
|
for action_id in ("merge_pr", "delete_branch"):
|
||||||
|
with self.subTest(action=action_id):
|
||||||
|
action = console_authz.get_action(action_id)
|
||||||
|
self.assertTrue(action.dual_control)
|
||||||
|
self.assertTrue(action.break_glass)
|
||||||
|
self.assertTrue(action.requires_confirmation)
|
||||||
|
|
||||||
|
def test_every_write_action_requires_confirmation(self):
|
||||||
|
for action in console_authz.ACTIONS.values():
|
||||||
|
with self.subTest(action=action.action_id):
|
||||||
|
self.assertTrue(action.requires_confirmation)
|
||||||
|
|
||||||
|
def test_delete_branch_is_admin_only(self):
|
||||||
|
self.assertEqual(
|
||||||
|
console_authz.get_action("delete_branch").minimum_role,
|
||||||
|
console_authz.ADMIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_actions_map_to_real_mcp_capability_vocabulary(self):
|
||||||
|
"""The console must not invent an authority the MCP layer lacks."""
|
||||||
|
for action in console_authz.ACTIONS.values():
|
||||||
|
with self.subTest(action=action.action_id):
|
||||||
|
self.assertIn(action.task_key, TASK_CAPABILITY_MAP)
|
||||||
|
self.assertEqual(
|
||||||
|
action.mcp_permission,
|
||||||
|
TASK_CAPABILITY_MAP[action.task_key]["permission"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
action.mcp_role,
|
||||||
|
TASK_CAPABILITY_MAP[action.task_key]["role"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_matrix_declares_deny_by_default_and_execution_disabled(self):
|
||||||
|
matrix = console_authz.rbac_matrix()
|
||||||
|
self.assertEqual(matrix["default_decision"], "deny")
|
||||||
|
self.assertFalse(matrix["execution_enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorizeDefaultDeny(unittest.TestCase):
|
||||||
|
"""Fail-closed behaviour of the authorization decision."""
|
||||||
|
|
||||||
|
def test_anonymous_is_denied_every_action(self):
|
||||||
|
for action_id in console_authz.ACTIONS:
|
||||||
|
with self.subTest(action=action_id):
|
||||||
|
decision = console_authz.authorize(action_id)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
decision.reason_code, console_authz.DENY_UNAUTHENTICATED
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_action_is_denied(self):
|
||||||
|
decision = console_authz.authorize(
|
||||||
|
"not_a_real_action", _principal("admin")
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ACTION)
|
||||||
|
|
||||||
|
def test_unknown_role_is_denied(self):
|
||||||
|
rogue = console_authz.Principal(
|
||||||
|
subject="[email protected]",
|
||||||
|
role="superuser",
|
||||||
|
identity_source=console_authz.IDENTITY_ACCESS_PROXY,
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
decision = console_authz.authorize("comment_issue", rogue)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual(decision.reason_code, console_authz.DENY_UNKNOWN_ROLE)
|
||||||
|
|
||||||
|
def test_insufficient_role_is_denied(self):
|
||||||
|
decision = console_authz.authorize("merge_pr", _principal("operator"))
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
decision.reason_code, console_authz.DENY_INSUFFICIENT_ROLE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sufficient_role_allows_preview_only(self):
|
||||||
|
decision = console_authz.authorize("merge_pr", _principal("controller"))
|
||||||
|
self.assertTrue(decision.allowed)
|
||||||
|
self.assertFalse(decision.execution_enabled)
|
||||||
|
|
||||||
|
def test_execution_is_refused_while_phase_is_not_active(self):
|
||||||
|
decision = console_authz.authorize(
|
||||||
|
"merge_pr", _principal("controller"), for_execution=True
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
self.assertEqual(
|
||||||
|
decision.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_allowed_decision_never_reports_execution_enabled(self):
|
||||||
|
for action_id in console_authz.ACTIONS:
|
||||||
|
with self.subTest(action=action_id):
|
||||||
|
decision = console_authz.authorize(
|
||||||
|
action_id, _principal("admin")
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.execution_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdentityResolution(unittest.TestCase):
|
||||||
|
"""AC5 — identity sources, including the insecure local-dev mode."""
|
||||||
|
|
||||||
|
def test_no_auth_mode_yields_anonymous_viewer(self):
|
||||||
|
principal = console_authz.resolve_principal(env={})
|
||||||
|
self.assertFalse(principal.authenticated)
|
||||||
|
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||||
|
self.assertEqual(principal.identity_source, console_authz.IDENTITY_NONE)
|
||||||
|
|
||||||
|
def test_local_dev_mode_warns_that_identity_is_unverified(self):
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
env={
|
||||||
|
console_authz.AUTH_MODE_ENV: "local-dev",
|
||||||
|
console_authz.DEV_SUBJECT_ENV: "[email protected]",
|
||||||
|
console_authz.DEV_ROLE_ENV: "admin",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(principal.authenticated)
|
||||||
|
self.assertEqual(principal.role, "admin")
|
||||||
|
self.assertTrue(principal.warnings)
|
||||||
|
self.assertIn("asserted", " ".join(principal.warnings).lower())
|
||||||
|
|
||||||
|
def test_local_dev_without_subject_falls_back_to_anonymous(self):
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
env={console_authz.AUTH_MODE_ENV: "local-dev"}
|
||||||
|
)
|
||||||
|
self.assertFalse(principal.authenticated)
|
||||||
|
|
||||||
|
def test_local_dev_unknown_role_degrades_to_viewer(self):
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
env={
|
||||||
|
console_authz.AUTH_MODE_ENV: "local_dev",
|
||||||
|
console_authz.DEV_SUBJECT_ENV: "[email protected]",
|
||||||
|
console_authz.DEV_ROLE_ENV: "root",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||||
|
|
||||||
|
def test_access_proxy_without_header_fails_closed(self):
|
||||||
|
"""A proxy-mode request that did not traverse the proxy is anonymous."""
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
headers={},
|
||||||
|
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
|
||||||
|
)
|
||||||
|
self.assertFalse(principal.authenticated)
|
||||||
|
|
||||||
|
def test_access_proxy_role_comes_from_server_config_not_client(self):
|
||||||
|
env = {
|
||||||
|
console_authz.AUTH_MODE_ENV: "access_proxy",
|
||||||
|
console_authz.ROLE_MAP_ENV: json.dumps(
|
||||||
|
{"[email protected]": "controller"}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
headers={
|
||||||
|
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]",
|
||||||
|
"x-role": "admin", # client-supplied role must be ignored
|
||||||
|
},
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
self.assertEqual(principal.role, "controller")
|
||||||
|
|
||||||
|
def test_access_proxy_unmapped_subject_defaults_to_viewer(self):
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
headers={
|
||||||
|
console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"
|
||||||
|
},
|
||||||
|
env={console_authz.AUTH_MODE_ENV: "access_proxy"},
|
||||||
|
)
|
||||||
|
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||||
|
|
||||||
|
def test_malformed_role_map_does_not_raise_and_denies(self):
|
||||||
|
principal = console_authz.resolve_principal(
|
||||||
|
headers={console_authz.ACCESS_SUBJECT_HEADER: "[email protected]"},
|
||||||
|
env={
|
||||||
|
console_authz.AUTH_MODE_ENV: "access_proxy",
|
||||||
|
console_authz.ROLE_MAP_ENV: "{not json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(principal.role, console_authz.VIEWER)
|
||||||
|
|
||||||
|
def test_probe_auth_is_opt_in(self):
|
||||||
|
self.assertFalse(console_authz.probe_auth_required(env={}))
|
||||||
|
self.assertTrue(
|
||||||
|
console_authz.probe_auth_required(
|
||||||
|
env={console_authz.REQUIRE_PROBE_AUTH_ENV: "1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_probe_auth_is_declared_but_not_yet_enforced(self):
|
||||||
|
"""Phase 1 declares the probe-auth policy; no route enforces it yet.
|
||||||
|
|
||||||
|
The flag exists so the Phase 2 action framework has a declared policy
|
||||||
|
to honour instead of inventing a second one. Pinning the current
|
||||||
|
not-enforced status here means wiring it later is a deliberate change
|
||||||
|
that updates this test and the documentation together, rather than a
|
||||||
|
silent behaviour shift. The documentation must say so plainly, because
|
||||||
|
an operator who sets the variable believing it protects a probe is
|
||||||
|
worse off than one who knows it does not.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from webui import app as webui_app
|
||||||
|
|
||||||
|
source = inspect.getsource(webui_app)
|
||||||
|
self.assertNotIn(
|
||||||
|
"probe_auth_required",
|
||||||
|
source,
|
||||||
|
msg=(
|
||||||
|
"webui.app now consults probe_auth_required, so probe auth is "
|
||||||
|
"no longer merely declared. Update the 'Probe authentication' "
|
||||||
|
"section of docs/webui-authz-audit.md, which states it "
|
||||||
|
"enforces nothing, and replace this test with real "
|
||||||
|
"enforcement coverage."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"enforces nothing today",
|
||||||
|
AUTHZ_DOC.read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedaction(unittest.TestCase):
|
||||||
|
"""AC2 — required redaction units: token, keychain, password patterns."""
|
||||||
|
|
||||||
|
def test_token_assignment_is_redacted(self):
|
||||||
|
out = redact_text("GITEA_TOKEN=abcd1234efgh5678ijkl")
|
||||||
|
self.assertIn(REDACTED, out)
|
||||||
|
self.assertNotIn("abcd1234efgh5678ijkl", out)
|
||||||
|
|
||||||
|
def test_password_assignment_is_redacted(self):
|
||||||
|
out = redact_text("password: hunter2supersecret")
|
||||||
|
self.assertIn(REDACTED, out)
|
||||||
|
self.assertNotIn("hunter2supersecret", out)
|
||||||
|
|
||||||
|
def test_keychain_reference_is_redacted(self):
|
||||||
|
out = redact_text("keychain:gitea-prgs-token")
|
||||||
|
self.assertIn(REDACTED, out)
|
||||||
|
self.assertNotIn("gitea-prgs-token", out)
|
||||||
|
|
||||||
|
def test_keychain_command_is_redacted(self):
|
||||||
|
out = redact_text("security find-generic-password -s gitea -w")
|
||||||
|
self.assertIn(REDACTED, out)
|
||||||
|
self.assertNotIn("find-generic-password -s gitea", out)
|
||||||
|
|
||||||
|
def test_bearer_credential_is_redacted(self):
|
||||||
|
out = redact_text("Authorization: Bearer abcdef1234567890abcdef")
|
||||||
|
self.assertNotIn("abcdef1234567890abcdef", out)
|
||||||
|
|
||||||
|
def test_jwt_is_redacted(self):
|
||||||
|
token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop"
|
||||||
|
out = redact_text(f"session={token}")
|
||||||
|
self.assertNotIn(token, out)
|
||||||
|
|
||||||
|
def test_private_key_block_is_redacted(self):
|
||||||
|
pem = (
|
||||||
|
"-----BEGIN RSA PRIVATE KEY-----\n"
|
||||||
|
"MIIEowIBAAKCAQEAsecretmaterial\n"
|
||||||
|
"-----END RSA PRIVATE KEY-----"
|
||||||
|
)
|
||||||
|
out = redact_text(pem)
|
||||||
|
self.assertNotIn("MIIEowIBAAKCAQEAsecretmaterial", out)
|
||||||
|
|
||||||
|
def test_api_key_assignment_is_redacted(self):
|
||||||
|
out = redact_text('api_key = "sk-live-9f8e7d6c5b4a3210"')
|
||||||
|
self.assertNotIn("sk-live-9f8e7d6c5b4a3210", out)
|
||||||
|
|
||||||
|
def test_nested_payload_is_redacted_recursively(self):
|
||||||
|
payload = {
|
||||||
|
"token": "abc123456789",
|
||||||
|
"nested": {"note": "password=letmein12345"},
|
||||||
|
"list": ["keychain:some-entry"],
|
||||||
|
"safe": "plain text",
|
||||||
|
}
|
||||||
|
out = redact_payload(payload)
|
||||||
|
self.assertEqual(out["token"], REDACTED)
|
||||||
|
self.assertNotIn("letmein12345", json.dumps(out))
|
||||||
|
self.assertNotIn("some-entry", json.dumps(out))
|
||||||
|
self.assertEqual(out["safe"], "plain text")
|
||||||
|
|
||||||
|
def test_scan_reports_findings_before_and_none_after(self):
|
||||||
|
dirty = "password: hunter2supersecret"
|
||||||
|
self.assertTrue(scan_for_secrets(dirty))
|
||||||
|
self.assertEqual(scan_for_secrets(redact_text(dirty)), [])
|
||||||
|
|
||||||
|
def test_non_strings_pass_through_untouched(self):
|
||||||
|
self.assertEqual(redact_text(42), 42)
|
||||||
|
self.assertEqual(
|
||||||
|
redact_payload({"n": 1, "b": True}), {"n": 1, "b": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_policy_is_documented_and_declares_redact_before_persist(self):
|
||||||
|
policy = redaction_policy()
|
||||||
|
self.assertTrue(policy["redact_before_persist"])
|
||||||
|
self.assertIn("audit_records", policy["applies_to"])
|
||||||
|
self.assertTrue(policy["console_rules"])
|
||||||
|
|
||||||
|
def test_policy_statement_contains_no_secret_material(self):
|
||||||
|
self.assertEqual(scan_for_secrets(redaction_policy()), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditSchema(unittest.TestCase):
|
||||||
|
"""AC3 — audit event schema, required fields, and retention defaults."""
|
||||||
|
|
||||||
|
def _event(self, action_id="merge_pr", **kwargs):
|
||||||
|
return console_audit.build_event(
|
||||||
|
action_id=action_id,
|
||||||
|
result=console_audit.RESULT_DENIED,
|
||||||
|
decision=console_authz.authorize(action_id, _principal("operator")),
|
||||||
|
target={"kind": "pr", "ref": "#123"},
|
||||||
|
request_id="req-test",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_every_required_field_is_present(self):
|
||||||
|
event = self._event()
|
||||||
|
for field in console_audit.REQUIRED_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.assertIn(field, event)
|
||||||
|
|
||||||
|
def test_actor_carries_who_and_how_they_were_identified(self):
|
||||||
|
event = self._event()
|
||||||
|
for field in console_audit.REQUIRED_ACTOR_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.assertIn(field, event["actor"])
|
||||||
|
|
||||||
|
def test_correlation_ids_are_present(self):
|
||||||
|
event = self._event()
|
||||||
|
for field in console_audit.REQUIRED_CORRELATION_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.assertIn(field, event["correlation"])
|
||||||
|
self.assertEqual(event["correlation"]["request_id"], "req-test")
|
||||||
|
self.assertEqual(event["correlation"]["mcp_task"], "merge_pr")
|
||||||
|
|
||||||
|
def test_timestamp_is_timezone_aware_utc_iso8601(self):
|
||||||
|
now = datetime.datetime(
|
||||||
|
2026, 7, 22, 10, 16, 42, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
event = self._event(now=now)
|
||||||
|
self.assertEqual(event["timestamp"], "2026-07-22T10:16:42+00:00")
|
||||||
|
parsed = datetime.datetime.fromisoformat(event["timestamp"])
|
||||||
|
self.assertIsNotNone(parsed.tzinfo)
|
||||||
|
|
||||||
|
def test_retention_defaults_by_class(self):
|
||||||
|
self.assertEqual(
|
||||||
|
console_audit.RETENTION_DAYS[console_audit.RETENTION_STANDARD], 90
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
console_audit.RETENTION_DAYS[console_audit.RETENTION_PRIVILEGED],
|
||||||
|
365,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
console_audit.RETENTION_DAYS[console_audit.RETENTION_BREAK_GLASS],
|
||||||
|
730,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_break_glass_action_retains_longest(self):
|
||||||
|
event = self._event("merge_pr")
|
||||||
|
self.assertEqual(
|
||||||
|
event["retention"]["class"], console_audit.RETENTION_BREAK_GLASS
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_routine_write_uses_standard_retention(self):
|
||||||
|
event = self._event("comment_issue")
|
||||||
|
self.assertEqual(
|
||||||
|
event["retention"]["class"], console_audit.RETENTION_STANDARD
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_action_retains_as_privileged_not_standard(self):
|
||||||
|
"""Conservative direction: keep an unclassifiable record longer."""
|
||||||
|
self.assertEqual(
|
||||||
|
console_audit.retention_class_for(None),
|
||||||
|
console_audit.RETENTION_PRIVILEGED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_retention_expiry_matches_declared_days(self):
|
||||||
|
now = datetime.datetime(2026, 7, 22, tzinfo=datetime.timezone.utc)
|
||||||
|
event = self._event("comment_issue", now=now)
|
||||||
|
expires = datetime.datetime.fromisoformat(
|
||||||
|
event["retention"]["expires_at"]
|
||||||
|
)
|
||||||
|
self.assertEqual((expires - now).days, 90)
|
||||||
|
|
||||||
|
def test_invalid_result_degrades_to_failed(self):
|
||||||
|
event = console_audit.build_event(action_id="merge_pr", result="banana")
|
||||||
|
self.assertEqual(event["result"], console_audit.RESULT_FAILED)
|
||||||
|
|
||||||
|
def test_denied_result_is_representable(self):
|
||||||
|
"""An authorization denial has no MCP-side mutation record."""
|
||||||
|
self.assertIn(console_audit.RESULT_DENIED, console_audit.RESULTS)
|
||||||
|
|
||||||
|
def test_event_is_redacted_before_it_is_returned(self):
|
||||||
|
event = console_audit.build_event(
|
||||||
|
action_id="merge_pr",
|
||||||
|
result=console_audit.RESULT_DENIED,
|
||||||
|
detail="failed with token=abcdef1234567890",
|
||||||
|
metadata={"password": "hunter2supersecret"},
|
||||||
|
)
|
||||||
|
serialized = json.dumps(event)
|
||||||
|
self.assertNotIn("abcdef1234567890", serialized)
|
||||||
|
self.assertNotIn("hunter2supersecret", serialized)
|
||||||
|
self.assertTrue(event["redacted"])
|
||||||
|
|
||||||
|
def test_audit_policy_reports_schema_and_retention(self):
|
||||||
|
policy = console_audit.audit_policy()
|
||||||
|
self.assertTrue(policy["append_only"])
|
||||||
|
self.assertTrue(policy["redact_before_persist"])
|
||||||
|
self.assertEqual(
|
||||||
|
policy["retention_defaults_days"], console_audit.RETENTION_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditSink(unittest.TestCase):
|
||||||
|
"""Append-only persistence behaviour."""
|
||||||
|
|
||||||
|
def test_write_is_a_noop_when_sink_is_unconfigured(self):
|
||||||
|
saved = os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
|
||||||
|
try:
|
||||||
|
self.assertFalse(console_audit.audit_enabled())
|
||||||
|
self.assertFalse(console_audit.write_event({"schema_version": 1}))
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
os.environ[console_audit.AUDIT_LOG_ENV] = saved
|
||||||
|
|
||||||
|
def test_records_append_one_json_line_each(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
sink = os.path.join(tmp, "console-audit.jsonl")
|
||||||
|
for _ in range(3):
|
||||||
|
event = console_audit.build_event(
|
||||||
|
action_id="merge_pr", result=console_audit.RESULT_DENIED
|
||||||
|
)
|
||||||
|
self.assertTrue(console_audit.write_event(event, path=sink))
|
||||||
|
with open(sink, encoding="utf-8") as handle:
|
||||||
|
lines = [json.loads(line) for line in handle if line.strip()]
|
||||||
|
self.assertEqual(len(lines), 3)
|
||||||
|
self.assertEqual(len({line["event_id"] for line in lines}), 3)
|
||||||
|
|
||||||
|
def test_a_record_that_still_carries_a_secret_is_not_persisted(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
sink = os.path.join(tmp, "console-audit.jsonl")
|
||||||
|
leaky = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"detail": "password: hunter2supersecret",
|
||||||
|
}
|
||||||
|
self.assertFalse(console_audit.write_event(leaky, path=sink))
|
||||||
|
self.assertFalse(os.path.exists(sink))
|
||||||
|
|
||||||
|
def test_write_never_raises_on_a_bad_path(self):
|
||||||
|
self.assertFalse(
|
||||||
|
console_audit.write_event(
|
||||||
|
{"schema_version": 1}, path="/nonexistent-dir/audit.jsonl"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_simulated_privileged_preview_creates_an_audit_record(self):
|
||||||
|
"""Required test: audit record creation for a privileged preview."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
sink = os.path.join(tmp, "console-audit.jsonl")
|
||||||
|
os.environ[console_audit.AUDIT_LOG_ENV] = sink
|
||||||
|
try:
|
||||||
|
decision = console_authz.authorize(
|
||||||
|
"merge_pr", _principal("controller")
|
||||||
|
)
|
||||||
|
outcome = console_audit.record_event(
|
||||||
|
action_id="merge_pr",
|
||||||
|
result=console_audit.RESULT_PREVIEWED,
|
||||||
|
decision=decision,
|
||||||
|
target={"kind": "pr", "ref": "#123"},
|
||||||
|
request_id="req-preview",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
os.environ.pop(console_audit.AUDIT_LOG_ENV, None)
|
||||||
|
self.assertTrue(outcome["written"])
|
||||||
|
with open(sink, encoding="utf-8") as handle:
|
||||||
|
record = json.loads(handle.read().strip())
|
||||||
|
self.assertEqual(record["action"], "merge_pr")
|
||||||
|
self.assertEqual(record["result"], console_audit.RESULT_PREVIEWED)
|
||||||
|
self.assertEqual(record["action_class"], "privileged")
|
||||||
|
self.assertTrue(record["decision"]["allowed"])
|
||||||
|
self.assertFalse(record["decision"]["execution_enabled"])
|
||||||
|
self.assertEqual(record["actor"]["role"], "controller")
|
||||||
|
|
||||||
|
def test_decision_block_survives_redaction(self):
|
||||||
|
"""Regression: naming it 'authorization' collided with a secret hint.
|
||||||
|
|
||||||
|
``gitea_audit._SECRET_KEY_HINTS`` contains "authorization" (for the
|
||||||
|
HTTP header), so a block under that key was replaced wholesale by the
|
||||||
|
placeholder and the record lost its decision entirely.
|
||||||
|
"""
|
||||||
|
event = console_audit.build_event(
|
||||||
|
action_id="merge_pr",
|
||||||
|
result=console_audit.RESULT_DENIED,
|
||||||
|
decision=console_authz.authorize("merge_pr", _principal("admin")),
|
||||||
|
)
|
||||||
|
self.assertIsInstance(event["decision"], dict)
|
||||||
|
self.assertIn("allowed", event["decision"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsoleRoutes(unittest.TestCase):
|
||||||
|
"""AC4 — the wired Phase 2 integration points, still fail-closed."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app(bind_host="127.0.0.1"))
|
||||||
|
|
||||||
|
def test_unauthenticated_write_stub_is_denied(self):
|
||||||
|
"""Required test: default-deny for unauthenticated write stubs."""
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/actions/merge_pr/attempt", json={"pr_number": 99}
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
body = response.json()
|
||||||
|
self.assertFalse(body["success"])
|
||||||
|
authorization = body["authorization"]
|
||||||
|
self.assertFalse(authorization["allowed"])
|
||||||
|
self.assertEqual(
|
||||||
|
authorization["reason_code"], console_authz.DENY_UNAUTHENTICATED
|
||||||
|
)
|
||||||
|
self.assertFalse(authorization["execution_enabled"])
|
||||||
|
|
||||||
|
def test_preview_reports_an_authorization_decision(self):
|
||||||
|
response = self.client.get("/api/actions/merge_pr/preview?pr_number=7")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
authorization = response.json()["authorization"]
|
||||||
|
self.assertFalse(authorization["allowed"])
|
||||||
|
self.assertTrue(authorization["dual_control"])
|
||||||
|
self.assertEqual(authorization["required_role"], "controller")
|
||||||
|
|
||||||
|
def test_unknown_action_preview_still_404s(self):
|
||||||
|
response = self.client.get("/api/actions/no_such_action/preview")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_security_model_endpoint_publishes_all_three_policies(self):
|
||||||
|
response = self.client.get("/api/console/security-model")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertIn("rbac", body)
|
||||||
|
self.assertIn("redaction", body)
|
||||||
|
self.assertIn("audit", body)
|
||||||
|
self.assertEqual(body["rbac"]["default_decision"], "deny")
|
||||||
|
|
||||||
|
def test_security_model_endpoint_leaks_no_secrets(self):
|
||||||
|
response = self.client.get("/api/console/security-model")
|
||||||
|
self.assertEqual(scan_for_secrets(response.json()), [])
|
||||||
|
|
||||||
|
def test_security_model_rejects_writes(self):
|
||||||
|
response = self.client.post("/api/console/security-model", json={})
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
def test_existing_read_routes_are_unaffected(self):
|
||||||
|
for path in ("/", "/health", "/actions", "/api/actions"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self.client.get(path).status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthzAuditDoc(unittest.TestCase):
|
||||||
|
"""The model must be written down, not only coded."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.text = (
|
||||||
|
AUTHZ_DOC.read_text(encoding="utf-8") if AUTHZ_DOC.exists() else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_doc_exists(self):
|
||||||
|
self.assertTrue(AUTHZ_DOC.exists(), f"missing {AUTHZ_DOC}")
|
||||||
|
|
||||||
|
def test_doc_covers_each_required_section(self):
|
||||||
|
for heading in (
|
||||||
|
"Identity sources",
|
||||||
|
"Role matrix",
|
||||||
|
"Privileged actions",
|
||||||
|
"Secret redaction",
|
||||||
|
"Audit event schema",
|
||||||
|
"Retention",
|
||||||
|
"Phase 2 integration",
|
||||||
|
"Local-dev mode",
|
||||||
|
):
|
||||||
|
with self.subTest(heading=heading):
|
||||||
|
self.assertIn(heading, self.text)
|
||||||
|
|
||||||
|
def test_doc_names_every_role(self):
|
||||||
|
for role in console_authz.ROLE_ORDER:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
self.assertIn(role, self.text)
|
||||||
|
|
||||||
|
def test_doc_names_every_console_action(self):
|
||||||
|
for action_id in console_authz.ACTIONS:
|
||||||
|
with self.subTest(action=action_id):
|
||||||
|
self.assertIn(action_id, self.text)
|
||||||
|
|
||||||
|
def test_doc_states_retention_defaults(self):
|
||||||
|
for days in console_audit.RETENTION_DAYS.values():
|
||||||
|
with self.subTest(days=days):
|
||||||
|
self.assertIn(str(days), self.text)
|
||||||
|
|
||||||
|
def test_doc_warns_local_dev_is_insecure(self):
|
||||||
|
self.assertIn("INSECURE", self.text.upper())
|
||||||
|
|
||||||
|
def test_doc_states_default_deny(self):
|
||||||
|
self.assertIn("deny", self.text.lower())
|
||||||
|
|
||||||
|
def test_doc_contains_no_secret_material(self):
|
||||||
|
self.assertEqual(scan_for_secrets(self.text), [])
|
||||||
|
|
||||||
|
def test_deployment_doc_links_to_the_model(self):
|
||||||
|
deployment = (DOCS / "webui-deployment.md").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("webui-authz-audit", deployment)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
unittest.main()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for web UI project registry (#427)."""
|
"""Tests for web UI project registry (#427) and its API evolution (#635)."""
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -11,57 +11,246 @@ from starlette.testclient import TestClient
|
|||||||
|
|
||||||
from webui.app import create_app
|
from webui.app import create_app
|
||||||
from webui.project_registry import (
|
from webui.project_registry import (
|
||||||
|
CURRENT_SCHEMA_VERSION,
|
||||||
|
REGISTRY_API_VERSION,
|
||||||
|
SUPPORTED_SCHEMA_VERSIONS,
|
||||||
|
RegistryError,
|
||||||
default_registry_path,
|
default_registry_path,
|
||||||
load_registry,
|
load_registry,
|
||||||
|
onboarding_summary,
|
||||||
project_to_dict,
|
project_to_dict,
|
||||||
)
|
)
|
||||||
|
from webui.registry_safety import is_forbidden_key
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_API_DOC = _REPO_ROOT / "docs" / "webui-project-registry-api.md"
|
||||||
|
|
||||||
|
|
||||||
class TestProjectRegistryLoader(unittest.TestCase):
|
def _valid_project(**overrides):
|
||||||
|
project = {
|
||||||
|
"id": "example",
|
||||||
|
"repo_name": "Example",
|
||||||
|
"gitea_owner": "Org",
|
||||||
|
"remote_host": "https://gitea.example.invalid",
|
||||||
|
"default_branch": "main",
|
||||||
|
"local_checkout_path": ".",
|
||||||
|
"profiles": {"author": "a", "reviewer": "r", "reconciler": "c"},
|
||||||
|
"workflow_paths": {"skill": "skills/x.md"},
|
||||||
|
}
|
||||||
|
project.update(overrides)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
def _write_registry(payload) -> Path:
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||||
|
json.dump(payload, handle)
|
||||||
|
return Path(handle.name)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryFileCase(unittest.TestCase):
|
||||||
|
"""Base class that cleans up temporary registry files."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._temp_paths: list[Path] = []
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
for path in self._temp_paths:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def write_registry(self, payload) -> Path:
|
||||||
|
path = _write_registry(payload)
|
||||||
|
self._temp_paths.append(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectRegistryLoader(RegistryFileCase):
|
||||||
def test_default_registry_loads_gitea_tools(self):
|
def test_default_registry_loads_gitea_tools(self):
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
self.assertEqual(registry.version, 1)
|
self.assertEqual(registry.version, CURRENT_SCHEMA_VERSION)
|
||||||
|
self.assertEqual(registry.schema_version, CURRENT_SCHEMA_VERSION)
|
||||||
|
self.assertEqual(registry.api_version, REGISTRY_API_VERSION)
|
||||||
self.assertEqual(len(registry.projects), 1)
|
self.assertEqual(len(registry.projects), 1)
|
||||||
project = registry.projects[0]
|
project = registry.projects[0]
|
||||||
self.assertEqual(project.id, "gitea-tools")
|
self.assertEqual(project.id, "gitea-tools")
|
||||||
self.assertEqual(project.repo_name, "Gitea-Tools")
|
self.assertEqual(project.repo_name, "Gitea-Tools")
|
||||||
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
|
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
|
||||||
|
self.assertEqual(project.repo_full_name, "Scaled-Tech-Consulting/Gitea-Tools")
|
||||||
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
|
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
|
||||||
|
self.assertEqual(project.remote_name, "prgs")
|
||||||
|
self.assertEqual(project.status, "active")
|
||||||
self.assertEqual(project.profiles["author"], "prgs-author")
|
self.assertEqual(project.profiles["author"], "prgs-author")
|
||||||
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
|
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
|
||||||
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
|
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
|
||||||
self.assertIn("skill", project.workflow_paths)
|
self.assertIn("skill", project.workflow_paths)
|
||||||
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
|
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
|
||||||
|
|
||||||
def test_registry_rejects_credential_keys(self):
|
def test_default_registry_onboarding_summary_is_complete(self):
|
||||||
payload = {
|
summary = onboarding_summary(load_registry().projects[0])
|
||||||
|
self.assertEqual(summary.total, summary.complete)
|
||||||
|
self.assertEqual(summary.required_outstanding, 0)
|
||||||
|
self.assertTrue(summary.onboarding_complete)
|
||||||
|
|
||||||
|
def test_version_1_registry_still_loads_with_defaults(self):
|
||||||
|
path = self.write_registry({
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
_valid_project(
|
||||||
"id": "bad",
|
onboarding_checklist=[
|
||||||
"repo_name": "Bad",
|
{"id": "step", "title": "Step", "description": "Do it"}
|
||||||
"gitea_owner": "Org",
|
]
|
||||||
"remote_host": "https://gitea.example.invalid",
|
)
|
||||||
"default_branch": "main",
|
|
||||||
"local_checkout_path": ".",
|
|
||||||
"profiles": {
|
|
||||||
"author": "a",
|
|
||||||
"reviewer": "r",
|
|
||||||
"reconciler": "c",
|
|
||||||
},
|
|
||||||
"workflow_paths": {"skill": "skills/x.md"},
|
|
||||||
"api_token": "secret",
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
|
})
|
||||||
|
registry = load_registry(path)
|
||||||
|
self.assertEqual(registry.schema_version, 1)
|
||||||
|
self.assertIn(1, SUPPORTED_SCHEMA_VERSIONS)
|
||||||
|
project = registry.projects[0]
|
||||||
|
self.assertEqual(project.status, "active")
|
||||||
|
self.assertIsNone(project.remote_name)
|
||||||
|
self.assertIsNone(project.last_seen_health)
|
||||||
|
step = project.onboarding_checklist[0]
|
||||||
|
self.assertEqual(step.state, "pending")
|
||||||
|
self.assertTrue(step.required)
|
||||||
|
self.assertFalse(onboarding_summary(project).onboarding_complete)
|
||||||
|
|
||||||
|
def test_onboarding_summary_counts_states(self):
|
||||||
|
path = self.write_registry({
|
||||||
|
"version": 2,
|
||||||
|
"projects": [
|
||||||
|
_valid_project(
|
||||||
|
onboarding_checklist=[
|
||||||
|
{"id": "a", "title": "A", "description": "d", "state": "complete"},
|
||||||
|
{"id": "b", "title": "B", "description": "d", "state": "blocked"},
|
||||||
|
{
|
||||||
|
"id": "c",
|
||||||
|
"title": "C",
|
||||||
|
"description": "d",
|
||||||
|
"state": "pending",
|
||||||
|
"required": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d",
|
||||||
|
"title": "D",
|
||||||
|
"description": "d",
|
||||||
|
"state": "not_applicable",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
summary = onboarding_summary(load_registry(path).projects[0])
|
||||||
|
self.assertEqual(summary.total, 4)
|
||||||
|
self.assertEqual(summary.complete, 1)
|
||||||
|
self.assertEqual(summary.blocked, 1)
|
||||||
|
self.assertEqual(summary.pending, 1)
|
||||||
|
self.assertEqual(summary.not_applicable, 1)
|
||||||
|
# Only the blocked step is both required and outstanding.
|
||||||
|
self.assertEqual(summary.required_outstanding, 1)
|
||||||
|
self.assertFalse(summary.onboarding_complete)
|
||||||
|
|
||||||
|
def test_last_seen_health_is_parsed_when_present(self):
|
||||||
|
path = self.write_registry({
|
||||||
|
"version": 2,
|
||||||
|
"projects": [
|
||||||
|
_valid_project(
|
||||||
|
last_seen_health={
|
||||||
|
"status": "degraded",
|
||||||
|
"checked_at": "2026-01-01T00:00:00Z",
|
||||||
|
"detail": "daemon restart pending",
|
||||||
}
|
}
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
)
|
||||||
json.dump(payload, handle)
|
],
|
||||||
path = Path(handle.name)
|
})
|
||||||
try:
|
health = load_registry(path).projects[0].last_seen_health
|
||||||
with self.assertRaises(ValueError):
|
self.assertIsNotNone(health)
|
||||||
|
self.assertEqual(health.status, "degraded")
|
||||||
|
self.assertEqual(health.checked_at, "2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
def test_registry_rejects_credential_keys(self):
|
||||||
|
path = self.write_registry({
|
||||||
|
"version": 1,
|
||||||
|
"projects": [_valid_project(id="bad", api_token="redacted-placeholder")],
|
||||||
|
})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
load_registry(path)
|
load_registry(path)
|
||||||
finally:
|
self.assertIn("credential", ctx.exception.remediation.lower())
|
||||||
path.unlink(missing_ok=True)
|
self.assertEqual(ctx.exception.field_path, "projects[0].api_token")
|
||||||
|
|
||||||
|
def test_unsupported_version_fails_closed_with_remediation(self):
|
||||||
|
path = self.write_registry({"version": 99, "projects": [_valid_project()]})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertIn("unsupported registry version", ctx.exception.message)
|
||||||
|
self.assertIn(str(CURRENT_SCHEMA_VERSION), ctx.exception.remediation)
|
||||||
|
self.assertEqual(ctx.exception.field_path, "version")
|
||||||
|
|
||||||
|
def test_missing_required_field_fails_closed(self):
|
||||||
|
broken = _valid_project()
|
||||||
|
del broken["default_branch"]
|
||||||
|
path = self.write_registry({"version": 2, "projects": [broken]})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertIn("default_branch", ctx.exception.message)
|
||||||
|
self.assertEqual(ctx.exception.field_path, "projects[0]")
|
||||||
|
|
||||||
|
def test_unknown_status_fails_closed(self):
|
||||||
|
path = self.write_registry({
|
||||||
|
"version": 2,
|
||||||
|
"projects": [_valid_project(status="mystery")],
|
||||||
|
})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertEqual(ctx.exception.field_path, "projects[0].status")
|
||||||
|
self.assertIn("active", ctx.exception.remediation)
|
||||||
|
|
||||||
|
def test_unknown_onboarding_state_fails_closed(self):
|
||||||
|
path = self.write_registry({
|
||||||
|
"version": 2,
|
||||||
|
"projects": [
|
||||||
|
_valid_project(
|
||||||
|
onboarding_checklist=[
|
||||||
|
{"id": "a", "title": "A", "description": "d", "state": "almost"}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertEqual(
|
||||||
|
ctx.exception.field_path,
|
||||||
|
"projects[0].onboarding_checklist[0].state",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_profile_role_fails_closed(self):
|
||||||
|
path = self.write_registry({
|
||||||
|
"version": 2,
|
||||||
|
"projects": [_valid_project(profiles={"author": "a", "reviewer": "r"})],
|
||||||
|
})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertEqual(ctx.exception.field_path, "projects[0].profiles.reconciler")
|
||||||
|
|
||||||
|
def test_empty_projects_fails_closed(self):
|
||||||
|
path = self.write_registry({"version": 2, "projects": []})
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertEqual(ctx.exception.field_path, "projects")
|
||||||
|
|
||||||
|
def test_invalid_json_fails_closed_with_location(self):
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||||
|
handle.write("{not json")
|
||||||
|
path = Path(handle.name)
|
||||||
|
self._temp_paths.append(path)
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(path)
|
||||||
|
self.assertIn("not valid JSON", ctx.exception.message)
|
||||||
|
self.assertIn("line", ctx.exception.remediation)
|
||||||
|
|
||||||
|
def test_missing_file_fails_closed(self):
|
||||||
|
missing = Path(tempfile.gettempdir()) / "webui-registry-does-not-exist.json"
|
||||||
|
with self.assertRaises(RegistryError) as ctx:
|
||||||
|
load_registry(missing)
|
||||||
|
self.assertIn("could not be read", ctx.exception.message)
|
||||||
|
|
||||||
def test_default_registry_path_points_at_packaged_data(self):
|
def test_default_registry_path_points_at_packaged_data(self):
|
||||||
path = default_registry_path()
|
path = default_registry_path()
|
||||||
@@ -81,30 +270,146 @@ class TestProjectRegistryRoutes(unittest.TestCase):
|
|||||||
self.assertIn("prgs-author", response.text)
|
self.assertIn("prgs-author", response.text)
|
||||||
self.assertNotIn("child issue", response.text.lower())
|
self.assertNotIn("child issue", response.text.lower())
|
||||||
|
|
||||||
|
def test_projects_page_shows_status_and_progress(self):
|
||||||
|
response = self.client.get("/projects")
|
||||||
|
self.assertIn("Status", response.text)
|
||||||
|
self.assertIn("Onboarding", response.text)
|
||||||
|
self.assertIn("4/4 complete", response.text)
|
||||||
|
|
||||||
def test_project_detail_renders_checklist(self):
|
def test_project_detail_renders_checklist(self):
|
||||||
response = self.client.get("/projects/gitea-tools")
|
response = self.client.get("/projects/gitea-tools")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn("Onboarding checklist", response.text)
|
self.assertIn("Onboarding checklist", response.text)
|
||||||
self.assertIn("Configure execution profiles", response.text)
|
self.assertIn("Configure execution profiles", response.text)
|
||||||
self.assertIn("branches/", response.text)
|
self.assertIn("branches/", response.text)
|
||||||
|
self.assertIn("Complete", response.text)
|
||||||
|
self.assertIn("required outstanding 0", response.text)
|
||||||
|
|
||||||
def test_project_detail_404(self):
|
def test_project_detail_404(self):
|
||||||
response = self.client.get("/projects/unknown-repo")
|
response = self.client.get("/projects/unknown-repo")
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
def test_api_projects_json(self):
|
def test_api_projects_alias_stays_compatible(self):
|
||||||
response = self.client.get("/api/projects")
|
response = self.client.get("/api/projects")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertEqual(data["version"], 1)
|
# #427 consumers keep these keys.
|
||||||
|
self.assertEqual(data["version"], CURRENT_SCHEMA_VERSION)
|
||||||
|
self.assertIn("source_path", data)
|
||||||
self.assertEqual(len(data["projects"]), 1)
|
self.assertEqual(len(data["projects"]), 1)
|
||||||
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
|
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
|
||||||
self.assertIn("onboarding_checklist", data["projects"][0])
|
self.assertIn("onboarding_checklist", data["projects"][0])
|
||||||
|
|
||||||
|
def test_api_v1_projects_payload(self):
|
||||||
|
response = self.client.get("/api/v1/projects")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
|
||||||
|
self.assertEqual(data["schema_version"], CURRENT_SCHEMA_VERSION)
|
||||||
|
self.assertEqual(data["project_count"], 1)
|
||||||
|
self.assertEqual(data["source"]["kind"], "file")
|
||||||
|
self.assertTrue(data["source"]["inventory_complete"])
|
||||||
|
project = data["projects"][0]
|
||||||
|
self.assertEqual(project["status"], "active")
|
||||||
|
self.assertEqual(project["remote_name"], "prgs")
|
||||||
|
self.assertEqual(
|
||||||
|
project["repo_full_name"], "Scaled-Tech-Consulting/Gitea-Tools"
|
||||||
|
)
|
||||||
|
self.assertTrue(project["onboarding_summary"]["onboarding_complete"])
|
||||||
|
self.assertEqual(project["onboarding_checklist"][0]["state"], "complete")
|
||||||
|
self.assertIsNone(project["last_seen_health"])
|
||||||
|
|
||||||
|
def test_api_v1_project_detail(self):
|
||||||
|
response = self.client.get("/api/v1/projects/gitea-tools")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["api_version"], REGISTRY_API_VERSION)
|
||||||
|
self.assertEqual(data["project"]["id"], "gitea-tools")
|
||||||
|
self.assertEqual(data["source"]["kind"], "file")
|
||||||
|
|
||||||
|
def test_api_v1_project_detail_missing_fails_closed(self):
|
||||||
|
response = self.client.get("/api/v1/projects/not-registered")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["error"], "project_not_found")
|
||||||
|
self.assertEqual(data["project_id"], "not-registered")
|
||||||
|
self.assertIn("gitea-tools", data["known_project_ids"])
|
||||||
|
self.assertIn("remediation", data)
|
||||||
|
|
||||||
|
def test_api_v1_projects_is_read_only(self):
|
||||||
|
response = self.client.post("/api/v1/projects", json={})
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||||
|
|
||||||
def test_project_to_dict_is_json_safe(self):
|
def test_project_to_dict_is_json_safe(self):
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
encoded = json.dumps(project_to_dict(registry.projects[0]))
|
dto = project_to_dict(registry.projects[0])
|
||||||
|
encoded = json.dumps(dto)
|
||||||
self.assertIn("gitea-tools", encoded)
|
self.assertIn("gitea-tools", encoded)
|
||||||
|
# Prose may mention tokens; no serialized *key* may look like a secret.
|
||||||
|
for key in dto:
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertFalse(is_forbidden_key(key))
|
||||||
|
|
||||||
|
|
||||||
|
class TestInvalidRegistryFailsClosedOverHttp(RegistryFileCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.path = self.write_registry({"version": 42, "projects": []})
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def _with_bad_registry(self, url: str):
|
||||||
|
import os
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ, {"WEBUI_PROJECT_REGISTRY": str(self.path)}, clear=False
|
||||||
|
):
|
||||||
|
return self.client.get(url)
|
||||||
|
|
||||||
|
def test_api_v1_reports_actionable_error(self):
|
||||||
|
response = self._with_bad_registry("/api/v1/projects")
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["error"], "registry_invalid")
|
||||||
|
self.assertIn("unsupported registry version", data["detail"])
|
||||||
|
self.assertTrue(data["remediation"])
|
||||||
|
self.assertEqual(data["field_path"], "version")
|
||||||
|
|
||||||
|
def test_unversioned_alias_reports_actionable_error(self):
|
||||||
|
response = self._with_bad_registry("/api/projects")
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
self.assertEqual(response.json()["error"], "registry_invalid")
|
||||||
|
|
||||||
|
def test_html_page_reports_actionable_error(self):
|
||||||
|
response = self._with_bad_registry("/projects")
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
self.assertIn("Project registry unavailable", response.text)
|
||||||
|
self.assertIn("Remediation", response.text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProjectRegistryApiDocs(unittest.TestCase):
|
||||||
|
def test_api_contract_is_documented(self):
|
||||||
|
self.assertTrue(_API_DOC.is_file(), f"missing {_API_DOC}")
|
||||||
|
text = _API_DOC.read_text(encoding="utf-8")
|
||||||
|
for token in (
|
||||||
|
"/api/v1/projects",
|
||||||
|
"/api/v1/projects/{project_id}",
|
||||||
|
"/api/projects",
|
||||||
|
"onboarding_summary",
|
||||||
|
"last_seen_health",
|
||||||
|
"registry_invalid",
|
||||||
|
"#635",
|
||||||
|
):
|
||||||
|
with self.subTest(token=token):
|
||||||
|
self.assertIn(token, text)
|
||||||
|
|
||||||
|
def test_route_table_lists_versioned_routes(self):
|
||||||
|
local_dev = (_REPO_ROOT / "docs" / "webui-local-dev.md").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
self.assertIn("/api/v1/projects", local_dev)
|
||||||
|
self.assertIn("webui-project-registry-api.md", local_dev)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"""Tests for the read-only system-health API (#634).
|
||||||
|
|
||||||
|
Covers the acceptance criteria directly: a structured payload with readiness
|
||||||
|
and a dependency list (AC1), version and uptime when knowable (AC2), stale
|
||||||
|
runtime reported without a false mutation-safe claim (AC3), and the healthy /
|
||||||
|
degraded-dependency / redaction cases (AC4).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import control_plane_db
|
||||||
|
from webui.app import create_app
|
||||||
|
from webui.deployment_boundary import scan_text_for_client_secrets
|
||||||
|
from webui.system_health import (
|
||||||
|
API_PATH,
|
||||||
|
STATUS_DEGRADED,
|
||||||
|
STATUS_DOWN,
|
||||||
|
STATUS_OK,
|
||||||
|
STATUS_SKIPPED,
|
||||||
|
DependencyProbe,
|
||||||
|
StaleRuntime,
|
||||||
|
assess_stale_runtime,
|
||||||
|
clear_probe_cache,
|
||||||
|
load_system_health,
|
||||||
|
namespace_summaries,
|
||||||
|
probe_control_plane_db,
|
||||||
|
probe_gitea,
|
||||||
|
process_uptime,
|
||||||
|
redact,
|
||||||
|
redact_url,
|
||||||
|
snapshot_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(name, status, *, required=True, detail="detail", kind="test"):
|
||||||
|
return DependencyProbe(
|
||||||
|
name=name,
|
||||||
|
kind=kind,
|
||||||
|
status=status,
|
||||||
|
detail=detail,
|
||||||
|
required=required,
|
||||||
|
latency_ms=1.5,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ALL_HEALTHY = (
|
||||||
|
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||||
|
_probe("repository", STATUS_OK, kind="git"),
|
||||||
|
_probe("gitea", STATUS_OK, required=False, kind="http"),
|
||||||
|
)
|
||||||
|
|
||||||
|
_CLEAN_PARITY = StaleRuntime(
|
||||||
|
daemon_head="abc123",
|
||||||
|
checkout_head="abc123",
|
||||||
|
remote_head="abc123",
|
||||||
|
stale=False,
|
||||||
|
determinable=True,
|
||||||
|
mutation_safe=True,
|
||||||
|
reasons=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CleanParityMixin:
|
||||||
|
"""Pin parity for tests about aggregation rather than staleness.
|
||||||
|
|
||||||
|
Without this the assertions depend on the real checkout: a worktree whose
|
||||||
|
branch is ahead of its upstream is genuinely stale, which would degrade the
|
||||||
|
overall status and make these cases fail for an unrelated reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
patcher = mock.patch(
|
||||||
|
"webui.system_health.assess_stale_runtime",
|
||||||
|
return_value=_CLEAN_PARITY,
|
||||||
|
)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDependencyAggregation(CleanParityMixin, unittest.TestCase):
|
||||||
|
"""AC1 — readiness and dependency list derived from probe results."""
|
||||||
|
|
||||||
|
def test_all_healthy_is_ok_and_ready(self):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
|
||||||
|
self.assertEqual(snapshot.status, STATUS_OK)
|
||||||
|
self.assertTrue(snapshot.ready)
|
||||||
|
self.assertTrue(snapshot.readiness_complete)
|
||||||
|
self.assertEqual(snapshot.readiness_reasons, ())
|
||||||
|
self.assertEqual(len(snapshot.dependencies), 3)
|
||||||
|
|
||||||
|
def test_required_dependency_down_blocks_readiness(self):
|
||||||
|
probes = (
|
||||||
|
_probe("control_plane_db", STATUS_DOWN, detail="file missing", kind="sqlite"),
|
||||||
|
_probe("repository", STATUS_OK, kind="git"),
|
||||||
|
_probe("gitea", STATUS_OK, required=False, kind="http"),
|
||||||
|
)
|
||||||
|
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||||
|
self.assertEqual(snapshot.status, STATUS_DOWN)
|
||||||
|
self.assertFalse(snapshot.ready)
|
||||||
|
self.assertTrue(
|
||||||
|
any("control_plane_db" in reason for reason in snapshot.readiness_reasons)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_optional_dependency_down_degrades_but_stays_ready(self):
|
||||||
|
"""A failing optional probe must not claim the process itself is unready."""
|
||||||
|
probes = (
|
||||||
|
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||||
|
_probe("repository", STATUS_OK, kind="git"),
|
||||||
|
_probe("gitea", STATUS_DOWN, required=False, detail="timeout", kind="http"),
|
||||||
|
)
|
||||||
|
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||||
|
self.assertEqual(snapshot.status, STATUS_DEGRADED)
|
||||||
|
self.assertTrue(snapshot.ready)
|
||||||
|
self.assertTrue(any("gitea" in reason for reason in snapshot.readiness_reasons))
|
||||||
|
|
||||||
|
def test_unrun_required_probe_leaves_readiness_incomplete(self):
|
||||||
|
"""Not probed is not the same as passing."""
|
||||||
|
probes = (
|
||||||
|
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||||
|
_probe("repository", STATUS_SKIPPED, detail="offline", kind="git"),
|
||||||
|
)
|
||||||
|
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||||
|
self.assertFalse(snapshot.ready)
|
||||||
|
self.assertFalse(snapshot.readiness_complete)
|
||||||
|
self.assertEqual(snapshot.status, STATUS_DEGRADED)
|
||||||
|
|
||||||
|
def test_skipped_optional_probe_does_not_block_readiness(self):
|
||||||
|
probes = (
|
||||||
|
_probe("control_plane_db", STATUS_OK, kind="sqlite"),
|
||||||
|
_probe("repository", STATUS_OK, kind="git"),
|
||||||
|
_probe("gitea", STATUS_SKIPPED, required=False, kind="http"),
|
||||||
|
)
|
||||||
|
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||||
|
self.assertTrue(snapshot.ready)
|
||||||
|
self.assertTrue(snapshot.readiness_complete)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionAndUptime(CleanParityMixin, unittest.TestCase):
|
||||||
|
"""AC2 — version and uptime present when knowable."""
|
||||||
|
|
||||||
|
def test_uptime_and_start_time_present(self):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
|
||||||
|
self.assertGreaterEqual(snapshot.uptime_seconds, 0.0)
|
||||||
|
self.assertIn("T", snapshot.started_at)
|
||||||
|
|
||||||
|
def test_process_uptime_helper_matches_shape(self):
|
||||||
|
started_at, uptime = process_uptime()
|
||||||
|
self.assertIn("T", started_at)
|
||||||
|
self.assertGreaterEqual(uptime, 0.0)
|
||||||
|
|
||||||
|
def test_version_reports_python_and_schema_version(self):
|
||||||
|
probes = (
|
||||||
|
DependencyProbe(
|
||||||
|
name="control_plane_db",
|
||||||
|
kind="sqlite",
|
||||||
|
status=STATUS_OK,
|
||||||
|
detail="ok",
|
||||||
|
required=True,
|
||||||
|
latency_ms=1.0,
|
||||||
|
metadata={"schema_version": control_plane_db.SCHEMA_VERSION},
|
||||||
|
),
|
||||||
|
_probe("repository", STATUS_OK, kind="git"),
|
||||||
|
)
|
||||||
|
snapshot = load_system_health(probes=probes, daemon_head="abc123")
|
||||||
|
self.assertEqual(
|
||||||
|
snapshot.version.control_plane_schema_version,
|
||||||
|
control_plane_db.SCHEMA_VERSION,
|
||||||
|
)
|
||||||
|
self.assertTrue(snapshot.version.python_version)
|
||||||
|
|
||||||
|
def test_version_known_flag_false_when_sha_unavailable(self):
|
||||||
|
with mock.patch("webui.system_health._git", return_value=None):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
|
||||||
|
self.assertIsNone(snapshot.version.git_sha)
|
||||||
|
self.assertFalse(snapshot.version.known)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleRuntime(unittest.TestCase):
|
||||||
|
"""AC3 — stale runtime reflected without a false mutation-safe claim."""
|
||||||
|
|
||||||
|
def test_matching_commits_are_mutation_safe(self):
|
||||||
|
assessment = assess_stale_runtime(
|
||||||
|
Path("/tmp"),
|
||||||
|
daemon_head="aaa",
|
||||||
|
git_reader=lambda *args: "aaa",
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment.stale)
|
||||||
|
self.assertTrue(assessment.determinable)
|
||||||
|
self.assertTrue(assessment.mutation_safe)
|
||||||
|
|
||||||
|
def test_diverged_commits_are_stale_and_not_mutation_safe(self):
|
||||||
|
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
|
||||||
|
assessment = assess_stale_runtime(
|
||||||
|
Path("/tmp"),
|
||||||
|
daemon_head="aaa",
|
||||||
|
git_reader=lambda *args: reads.get(args[-1]),
|
||||||
|
)
|
||||||
|
self.assertTrue(assessment.stale)
|
||||||
|
self.assertFalse(assessment.mutation_safe)
|
||||||
|
self.assertTrue(assessment.reasons)
|
||||||
|
|
||||||
|
def test_unknown_remote_is_not_mutation_safe(self):
|
||||||
|
"""Indeterminate must never read as safe."""
|
||||||
|
reads = {"HEAD": "aaa", "@{upstream}": None}
|
||||||
|
assessment = assess_stale_runtime(
|
||||||
|
Path("/tmp"),
|
||||||
|
daemon_head="aaa",
|
||||||
|
git_reader=lambda *args: reads.get(args[-1]),
|
||||||
|
)
|
||||||
|
self.assertFalse(assessment.determinable)
|
||||||
|
self.assertFalse(assessment.mutation_safe)
|
||||||
|
self.assertFalse(assessment.stale)
|
||||||
|
self.assertTrue(
|
||||||
|
any("indeterminate" in reason for reason in assessment.reasons)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unobservable_daemon_head_is_disclosed(self):
|
||||||
|
assessment = assess_stale_runtime(
|
||||||
|
Path("/tmp"),
|
||||||
|
git_reader=lambda *args: "aaa",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any("not observable" in reason for reason in assessment.reasons)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_runtime_degrades_overall_status(self):
|
||||||
|
reads = {"HEAD": "aaa", "@{upstream}": "bbb"}
|
||||||
|
# Pinned rather than inherited: this path uses the default git reader,
|
||||||
|
# so the assertion must hold whether or not the suite runs offline.
|
||||||
|
with mock.patch.dict(os.environ, {"WEBUI_TEST_OFFLINE": ""}), mock.patch(
|
||||||
|
"webui.system_health._git",
|
||||||
|
side_effect=lambda repo, *args: reads.get(args[-1]),
|
||||||
|
):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="aaa")
|
||||||
|
self.assertTrue(snapshot.stale_runtime.stale)
|
||||||
|
self.assertFalse(snapshot.stale_runtime.mutation_safe)
|
||||||
|
self.assertEqual(snapshot.status, STATUS_DEGRADED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestControlPlaneDbProbe(unittest.TestCase):
|
||||||
|
"""The required local dependency, probed read-only."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.db_path = str(Path(self.tmp.name) / "control-plane.db")
|
||||||
|
|
||||||
|
def _build_db(self, schema_version):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.execute("CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT)")
|
||||||
|
conn.execute("CREATE TABLE leases (lease_id TEXT PRIMARY KEY, status TEXT)")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_meta(key, value) VALUES ('schema_version', ?)",
|
||||||
|
(str(schema_version),),
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO leases(lease_id, status) VALUES ('l1', 'active')")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_missing_database_is_down(self):
|
||||||
|
probe = probe_control_plane_db(str(Path(self.tmp.name) / "absent.db"))
|
||||||
|
self.assertEqual(probe.status, STATUS_DOWN)
|
||||||
|
self.assertTrue(probe.required)
|
||||||
|
self.assertIsNotNone(probe.latency_ms)
|
||||||
|
|
||||||
|
def test_matching_schema_is_ok(self):
|
||||||
|
self._build_db(control_plane_db.SCHEMA_VERSION)
|
||||||
|
probe = probe_control_plane_db(self.db_path)
|
||||||
|
self.assertEqual(probe.status, STATUS_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
probe.metadata["schema_version"], control_plane_db.SCHEMA_VERSION
|
||||||
|
)
|
||||||
|
self.assertEqual(probe.metadata["active_leases"], 1)
|
||||||
|
|
||||||
|
def test_mismatched_schema_is_degraded(self):
|
||||||
|
self._build_db(control_plane_db.SCHEMA_VERSION + 99)
|
||||||
|
probe = probe_control_plane_db(self.db_path)
|
||||||
|
self.assertEqual(probe.status, STATUS_DEGRADED)
|
||||||
|
|
||||||
|
def test_probe_does_not_create_a_database(self):
|
||||||
|
"""A health check must never initialise the substrate it inspects."""
|
||||||
|
absent = str(Path(self.tmp.name) / "never-created.db")
|
||||||
|
probe_control_plane_db(absent)
|
||||||
|
self.assertFalse(Path(absent).exists())
|
||||||
|
|
||||||
|
def test_unreadable_database_is_down_not_raised(self):
|
||||||
|
Path(self.db_path).write_text("this is not a sqlite database")
|
||||||
|
probe = probe_control_plane_db(self.db_path)
|
||||||
|
self.assertEqual(probe.status, STATUS_DOWN)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedaction(unittest.TestCase):
|
||||||
|
"""AC4 — redaction. No credential-shaped text crosses the boundary."""
|
||||||
|
|
||||||
|
def test_redacts_token_assignment(self):
|
||||||
|
cleaned = redact("failed with token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")
|
||||||
|
self.assertNotIn("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ012345", cleaned)
|
||||||
|
self.assertIn("[redacted]", cleaned)
|
||||||
|
|
||||||
|
def test_redacts_authorization_header_text(self):
|
||||||
|
cleaned = redact("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456")
|
||||||
|
self.assertNotIn("abcdefghijklmnopqrstuvwxyz123456", cleaned)
|
||||||
|
|
||||||
|
def test_redacts_long_opaque_strings(self):
|
||||||
|
cleaned = redact("value 0123456789abcdef0123456789abcdef here")
|
||||||
|
self.assertNotIn("0123456789abcdef0123456789abcdef", cleaned)
|
||||||
|
|
||||||
|
def test_url_userinfo_and_query_are_stripped(self):
|
||||||
|
cleaned = redact_url("https://user:[email protected]/api/v1?token=xyz")
|
||||||
|
self.assertNotIn("secretpass", cleaned)
|
||||||
|
self.assertNotIn("token=xyz", cleaned)
|
||||||
|
self.assertEqual(cleaned, "https://gitea.example.com/api/v1")
|
||||||
|
|
||||||
|
def test_url_inside_free_text_is_redacted(self):
|
||||||
|
cleaned = redact("GET https://u:[email protected]/x?token=abc failed")
|
||||||
|
self.assertNotIn("u:p@", cleaned)
|
||||||
|
self.assertNotIn("token=abc", cleaned)
|
||||||
|
|
||||||
|
def test_gitea_probe_failure_detail_is_redacted(self):
|
||||||
|
boom = RuntimeError(
|
||||||
|
"connection refused for https://user:[email protected]/api/v1/version"
|
||||||
|
)
|
||||||
|
with mock.patch("webui.system_health.get_auth_header", return_value="token x"), \
|
||||||
|
mock.patch("webui.system_health.api_request", side_effect=boom):
|
||||||
|
probe = probe_gitea("gitea.example.com")
|
||||||
|
self.assertEqual(probe.status, STATUS_DOWN)
|
||||||
|
self.assertNotIn("hunter2", probe.detail)
|
||||||
|
self.assertEqual(scan_text_for_client_secrets(probe.detail), [])
|
||||||
|
|
||||||
|
def test_credential_guard_refusal_is_a_status_not_a_crash(self):
|
||||||
|
with mock.patch(
|
||||||
|
"webui.system_health.get_auth_header",
|
||||||
|
side_effect=RuntimeError("daemon guard refused"),
|
||||||
|
):
|
||||||
|
probe = probe_gitea("gitea.example.com")
|
||||||
|
self.assertEqual(probe.status, STATUS_DEGRADED)
|
||||||
|
self.assertFalse(probe.required)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNamespaceSummaries(unittest.TestCase):
|
||||||
|
"""A web process cannot prove IDE namespace health, and must not claim to."""
|
||||||
|
|
||||||
|
def test_every_namespace_reports_unproven(self):
|
||||||
|
rows = namespace_summaries()
|
||||||
|
self.assertTrue(rows)
|
||||||
|
for row in rows:
|
||||||
|
with self.subTest(namespace=row["namespace"]):
|
||||||
|
self.assertEqual(row["status"], "unproven")
|
||||||
|
self.assertFalse(row["ide_namespace_proven"])
|
||||||
|
self.assertIn("client_namespace", row["reason"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemHealthRoutes(CleanParityMixin, unittest.TestCase):
|
||||||
|
"""The HTTP surface: versioned path, status codes, read-only guard."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
clear_probe_cache()
|
||||||
|
self.addCleanup(clear_probe_cache)
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def _patch_snapshot(self, probes, daemon_head="abc123"):
|
||||||
|
snapshot = load_system_health(probes=probes, daemon_head=daemon_head)
|
||||||
|
patcher = mock.patch(
|
||||||
|
"webui.app.load_system_health",
|
||||||
|
return_value=snapshot,
|
||||||
|
)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def test_versioned_route_is_registered(self):
|
||||||
|
self.assertEqual(API_PATH, "/api/v1/system/health")
|
||||||
|
self._patch_snapshot(_ALL_HEALTHY)
|
||||||
|
response = self.client.get(API_PATH)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_healthy_payload_shape(self):
|
||||||
|
self._patch_snapshot(_ALL_HEALTHY)
|
||||||
|
data = self.client.get(API_PATH).json()
|
||||||
|
self.assertEqual(data["status"], STATUS_OK)
|
||||||
|
self.assertTrue(data["readiness"]["ready"])
|
||||||
|
self.assertTrue(data["readiness"]["complete"])
|
||||||
|
self.assertEqual(data["api"], API_PATH)
|
||||||
|
self.assertEqual(len(data["dependencies"]), 3)
|
||||||
|
for key in ("version", "process", "stale_runtime", "mcp_namespaces"):
|
||||||
|
self.assertIn(key, data)
|
||||||
|
self.assertIn("uptime_seconds", data["process"])
|
||||||
|
self.assertIn("mutation_safe", data["stale_runtime"])
|
||||||
|
|
||||||
|
def test_degraded_dependency_returns_503(self):
|
||||||
|
probes = (
|
||||||
|
_probe("control_plane_db", STATUS_DOWN, detail="missing", kind="sqlite"),
|
||||||
|
_probe("repository", STATUS_OK, kind="git"),
|
||||||
|
)
|
||||||
|
self._patch_snapshot(probes)
|
||||||
|
response = self.client.get(API_PATH)
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
data = response.json()
|
||||||
|
self.assertFalse(data["readiness"]["ready"])
|
||||||
|
self.assertTrue(data["readiness"]["reasons"])
|
||||||
|
|
||||||
|
def test_dependency_entries_expose_status_and_latency(self):
|
||||||
|
self._patch_snapshot(_ALL_HEALTHY)
|
||||||
|
data = self.client.get(API_PATH).json()
|
||||||
|
names = {entry["name"] for entry in data["dependencies"]}
|
||||||
|
self.assertEqual(names, {"control_plane_db", "repository", "gitea"})
|
||||||
|
for entry in data["dependencies"]:
|
||||||
|
with self.subTest(dependency=entry["name"]):
|
||||||
|
self.assertIn("status", entry)
|
||||||
|
self.assertIn("required", entry)
|
||||||
|
self.assertIn("latency_ms", entry)
|
||||||
|
|
||||||
|
def test_response_body_carries_no_client_secrets(self):
|
||||||
|
self._patch_snapshot(_ALL_HEALTHY)
|
||||||
|
body = self.client.get(API_PATH).text
|
||||||
|
self.assertEqual(scan_text_for_client_secrets(body), [])
|
||||||
|
|
||||||
|
def test_deep_flag_is_forwarded(self):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
|
||||||
|
with mock.patch(
|
||||||
|
"webui.app.load_system_health", return_value=snapshot
|
||||||
|
) as loader:
|
||||||
|
self.client.get(f"{API_PATH}?deep=1")
|
||||||
|
loader.assert_called_once_with(deep=True)
|
||||||
|
|
||||||
|
def test_shallow_is_the_default(self):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc")
|
||||||
|
with mock.patch(
|
||||||
|
"webui.app.load_system_health", return_value=snapshot
|
||||||
|
) as loader:
|
||||||
|
self.client.get(API_PATH)
|
||||||
|
loader.assert_called_once_with(deep=False)
|
||||||
|
|
||||||
|
def test_route_rejects_mutation_methods(self):
|
||||||
|
for method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
with self.subTest(method=method):
|
||||||
|
response = self.client.request(method, API_PATH)
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||||
|
|
||||||
|
def test_default_shallow_call_skips_the_network_probe(self):
|
||||||
|
"""The expensive probe must not run unless it was asked for."""
|
||||||
|
with mock.patch("webui.system_health.probe_gitea") as probe:
|
||||||
|
snapshot = load_system_health(deep=False)
|
||||||
|
probe.assert_not_called()
|
||||||
|
gitea = next(p for p in snapshot.dependencies if p.name == "gitea")
|
||||||
|
self.assertEqual(gitea.status, STATUS_SKIPPED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthRouteBackwardCompatibility(unittest.TestCase):
|
||||||
|
"""`/health` is expanded additively; MVP consumers must keep working."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_mvp_keys_are_unchanged(self):
|
||||||
|
data = self.client.get("/health").json()
|
||||||
|
self.assertEqual(data["status"], "ok")
|
||||||
|
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
||||||
|
self.assertEqual(data["mode"], "read-only-mvp")
|
||||||
|
self.assertIn("timestamp", data)
|
||||||
|
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
|
||||||
|
|
||||||
|
def test_health_points_at_the_versioned_api(self):
|
||||||
|
data = self.client.get("/health").json()
|
||||||
|
self.assertEqual(data["system_health_api"], API_PATH)
|
||||||
|
self.assertIn("uptime_seconds", data)
|
||||||
|
self.assertIn("started_at", data)
|
||||||
|
|
||||||
|
def test_health_runs_no_dependency_probe(self):
|
||||||
|
"""Liveness must stay cheap: no probe, no snapshot assembly."""
|
||||||
|
with mock.patch("webui.app.load_system_health") as loader:
|
||||||
|
response = self.client.get("/health")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
loader.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshotSerialisation(CleanParityMixin, unittest.TestCase):
|
||||||
|
def test_snapshot_dict_is_json_serialisable(self):
|
||||||
|
snapshot = load_system_health(probes=_ALL_HEALTHY, daemon_head="abc123")
|
||||||
|
encoded = json.dumps(snapshot_to_dict(snapshot))
|
||||||
|
self.assertIn("readiness", encoded)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Tests for the worker registry and configuration schema (#798, epic #797)."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from webui.worker_registry import (
|
||||||
|
ALLOWED_ROLES,
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
RegistryValidationError,
|
||||||
|
WorkerRegistry,
|
||||||
|
default_registry_path,
|
||||||
|
find_provider,
|
||||||
|
find_worker,
|
||||||
|
history_dir,
|
||||||
|
list_revisions,
|
||||||
|
load_registry,
|
||||||
|
registry_to_dict,
|
||||||
|
registry_to_document,
|
||||||
|
rollback_to_revision,
|
||||||
|
save_registry,
|
||||||
|
validate_payload,
|
||||||
|
worker_to_dict,
|
||||||
|
workers_for_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
_EXPECTED_PROVIDER_IDS = ("claude", "grok", "codex", "agy", "kimi-k")
|
||||||
|
|
||||||
|
|
||||||
|
def _provider(provider_id: str = "claude", **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"id": provider_id,
|
||||||
|
"display_name": "Claude",
|
||||||
|
"vendor": "Anthropic",
|
||||||
|
"executable": "claude",
|
||||||
|
"available": True,
|
||||||
|
"models": ["claude-opus-4-8"],
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _worker(worker_id: str = "claude-author", **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"id": worker_id,
|
||||||
|
"display_name": "Claude author",
|
||||||
|
"provider": "claude",
|
||||||
|
"model": "claude-opus-4-8",
|
||||||
|
"project": "gitea-tools",
|
||||||
|
"role": "author",
|
||||||
|
"namespace": "gitea-author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"workflow": "skills/llm-project-workflow/workflows/work-issue.md",
|
||||||
|
"schedule": {"kind": "cron", "expression": "0 * * * *"},
|
||||||
|
"timeout_seconds": 3600,
|
||||||
|
"enabled": True,
|
||||||
|
"scheduler": {"kind": "launchd", "label": "cc.prgs.claude.author"},
|
||||||
|
"notes": "",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _document(providers=None, workers=None, **overrides) -> dict:
|
||||||
|
payload = {
|
||||||
|
"version": SCHEMA_VERSION,
|
||||||
|
"revision": 1,
|
||||||
|
"updated_at": "2026-07-22T00:00:00Z",
|
||||||
|
"providers": providers if providers is not None else [_provider()],
|
||||||
|
"workers": workers if workers is not None else [_worker()],
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class _TempRegistryCase(unittest.TestCase):
|
||||||
|
"""Base case giving each test an isolated registry file."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.path = Path(self._tmp.name) / "workers.registry.json"
|
||||||
|
|
||||||
|
def write(self, document: dict) -> Path:
|
||||||
|
self.path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
def parse(self, document: dict) -> WorkerRegistry:
|
||||||
|
return validate_payload(document, source_path=self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPackagedRegistry(unittest.TestCase):
|
||||||
|
"""AC: the declarative registry is the source of truth and ships with the app."""
|
||||||
|
|
||||||
|
def test_default_path_points_at_packaged_data(self):
|
||||||
|
path = default_registry_path()
|
||||||
|
self.assertEqual(path.name, "workers.registry.json")
|
||||||
|
self.assertEqual(path.parent.name, "data")
|
||||||
|
|
||||||
|
def test_packaged_registry_loads_and_validates(self):
|
||||||
|
registry = load_registry()
|
||||||
|
self.assertEqual(registry.version, SCHEMA_VERSION)
|
||||||
|
self.assertGreaterEqual(registry.revision, 1)
|
||||||
|
|
||||||
|
def test_packaged_registry_declares_all_five_providers(self):
|
||||||
|
registry = load_registry()
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(provider.id for provider in registry.providers),
|
||||||
|
_EXPECTED_PROVIDER_IDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_packaged_registry_carries_no_credentials(self):
|
||||||
|
raw = default_registry_path().read_text(encoding="utf-8").lower()
|
||||||
|
for marker in ("token", "password", "secret", "api_key", "credential"):
|
||||||
|
self.assertNotIn(marker, raw)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeparateEntities(_TempRegistryCase):
|
||||||
|
"""AC: providers and configured workers are separate entities."""
|
||||||
|
|
||||||
|
def test_provider_may_exist_with_no_workers(self):
|
||||||
|
registry = self.parse(
|
||||||
|
_document(providers=[_provider("grok", display_name="Grok")], workers=[])
|
||||||
|
)
|
||||||
|
self.assertEqual(len(registry.providers), 1)
|
||||||
|
self.assertEqual(registry.workers, ())
|
||||||
|
self.assertEqual(workers_for_provider(registry, "grok"), ())
|
||||||
|
|
||||||
|
def test_many_workers_may_share_one_provider(self):
|
||||||
|
registry = self.parse(
|
||||||
|
_document(
|
||||||
|
workers=[
|
||||||
|
_worker("claude-author"),
|
||||||
|
_worker(
|
||||||
|
"claude-reviewer",
|
||||||
|
role="reviewer",
|
||||||
|
namespace="gitea-reviewer",
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
scheduler={"kind": "launchd", "label": "cc.prgs.claude.reviewer"},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(workers_for_provider(registry, "claude")), 2)
|
||||||
|
self.assertEqual(len(registry.providers), 1)
|
||||||
|
|
||||||
|
def test_worker_referencing_unknown_provider_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=[_worker(provider="mystery")]))
|
||||||
|
self.assertIn("unknown provider", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_lookup_helpers(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
self.assertIsNotNone(find_worker(registry, "claude-author"))
|
||||||
|
self.assertIsNone(find_worker(registry, "absent"))
|
||||||
|
self.assertIsNotNone(find_provider(registry, "claude"))
|
||||||
|
self.assertIsNone(find_provider(registry, "absent"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecordedFields(_TempRegistryCase):
|
||||||
|
"""AC: records provider, model, project, role, namespace/profile, workflow,
|
||||||
|
schedule, timeout, enabled state, and scheduler metadata."""
|
||||||
|
|
||||||
|
def test_every_required_field_is_recorded(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
worker = registry.workers[0]
|
||||||
|
self.assertEqual(worker.provider, "claude")
|
||||||
|
self.assertEqual(worker.model, "claude-opus-4-8")
|
||||||
|
self.assertEqual(worker.project, "gitea-tools")
|
||||||
|
self.assertEqual(worker.role, "author")
|
||||||
|
self.assertEqual(worker.namespace, "gitea-author")
|
||||||
|
self.assertEqual(worker.profile, "prgs-author")
|
||||||
|
self.assertEqual(worker.workflow, "skills/llm-project-workflow/workflows/work-issue.md")
|
||||||
|
self.assertEqual(worker.schedule.kind, "cron")
|
||||||
|
self.assertEqual(worker.schedule.expression, "0 * * * *")
|
||||||
|
self.assertEqual(worker.timeout_seconds, 3600)
|
||||||
|
self.assertTrue(worker.enabled)
|
||||||
|
self.assertEqual(worker.scheduler.kind, "launchd")
|
||||||
|
self.assertEqual(worker.scheduler.label, "cc.prgs.claude.author")
|
||||||
|
|
||||||
|
def test_each_required_field_is_individually_required(self):
|
||||||
|
for field in (
|
||||||
|
"provider", "model", "project", "role", "namespace",
|
||||||
|
"profile", "workflow", "schedule", "timeout_seconds",
|
||||||
|
"enabled", "scheduler", "id", "display_name",
|
||||||
|
):
|
||||||
|
with self.subTest(field=field):
|
||||||
|
worker = _worker()
|
||||||
|
worker.pop(field)
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[worker]))
|
||||||
|
|
||||||
|
def test_all_sanctioned_roles_are_accepted(self):
|
||||||
|
for role in ALLOWED_ROLES:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
registry = self.parse(_document(workers=[_worker(role=role)]))
|
||||||
|
self.assertEqual(registry.workers[0].role, role)
|
||||||
|
|
||||||
|
def test_unsanctioned_role_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=[_worker(role="admin")]))
|
||||||
|
self.assertIn("role must be one of", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_worker_dict_round_trips_every_field(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
encoded = worker_to_dict(registry.workers[0])
|
||||||
|
self.assertEqual(encoded, _worker())
|
||||||
|
json.dumps(encoded) # must stay JSON-safe for the #799 API
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaValidation(_TempRegistryCase):
|
||||||
|
"""AC: supports schema validation — and fails closed."""
|
||||||
|
|
||||||
|
def test_unsupported_version_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(version=2))
|
||||||
|
|
||||||
|
def test_root_must_be_an_object(self):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
validate_payload([], source_path=self.path)
|
||||||
|
|
||||||
|
def test_providers_must_be_non_empty(self):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(providers=[]))
|
||||||
|
|
||||||
|
def test_unknown_top_level_field_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(fleet=[]))
|
||||||
|
self.assertIn("unknown fields", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_worker_field_is_refused_not_ignored(self):
|
||||||
|
# A typo'd field must not be silently dropped: "timeout_second" would
|
||||||
|
# otherwise read as "no timeout declared".
|
||||||
|
worker = _worker()
|
||||||
|
worker["timeout_second"] = 30
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=[worker]))
|
||||||
|
self.assertIn("timeout_second", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_credentials_are_refused_anywhere_in_the_document(self):
|
||||||
|
for label, mutate in (
|
||||||
|
("provider.api_token", lambda doc: doc["providers"][0].__setitem__("api_token", "x")),
|
||||||
|
("worker.password", lambda doc: doc["workers"][0].__setitem__("password", "x")),
|
||||||
|
("root.secret", lambda doc: doc.__setitem__("secret", "x")),
|
||||||
|
):
|
||||||
|
with self.subTest(field=label):
|
||||||
|
document = _document()
|
||||||
|
mutate(document)
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
self.parse(document)
|
||||||
|
self.assertIn("credential", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
def test_duplicate_worker_id_is_refused(self):
|
||||||
|
workers = [_worker("dup"), _worker("dup", scheduler={"kind": "manual"})]
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=workers))
|
||||||
|
self.assertIn("duplicate worker id", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_duplicate_provider_id_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(providers=[_provider("claude"), _provider("claude")], workers=[]))
|
||||||
|
self.assertIn("duplicate provider id", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_duplicate_launchagent_label_is_refused(self):
|
||||||
|
# Two workers sharing a label would silently overwrite each other's agent.
|
||||||
|
workers = [
|
||||||
|
_worker("a", scheduler={"kind": "launchd", "label": "cc.prgs.same"}),
|
||||||
|
_worker("b", scheduler={"kind": "launchd", "label": "cc.prgs.same"}),
|
||||||
|
]
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=workers))
|
||||||
|
self.assertIn("duplicate scheduler label", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_manual_scheduler_needs_no_label_and_many_may_coexist(self):
|
||||||
|
workers = [
|
||||||
|
_worker("a", scheduler={"kind": "manual"}),
|
||||||
|
_worker("b", scheduler={"kind": "manual"}),
|
||||||
|
]
|
||||||
|
registry = self.parse(_document(workers=workers))
|
||||||
|
self.assertEqual([w.scheduler.label for w in registry.workers], [None, None])
|
||||||
|
|
||||||
|
def test_launchd_scheduler_requires_a_label(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=[_worker(scheduler={"kind": "launchd"})]))
|
||||||
|
self.assertIn("label is required", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_scheduler_kind_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(scheduler={"kind": "systemd", "label": "x"})]))
|
||||||
|
|
||||||
|
def test_timeout_must_be_a_positive_bounded_integer(self):
|
||||||
|
for bad in (0, -1, "3600", 1.5, True, 86_401):
|
||||||
|
with self.subTest(timeout=bad):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(timeout_seconds=bad)]))
|
||||||
|
|
||||||
|
def test_enabled_must_be_a_real_boolean(self):
|
||||||
|
for bad in ("true", 1, None):
|
||||||
|
with self.subTest(enabled=bad):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(enabled=bad)]))
|
||||||
|
|
||||||
|
def test_identifier_shape_is_enforced(self):
|
||||||
|
for bad in ("Claude Author", "-leading", "UPPER", ""):
|
||||||
|
with self.subTest(worker_id=bad):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(bad)]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestScheduleValidation(_TempRegistryCase):
|
||||||
|
"""Schedules are declarations; next-run computation belongs to #803."""
|
||||||
|
|
||||||
|
def test_interval_schedule_requires_positive_seconds(self):
|
||||||
|
registry = self.parse(
|
||||||
|
_document(workers=[_worker(schedule={"kind": "interval", "seconds": 900})])
|
||||||
|
)
|
||||||
|
self.assertEqual(registry.workers[0].schedule.seconds, 900)
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(schedule={"kind": "interval"})]))
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(schedule={"kind": "interval", "seconds": 0})]))
|
||||||
|
|
||||||
|
def test_cron_schedule_requires_five_fields(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=[_worker(schedule={"kind": "cron", "expression": "0 *"})]))
|
||||||
|
self.assertIn("five crontab fields", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_manual_schedule_needs_no_timing(self):
|
||||||
|
registry = self.parse(_document(workers=[_worker(schedule={"kind": "manual"})]))
|
||||||
|
schedule = registry.workers[0].schedule
|
||||||
|
self.assertEqual(schedule.kind, "manual")
|
||||||
|
self.assertIsNone(schedule.seconds)
|
||||||
|
self.assertIsNone(schedule.expression)
|
||||||
|
|
||||||
|
def test_fields_from_the_wrong_kind_are_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
self.parse(_document(workers=[_worker(schedule={"kind": "manual", "seconds": 60})]))
|
||||||
|
self.assertIn("not valid for kind", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_schedule_kind_is_refused(self):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(workers=[_worker(schedule={"kind": "hourly"})]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtomicPersistence(_TempRegistryCase):
|
||||||
|
"""AC: atomic persistence."""
|
||||||
|
|
||||||
|
def test_save_then_load_round_trips(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
save_registry(registry, self.path)
|
||||||
|
reloaded = load_registry(self.path)
|
||||||
|
self.assertEqual(
|
||||||
|
[worker_to_dict(w) for w in reloaded.workers],
|
||||||
|
[worker_to_dict(w) for w in registry.workers],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_save_leaves_no_temp_files_behind(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
save_registry(registry, self.path)
|
||||||
|
save_registry(registry, self.path)
|
||||||
|
leftovers = [p.name for p in self.path.parent.iterdir() if p.name.startswith(".")]
|
||||||
|
self.assertEqual(leftovers, [])
|
||||||
|
|
||||||
|
def test_save_refuses_to_persist_an_invalid_document(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
broken = WorkerRegistry(
|
||||||
|
version=registry.version,
|
||||||
|
revision=registry.revision,
|
||||||
|
updated_at=registry.updated_at,
|
||||||
|
providers=registry.providers,
|
||||||
|
# A worker whose provider is not declared in the registry.
|
||||||
|
workers=tuple(
|
||||||
|
type(worker)(**{**worker.__dict__, "provider": "vanished"})
|
||||||
|
for worker in registry.workers
|
||||||
|
),
|
||||||
|
source_path=self.path,
|
||||||
|
)
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
save_registry(broken, self.path)
|
||||||
|
self.assertFalse(self.path.exists(), "invalid save must not create the file")
|
||||||
|
|
||||||
|
def test_document_shape_excludes_local_paths_but_api_shape_includes_it(self):
|
||||||
|
registry = self.parse(_document())
|
||||||
|
self.assertNotIn("source_path", registry_to_document(registry))
|
||||||
|
self.assertEqual(registry_to_dict(registry)["source_path"], str(self.path))
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersioningAndRollback(_TempRegistryCase):
|
||||||
|
"""AC: versioning and rollback."""
|
||||||
|
|
||||||
|
def _seed(self) -> WorkerRegistry:
|
||||||
|
self.write(_document())
|
||||||
|
return load_registry(self.path)
|
||||||
|
|
||||||
|
def test_revision_increments_on_each_save(self):
|
||||||
|
registry = self._seed()
|
||||||
|
self.assertEqual(registry.revision, 1)
|
||||||
|
second = save_registry(registry, self.path)
|
||||||
|
self.assertEqual(second.revision, 2)
|
||||||
|
third = save_registry(second, self.path)
|
||||||
|
self.assertEqual(third.revision, 3)
|
||||||
|
|
||||||
|
def test_updated_at_is_refreshed_and_utc(self):
|
||||||
|
registry = self._seed()
|
||||||
|
saved = save_registry(registry, self.path)
|
||||||
|
self.assertRegex(saved.updated_at, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||||
|
|
||||||
|
def test_superseded_revisions_are_retained(self):
|
||||||
|
registry = self._seed()
|
||||||
|
second = save_registry(registry, self.path)
|
||||||
|
save_registry(second, self.path)
|
||||||
|
self.assertEqual(list_revisions(self.path), (1, 2))
|
||||||
|
self.assertTrue(history_dir(self.path).is_dir())
|
||||||
|
|
||||||
|
def test_rollback_restores_prior_content_as_a_new_revision(self):
|
||||||
|
self.write(_document(workers=[_worker("original")]))
|
||||||
|
registry = load_registry(self.path)
|
||||||
|
|
||||||
|
changed = WorkerRegistry(
|
||||||
|
version=registry.version,
|
||||||
|
revision=registry.revision,
|
||||||
|
updated_at=registry.updated_at,
|
||||||
|
providers=registry.providers,
|
||||||
|
workers=(), # operator deletes every worker
|
||||||
|
source_path=self.path,
|
||||||
|
)
|
||||||
|
save_registry(changed, self.path)
|
||||||
|
self.assertEqual(load_registry(self.path).workers, ())
|
||||||
|
|
||||||
|
restored = rollback_to_revision(1, self.path)
|
||||||
|
self.assertEqual([w.id for w in restored.workers], ["original"])
|
||||||
|
# Append-only: the rollback publishes a new head rather than rewinding.
|
||||||
|
self.assertGreater(restored.revision, 2)
|
||||||
|
self.assertEqual([w.id for w in load_registry(self.path).workers], ["original"])
|
||||||
|
|
||||||
|
def test_rollback_to_unknown_revision_fails_closed(self):
|
||||||
|
self._seed()
|
||||||
|
with self.assertRaises(RegistryValidationError) as ctx:
|
||||||
|
rollback_to_revision(99, self.path)
|
||||||
|
self.assertIn("not retained", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_revision_must_be_a_positive_integer(self):
|
||||||
|
for bad in (0, -1, "1", None):
|
||||||
|
with self.subTest(revision=bad):
|
||||||
|
with self.assertRaises(RegistryValidationError):
|
||||||
|
self.parse(_document(revision=bad))
|
||||||
|
|
||||||
|
def test_history_is_empty_before_any_save(self):
|
||||||
|
self.write(_document())
|
||||||
|
self.assertEqual(list_revisions(self.path), ())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+186
-5
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
@@ -11,14 +12,29 @@ from starlette.routing import Route
|
|||||||
|
|
||||||
from webui.deployment_boundary import deployment_snapshot
|
from webui.deployment_boundary import deployment_snapshot
|
||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
from webui.project_registry import (
|
||||||
from webui.project_views import render_project_detail, render_projects_list
|
ProjectRegistry,
|
||||||
|
RegistryError,
|
||||||
|
find_project,
|
||||||
|
known_project_ids,
|
||||||
|
load_registry,
|
||||||
|
project_detail_to_dict,
|
||||||
|
registry_to_dict,
|
||||||
|
)
|
||||||
|
from webui.project_views import (
|
||||||
|
render_project_detail,
|
||||||
|
render_projects_list,
|
||||||
|
render_registry_error,
|
||||||
|
)
|
||||||
from webui.prompt_library import find_prompt, library_to_dict
|
from webui.prompt_library import find_prompt, library_to_dict
|
||||||
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
from webui.prompt_views import render_prompt_detail, render_prompts_page
|
||||||
from final_report_validator import FINAL_REPORT_TASK_KINDS
|
from final_report_validator import FINAL_REPORT_TASK_KINDS
|
||||||
|
|
||||||
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
from webui.gated_actions import attempt_action, load_action_registry, preview_action
|
||||||
from webui.gated_action_views import render_actions_page
|
from webui.gated_action_views import render_actions_page
|
||||||
|
from webui import console_audit
|
||||||
|
from webui.console_authz import authorize, rbac_matrix, resolve_principal
|
||||||
|
from webui.console_redaction import redaction_policy
|
||||||
from webui.audit_validator import audit_report, audit_to_dict
|
from webui.audit_validator import audit_report, audit_to_dict
|
||||||
from webui.audit_views import render_audit_page
|
from webui.audit_views import render_audit_page
|
||||||
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict
|
||||||
@@ -29,6 +45,12 @@ from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as wo
|
|||||||
from webui.worktree_views import render_worktrees_page
|
from webui.worktree_views import render_worktrees_page
|
||||||
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
|
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
|
||||||
from webui.runtime_views import render_runtime_page
|
from webui.runtime_views import render_runtime_page
|
||||||
|
from webui.system_health import (
|
||||||
|
API_PATH as SYSTEM_HEALTH_API_PATH,
|
||||||
|
load_system_health,
|
||||||
|
process_uptime,
|
||||||
|
snapshot_to_dict as system_health_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||||
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
||||||
@@ -62,16 +84,43 @@ async def home(_request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
async def health(_request: Request) -> JSONResponse:
|
async def health(_request: Request) -> JSONResponse:
|
||||||
|
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
|
||||||
|
|
||||||
|
Every MVP key is retained so existing pollers keep working; the additions
|
||||||
|
are a pointer to the structured API and the in-memory process uptime.
|
||||||
|
Readiness lives at that API because answering it costs real probes.
|
||||||
|
"""
|
||||||
bind_host = _request.app.state.webui_bind_host
|
bind_host = _request.app.state.webui_bind_host
|
||||||
|
started_at, uptime_seconds = process_uptime()
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "mcp-control-plane-webui",
|
"service": "mcp-control-plane-webui",
|
||||||
"mode": "read-only-mvp",
|
"mode": "read-only-mvp",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"deployment": deployment_snapshot(bind_host=bind_host),
|
"deployment": deployment_snapshot(bind_host=bind_host),
|
||||||
|
"started_at": started_at,
|
||||||
|
"uptime_seconds": uptime_seconds,
|
||||||
|
"system_health_api": SYSTEM_HEALTH_API_PATH,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy_flag(value: str | None) -> bool:
|
||||||
|
return (value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
async def api_system_health(request: Request) -> JSONResponse:
|
||||||
|
"""Structured read-only system health (#634).
|
||||||
|
|
||||||
|
`?deep=1` opts into the expensive network probe. The response status code
|
||||||
|
reflects readiness so automated checks can branch on it without parsing the
|
||||||
|
body: 200 when ready, 503 when a required dependency failed or never ran.
|
||||||
|
"""
|
||||||
|
deep = _truthy_flag(request.query_params.get("deep"))
|
||||||
|
snapshot = load_system_health(deep=deep)
|
||||||
|
payload = system_health_to_dict(snapshot)
|
||||||
|
return JSONResponse(payload, status_code=200 if snapshot.ready else 503)
|
||||||
|
|
||||||
|
|
||||||
async def queue(_request: Request) -> HTMLResponse:
|
async def queue(_request: Request) -> HTMLResponse:
|
||||||
snapshot = load_queue_snapshot()
|
snapshot = load_queue_snapshot()
|
||||||
return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
|
return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
|
||||||
@@ -81,14 +130,26 @@ async def api_queue(_request: Request) -> JSONResponse:
|
|||||||
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
|
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_project_registry() -> tuple[ProjectRegistry | None, RegistryError | None]:
|
||||||
|
"""Load the registry, converting validation failure into a fail-closed pair."""
|
||||||
|
try:
|
||||||
|
return load_registry(), None
|
||||||
|
except RegistryError as exc:
|
||||||
|
return None, exc
|
||||||
|
|
||||||
|
|
||||||
async def projects(_request: Request) -> HTMLResponse:
|
async def projects(_request: Request) -> HTMLResponse:
|
||||||
registry = load_registry()
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None:
|
||||||
|
return HTMLResponse(render_registry_error(error), status_code=500)
|
||||||
return HTMLResponse(render_projects_list(registry))
|
return HTMLResponse(render_projects_list(registry))
|
||||||
|
|
||||||
|
|
||||||
async def project_detail(request: Request) -> HTMLResponse:
|
async def project_detail(request: Request) -> HTMLResponse:
|
||||||
project_id = request.path_params["project_id"]
|
project_id = request.path_params["project_id"]
|
||||||
registry = load_registry()
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None:
|
||||||
|
return HTMLResponse(render_registry_error(error), status_code=500)
|
||||||
project = find_project(registry, project_id)
|
project = find_project(registry, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
@@ -106,10 +167,47 @@ async def project_detail(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
async def api_projects(_request: Request) -> JSONResponse:
|
async def api_projects(_request: Request) -> JSONResponse:
|
||||||
registry = load_registry()
|
"""Unversioned MVP alias, retained through Phase 1 (#632 section 6)."""
|
||||||
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=500)
|
||||||
return JSONResponse(registry_to_dict(registry))
|
return JSONResponse(registry_to_dict(registry))
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_projects(_request: Request) -> JSONResponse:
|
||||||
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=500)
|
||||||
|
return JSONResponse(registry_to_dict(registry))
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_project_detail(request: Request) -> JSONResponse:
|
||||||
|
project_id = request.path_params["project_id"]
|
||||||
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=500)
|
||||||
|
project = find_project(registry, project_id)
|
||||||
|
if project is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"error": "project_not_found",
|
||||||
|
"project_id": project_id,
|
||||||
|
"known_project_ids": known_project_ids(registry),
|
||||||
|
"remediation": (
|
||||||
|
"Request one of the known project ids, or add the project to the "
|
||||||
|
"registry file named in 'source'."
|
||||||
|
),
|
||||||
|
"source": {
|
||||||
|
"kind": "file",
|
||||||
|
"path": str(registry.source_path),
|
||||||
|
"inventory_complete": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
return JSONResponse(project_detail_to_dict(registry, project))
|
||||||
|
|
||||||
|
|
||||||
async def prompts(_request: Request) -> HTMLResponse:
|
async def prompts(_request: Request) -> HTMLResponse:
|
||||||
return HTMLResponse(render_prompts_page())
|
return HTMLResponse(render_prompts_page())
|
||||||
|
|
||||||
@@ -215,6 +313,49 @@ async def api_actions(_request: Request) -> JSONResponse:
|
|||||||
return JSONResponse(load_action_registry().to_dict())
|
return JSONResponse(load_action_registry().to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def _request_id() -> str:
|
||||||
|
return f"req-{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_target(action_id: str, params: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""Describe the action target for the audit record (never secrets)."""
|
||||||
|
if "pr_number" in params:
|
||||||
|
return {"kind": "pr", "ref": f"#{params['pr_number']}"}
|
||||||
|
if "issue_number" in params:
|
||||||
|
return {"kind": "issue", "ref": f"#{params['issue_number']}"}
|
||||||
|
if "branch_name" in params:
|
||||||
|
return {"kind": "branch", "ref": str(params["branch_name"])}
|
||||||
|
return {"kind": "unspecified", "ref": action_id}
|
||||||
|
|
||||||
|
|
||||||
|
def _authorize_request(
|
||||||
|
request: Request,
|
||||||
|
action_id: str,
|
||||||
|
params: dict[str, object],
|
||||||
|
*,
|
||||||
|
for_execution: bool,
|
||||||
|
result: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Resolve principal, decide, and audit. Returns the decision payload.
|
||||||
|
|
||||||
|
Phase 1 records the decision rather than enforcing it as the terminal
|
||||||
|
outcome: ``webui.gated_actions`` already fails closed for every action, so
|
||||||
|
this layer cannot loosen anything. Phase 2 enforces on this same decision.
|
||||||
|
"""
|
||||||
|
principal = resolve_principal(headers=dict(request.headers))
|
||||||
|
decision = authorize(action_id, principal, for_execution=for_execution)
|
||||||
|
console_audit.record_event(
|
||||||
|
action_id=action_id,
|
||||||
|
result=result,
|
||||||
|
decision=decision,
|
||||||
|
principal=principal,
|
||||||
|
target=_audit_target(action_id, params),
|
||||||
|
request_id=_request_id(),
|
||||||
|
detail=decision.detail,
|
||||||
|
)
|
||||||
|
return decision.to_dict()
|
||||||
|
|
||||||
|
|
||||||
async def api_action_preview(request: Request) -> JSONResponse:
|
async def api_action_preview(request: Request) -> JSONResponse:
|
||||||
action_id = request.path_params["action_id"]
|
action_id = request.path_params["action_id"]
|
||||||
params = dict(request.query_params)
|
params = dict(request.query_params)
|
||||||
@@ -224,6 +365,13 @@ async def api_action_preview(request: Request) -> JSONResponse:
|
|||||||
result = preview_action(action_id, **params)
|
result = preview_action(action_id, **params)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return JSONResponse(result, status_code=404)
|
return JSONResponse(result, status_code=404)
|
||||||
|
result["authorization"] = _authorize_request(
|
||||||
|
request,
|
||||||
|
action_id,
|
||||||
|
params,
|
||||||
|
for_execution=False,
|
||||||
|
result=console_audit.RESULT_PREVIEWED,
|
||||||
|
)
|
||||||
return JSONResponse(result)
|
return JSONResponse(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -237,10 +385,31 @@ async def api_action_attempt(request: Request) -> JSONResponse:
|
|||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
body = {}
|
body = {}
|
||||||
result = attempt_action(action_id, **body)
|
result = attempt_action(action_id, **body)
|
||||||
|
authorization = _authorize_request(
|
||||||
|
request,
|
||||||
|
action_id,
|
||||||
|
body,
|
||||||
|
for_execution=True,
|
||||||
|
result=(
|
||||||
|
console_audit.RESULT_DENIED
|
||||||
|
if not result.get("success")
|
||||||
|
else console_audit.RESULT_ALLOWED
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result["authorization"] = authorization
|
||||||
status = 403 if not result.get("success") else 200
|
status = 403 if not result.get("success") else 200
|
||||||
return JSONResponse(result, status_code=status)
|
return JSONResponse(result, status_code=status)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_console_security_model(_request: Request) -> JSONResponse:
|
||||||
|
"""Read-only publication of the #633 authorization/redaction/audit model."""
|
||||||
|
return JSONResponse({
|
||||||
|
"rbac": rbac_matrix(),
|
||||||
|
"redaction": redaction_policy(),
|
||||||
|
"audit": console_audit.audit_policy(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||||
@@ -263,11 +432,18 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
routes=[
|
routes=[
|
||||||
Route("/", home, methods=["GET"]),
|
Route("/", home, methods=["GET"]),
|
||||||
Route("/health", health, methods=["GET"]),
|
Route("/health", health, methods=["GET"]),
|
||||||
|
Route(SYSTEM_HEALTH_API_PATH, api_system_health, methods=["GET"]),
|
||||||
Route("/queue", queue, methods=["GET"]),
|
Route("/queue", queue, methods=["GET"]),
|
||||||
Route("/api/queue", api_queue, methods=["GET"]),
|
Route("/api/queue", api_queue, methods=["GET"]),
|
||||||
Route("/projects", projects, methods=["GET"]),
|
Route("/projects", projects, methods=["GET"]),
|
||||||
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
||||||
Route("/api/projects", api_projects, methods=["GET"]),
|
Route("/api/projects", api_projects, methods=["GET"]),
|
||||||
|
Route("/api/v1/projects", api_v1_projects, methods=["GET"]),
|
||||||
|
Route(
|
||||||
|
"/api/v1/projects/{project_id}",
|
||||||
|
api_v1_project_detail,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
Route("/prompts", prompts, methods=["GET"]),
|
Route("/prompts", prompts, methods=["GET"]),
|
||||||
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
|
||||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||||
@@ -291,6 +467,11 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
),
|
),
|
||||||
Route("/api/leases", api_leases, methods=["GET"]),
|
Route("/api/leases", api_leases, methods=["GET"]),
|
||||||
|
Route(
|
||||||
|
"/api/console/security-model",
|
||||||
|
api_console_security_model,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
exception_handlers={405: method_not_allowed},
|
exception_handlers={405: method_not_allowed},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
"""Console audit event schema, retention, and append-only sink (#633).
|
||||||
|
|
||||||
|
``gitea_audit`` records MCP-side *mutations*: which profile and Gitea user
|
||||||
|
performed which tool call. It carries no console actor, no identity source, no
|
||||||
|
correlation identifier, and no retention class, so it cannot answer the
|
||||||
|
question #633 exists to answer — *who sat at the console, what did they
|
||||||
|
attempt, and was it authorized?* An authorization denial is not a mutation and
|
||||||
|
would never appear there at all.
|
||||||
|
|
||||||
|
This module adds the console-side record. It does not replace ``gitea_audit``:
|
||||||
|
when a Phase 2 action eventually reaches MCP, both fire, correlated by
|
||||||
|
``correlation.request_id``.
|
||||||
|
|
||||||
|
Design constraints:
|
||||||
|
|
||||||
|
- **Redact before persist.** Every record passes through
|
||||||
|
``webui.console_redaction.redact_payload`` before serialization, so an
|
||||||
|
unredacted field is never durable.
|
||||||
|
- **Append-only.** Records are appended as JSON lines. Nothing here updates or
|
||||||
|
deletes; retention is metadata on each record, enforced by an operator-run
|
||||||
|
policy, never by silent rewriting.
|
||||||
|
- **Never raises.** Auditing must not break the request it describes. A failed
|
||||||
|
write returns ``False``.
|
||||||
|
- **Off by default.** With ``WEBUI_CONSOLE_AUDIT_LOG`` unset, events are still
|
||||||
|
*built* (so callers and tests see the schema) but nothing is written.
|
||||||
|
|
||||||
|
A record looks like this (synthetic values):
|
||||||
|
|
||||||
|
{"schema_version": 1, "event_id": "evt-0001",
|
||||||
|
"timestamp": "2026-07-22T10:16:42+00:00",
|
||||||
|
"actor": {"subject": "[email protected]", "role": "operator",
|
||||||
|
"identity_source": "access_proxy", "authenticated": true},
|
||||||
|
"action": "merge_pr", "action_class": "privileged",
|
||||||
|
"target": {"kind": "pr", "ref": "#123"},
|
||||||
|
"result": "denied", "reason_code": "insufficient_role",
|
||||||
|
"correlation": {"request_id": "req-abc", "session_id": null,
|
||||||
|
"mcp_task": "merge_pr", "mcp_permission": "gitea.pr.merge"},
|
||||||
|
"retention": {"class": "privileged", "days": 365,
|
||||||
|
"expires_at": "2027-07-22T10:16:42+00:00"},
|
||||||
|
"redacted": true}
|
||||||
|
|
||||||
|
Timestamps are timezone-aware ISO-8601 in UTC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from webui import console_authz
|
||||||
|
from webui.console_redaction import redact_payload, scan_for_secrets
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
AUDIT_LOG_ENV = "WEBUI_CONSOLE_AUDIT_LOG"
|
||||||
|
|
||||||
|
# Result vocabulary. ``denied`` is the one ``gitea_audit`` has no equivalent
|
||||||
|
# for: an authorization refusal never reaches the MCP layer.
|
||||||
|
RESULT_ALLOWED = "allowed"
|
||||||
|
RESULT_DENIED = "denied"
|
||||||
|
RESULT_PREVIEWED = "previewed"
|
||||||
|
RESULT_FAILED = "failed"
|
||||||
|
RESULT_SUCCEEDED = "succeeded"
|
||||||
|
|
||||||
|
RESULTS = frozenset(
|
||||||
|
{
|
||||||
|
RESULT_ALLOWED,
|
||||||
|
RESULT_DENIED,
|
||||||
|
RESULT_PREVIEWED,
|
||||||
|
RESULT_FAILED,
|
||||||
|
RESULT_SUCCEEDED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retention classes and default lifetimes in days. Privileged and break-glass
|
||||||
|
# records outlive routine ones because they are what an incident review needs.
|
||||||
|
RETENTION_STANDARD = "standard"
|
||||||
|
RETENTION_PRIVILEGED = "privileged"
|
||||||
|
RETENTION_BREAK_GLASS = "break_glass"
|
||||||
|
|
||||||
|
RETENTION_DAYS: dict[str, int] = {
|
||||||
|
RETENTION_STANDARD: 90,
|
||||||
|
RETENTION_PRIVILEGED: 365,
|
||||||
|
RETENTION_BREAK_GLASS: 730,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fields every record must carry. Asserted by the test suite so a future edit
|
||||||
|
# cannot quietly drop one.
|
||||||
|
REQUIRED_FIELDS: tuple[str, ...] = (
|
||||||
|
"schema_version",
|
||||||
|
"event_id",
|
||||||
|
"timestamp",
|
||||||
|
"actor",
|
||||||
|
"action",
|
||||||
|
"action_class",
|
||||||
|
"target",
|
||||||
|
"result",
|
||||||
|
"reason_code",
|
||||||
|
"correlation",
|
||||||
|
"retention",
|
||||||
|
"redacted",
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_ACTOR_FIELDS: tuple[str, ...] = (
|
||||||
|
"subject",
|
||||||
|
"role",
|
||||||
|
"identity_source",
|
||||||
|
"authenticated",
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_CORRELATION_FIELDS: tuple[str, ...] = (
|
||||||
|
"request_id",
|
||||||
|
"session_id",
|
||||||
|
"mcp_task",
|
||||||
|
"mcp_permission",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_log_path() -> str | None:
|
||||||
|
"""Configured sink path, or ``None`` when console auditing is off."""
|
||||||
|
return (os.environ.get(AUDIT_LOG_ENV) or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def audit_enabled() -> bool:
|
||||||
|
return audit_log_path() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def retention_class_for(action: console_authz.ConsoleAction | None) -> str:
|
||||||
|
"""Classify retention from the action, defaulting to the longest-lived.
|
||||||
|
|
||||||
|
An unknown action is treated as privileged rather than standard: for a
|
||||||
|
safety control the conservative direction is to keep the record longer.
|
||||||
|
"""
|
||||||
|
if action is None:
|
||||||
|
return RETENTION_PRIVILEGED
|
||||||
|
if action.break_glass:
|
||||||
|
return RETENTION_BREAK_GLASS
|
||||||
|
if action.privileged:
|
||||||
|
return RETENTION_PRIVILEGED
|
||||||
|
return RETENTION_STANDARD
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_block(
|
||||||
|
retention_class: str, now: datetime.datetime
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
days = RETENTION_DAYS.get(
|
||||||
|
retention_class, RETENTION_DAYS[RETENTION_PRIVILEGED]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"class": retention_class,
|
||||||
|
"days": days,
|
||||||
|
"expires_at": (now + datetime.timedelta(days=days)).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_event(
|
||||||
|
*,
|
||||||
|
action_id: str,
|
||||||
|
result: str,
|
||||||
|
decision: console_authz.AuthorizationDecision | None = None,
|
||||||
|
principal: console_authz.Principal | None = None,
|
||||||
|
target: dict[str, Any] | None = None,
|
||||||
|
reason_code: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
detail: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
now: datetime.datetime | None = None,
|
||||||
|
event_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build one redacted, JSON-able console audit record.
|
||||||
|
|
||||||
|
Redaction runs here rather than at write time so an in-memory record handed
|
||||||
|
to a template or an API response is already clean.
|
||||||
|
"""
|
||||||
|
ts = now or datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
action = console_authz.get_action(action_id)
|
||||||
|
who = principal or (
|
||||||
|
decision.principal if decision else console_authz.ANONYMOUS
|
||||||
|
)
|
||||||
|
resolved_result = result if result in RESULTS else RESULT_FAILED
|
||||||
|
resolved_reason = reason_code or (
|
||||||
|
decision.reason_code if decision else "unspecified"
|
||||||
|
)
|
||||||
|
retention_class = retention_class_for(action)
|
||||||
|
|
||||||
|
event: dict[str, Any] = {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"event_id": event_id or f"evt-{uuid.uuid4().hex}",
|
||||||
|
"timestamp": ts.isoformat(),
|
||||||
|
"actor": who.to_dict(),
|
||||||
|
"action": action_id,
|
||||||
|
"action_class": action.action_class if action else "unknown",
|
||||||
|
"target": dict(target or {}),
|
||||||
|
"result": resolved_result,
|
||||||
|
"reason_code": resolved_reason,
|
||||||
|
"correlation": {
|
||||||
|
"request_id": request_id,
|
||||||
|
"session_id": session_id,
|
||||||
|
"mcp_task": action.task_key if action else None,
|
||||||
|
"mcp_permission": action.mcp_permission if action else None,
|
||||||
|
},
|
||||||
|
"retention": _retention_block(retention_class, ts),
|
||||||
|
"redacted": True,
|
||||||
|
"detail": detail,
|
||||||
|
"metadata": dict(metadata or {}),
|
||||||
|
}
|
||||||
|
if decision is not None:
|
||||||
|
# Deliberately *not* named "authorization": ``gitea_audit`` treats that
|
||||||
|
# substring as a secret key hint (it matches the HTTP Authorization
|
||||||
|
# header) and would replace this whole block with the placeholder.
|
||||||
|
event["decision"] = {
|
||||||
|
"allowed": decision.allowed,
|
||||||
|
"required_role": decision.required_role,
|
||||||
|
"requires_confirmation": decision.requires_confirmation,
|
||||||
|
"dual_control": decision.dual_control,
|
||||||
|
"break_glass": decision.break_glass,
|
||||||
|
"execution_enabled": decision.execution_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
redacted = redact_payload(event)
|
||||||
|
if not isinstance(redacted, dict): # pragma: no cover - defensive
|
||||||
|
return {"schema_version": SCHEMA_VERSION, "redacted": True}
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def write_event(event: dict[str, Any], path: str | None = None) -> bool:
|
||||||
|
"""Append *event* as one JSON line. Never raises.
|
||||||
|
|
||||||
|
Returns ``True`` when a line was written, ``False`` when auditing is off or
|
||||||
|
the write failed. A record that still trips a secret detector is dropped
|
||||||
|
rather than persisted.
|
||||||
|
"""
|
||||||
|
sink = path or audit_log_path()
|
||||||
|
if not sink:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if scan_for_secrets(event):
|
||||||
|
return False
|
||||||
|
line = json.dumps(event, default=str, sort_keys=True)
|
||||||
|
with open(sink, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def record_event(**kwargs: Any) -> dict[str, Any]:
|
||||||
|
"""Build and persist one record; return the record either way.
|
||||||
|
|
||||||
|
Callers get the record back so it can be surfaced in a response or a test
|
||||||
|
regardless of whether a sink is configured.
|
||||||
|
"""
|
||||||
|
event = build_event(**kwargs)
|
||||||
|
written = write_event(event)
|
||||||
|
return {"event": event, "written": written}
|
||||||
|
|
||||||
|
|
||||||
|
def audit_policy() -> dict[str, Any]:
|
||||||
|
"""Machine-readable audit schema and retention defaults (never secrets)."""
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"required_fields": list(REQUIRED_FIELDS),
|
||||||
|
"required_actor_fields": list(REQUIRED_ACTOR_FIELDS),
|
||||||
|
"required_correlation_fields": list(REQUIRED_CORRELATION_FIELDS),
|
||||||
|
"results": sorted(RESULTS),
|
||||||
|
"retention_defaults_days": dict(RETENTION_DAYS),
|
||||||
|
"sink_env": AUDIT_LOG_ENV,
|
||||||
|
"enabled": audit_enabled(),
|
||||||
|
"append_only": True,
|
||||||
|
"redact_before_persist": True,
|
||||||
|
"timestamp_format": "ISO-8601, timezone-aware, UTC",
|
||||||
|
"relationship_to_mcp_audit": (
|
||||||
|
"webui.console_audit records console intent and authorization "
|
||||||
|
"outcomes; gitea_audit records MCP mutations. A Phase 2 action "
|
||||||
|
"emits both, correlated by correlation.request_id."
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
"""Console authorization and RBAC model (#633, Phase 1).
|
||||||
|
|
||||||
|
The read-only MVP (#426–#436) ships with no authentication: protection comes
|
||||||
|
from network placement alone (#435). That is adequate while every route is a
|
||||||
|
GET, and inadequate the moment Phase 2 wires a gated write. This module is the
|
||||||
|
authorization model those writes must go through, landed *before* any of them
|
||||||
|
exists so no write can be added without an authority to check against.
|
||||||
|
|
||||||
|
Phase 1 scope is the model itself: identity resolution, the role matrix, the
|
||||||
|
privileged-action list, and a fail-closed :func:`authorize`. It deliberately
|
||||||
|
does **not** enable any write. ``webui.gated_actions`` stays globally disabled,
|
||||||
|
so an allow decision here is necessary but never sufficient.
|
||||||
|
|
||||||
|
Two invariants hold for every caller:
|
||||||
|
|
||||||
|
- **Default deny.** An unrecognised action, an unknown role, or an absent
|
||||||
|
principal denies. There is no implicit allow branch and no "unless" clause.
|
||||||
|
- **Authorization is not execution.** :func:`authorize` returns a decision
|
||||||
|
record. It never calls MCP, never mutates, and never consults credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from task_capability_map import required_permission, required_role
|
||||||
|
|
||||||
|
# --- Roles ------------------------------------------------------------------
|
||||||
|
# Ordered least to most authority. Higher ranks inherit every lower rank's
|
||||||
|
# permitted actions; the matrix below is expressed as a minimum required rank.
|
||||||
|
VIEWER = "viewer"
|
||||||
|
OPERATOR = "operator"
|
||||||
|
CONTROLLER = "controller"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
ROLE_ORDER: tuple[str, ...] = (VIEWER, OPERATOR, CONTROLLER, ADMIN)
|
||||||
|
_ROLE_RANK: dict[str, int] = {role: idx for idx, role in enumerate(ROLE_ORDER)}
|
||||||
|
|
||||||
|
ROLE_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
VIEWER: "Read every console view. No write, ever, in any phase.",
|
||||||
|
OPERATOR: "Viewer, plus author-class work: claim, comment, open a PR.",
|
||||||
|
CONTROLLER: "Operator, plus reviewer/merger-class decisions on a PR.",
|
||||||
|
ADMIN: "Controller, plus destructive and policy-editing actions.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Identity sources -------------------------------------------------------
|
||||||
|
IDENTITY_NONE = "none"
|
||||||
|
IDENTITY_LOCAL_DEV = "local_dev"
|
||||||
|
IDENTITY_ACCESS_PROXY = "access_proxy"
|
||||||
|
|
||||||
|
IDENTITY_SOURCES: dict[str, dict[str, Any]] = {
|
||||||
|
IDENTITY_NONE: {
|
||||||
|
"description": (
|
||||||
|
"No authentication configured. Every request is anonymous and "
|
||||||
|
"capped at viewer. This is the MVP default and the only mode "
|
||||||
|
"whose safety rests entirely on network placement (#435)."
|
||||||
|
),
|
||||||
|
"authenticated": False,
|
||||||
|
"safe_for_shared_host": False,
|
||||||
|
"phase_available": 1,
|
||||||
|
},
|
||||||
|
IDENTITY_LOCAL_DEV: {
|
||||||
|
"description": (
|
||||||
|
"Developer-supplied principal read from the environment. INSECURE: "
|
||||||
|
"the subject and role are asserted, never verified. Loopback only."
|
||||||
|
),
|
||||||
|
"authenticated": True,
|
||||||
|
"safe_for_shared_host": False,
|
||||||
|
"phase_available": 1,
|
||||||
|
},
|
||||||
|
IDENTITY_ACCESS_PROXY: {
|
||||||
|
"description": (
|
||||||
|
"Subject asserted by a trusted access proxy (Cloudflare Access, "
|
||||||
|
"WARP, or an org VPN portal) via a verified request header. The "
|
||||||
|
"proxy performs authentication; the console performs authorization."
|
||||||
|
),
|
||||||
|
"authenticated": True,
|
||||||
|
"safe_for_shared_host": True,
|
||||||
|
"phase_available": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Environment configuration. All are read server-side and never rendered.
|
||||||
|
AUTH_MODE_ENV = "WEBUI_AUTH_MODE"
|
||||||
|
DEV_SUBJECT_ENV = "WEBUI_DEV_SUBJECT"
|
||||||
|
DEV_ROLE_ENV = "WEBUI_DEV_ROLE"
|
||||||
|
ROLE_MAP_ENV = "WEBUI_ROLE_MAP"
|
||||||
|
REQUIRE_PROBE_AUTH_ENV = "WEBUI_REQUIRE_PROBE_AUTH"
|
||||||
|
ACCESS_SUBJECT_HEADER = "cf-access-authenticated-user-email"
|
||||||
|
|
||||||
|
# --- Action classes ---------------------------------------------------------
|
||||||
|
CLASS_READ = "read"
|
||||||
|
CLASS_WRITE = "gated_write"
|
||||||
|
CLASS_PRIVILEGED = "privileged"
|
||||||
|
CLASS_DESTRUCTIVE = "destructive"
|
||||||
|
|
||||||
|
# --- Privileged action list -------------------------------------------------
|
||||||
|
# ``task_key`` ties each console action back to ``task_capability_map``, so the
|
||||||
|
# console cannot invent an authority the MCP layer does not already define.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConsoleAction:
|
||||||
|
"""One console action and the authority required to invoke it."""
|
||||||
|
|
||||||
|
action_id: str
|
||||||
|
task_key: str
|
||||||
|
action_class: str
|
||||||
|
minimum_role: str
|
||||||
|
requires_confirmation: bool
|
||||||
|
dual_control: bool
|
||||||
|
break_glass: bool
|
||||||
|
phase: int
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mcp_permission(self) -> str:
|
||||||
|
return required_permission(self.task_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mcp_role(self) -> str:
|
||||||
|
return required_role(self.task_key)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privileged(self) -> bool:
|
||||||
|
return self.action_class in {CLASS_PRIVILEGED, CLASS_DESTRUCTIVE}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
data = asdict(self)
|
||||||
|
data["mcp_permission"] = self.mcp_permission
|
||||||
|
data["mcp_role"] = self.mcp_role
|
||||||
|
data["privileged"] = self.privileged
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
_ACTION_SPECS: tuple[ConsoleAction, ...] = (
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="claim_issue",
|
||||||
|
task_key="claim_issue",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Apply status:in-progress to an issue.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="comment_issue",
|
||||||
|
task_key="comment_issue",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Post an issue comment.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="create_issue",
|
||||||
|
task_key="create_issue",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Open a new tracking issue.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="comment_pr",
|
||||||
|
task_key="comment_pr",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Post a PR thread comment.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="create_pr",
|
||||||
|
task_key="create_pr",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Open a PR from a locked feature branch.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="review_pr",
|
||||||
|
task_key="review_pr",
|
||||||
|
action_class=CLASS_PRIVILEGED,
|
||||||
|
minimum_role=CONTROLLER,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=3,
|
||||||
|
summary="Submit an approve / request-changes verdict.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="close_pr",
|
||||||
|
task_key="close_pr",
|
||||||
|
action_class=CLASS_PRIVILEGED,
|
||||||
|
minimum_role=CONTROLLER,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=3,
|
||||||
|
summary="Close a pull request without merging.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="merge_pr",
|
||||||
|
task_key="merge_pr",
|
||||||
|
action_class=CLASS_PRIVILEGED,
|
||||||
|
minimum_role=CONTROLLER,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=True,
|
||||||
|
break_glass=True,
|
||||||
|
phase=3,
|
||||||
|
summary="Merge an approved pull request.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="delete_branch",
|
||||||
|
task_key="delete_branch",
|
||||||
|
action_class=CLASS_DESTRUCTIVE,
|
||||||
|
minimum_role=ADMIN,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=True,
|
||||||
|
break_glass=True,
|
||||||
|
phase=3,
|
||||||
|
summary="Remove a remote feature branch.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
||||||
|
|
||||||
|
|
||||||
|
def privileged_actions() -> tuple[ConsoleAction, ...]:
|
||||||
|
"""Actions requiring dual control, break-glass, or controller+ authority."""
|
||||||
|
return tuple(a for a in _ACTION_SPECS if a.privileged)
|
||||||
|
|
||||||
|
|
||||||
|
def get_action(action_id: str) -> ConsoleAction | None:
|
||||||
|
return ACTIONS.get(action_id)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Principals -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Principal:
|
||||||
|
"""Who is making a request, and how strongly that is known."""
|
||||||
|
|
||||||
|
subject: str
|
||||||
|
role: str
|
||||||
|
identity_source: str
|
||||||
|
authenticated: bool
|
||||||
|
warnings: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rank(self) -> int:
|
||||||
|
return _ROLE_RANK.get(self.role, -1)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"subject": self.subject,
|
||||||
|
"role": self.role,
|
||||||
|
"identity_source": self.identity_source,
|
||||||
|
"authenticated": self.authenticated,
|
||||||
|
"warnings": list(self.warnings),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ANONYMOUS = Principal(
|
||||||
|
subject="anonymous",
|
||||||
|
role=VIEWER,
|
||||||
|
identity_source=IDENTITY_NONE,
|
||||||
|
authenticated=False,
|
||||||
|
warnings=("No authentication configured; capped at viewer.",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def auth_mode(env: dict[str, str] | None = None) -> str:
|
||||||
|
"""Resolve the configured identity source, defaulting to ``none``."""
|
||||||
|
source = env if env is not None else os.environ
|
||||||
|
raw = (source.get(AUTH_MODE_ENV) or "").strip().lower().replace("-", "_")
|
||||||
|
if raw in IDENTITY_SOURCES:
|
||||||
|
return raw
|
||||||
|
return IDENTITY_NONE
|
||||||
|
|
||||||
|
|
||||||
|
def _role_map(env: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Parse ``WEBUI_ROLE_MAP`` (JSON subject→role). Invalid config yields {}."""
|
||||||
|
raw = (env.get(ROLE_MAP_ENV) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(k): str(v).strip().lower()
|
||||||
|
for k, v in parsed.items()
|
||||||
|
if str(v).strip().lower() in _ROLE_RANK
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_principal(
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> Principal:
|
||||||
|
"""Resolve the requesting principal. Unknown or unconfigured → anonymous.
|
||||||
|
|
||||||
|
Never raises and never trusts a client-supplied role: the role always comes
|
||||||
|
from server-side configuration keyed by the resolved subject.
|
||||||
|
"""
|
||||||
|
source_env = dict(env) if env is not None else dict(os.environ)
|
||||||
|
lowered = {str(k).lower(): str(v) for k, v in (headers or {}).items()}
|
||||||
|
mode = auth_mode(source_env)
|
||||||
|
|
||||||
|
if mode == IDENTITY_LOCAL_DEV:
|
||||||
|
subject = (source_env.get(DEV_SUBJECT_ENV) or "").strip()
|
||||||
|
if not subject:
|
||||||
|
return ANONYMOUS
|
||||||
|
role = (source_env.get(DEV_ROLE_ENV) or VIEWER).strip().lower()
|
||||||
|
if role not in _ROLE_RANK:
|
||||||
|
role = VIEWER
|
||||||
|
return Principal(
|
||||||
|
subject=subject,
|
||||||
|
role=role,
|
||||||
|
identity_source=IDENTITY_LOCAL_DEV,
|
||||||
|
authenticated=True,
|
||||||
|
warnings=(
|
||||||
|
"local-dev identity is asserted, not verified; never use "
|
||||||
|
"outside loopback.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == IDENTITY_ACCESS_PROXY:
|
||||||
|
subject = (lowered.get(ACCESS_SUBJECT_HEADER) or "").strip()
|
||||||
|
if not subject:
|
||||||
|
# Proxy mode with no proxy header means the request did not
|
||||||
|
# traverse the proxy. Fail closed rather than trust it.
|
||||||
|
return ANONYMOUS
|
||||||
|
role = _role_map(source_env).get(subject, VIEWER)
|
||||||
|
return Principal(
|
||||||
|
subject=subject,
|
||||||
|
role=role,
|
||||||
|
identity_source=IDENTITY_ACCESS_PROXY,
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ANONYMOUS
|
||||||
|
|
||||||
|
|
||||||
|
def probe_auth_required(env: dict[str, str] | None = None) -> bool:
|
||||||
|
"""Whether non-public probes must be authenticated. Default False.
|
||||||
|
|
||||||
|
#633 requires the console to *fail closed on missing auth for non-public
|
||||||
|
health probes if configured*. The default stays off so the MVP ``/health``
|
||||||
|
contract is unchanged; an operator opts in explicitly.
|
||||||
|
"""
|
||||||
|
source = env if env is not None else os.environ
|
||||||
|
return (source.get(REQUIRE_PROBE_AUTH_ENV) or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Authorization ----------------------------------------------------------
|
||||||
|
|
||||||
|
DENY_UNKNOWN_ACTION = "unknown_action"
|
||||||
|
DENY_UNAUTHENTICATED = "unauthenticated"
|
||||||
|
DENY_INSUFFICIENT_ROLE = "insufficient_role"
|
||||||
|
DENY_UNKNOWN_ROLE = "unknown_role"
|
||||||
|
DENY_PHASE_NOT_ACTIVE = "phase_not_active"
|
||||||
|
ALLOW_PREVIEW = "allowed_preview_only"
|
||||||
|
|
||||||
|
# Phase 1 is the only active console phase. Phase 2 opens gated writes and is
|
||||||
|
# gated on this model landing; nothing here enables it.
|
||||||
|
ACTIVE_PHASE = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthorizationDecision:
|
||||||
|
"""Result of an authorization check. Never an execution grant."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
reason_code: str
|
||||||
|
detail: str
|
||||||
|
action_id: str
|
||||||
|
principal: Principal
|
||||||
|
required_role: str | None = None
|
||||||
|
action_class: str | None = None
|
||||||
|
requires_confirmation: bool = False
|
||||||
|
dual_control: bool = False
|
||||||
|
break_glass: bool = False
|
||||||
|
execution_enabled: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"reason_code": self.reason_code,
|
||||||
|
"detail": self.detail,
|
||||||
|
"action_id": self.action_id,
|
||||||
|
"principal": self.principal.to_dict(),
|
||||||
|
"required_role": self.required_role,
|
||||||
|
"action_class": self.action_class,
|
||||||
|
"requires_confirmation": self.requires_confirmation,
|
||||||
|
"dual_control": self.dual_control,
|
||||||
|
"break_glass": self.break_glass,
|
||||||
|
"execution_enabled": self.execution_enabled,
|
||||||
|
"active_phase": ACTIVE_PHASE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
action_id: str,
|
||||||
|
principal: Principal | None = None,
|
||||||
|
*,
|
||||||
|
for_execution: bool = False,
|
||||||
|
) -> 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.
|
||||||
|
"""
|
||||||
|
who = principal if principal is not None else ANONYMOUS
|
||||||
|
action = get_action(action_id)
|
||||||
|
|
||||||
|
if action is None:
|
||||||
|
return AuthorizationDecision(
|
||||||
|
allowed=False,
|
||||||
|
reason_code=DENY_UNKNOWN_ACTION,
|
||||||
|
detail=f"No console action registered as {action_id!r}.",
|
||||||
|
action_id=action_id,
|
||||||
|
principal=who,
|
||||||
|
)
|
||||||
|
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"action_id": action_id,
|
||||||
|
"principal": who,
|
||||||
|
"required_role": action.minimum_role,
|
||||||
|
"action_class": action.action_class,
|
||||||
|
"requires_confirmation": action.requires_confirmation,
|
||||||
|
"dual_control": action.dual_control,
|
||||||
|
"break_glass": action.break_glass,
|
||||||
|
"execution_enabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not who.authenticated:
|
||||||
|
return AuthorizationDecision(
|
||||||
|
allowed=False,
|
||||||
|
reason_code=DENY_UNAUTHENTICATED,
|
||||||
|
detail=(
|
||||||
|
"Write actions require an authenticated principal; this "
|
||||||
|
"request is anonymous."
|
||||||
|
),
|
||||||
|
**base,
|
||||||
|
)
|
||||||
|
|
||||||
|
if who.rank < 0:
|
||||||
|
return AuthorizationDecision(
|
||||||
|
allowed=False,
|
||||||
|
reason_code=DENY_UNKNOWN_ROLE,
|
||||||
|
detail=f"Role {who.role!r} is not in the console role matrix.",
|
||||||
|
**base,
|
||||||
|
)
|
||||||
|
|
||||||
|
if who.rank < _ROLE_RANK[action.minimum_role]:
|
||||||
|
return AuthorizationDecision(
|
||||||
|
allowed=False,
|
||||||
|
reason_code=DENY_INSUFFICIENT_ROLE,
|
||||||
|
detail=(
|
||||||
|
f"Action {action_id!r} requires {action.minimum_role!r}; "
|
||||||
|
f"principal holds {who.role!r}."
|
||||||
|
),
|
||||||
|
**base,
|
||||||
|
)
|
||||||
|
|
||||||
|
if for_execution and action.phase > ACTIVE_PHASE:
|
||||||
|
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."
|
||||||
|
),
|
||||||
|
**base,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthorizationDecision(
|
||||||
|
allowed=True,
|
||||||
|
reason_code=ALLOW_PREVIEW,
|
||||||
|
detail=(
|
||||||
|
"Principal holds the required role. Preview only — execution "
|
||||||
|
"remains disabled until the Phase 2 action framework ships."
|
||||||
|
),
|
||||||
|
**base,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rbac_matrix() -> dict[str, Any]:
|
||||||
|
"""Machine-readable RBAC matrix and privileged-action list."""
|
||||||
|
return {
|
||||||
|
"model_version": 1,
|
||||||
|
"active_phase": ACTIVE_PHASE,
|
||||||
|
"roles": [
|
||||||
|
{
|
||||||
|
"role": role,
|
||||||
|
"rank": _ROLE_RANK[role],
|
||||||
|
"description": ROLE_DESCRIPTIONS[role],
|
||||||
|
"permitted_actions": sorted(
|
||||||
|
a.action_id
|
||||||
|
for a in _ACTION_SPECS
|
||||||
|
if _ROLE_RANK[role] >= _ROLE_RANK[a.minimum_role]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for role in ROLE_ORDER
|
||||||
|
],
|
||||||
|
"identity_sources": IDENTITY_SOURCES,
|
||||||
|
"actions": [a.to_dict() for a in _ACTION_SPECS],
|
||||||
|
"privileged_actions": [a.action_id for a in privileged_actions()],
|
||||||
|
"default_decision": "deny",
|
||||||
|
"execution_enabled": False,
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Secret redaction policy for every console surface (#633).
|
||||||
|
|
||||||
|
The MVP already redacts MCP-side mutation records through ``gitea_audit``.
|
||||||
|
This module is the console-facing policy: one redaction pass applied to API
|
||||||
|
payloads, rendered HTML, log lines, and audit records *before* they leave the
|
||||||
|
server or reach persistent storage.
|
||||||
|
|
||||||
|
Design constraints:
|
||||||
|
|
||||||
|
- **Reuse, never fork.** ``gitea_audit.redact`` remains the authority for
|
||||||
|
secret-looking dict keys, ``Authorization`` material, and raw URLs. This
|
||||||
|
module runs that pass first and then applies console-specific patterns for
|
||||||
|
keychain references, key/value assignments, private-key blocks, and JWTs.
|
||||||
|
- **Never raises.** Redaction is a safety control; a malformed payload must
|
||||||
|
degrade to a redacted placeholder rather than propagate an exception.
|
||||||
|
- **Redact before persist.** ``webui.console_audit`` calls this module before
|
||||||
|
writing, so an unredacted record is never durable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import gitea_audit
|
||||||
|
|
||||||
|
REDACTED = gitea_audit.REDACTED
|
||||||
|
|
||||||
|
# Console-specific patterns applied after the shared ``gitea_audit`` pass.
|
||||||
|
# Each keeps the identifying key so an operator can still tell *what* was
|
||||||
|
# removed, and replaces only the secret run itself.
|
||||||
|
_KEYCHAIN_REF = re.compile(r"(?i)\bkeychain:[\w.\-/@]+")
|
||||||
|
_KEYCHAIN_CMD = re.compile(
|
||||||
|
r"(?i)\bsecurity\s+find-(?:generic|internet)-password\b[^\n]*"
|
||||||
|
)
|
||||||
|
_ASSIGNMENT = re.compile(
|
||||||
|
r"(?i)\b(token|password|passwd|secret|api[_-]?key|access[_-]?key|"
|
||||||
|
r"client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)"
|
||||||
|
r"(\"[^\"]*\"|'[^']*'|\S+)"
|
||||||
|
)
|
||||||
|
_ENV_ASSIGNMENT = re.compile(
|
||||||
|
r"(?i)\b(GITEA_(?:TOKEN|PASS|PASSWORD)[A-Z0-9_]*)(\s*=\s*)"
|
||||||
|
r"(\"[^\"]*\"|'[^']*'|\S+)"
|
||||||
|
)
|
||||||
|
_PRIVATE_KEY_BLOCK = re.compile(
|
||||||
|
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
||||||
|
re.S,
|
||||||
|
)
|
||||||
|
_JWT = re.compile(
|
||||||
|
r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shapes that mean a payload still carries a secret. ``scan_for_secrets`` uses
|
||||||
|
# these to assert a surface is clean.
|
||||||
|
_DETECTORS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||||
|
("keychain_reference", _KEYCHAIN_REF),
|
||||||
|
("keychain_command", _KEYCHAIN_CMD),
|
||||||
|
("credential_assignment", _ASSIGNMENT),
|
||||||
|
("credential_env_assignment", _ENV_ASSIGNMENT),
|
||||||
|
("private_key_block", _PRIVATE_KEY_BLOCK),
|
||||||
|
("json_web_token", _JWT),
|
||||||
|
("bearer_credential", re.compile(r"(?i)\b(?:bearer|basic)\s+\S{8,}")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_assignment(match: re.Match[str]) -> str:
|
||||||
|
"""Keep the key and separator, replace the value."""
|
||||||
|
return f"{match.group(1)}{match.group(2)}{REDACTED}"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_text(text: Any) -> Any:
|
||||||
|
"""Redact secret material from a single string.
|
||||||
|
|
||||||
|
Non-strings are returned unchanged so this is safe to map over mixed
|
||||||
|
payloads. Runs the shared ``gitea_audit`` pass first, then the
|
||||||
|
console-specific patterns.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
out = gitea_audit.redact(text)
|
||||||
|
if not isinstance(out, str): # defensive; redact() returns str for str
|
||||||
|
return REDACTED
|
||||||
|
out = _PRIVATE_KEY_BLOCK.sub(f"{REDACTED}_PRIVATE_KEY", out)
|
||||||
|
out = _ENV_ASSIGNMENT.sub(_mask_assignment, out)
|
||||||
|
out = _ASSIGNMENT.sub(_mask_assignment, out)
|
||||||
|
out = _KEYCHAIN_CMD.sub(f"{REDACTED}_KEYCHAIN_COMMAND", out)
|
||||||
|
out = _KEYCHAIN_REF.sub(f"{REDACTED}_KEYCHAIN_REF", out)
|
||||||
|
out = _JWT.sub(f"{REDACTED}_JWT", out)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
# Fail closed: an unredactable string is dropped rather than emitted raw.
|
||||||
|
return REDACTED
|
||||||
|
|
||||||
|
|
||||||
|
def redact_payload(value: Any) -> Any:
|
||||||
|
"""Recursively redact a JSON-able payload for any console surface.
|
||||||
|
|
||||||
|
Secret-looking dict keys are replaced wholesale by the shared
|
||||||
|
``gitea_audit`` policy; every remaining string is run through
|
||||||
|
:func:`redact_text`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
shared = gitea_audit.redact(value)
|
||||||
|
except Exception:
|
||||||
|
return REDACTED
|
||||||
|
return _walk(shared)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(value: Any) -> Any:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: _walk(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [_walk(v) for v in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return redact_text(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def scan_for_secrets(value: Any) -> list[str]:
|
||||||
|
"""Return detector names that still match *value* after serialization.
|
||||||
|
|
||||||
|
Used to assert an outbound payload or rendered page is clean. An empty
|
||||||
|
list means no known secret shape was found. Already-redacted hits are not
|
||||||
|
findings.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
text = json.dumps(value, default=str)
|
||||||
|
except Exception:
|
||||||
|
text = str(value)
|
||||||
|
findings: list[str] = []
|
||||||
|
for name, pattern in _DETECTORS:
|
||||||
|
for match in pattern.finditer(text):
|
||||||
|
if REDACTED in match.group(0):
|
||||||
|
continue
|
||||||
|
findings.append(name)
|
||||||
|
break
|
||||||
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
def redaction_policy() -> dict[str, Any]:
|
||||||
|
"""Machine-readable statement of the redaction rules (never secrets)."""
|
||||||
|
return {
|
||||||
|
"policy_version": 1,
|
||||||
|
"applies_to": [
|
||||||
|
"json_api_responses",
|
||||||
|
"rendered_html",
|
||||||
|
"server_logs",
|
||||||
|
"audit_records",
|
||||||
|
],
|
||||||
|
"ordering": "shared gitea_audit pass, then console patterns",
|
||||||
|
"redact_before_persist": True,
|
||||||
|
"shared_rules": {
|
||||||
|
"source": "gitea_audit.redact",
|
||||||
|
"secret_key_hints": list(gitea_audit._SECRET_KEY_HINTS),
|
||||||
|
"secret_value_prefixes": list(gitea_audit._SECRET_VALUE_PREFIXES),
|
||||||
|
"urls": "credentials, secret query parameters, and real hosts redacted",
|
||||||
|
},
|
||||||
|
"console_rules": [
|
||||||
|
{"name": name, "pattern": pattern.pattern}
|
||||||
|
for name, pattern in _DETECTORS
|
||||||
|
],
|
||||||
|
"placeholder": REDACTED,
|
||||||
|
"failure_mode": "fail closed — unredactable values become the placeholder",
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
"id": "gitea-tools",
|
"id": "gitea-tools",
|
||||||
"repo_name": "Gitea-Tools",
|
"repo_name": "Gitea-Tools",
|
||||||
"gitea_owner": "Scaled-Tech-Consulting",
|
"gitea_owner": "Scaled-Tech-Consulting",
|
||||||
|
"remote_name": "prgs",
|
||||||
"remote_host": "https://gitea.prgs.cc",
|
"remote_host": "https://gitea.prgs.cc",
|
||||||
"default_branch": "master",
|
"default_branch": "master",
|
||||||
"local_checkout_path": ".",
|
"local_checkout_path": ".",
|
||||||
|
"status": "active",
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"author": "prgs-author",
|
"author": "prgs-author",
|
||||||
"reviewer": "prgs-reviewer",
|
"reviewer": "prgs-reviewer",
|
||||||
@@ -26,22 +28,30 @@
|
|||||||
{
|
{
|
||||||
"id": "profiles",
|
"id": "profiles",
|
||||||
"title": "Configure execution profiles",
|
"title": "Configure execution profiles",
|
||||||
"description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry."
|
"description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry.",
|
||||||
|
"state": "complete",
|
||||||
|
"required": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mcp_config",
|
"id": "mcp_config",
|
||||||
"title": "Wire MCP v2 contexts",
|
"title": "Wire MCP v2 contexts",
|
||||||
"description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools."
|
"description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools.",
|
||||||
|
"state": "complete",
|
||||||
|
"required": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wiki_gate",
|
"id": "wiki_gate",
|
||||||
"title": "Wiki publication readiness",
|
"title": "Wiki publication readiness",
|
||||||
"description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md."
|
"description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md.",
|
||||||
|
"state": "complete",
|
||||||
|
"required": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "branches_layout",
|
"id": "branches_layout",
|
||||||
"title": "Isolate work under branches/",
|
"title": "Isolate work under branches/",
|
||||||
"description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows."
|
"description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows.",
|
||||||
|
"state": "complete",
|
||||||
|
"required": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"revision": 1,
|
||||||
|
"updated_at": "2026-07-22T00:00:00Z",
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"id": "claude",
|
||||||
|
"display_name": "Claude",
|
||||||
|
"vendor": "Anthropic",
|
||||||
|
"executable": "claude",
|
||||||
|
"available": true,
|
||||||
|
"models": [
|
||||||
|
"claude-opus-4-8",
|
||||||
|
"claude-sonnet-5",
|
||||||
|
"claude-haiku-4-5-20251001"
|
||||||
|
],
|
||||||
|
"notes": "Model list is a declaration. Live enumeration and version inspection belong to the provider adapter framework (#800)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grok",
|
||||||
|
"display_name": "Grok",
|
||||||
|
"vendor": "xAI",
|
||||||
|
"executable": "grok",
|
||||||
|
"available": true,
|
||||||
|
"models": [],
|
||||||
|
"notes": "Models enumerated by the provider adapter (#800); not declared here."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "codex",
|
||||||
|
"display_name": "Codex",
|
||||||
|
"vendor": "OpenAI",
|
||||||
|
"executable": "codex",
|
||||||
|
"available": true,
|
||||||
|
"models": [],
|
||||||
|
"notes": "Models enumerated by the provider adapter (#800); not declared here."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "agy",
|
||||||
|
"display_name": "AGY",
|
||||||
|
"vendor": "Antigravity",
|
||||||
|
"executable": "agy",
|
||||||
|
"available": true,
|
||||||
|
"models": [],
|
||||||
|
"notes": "MCP allowlist gating applies to this provider; confirm server-side allowlist before configuring a worker."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kimi-k",
|
||||||
|
"display_name": "Kimi K",
|
||||||
|
"vendor": "Moonshot AI",
|
||||||
|
"executable": "kimi",
|
||||||
|
"available": true,
|
||||||
|
"models": [],
|
||||||
|
"notes": "Provider id is kimi-k; the executable on PATH is kimi. Models enumerated by the provider adapter (#800)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workers": []
|
||||||
|
}
|
||||||
+448
-45
@@ -1,4 +1,16 @@
|
|||||||
"""Load and validate the web UI project registry (#427)."""
|
"""Load and validate the web UI project registry (#427, evolved for #635).
|
||||||
|
|
||||||
|
Phase 1 of the console architecture ADR keeps this loader read-only. It owns
|
||||||
|
the versioned project registry contract served at ``/api/v1/projects``:
|
||||||
|
|
||||||
|
* the on-disk file carries a ``version`` (schema version 1 or 2);
|
||||||
|
* version 1 files stay loadable and are normalized with explicit defaults, so
|
||||||
|
an operator registry written for #427 keeps working;
|
||||||
|
* every validation failure raises :class:`RegistryError`, which carries an
|
||||||
|
actionable ``remediation`` string instead of leaking a traceback;
|
||||||
|
* serialization never emits credentials — credential-shaped keys are rejected
|
||||||
|
at load time, before any DTO is built.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,27 +20,59 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_FORBIDDEN_EXACT_KEYS = frozenset({
|
from webui.registry_safety import is_forbidden_key
|
||||||
"token",
|
|
||||||
"password",
|
|
||||||
"secret",
|
|
||||||
"credential",
|
|
||||||
"auth",
|
|
||||||
"api_key",
|
|
||||||
"api-key",
|
|
||||||
})
|
|
||||||
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
|
||||||
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
|
||||||
|
|
||||||
|
#: Version of the JSON contract served under ``/api/v1/...``.
|
||||||
|
REGISTRY_API_VERSION = "v1"
|
||||||
|
|
||||||
|
#: Schema version written by this repository's packaged registry.
|
||||||
|
CURRENT_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
#: Schema versions this loader accepts. Version 1 is normalized on load.
|
||||||
|
SUPPORTED_SCHEMA_VERSIONS = (1, 2)
|
||||||
|
|
||||||
|
#: Lifecycle state of a registered project.
|
||||||
|
PROJECT_STATUSES = ("active", "onboarding", "paused", "archived")
|
||||||
|
_DEFAULT_PROJECT_STATUS = "active"
|
||||||
|
|
||||||
|
#: Completion state of a single onboarding step.
|
||||||
|
ONBOARDING_STATES = ("complete", "pending", "blocked", "not_applicable")
|
||||||
|
_DEFAULT_ONBOARDING_STATE = "pending"
|
||||||
|
|
||||||
|
#: Redacted, last-seen health of a project's control plane.
|
||||||
|
HEALTH_STATUSES = ("healthy", "degraded", "unreachable", "unknown")
|
||||||
|
|
||||||
|
class RegistryError(ValueError):
|
||||||
|
"""A registry file could not be loaded or failed validation.
|
||||||
|
|
||||||
|
Carries an operator-facing ``remediation`` so routes can fail closed with
|
||||||
|
an actionable message rather than a stack trace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
remediation: str,
|
||||||
|
source_path: Path | None = None,
|
||||||
|
field_path: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.remediation = remediation
|
||||||
|
self.source_path = source_path
|
||||||
|
self.field_path = field_path
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialize for a fail-closed JSON error response."""
|
||||||
|
return {
|
||||||
|
"error": "registry_invalid",
|
||||||
|
"detail": self.message,
|
||||||
|
"remediation": self.remediation,
|
||||||
|
"field_path": self.field_path,
|
||||||
|
"source_path": str(self.source_path) if self.source_path else None,
|
||||||
|
}
|
||||||
|
|
||||||
def _is_forbidden_key(key: str) -> bool:
|
|
||||||
lowered = key.lower()
|
|
||||||
if lowered in _FORBIDDEN_EXACT_KEYS:
|
|
||||||
return True
|
|
||||||
return (
|
|
||||||
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
|
||||||
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
|
||||||
)
|
|
||||||
|
|
||||||
_REQUIRED_PROJECT_FIELDS = (
|
_REQUIRED_PROJECT_FIELDS = (
|
||||||
"id",
|
"id",
|
||||||
@@ -49,6 +93,30 @@ class OnboardingStep:
|
|||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
state: str = _DEFAULT_ONBOARDING_STATE
|
||||||
|
required: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OnboardingSummary:
|
||||||
|
"""Aggregate onboarding progress for a single project."""
|
||||||
|
|
||||||
|
total: int
|
||||||
|
complete: int
|
||||||
|
pending: int
|
||||||
|
blocked: int
|
||||||
|
not_applicable: int
|
||||||
|
required_outstanding: int
|
||||||
|
onboarding_complete: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectHealth:
|
||||||
|
"""Redacted last-seen health. Never carries endpoints or credentials."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
checked_at: str | None
|
||||||
|
detail: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -63,6 +131,13 @@ class ProjectRecord:
|
|||||||
workflow_paths: dict[str, str]
|
workflow_paths: dict[str, str]
|
||||||
schema_paths: dict[str, str]
|
schema_paths: dict[str, str]
|
||||||
onboarding_checklist: tuple[OnboardingStep, ...]
|
onboarding_checklist: tuple[OnboardingStep, ...]
|
||||||
|
status: str = _DEFAULT_PROJECT_STATUS
|
||||||
|
remote_name: str | None = None
|
||||||
|
last_seen_health: ProjectHealth | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo_full_name(self) -> str:
|
||||||
|
return f"{self.gitea_owner}/{self.repo_name}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -71,6 +146,15 @@ class ProjectRegistry:
|
|||||||
projects: tuple[ProjectRecord, ...]
|
projects: tuple[ProjectRecord, ...]
|
||||||
source_path: Path
|
source_path: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schema_version(self) -> int:
|
||||||
|
"""Alias of :attr:`version` — the schema version read from disk."""
|
||||||
|
return self.version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_version(self) -> str:
|
||||||
|
return REGISTRY_API_VERSION
|
||||||
|
|
||||||
|
|
||||||
def default_registry_path() -> Path:
|
def default_registry_path() -> Path:
|
||||||
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
|
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
|
||||||
@@ -79,52 +163,221 @@ def default_registry_path() -> Path:
|
|||||||
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
|
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
|
||||||
|
|
||||||
|
|
||||||
def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
|
def _reject_credential_keys(obj: Any, *, path: str = "", source: Path | None = None) -> None:
|
||||||
|
"""Recursive credential-key guard that reports an actionable ``field_path``.
|
||||||
|
|
||||||
|
Key *shape* is decided by :func:`webui.registry_safety.is_forbidden_key`, the
|
||||||
|
single source of truth shared with the worker registry (#798).
|
||||||
|
"""
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
for key, value in obj.items():
|
for key, value in obj.items():
|
||||||
key_path = f"{path}.{key}" if path else key
|
key_path = f"{path}.{key}" if path else key
|
||||||
if _is_forbidden_key(key):
|
if is_forbidden_key(key):
|
||||||
raise ValueError(f"registry must not store credentials ({key_path})")
|
raise RegistryError(
|
||||||
_reject_credential_keys(value, path=key_path)
|
f"registry must not store credentials ({key_path})",
|
||||||
|
remediation=(
|
||||||
|
f"Remove the credential-shaped key '{key_path}' from the registry. "
|
||||||
|
"Tokens live in the keychain and are resolved server-side by "
|
||||||
|
"gitea_auth; the registry is redacted metadata only."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=key_path,
|
||||||
|
)
|
||||||
|
_reject_credential_keys(value, path=key_path, source=source)
|
||||||
elif isinstance(obj, list):
|
elif isinstance(obj, list):
|
||||||
for index, item in enumerate(obj):
|
for index, item in enumerate(obj):
|
||||||
_reject_credential_keys(item, path=f"{path}[{index}]")
|
_reject_credential_keys(item, path=f"{path}[{index}]", source=source)
|
||||||
|
|
||||||
|
|
||||||
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
|
def _require_enum(
|
||||||
if not raw:
|
value: Any,
|
||||||
|
*,
|
||||||
|
allowed: tuple[str, ...],
|
||||||
|
field_path: str,
|
||||||
|
source: Path | None,
|
||||||
|
) -> str:
|
||||||
|
text = str(value)
|
||||||
|
if text not in allowed:
|
||||||
|
raise RegistryError(
|
||||||
|
f"{field_path} must be one of {', '.join(allowed)} (got {text!r})",
|
||||||
|
remediation=(
|
||||||
|
f"Set {field_path} to one of: {', '.join(allowed)}. "
|
||||||
|
"Unknown values fail closed so the console never renders an "
|
||||||
|
"unverified state."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=field_path,
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_onboarding(
|
||||||
|
raw: Any,
|
||||||
|
*,
|
||||||
|
project_path: str,
|
||||||
|
source: Path | None,
|
||||||
|
) -> tuple[OnboardingStep, ...]:
|
||||||
|
if raw is None:
|
||||||
return ()
|
return ()
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise RegistryError(
|
||||||
|
f"{project_path}.onboarding_checklist must be an array",
|
||||||
|
remediation=(
|
||||||
|
f"Rewrite {project_path}.onboarding_checklist as a JSON array of "
|
||||||
|
"steps with id, title, description, and optional state."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=f"{project_path}.onboarding_checklist",
|
||||||
|
)
|
||||||
steps: list[OnboardingStep] = []
|
steps: list[OnboardingStep] = []
|
||||||
for item in raw:
|
for index, item in enumerate(raw):
|
||||||
|
step_path = f"{project_path}.onboarding_checklist[{index}]"
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise RegistryError(
|
||||||
|
f"{step_path} must be an object",
|
||||||
|
remediation=f"Rewrite {step_path} as an object with id, title, description.",
|
||||||
|
source_path=source,
|
||||||
|
field_path=step_path,
|
||||||
|
)
|
||||||
|
missing = [field for field in ("id", "title", "description") if field not in item]
|
||||||
|
if missing:
|
||||||
|
raise RegistryError(
|
||||||
|
f"{step_path} missing required fields: {', '.join(missing)}",
|
||||||
|
remediation=(
|
||||||
|
f"Add {', '.join(missing)} to {step_path}. Every onboarding step "
|
||||||
|
"must be self-describing for an operator who has no chat history."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=step_path,
|
||||||
|
)
|
||||||
|
state = _require_enum(
|
||||||
|
item.get("state", _DEFAULT_ONBOARDING_STATE),
|
||||||
|
allowed=ONBOARDING_STATES,
|
||||||
|
field_path=f"{step_path}.state",
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
steps.append(
|
steps.append(
|
||||||
OnboardingStep(
|
OnboardingStep(
|
||||||
id=str(item["id"]),
|
id=str(item["id"]),
|
||||||
title=str(item["title"]),
|
title=str(item["title"]),
|
||||||
description=str(item["description"]),
|
description=str(item["description"]),
|
||||||
|
state=state,
|
||||||
|
required=bool(item.get("required", True)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tuple(steps)
|
return tuple(steps)
|
||||||
|
|
||||||
|
|
||||||
def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
|
def _parse_health(
|
||||||
|
raw: Any,
|
||||||
|
*,
|
||||||
|
project_path: str,
|
||||||
|
source: Path | None,
|
||||||
|
) -> ProjectHealth | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RegistryError(
|
||||||
|
f"{project_path}.last_seen_health must be an object when present",
|
||||||
|
remediation=(
|
||||||
|
f"Rewrite {project_path}.last_seen_health as an object with status "
|
||||||
|
f"(one of {', '.join(HEALTH_STATUSES)}), optional checked_at and detail, "
|
||||||
|
"or remove it. Never store endpoints or credentials here."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=f"{project_path}.last_seen_health",
|
||||||
|
)
|
||||||
|
status = _require_enum(
|
||||||
|
raw.get("status", "unknown"),
|
||||||
|
allowed=HEALTH_STATUSES,
|
||||||
|
field_path=f"{project_path}.last_seen_health.status",
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
checked_at = raw.get("checked_at")
|
||||||
|
detail = raw.get("detail")
|
||||||
|
return ProjectHealth(
|
||||||
|
status=status,
|
||||||
|
checked_at=str(checked_at) if checked_at is not None else None,
|
||||||
|
detail=str(detail) if detail is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_project(raw: Any, *, index: int, source: Path | None) -> ProjectRecord:
|
||||||
|
project_path = f"projects[{index}]"
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RegistryError(
|
||||||
|
f"{project_path} must be an object",
|
||||||
|
remediation=f"Rewrite {project_path} as a JSON object describing one project.",
|
||||||
|
source_path=source,
|
||||||
|
field_path=project_path,
|
||||||
|
)
|
||||||
|
|
||||||
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
|
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
|
||||||
if missing:
|
if missing:
|
||||||
raise ValueError(f"project missing required fields: {', '.join(missing)}")
|
raise RegistryError(
|
||||||
|
f"{project_path} missing required fields: {', '.join(missing)}",
|
||||||
|
remediation=(
|
||||||
|
f"Add {', '.join(missing)} to {project_path}. See "
|
||||||
|
"docs/webui-project-registry-api.md for the field-by-field contract."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=project_path,
|
||||||
|
)
|
||||||
|
|
||||||
profiles = raw["profiles"]
|
profiles = raw["profiles"]
|
||||||
if not isinstance(profiles, dict):
|
if not isinstance(profiles, dict):
|
||||||
raise ValueError("profiles must be an object")
|
raise RegistryError(
|
||||||
|
f"{project_path}.profiles must be an object",
|
||||||
|
remediation=(
|
||||||
|
f"Rewrite {project_path}.profiles as an object mapping "
|
||||||
|
f"{', '.join(_REQUIRED_PROFILE_ROLES)} to MCP profile names."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=f"{project_path}.profiles",
|
||||||
|
)
|
||||||
for role in _REQUIRED_PROFILE_ROLES:
|
for role in _REQUIRED_PROFILE_ROLES:
|
||||||
if role not in profiles or not profiles[role]:
|
if role not in profiles or not profiles[role]:
|
||||||
raise ValueError(f"profiles.{role} is required")
|
raise RegistryError(
|
||||||
|
f"{project_path}.profiles.{role} is required",
|
||||||
|
remediation=(
|
||||||
|
f"Set {project_path}.profiles.{role} to the configured MCP profile "
|
||||||
|
"name for that role. Role separation is a workflow-safety invariant."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=f"{project_path}.profiles.{role}",
|
||||||
|
)
|
||||||
|
|
||||||
workflow_paths = raw["workflow_paths"]
|
workflow_paths = raw["workflow_paths"]
|
||||||
if not isinstance(workflow_paths, dict) or not workflow_paths:
|
if not isinstance(workflow_paths, dict) or not workflow_paths:
|
||||||
raise ValueError("workflow_paths must be a non-empty object")
|
raise RegistryError(
|
||||||
|
f"{project_path}.workflow_paths must be a non-empty object",
|
||||||
|
remediation=(
|
||||||
|
f"Add at least a 'skill' entry to {project_path}.workflow_paths pointing "
|
||||||
|
"at the project's canonical workflow skill."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=f"{project_path}.workflow_paths",
|
||||||
|
)
|
||||||
|
|
||||||
schema_paths = raw.get("schema_paths") or {}
|
schema_paths = raw.get("schema_paths") or {}
|
||||||
if not isinstance(schema_paths, dict):
|
if not isinstance(schema_paths, dict):
|
||||||
raise ValueError("schema_paths must be an object when present")
|
raise RegistryError(
|
||||||
|
f"{project_path}.schema_paths must be an object when present",
|
||||||
|
remediation=(
|
||||||
|
f"Rewrite {project_path}.schema_paths as an object of label to repo path, "
|
||||||
|
"or remove it."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path=f"{project_path}.schema_paths",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = _require_enum(
|
||||||
|
raw.get("status", _DEFAULT_PROJECT_STATUS),
|
||||||
|
allowed=PROJECT_STATUSES,
|
||||||
|
field_path=f"{project_path}.status",
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
remote_name = raw.get("remote_name")
|
||||||
|
|
||||||
return ProjectRecord(
|
return ProjectRecord(
|
||||||
id=str(raw["id"]),
|
id=str(raw["id"]),
|
||||||
@@ -136,61 +389,211 @@ def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
|
|||||||
profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
|
profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
|
||||||
workflow_paths={key: str(value) for key, value in workflow_paths.items()},
|
workflow_paths={key: str(value) for key, value in workflow_paths.items()},
|
||||||
schema_paths={key: str(value) for key, value in schema_paths.items()},
|
schema_paths={key: str(value) for key, value in schema_paths.items()},
|
||||||
onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
|
onboarding_checklist=_parse_onboarding(
|
||||||
|
raw.get("onboarding_checklist"),
|
||||||
|
project_path=project_path,
|
||||||
|
source=source,
|
||||||
|
),
|
||||||
|
status=status,
|
||||||
|
remote_name=str(remote_name) if remote_name else None,
|
||||||
|
last_seen_health=_parse_health(
|
||||||
|
raw.get("last_seen_health"),
|
||||||
|
project_path=project_path,
|
||||||
|
source=source,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_registry(path: Path | None = None) -> ProjectRegistry:
|
def load_registry(path: Path | None = None) -> ProjectRegistry:
|
||||||
"""Load the versioned project registry from disk."""
|
"""Load the versioned project registry from disk.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RegistryError: whenever the file is unreadable, is not valid JSON, or
|
||||||
|
fails schema validation. The error carries an operator remediation.
|
||||||
|
"""
|
||||||
source = (path or default_registry_path()).resolve()
|
source = (path or default_registry_path()).resolve()
|
||||||
|
try:
|
||||||
raw_text = source.read_text(encoding="utf-8")
|
raw_text = source.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise RegistryError(
|
||||||
|
f"registry file could not be read: {exc.strerror or exc}",
|
||||||
|
remediation=(
|
||||||
|
f"Create a readable registry at {source}, or point "
|
||||||
|
"WEBUI_PROJECT_REGISTRY at an existing file."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
payload = json.loads(raw_text)
|
payload = json.loads(raw_text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RegistryError(
|
||||||
|
f"registry is not valid JSON: {exc.msg} (line {exc.lineno}, column {exc.colno})",
|
||||||
|
remediation=(
|
||||||
|
f"Fix the JSON syntax in {source} at line {exc.lineno}, column {exc.colno}."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
) from exc
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("registry root must be an object")
|
raise RegistryError(
|
||||||
|
"registry root must be an object",
|
||||||
|
remediation=(
|
||||||
|
"Wrap the registry in a JSON object with 'version' and 'projects' keys."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
)
|
||||||
|
|
||||||
version = payload.get("version")
|
version = payload.get("version")
|
||||||
if version != 1:
|
if version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError(f"unsupported registry version: {version!r}")
|
supported = ", ".join(str(item) for item in SUPPORTED_SCHEMA_VERSIONS)
|
||||||
|
raise RegistryError(
|
||||||
|
f"unsupported registry version: {version!r}",
|
||||||
|
remediation=(
|
||||||
|
f"Set 'version' to one of {supported} (current schema is "
|
||||||
|
f"{CURRENT_SCHEMA_VERSION}). Migration notes live in "
|
||||||
|
"docs/webui-project-registry-api.md."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path="version",
|
||||||
|
)
|
||||||
|
|
||||||
_reject_credential_keys(payload)
|
_reject_credential_keys(payload, source=source)
|
||||||
|
|
||||||
projects_raw = payload.get("projects")
|
projects_raw = payload.get("projects")
|
||||||
if not isinstance(projects_raw, list) or not projects_raw:
|
if not isinstance(projects_raw, list) or not projects_raw:
|
||||||
raise ValueError("projects must be a non-empty array")
|
raise RegistryError(
|
||||||
|
"projects must be a non-empty array",
|
||||||
|
remediation=(
|
||||||
|
"Add at least one project object to 'projects'. An empty console "
|
||||||
|
"registry fails closed rather than rendering a blank inventory."
|
||||||
|
),
|
||||||
|
source_path=source,
|
||||||
|
field_path="projects",
|
||||||
|
)
|
||||||
|
|
||||||
projects = tuple(_parse_project(item) for item in projects_raw)
|
projects = tuple(
|
||||||
return ProjectRegistry(version=version, projects=projects, source_path=source)
|
_parse_project(item, index=index, source=source)
|
||||||
|
for index, item in enumerate(projects_raw)
|
||||||
|
)
|
||||||
|
return ProjectRegistry(version=int(version), projects=projects, source_path=source)
|
||||||
|
|
||||||
|
|
||||||
|
def onboarding_summary(project: ProjectRecord) -> OnboardingSummary:
|
||||||
|
"""Aggregate a project's onboarding checklist state."""
|
||||||
|
steps = project.onboarding_checklist
|
||||||
|
counts = {state: 0 for state in ONBOARDING_STATES}
|
||||||
|
for step in steps:
|
||||||
|
counts[step.state] += 1
|
||||||
|
required_outstanding = sum(
|
||||||
|
1
|
||||||
|
for step in steps
|
||||||
|
if step.required and step.state in ("pending", "blocked")
|
||||||
|
)
|
||||||
|
return OnboardingSummary(
|
||||||
|
total=len(steps),
|
||||||
|
complete=counts["complete"],
|
||||||
|
pending=counts["pending"],
|
||||||
|
blocked=counts["blocked"],
|
||||||
|
not_applicable=counts["not_applicable"],
|
||||||
|
required_outstanding=required_outstanding,
|
||||||
|
onboarding_complete=required_outstanding == 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
|
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
|
||||||
"""Serialize a project for JSON API responses."""
|
"""Serialize a project for JSON API responses and HTML views.
|
||||||
|
|
||||||
|
The HTML views render from this same DTO, so the console and the API can
|
||||||
|
never disagree about a project's status or onboarding progress.
|
||||||
|
"""
|
||||||
|
summary = onboarding_summary(project)
|
||||||
|
health = project.last_seen_health
|
||||||
return {
|
return {
|
||||||
"id": project.id,
|
"id": project.id,
|
||||||
"repo_name": project.repo_name,
|
"repo_name": project.repo_name,
|
||||||
"gitea_owner": project.gitea_owner,
|
"gitea_owner": project.gitea_owner,
|
||||||
|
"repo_full_name": project.repo_full_name,
|
||||||
"remote_host": project.remote_host,
|
"remote_host": project.remote_host,
|
||||||
|
"remote_name": project.remote_name,
|
||||||
"default_branch": project.default_branch,
|
"default_branch": project.default_branch,
|
||||||
"local_checkout_path": project.local_checkout_path,
|
"local_checkout_path": project.local_checkout_path,
|
||||||
|
"status": project.status,
|
||||||
"profiles": dict(project.profiles),
|
"profiles": dict(project.profiles),
|
||||||
"workflow_paths": dict(project.workflow_paths),
|
"workflow_paths": dict(project.workflow_paths),
|
||||||
"schema_paths": dict(project.schema_paths),
|
"schema_paths": dict(project.schema_paths),
|
||||||
"onboarding_checklist": [
|
"onboarding_checklist": [
|
||||||
{"id": step.id, "title": step.title, "description": step.description}
|
{
|
||||||
|
"id": step.id,
|
||||||
|
"title": step.title,
|
||||||
|
"description": step.description,
|
||||||
|
"state": step.state,
|
||||||
|
"required": step.required,
|
||||||
|
}
|
||||||
for step in project.onboarding_checklist
|
for step in project.onboarding_checklist
|
||||||
],
|
],
|
||||||
|
"onboarding_summary": {
|
||||||
|
"total": summary.total,
|
||||||
|
"complete": summary.complete,
|
||||||
|
"pending": summary.pending,
|
||||||
|
"blocked": summary.blocked,
|
||||||
|
"not_applicable": summary.not_applicable,
|
||||||
|
"required_outstanding": summary.required_outstanding,
|
||||||
|
"onboarding_complete": summary.onboarding_complete,
|
||||||
|
},
|
||||||
|
"last_seen_health": (
|
||||||
|
None
|
||||||
|
if health is None
|
||||||
|
else {
|
||||||
|
"status": health.status,
|
||||||
|
"checked_at": health.checked_at,
|
||||||
|
"detail": health.detail,
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
|
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
|
||||||
|
"""Serialize the whole registry, including API provenance (#632 section 6)."""
|
||||||
return {
|
return {
|
||||||
|
"api_version": registry.api_version,
|
||||||
|
"schema_version": registry.schema_version,
|
||||||
|
# Retained for the unversioned MVP alias consumers (#427).
|
||||||
"version": registry.version,
|
"version": registry.version,
|
||||||
"source_path": str(registry.source_path),
|
"source_path": str(registry.source_path),
|
||||||
|
"source": {
|
||||||
|
"kind": "file",
|
||||||
|
"path": str(registry.source_path),
|
||||||
|
"inventory_complete": True,
|
||||||
|
},
|
||||||
|
"project_count": len(registry.projects),
|
||||||
"projects": [project_to_dict(project) for project in registry.projects],
|
"projects": [project_to_dict(project) for project in registry.projects],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def project_detail_to_dict(
|
||||||
|
registry: ProjectRegistry,
|
||||||
|
project: ProjectRecord,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Serialize a single project for ``/api/v1/projects/{project_id}``."""
|
||||||
|
return {
|
||||||
|
"api_version": registry.api_version,
|
||||||
|
"schema_version": registry.schema_version,
|
||||||
|
"source": {
|
||||||
|
"kind": "file",
|
||||||
|
"path": str(registry.source_path),
|
||||||
|
"inventory_complete": True,
|
||||||
|
},
|
||||||
|
"project": project_to_dict(project),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
|
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
|
||||||
for project in registry.projects:
|
for project in registry.projects:
|
||||||
if project.id == project_id:
|
if project.id == project_id:
|
||||||
return project
|
return project
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def known_project_ids(registry: ProjectRegistry) -> list[str]:
|
||||||
|
return [project.id for project in registry.projects]
|
||||||
|
|||||||
+107
-25
@@ -1,34 +1,65 @@
|
|||||||
"""HTML views for project registry pages (#427)."""
|
"""HTML views for project registry pages (#427, evolved for #635).
|
||||||
|
|
||||||
|
Every view renders from :func:`webui.project_registry.project_to_dict`, the
|
||||||
|
same DTO the ``/api/v1/projects`` JSON responses use, so the HTML console and
|
||||||
|
the API can never disagree about status or onboarding progress.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
from webui.project_registry import ProjectRecord, ProjectRegistry
|
from webui.project_registry import (
|
||||||
|
ProjectRecord,
|
||||||
|
ProjectRegistry,
|
||||||
|
RegistryError,
|
||||||
|
project_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STATE_LABELS = {
|
||||||
|
"complete": "Complete",
|
||||||
|
"pending": "Pending",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
"not_applicable": "Not applicable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _escape(text: str) -> str:
|
def _escape(text: str) -> str:
|
||||||
return html.escape(text, quote=True)
|
return html.escape(text, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_label(summary: dict[str, Any]) -> str:
|
||||||
|
total = summary["total"]
|
||||||
|
if not total:
|
||||||
|
return "no steps"
|
||||||
|
label = f"{summary['complete']}/{total} complete"
|
||||||
|
if summary["blocked"]:
|
||||||
|
label += f", {summary['blocked']} blocked"
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
def render_projects_list(registry: ProjectRegistry) -> str:
|
def render_projects_list(registry: ProjectRegistry) -> str:
|
||||||
rows = []
|
rows = []
|
||||||
for project in registry.projects:
|
for project in registry.projects:
|
||||||
|
dto = project_to_dict(project)
|
||||||
rows.append(
|
rows.append(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
f"<td><a href=\"/projects/{_escape(project.id)}\">{_escape(project.repo_name)}</a></td>"
|
f"<td><a href=\"/projects/{_escape(dto['id'])}\">{_escape(dto['repo_name'])}</a></td>"
|
||||||
f"<td>{_escape(project.gitea_owner)}</td>"
|
f"<td>{_escape(dto['gitea_owner'])}</td>"
|
||||||
f"<td>{_escape(project.remote_host)}</td>"
|
f"<td>{_escape(dto['remote_host'])}</td>"
|
||||||
f"<td>{_escape(project.default_branch)}</td>"
|
f"<td>{_escape(dto['default_branch'])}</td>"
|
||||||
f"<td><code>{_escape(project.profiles['author'])}</code></td>"
|
f"<td><code>{_escape(dto['status'])}</code></td>"
|
||||||
|
f"<td>{_escape(_progress_label(dto['onboarding_summary']))}</td>"
|
||||||
|
f"<td><code>{_escape(dto['profiles']['author'])}</code></td>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
table = (
|
table = (
|
||||||
"<table class=\"registry\">"
|
"<table class=\"registry\">"
|
||||||
"<thead><tr>"
|
"<thead><tr>"
|
||||||
"<th>Repository</th><th>Owner</th><th>Remote</th>"
|
"<th>Repository</th><th>Owner</th><th>Remote</th>"
|
||||||
"<th>Branch</th><th>Author profile</th>"
|
"<th>Branch</th><th>Status</th><th>Onboarding</th><th>Author profile</th>"
|
||||||
"</tr></thead>"
|
"</tr></thead>"
|
||||||
f"<tbody>{''.join(rows)}</tbody></table>"
|
f"<tbody>{''.join(rows)}</tbody></table>"
|
||||||
)
|
)
|
||||||
@@ -36,32 +67,39 @@ def render_projects_list(registry: ProjectRegistry) -> str:
|
|||||||
"<h2>Projects</h2>"
|
"<h2>Projects</h2>"
|
||||||
"<p>Configured repositories managed by the MCP Control Plane.</p>"
|
"<p>Configured repositories managed by the MCP Control Plane.</p>"
|
||||||
f"<p class=\"meta\">Registry: <code>{_escape(str(registry.source_path))}</code> "
|
f"<p class=\"meta\">Registry: <code>{_escape(str(registry.source_path))}</code> "
|
||||||
f"(version {registry.version})</p>"
|
f"(schema version {registry.schema_version}, "
|
||||||
|
f"API {_escape(registry.api_version)})</p>"
|
||||||
f"{table}"
|
f"{table}"
|
||||||
"<p><a href=\"/api/projects\">JSON API</a></p>"
|
"<p><a href=\"/api/v1/projects\">JSON API</a> "
|
||||||
|
"(<a href=\"/api/projects\">unversioned alias</a>)</p>"
|
||||||
)
|
)
|
||||||
return render_page(title="Projects", body_html=body)
|
return render_page(title="Projects", body_html=body)
|
||||||
|
|
||||||
|
|
||||||
def render_project_detail(project: ProjectRecord) -> str:
|
def render_project_detail(project: ProjectRecord) -> str:
|
||||||
|
dto = project_to_dict(project)
|
||||||
profile_rows = "".join(
|
profile_rows = "".join(
|
||||||
f"<tr><th>{_escape(role)}</th><td><code>{_escape(name)}</code></td></tr>"
|
f"<tr><th>{_escape(role)}</th><td><code>{_escape(name)}</code></td></tr>"
|
||||||
for role, name in project.profiles.items()
|
for role, name in dto["profiles"].items()
|
||||||
)
|
)
|
||||||
workflow_rows = "".join(
|
workflow_rows = "".join(
|
||||||
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
||||||
for key, path in project.workflow_paths.items()
|
for key, path in dto["workflow_paths"].items()
|
||||||
)
|
)
|
||||||
schema_rows = "".join(
|
schema_rows = "".join(
|
||||||
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
|
||||||
for key, path in project.schema_paths.items()
|
for key, path in dto["schema_paths"].items()
|
||||||
)
|
)
|
||||||
checklist_items = []
|
checklist_items = []
|
||||||
for index, step in enumerate(project.onboarding_checklist, start=1):
|
for index, step in enumerate(dto["onboarding_checklist"], start=1):
|
||||||
|
state_label = _STATE_LABELS.get(step["state"], step["state"])
|
||||||
|
requirement = "required" if step["required"] else "optional"
|
||||||
checklist_items.append(
|
checklist_items.append(
|
||||||
"<li>"
|
f"<li class=\"step-{_escape(step['state'])}\">"
|
||||||
f"<strong>{index}. {_escape(step.title)}</strong>"
|
f"<strong>{index}. {_escape(step['title'])}</strong>"
|
||||||
f"<p>{_escape(step.description)}</p>"
|
f" <span class=\"badge\">{_escape(state_label)}</span>"
|
||||||
|
f" <span class=\"meta\">({_escape(requirement)})</span>"
|
||||||
|
f"<p>{_escape(step['description'])}</p>"
|
||||||
"</li>"
|
"</li>"
|
||||||
)
|
)
|
||||||
checklist_html = (
|
checklist_html = (
|
||||||
@@ -69,16 +107,38 @@ def render_project_detail(project: ProjectRecord) -> str:
|
|||||||
if checklist_items
|
if checklist_items
|
||||||
else "<p>No onboarding steps defined.</p>"
|
else "<p>No onboarding steps defined.</p>"
|
||||||
)
|
)
|
||||||
|
summary = dto["onboarding_summary"]
|
||||||
|
summary_html = (
|
||||||
|
"<p class=\"meta\">Onboarding: "
|
||||||
|
f"{_escape(_progress_label(summary))}; required outstanding "
|
||||||
|
f"{summary['required_outstanding']}.</p>"
|
||||||
|
)
|
||||||
|
health = dto["last_seen_health"]
|
||||||
|
health_html = (
|
||||||
|
"<p class=\"meta\">No health probe recorded (Phase 1 is read-only).</p>"
|
||||||
|
if health is None
|
||||||
|
else (
|
||||||
|
"<table class=\"detail\">"
|
||||||
|
f"<tr><th>Status</th><td><code>{_escape(health['status'])}</code></td></tr>"
|
||||||
|
f"<tr><th>Checked at</th><td>{_escape(str(health['checked_at'] or 'unknown'))}</td></tr>"
|
||||||
|
f"<tr><th>Detail</th><td>{_escape(str(health['detail'] or ''))}</td></tr>"
|
||||||
|
"</table>"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
remote_name = dto["remote_name"] or "unset"
|
||||||
body = (
|
body = (
|
||||||
f"<h2>{_escape(project.repo_name)}</h2>"
|
f"<h2>{_escape(dto['repo_name'])}</h2>"
|
||||||
"<p><a href=\"/projects\">← All projects</a></p>"
|
"<p><a href=\"/projects\">← All projects</a></p>"
|
||||||
"<h3>Identity</h3>"
|
"<h3>Identity</h3>"
|
||||||
"<table class=\"detail\">"
|
"<table class=\"detail\">"
|
||||||
f"<tr><th>Registry id</th><td><code>{_escape(project.id)}</code></td></tr>"
|
f"<tr><th>Registry id</th><td><code>{_escape(dto['id'])}</code></td></tr>"
|
||||||
f"<tr><th>Gitea owner</th><td>{_escape(project.gitea_owner)}</td></tr>"
|
f"<tr><th>Status</th><td><code>{_escape(dto['status'])}</code></td></tr>"
|
||||||
f"<tr><th>Remote host</th><td>{_escape(project.remote_host)}</td></tr>"
|
f"<tr><th>Gitea owner</th><td>{_escape(dto['gitea_owner'])}</td></tr>"
|
||||||
f"<tr><th>Default branch</th><td><code>{_escape(project.default_branch)}</code></td></tr>"
|
f"<tr><th>Repository</th><td><code>{_escape(dto['repo_full_name'])}</code></td></tr>"
|
||||||
f"<tr><th>Local checkout</th><td><code>{_escape(project.local_checkout_path)}</code></td></tr>"
|
f"<tr><th>Remote name</th><td><code>{_escape(remote_name)}</code></td></tr>"
|
||||||
|
f"<tr><th>Remote host</th><td>{_escape(dto['remote_host'])}</td></tr>"
|
||||||
|
f"<tr><th>Default branch</th><td><code>{_escape(dto['default_branch'])}</code></td></tr>"
|
||||||
|
f"<tr><th>Local checkout</th><td><code>{_escape(dto['local_checkout_path'])}</code></td></tr>"
|
||||||
"</table>"
|
"</table>"
|
||||||
"<h3>Profiles</h3>"
|
"<h3>Profiles</h3>"
|
||||||
f"<table class=\"detail\">{profile_rows}</table>"
|
f"<table class=\"detail\">{profile_rows}</table>"
|
||||||
@@ -86,8 +146,30 @@ def render_project_detail(project: ProjectRecord) -> str:
|
|||||||
f"<table class=\"detail\">{workflow_rows}</table>"
|
f"<table class=\"detail\">{workflow_rows}</table>"
|
||||||
"<h3>Schema paths</h3>"
|
"<h3>Schema paths</h3>"
|
||||||
f"<table class=\"detail\">{schema_rows}</table>"
|
f"<table class=\"detail\">{schema_rows}</table>"
|
||||||
|
"<h3>Last seen health</h3>"
|
||||||
|
f"{health_html}"
|
||||||
"<h3>Onboarding checklist</h3>"
|
"<h3>Onboarding checklist</h3>"
|
||||||
"<p class=\"meta\">Read-only MVP — complete these steps outside the UI.</p>"
|
"<p class=\"meta\">Read-only — complete these steps outside the UI.</p>"
|
||||||
|
f"{summary_html}"
|
||||||
f"{checklist_html}"
|
f"{checklist_html}"
|
||||||
|
f"<p><a href=\"/api/v1/projects/{_escape(dto['id'])}\">JSON detail</a></p>"
|
||||||
)
|
)
|
||||||
return render_page(title=project.repo_name, body_html=body)
|
return render_page(title=dto["repo_name"], body_html=body)
|
||||||
|
|
||||||
|
|
||||||
|
def render_registry_error(error: RegistryError) -> str:
|
||||||
|
"""Render a fail-closed page for an invalid registry."""
|
||||||
|
source = str(error.source_path) if error.source_path else "unknown"
|
||||||
|
field = error.field_path or "n/a"
|
||||||
|
body = (
|
||||||
|
"<h2>Project registry unavailable</h2>"
|
||||||
|
"<p>The registry failed validation, so the console refuses to render a "
|
||||||
|
"partial inventory.</p>"
|
||||||
|
"<table class=\"detail\">"
|
||||||
|
f"<tr><th>Detail</th><td>{_escape(error.message)}</td></tr>"
|
||||||
|
f"<tr><th>Field</th><td><code>{_escape(field)}</code></td></tr>"
|
||||||
|
f"<tr><th>Source</th><td><code>{_escape(source)}</code></td></tr>"
|
||||||
|
f"<tr><th>Remediation</th><td>{_escape(error.remediation)}</td></tr>"
|
||||||
|
"</table>"
|
||||||
|
)
|
||||||
|
return render_page(title="Project registry unavailable", body_html=body)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Shared credential-rejection guard for web UI registries (#427, #798).
|
||||||
|
|
||||||
|
Registries are operator-editable declarative files that the web UI loads and,
|
||||||
|
for the worker registry, writes back. None of them may ever carry a secret:
|
||||||
|
credentials belong in the keychain and reach worker processes through
|
||||||
|
environment injection, never through a file the browser layer can read.
|
||||||
|
|
||||||
|
The check is structural rather than value-based on purpose. A value scanner has
|
||||||
|
to guess what a secret looks like; a key scanner refuses the *shape* of a
|
||||||
|
credential field, so an operator cannot introduce one by accident and a later
|
||||||
|
loader cannot silently pass one through.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_FORBIDDEN_EXACT_KEYS = frozenset({
|
||||||
|
"token",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"credential",
|
||||||
|
"auth",
|
||||||
|
"api_key",
|
||||||
|
"api-key",
|
||||||
|
})
|
||||||
|
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
||||||
|
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
||||||
|
|
||||||
|
|
||||||
|
def is_forbidden_key(key: str) -> bool:
|
||||||
|
"""Return True when *key* names a credential field."""
|
||||||
|
lowered = key.lower()
|
||||||
|
if lowered in _FORBIDDEN_EXACT_KEYS:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
||||||
|
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_credential_keys(obj: Any, *, path: str = "", subject: str = "registry") -> None:
|
||||||
|
"""Raise ValueError when *obj* carries a credential-shaped key at any depth."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key, value in obj.items():
|
||||||
|
key_path = f"{path}.{key}" if path else key
|
||||||
|
if is_forbidden_key(key):
|
||||||
|
raise ValueError(f"{subject} must not store credentials ({key_path})")
|
||||||
|
reject_credential_keys(value, path=key_path, subject=subject)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for index, item in enumerate(obj):
|
||||||
|
reject_credential_keys(item, path=f"{path}[{index}]", subject=subject)
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
"""Read-only system-health model for the operator console API (#634).
|
||||||
|
|
||||||
|
`/health` answers liveness only. Operators automating readiness checks need a
|
||||||
|
structured view of *why* the control plane is or is not usable: which
|
||||||
|
dependencies answered, how long they took, what version of the code is running,
|
||||||
|
and whether the runtime is stale relative to its remote.
|
||||||
|
|
||||||
|
Three rules shape this module.
|
||||||
|
|
||||||
|
* **Read-only.** Every probe opens its subject read-only. The control-plane
|
||||||
|
database is opened through a ``mode=ro`` URI so a health check can never
|
||||||
|
create or migrate a schema, and no probe writes, restarts, or reloads
|
||||||
|
anything — restart controls are Phase 2, and #630 forbids process-kill
|
||||||
|
recovery outright.
|
||||||
|
* **Fail-soft.** A dependency that is unreachable is a *status*, not an
|
||||||
|
exception. Probes catch their own failures and report them as a degraded or
|
||||||
|
down entry carrying a reason.
|
||||||
|
* **Never claim more than was proven.** Readiness is derived only from probes
|
||||||
|
that actually ran, ``mutation_safe`` stays false unless the parity commits are
|
||||||
|
known and equal, and an MCP namespace is reported unproven because a web
|
||||||
|
process cannot exercise the IDE-managed client path (#543).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import control_plane_db
|
||||||
|
import mcp_namespace_health
|
||||||
|
from gitea_auth import api_request, get_auth_header, gitea_url
|
||||||
|
|
||||||
|
from webui.project_registry import load_registry
|
||||||
|
|
||||||
|
SERVICE_NAME = "mcp-control-plane-webui"
|
||||||
|
API_PATH = "/api/v1/system/health"
|
||||||
|
|
||||||
|
STATUS_OK = "ok"
|
||||||
|
STATUS_DEGRADED = "degraded"
|
||||||
|
STATUS_DOWN = "down"
|
||||||
|
STATUS_SKIPPED = "skipped"
|
||||||
|
STATUS_UNPROVEN = "unproven"
|
||||||
|
|
||||||
|
# Statuses that count as a healthy answer from a probe.
|
||||||
|
_HEALTHY_STATUSES = frozenset({STATUS_OK})
|
||||||
|
# Statuses meaning "this probe did not run", as opposed to "it ran and failed".
|
||||||
|
_NOT_RUN_STATUSES = frozenset({STATUS_SKIPPED})
|
||||||
|
|
||||||
|
_DEEP_PROBE_TTL_ENV = "WEBUI_HEALTH_PROBE_TTL_SECONDS"
|
||||||
|
_DEFAULT_DEEP_PROBE_TTL = 15.0
|
||||||
|
_GITEA_PROBE_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
_OFFLINE_ENV = "WEBUI_TEST_OFFLINE"
|
||||||
|
|
||||||
|
# Credential-shaped material that must never reach the browser, mirroring the
|
||||||
|
# forbidden client patterns in webui/deployment_boundary.py.
|
||||||
|
_SECRET_RE = re.compile(
|
||||||
|
r"(?i)\b(token|password|passwd|secret|authorization|bearer)\b\s*[:=]?\s*\S+"
|
||||||
|
)
|
||||||
|
_LONG_OPAQUE_RE = re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")
|
||||||
|
|
||||||
|
# Captured once at import so uptime measures this process, not the request.
|
||||||
|
_STARTED_AT = datetime.now(timezone.utc)
|
||||||
|
_STARTED_MONOTONIC = time.monotonic()
|
||||||
|
|
||||||
|
# TTL cache for the expensive (network) probe only.
|
||||||
|
_deep_cache: dict[str, tuple[float, "DependencyProbe"]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DependencyProbe:
|
||||||
|
"""One dependency check, fail-soft, with its own latency."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
status: str
|
||||||
|
detail: str
|
||||||
|
required: bool
|
||||||
|
latency_ms: float | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def healthy(self) -> bool:
|
||||||
|
return self.status in _HEALTHY_STATUSES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ran(self) -> bool:
|
||||||
|
return self.status not in _NOT_RUN_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VersionInfo:
|
||||||
|
git_sha: str | None
|
||||||
|
git_describe: str | None
|
||||||
|
control_plane_schema_version: int | None
|
||||||
|
python_version: str
|
||||||
|
known: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StaleRuntime:
|
||||||
|
"""Parity between the running code, the checkout, and the remote.
|
||||||
|
|
||||||
|
``mutation_safe`` is deliberately conservative: unknown is not safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
daemon_head: str | None
|
||||||
|
checkout_head: str | None
|
||||||
|
remote_head: str | None
|
||||||
|
stale: bool
|
||||||
|
determinable: bool
|
||||||
|
mutation_safe: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemHealthSnapshot:
|
||||||
|
status: str
|
||||||
|
ready: bool
|
||||||
|
readiness_complete: bool
|
||||||
|
readiness_reasons: tuple[str, ...]
|
||||||
|
service: str
|
||||||
|
mode: str
|
||||||
|
version: VersionInfo
|
||||||
|
started_at: str
|
||||||
|
uptime_seconds: float
|
||||||
|
timestamp: str
|
||||||
|
deep_probes_requested: bool
|
||||||
|
dependencies: tuple[DependencyProbe, ...]
|
||||||
|
mcp_namespaces: tuple[dict[str, Any], ...]
|
||||||
|
stale_runtime: StaleRuntime
|
||||||
|
probe_errors: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def process_uptime() -> tuple[str, float]:
|
||||||
|
"""Process start timestamp and uptime — in-memory, safe for `/health`."""
|
||||||
|
return _STARTED_AT.isoformat(), round(time.monotonic() - _STARTED_MONOTONIC, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _offline() -> bool:
|
||||||
|
return (os.environ.get(_OFFLINE_ENV) or "").strip().lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root() -> Path:
|
||||||
|
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).resolve()
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_probe_ttl() -> float:
|
||||||
|
raw = (os.environ.get(_DEEP_PROBE_TTL_ENV) or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return _DEFAULT_DEEP_PROBE_TTL
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return _DEFAULT_DEEP_PROBE_TTL
|
||||||
|
return value if value >= 0 else _DEFAULT_DEEP_PROBE_TTL
|
||||||
|
|
||||||
|
|
||||||
|
def redact(text: str) -> str:
|
||||||
|
"""Strip credential-shaped material from operator-visible probe text.
|
||||||
|
|
||||||
|
Probe details carry exception strings, and an exception raised by an HTTP
|
||||||
|
client can quote the request that failed. Redaction happens here, at the
|
||||||
|
boundary where those strings become part of a browser-bound payload.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
cleaned = _redact_urls(text)
|
||||||
|
cleaned = _SECRET_RE.sub(lambda m: f"{m.group(1)}=[redacted]", cleaned)
|
||||||
|
return _LONG_OPAQUE_RE.sub("[redacted]", cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_urls(text: str) -> str:
|
||||||
|
return re.sub(r"https?://\S+", lambda m: redact_url(m.group(0)), text)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url(url: str) -> str:
|
||||||
|
"""Reduce a URL to scheme://host/path — no userinfo, no query, no fragment."""
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
except ValueError:
|
||||||
|
return "[redacted-url]"
|
||||||
|
if not parts.scheme or not parts.hostname:
|
||||||
|
return "[redacted-url]"
|
||||||
|
netloc = parts.hostname
|
||||||
|
if parts.port:
|
||||||
|
netloc = f"{netloc}:{parts.port}"
|
||||||
|
return urlunsplit((parts.scheme, netloc, parts.path, "", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _git(repo: Path, *args: str) -> str | None:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["git", "-C", str(repo), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (completed.stdout or "").strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_version(repo: Path, *, schema_version: int | None) -> VersionInfo:
|
||||||
|
import platform
|
||||||
|
|
||||||
|
git_sha = None if _offline() else _git(repo, "rev-parse", "HEAD")
|
||||||
|
describe = None if _offline() else _git(repo, "describe", "--tags", "--always")
|
||||||
|
return VersionInfo(
|
||||||
|
git_sha=git_sha,
|
||||||
|
git_describe=describe,
|
||||||
|
control_plane_schema_version=schema_version,
|
||||||
|
python_version=platform.python_version(),
|
||||||
|
known=bool(git_sha),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dependency probes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_ms(started: float) -> float:
|
||||||
|
return round((time.monotonic() - started) * 1000, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_control_plane_db(db_path: str | None = None) -> DependencyProbe:
|
||||||
|
"""Read-only reachability check for the control-plane SQLite substrate.
|
||||||
|
|
||||||
|
Opened through a ``mode=ro`` URI on purpose: ``ControlPlaneDB.__init__``
|
||||||
|
creates directories and runs schema migrations, which a health check must
|
||||||
|
never do.
|
||||||
|
"""
|
||||||
|
path = (db_path or control_plane_db.default_db_path()).strip()
|
||||||
|
started = time.monotonic()
|
||||||
|
metadata: dict[str, Any] = {"path": path}
|
||||||
|
|
||||||
|
def _result(status: str, detail: str) -> DependencyProbe:
|
||||||
|
return DependencyProbe(
|
||||||
|
name="control_plane_db",
|
||||||
|
kind="sqlite",
|
||||||
|
status=status,
|
||||||
|
detail=detail,
|
||||||
|
required=True,
|
||||||
|
latency_ms=_elapsed_ms(started),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not path or not os.path.exists(path):
|
||||||
|
return _result(STATUS_DOWN, "control-plane database file does not exist yet")
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM schema_meta WHERE key = 'schema_version'"
|
||||||
|
).fetchone()
|
||||||
|
leases = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM leases WHERE status = 'active'"
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
return _result(STATUS_DOWN, redact(f"control-plane database unreadable: {exc}"))
|
||||||
|
|
||||||
|
schema_version = int(row[0]) if row and str(row[0]).isdigit() else None
|
||||||
|
metadata["schema_version"] = schema_version
|
||||||
|
metadata["active_leases"] = int(leases[0]) if leases else None
|
||||||
|
if schema_version is None:
|
||||||
|
return _result(
|
||||||
|
STATUS_DEGRADED, "control-plane database has no recorded schema version"
|
||||||
|
)
|
||||||
|
if schema_version != control_plane_db.SCHEMA_VERSION:
|
||||||
|
return _result(
|
||||||
|
STATUS_DEGRADED,
|
||||||
|
f"control-plane schema version {schema_version} does not match the "
|
||||||
|
f"version this code expects ({control_plane_db.SCHEMA_VERSION})",
|
||||||
|
)
|
||||||
|
return _result(STATUS_OK, f"schema v{schema_version} readable")
|
||||||
|
|
||||||
|
|
||||||
|
def probe_repository(repo: Path) -> DependencyProbe:
|
||||||
|
"""Local checkout reachability — required, cheap, no network."""
|
||||||
|
started = time.monotonic()
|
||||||
|
metadata: dict[str, Any] = {"repo_root": str(repo)}
|
||||||
|
if _offline():
|
||||||
|
return DependencyProbe(
|
||||||
|
name="repository",
|
||||||
|
kind="git",
|
||||||
|
status=STATUS_SKIPPED,
|
||||||
|
detail=f"{_OFFLINE_ENV} is set; git probe skipped",
|
||||||
|
required=True,
|
||||||
|
latency_ms=_elapsed_ms(started),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
head = _git(repo, "rev-parse", "HEAD")
|
||||||
|
if not head:
|
||||||
|
return DependencyProbe(
|
||||||
|
name="repository",
|
||||||
|
kind="git",
|
||||||
|
status=STATUS_DOWN,
|
||||||
|
detail=f"HEAD could not be read at {repo}",
|
||||||
|
required=True,
|
||||||
|
latency_ms=_elapsed_ms(started),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
branch = _git(repo, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
metadata["head"] = head
|
||||||
|
metadata["branch"] = branch
|
||||||
|
return DependencyProbe(
|
||||||
|
name="repository",
|
||||||
|
kind="git",
|
||||||
|
status=STATUS_OK,
|
||||||
|
detail=f"checkout readable at {branch or 'detached HEAD'}",
|
||||||
|
required=True,
|
||||||
|
latency_ms=_elapsed_ms(started),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def probe_gitea(host: str) -> DependencyProbe:
|
||||||
|
"""Live Gitea reachability. Expensive (network), so opt-in via ``deep``.
|
||||||
|
|
||||||
|
Optional by design: the console stays useful for local inventory when the
|
||||||
|
remote is unreachable, so a failure here degrades status without claiming
|
||||||
|
the process itself is unready.
|
||||||
|
"""
|
||||||
|
started = time.monotonic()
|
||||||
|
metadata: dict[str, Any] = {"host": host}
|
||||||
|
|
||||||
|
def _failure(status: str, detail: str) -> DependencyProbe:
|
||||||
|
return DependencyProbe(
|
||||||
|
name="gitea",
|
||||||
|
kind="http",
|
||||||
|
status=status,
|
||||||
|
detail=detail,
|
||||||
|
required=False,
|
||||||
|
latency_ms=_elapsed_ms(started),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
return _failure(STATUS_DEGRADED, "no Gitea host is configured in the registry")
|
||||||
|
try:
|
||||||
|
auth = get_auth_header(host)
|
||||||
|
except Exception as exc: # noqa: BLE001 — credential guards are a status here
|
||||||
|
return _failure(STATUS_DEGRADED, redact(f"credential lookup refused: {exc}"))
|
||||||
|
if not auth:
|
||||||
|
return _failure(STATUS_DEGRADED, f"no credentials available for {host}")
|
||||||
|
|
||||||
|
url = gitea_url(host, "/api/v1/version")
|
||||||
|
metadata["endpoint"] = redact_url(url)
|
||||||
|
try:
|
||||||
|
data = api_request("GET", url, auth, timeout=_GITEA_PROBE_TIMEOUT_SECONDS)
|
||||||
|
except Exception as exc: # noqa: BLE001 — a down dependency is a status
|
||||||
|
return _failure(STATUS_DOWN, redact(f"Gitea probe failed: {exc}"))
|
||||||
|
|
||||||
|
if isinstance(data, dict) and data.get("version"):
|
||||||
|
metadata["gitea_version"] = str(data["version"])
|
||||||
|
return DependencyProbe(
|
||||||
|
name="gitea",
|
||||||
|
kind="http",
|
||||||
|
status=STATUS_OK,
|
||||||
|
detail=f"{host} reachable",
|
||||||
|
required=False,
|
||||||
|
latency_ms=_elapsed_ms(started),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _skipped_gitea(host: str) -> DependencyProbe:
|
||||||
|
return DependencyProbe(
|
||||||
|
name="gitea",
|
||||||
|
kind="http",
|
||||||
|
status=STATUS_SKIPPED,
|
||||||
|
detail="network probe not requested; call with ?deep=1 to run it",
|
||||||
|
required=False,
|
||||||
|
latency_ms=None,
|
||||||
|
metadata={"host": host},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def namespace_summaries() -> tuple[dict[str, Any], ...]:
|
||||||
|
"""Declared MCP namespaces, each honestly reported as unproven.
|
||||||
|
|
||||||
|
The web process runs outside the IDE-managed MCP client, so it cannot
|
||||||
|
invoke a namespace tool. Per #543 only a ``client_namespace`` probe proves
|
||||||
|
that path, and inventing a healthy verdict here is exactly the false claim
|
||||||
|
the mutation gates exist to prevent.
|
||||||
|
"""
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for namespace, required_tool in sorted(
|
||||||
|
mcp_namespace_health.REQUIRED_NAMESPACE_TOOLS.items()
|
||||||
|
):
|
||||||
|
classification = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
namespace,
|
||||||
|
required_tool=required_tool,
|
||||||
|
probe_result=None,
|
||||||
|
probe_source=mcp_namespace_health.PROBE_SOURCE_UNKNOWN,
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"namespace": namespace,
|
||||||
|
"required_tool": required_tool,
|
||||||
|
"status": STATUS_UNPROVEN,
|
||||||
|
"ide_namespace_proven": bool(classification.get("ide_namespace_proven")),
|
||||||
|
"reason": (
|
||||||
|
"the web console cannot invoke the IDE-managed MCP client; "
|
||||||
|
"namespace health must be proven with a client_namespace "
|
||||||
|
"probe (#543)"
|
||||||
|
),
|
||||||
|
"error_type": classification.get("error_type"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def assess_stale_runtime(
|
||||||
|
repo: Path,
|
||||||
|
*,
|
||||||
|
daemon_head: str | None = None,
|
||||||
|
git_reader: Callable[..., str | None] | None = None,
|
||||||
|
) -> StaleRuntime:
|
||||||
|
"""Three-way parity view: running code, local checkout, remote-tracking ref.
|
||||||
|
|
||||||
|
``mutation_safe`` requires all three to be known and equal. Anything less —
|
||||||
|
including "the remote ref was never fetched" — is reported as not safe with
|
||||||
|
a reason, so an operator never reads an unproven green.
|
||||||
|
"""
|
||||||
|
reader = git_reader or (lambda *args: _git(repo, *args))
|
||||||
|
reasons: list[str] = []
|
||||||
|
# The offline switch suppresses real subprocess calls; an explicitly
|
||||||
|
# injected reader is already a substitute for them and is always used.
|
||||||
|
offline = _offline() and git_reader is None
|
||||||
|
|
||||||
|
checkout_head = None if offline else reader("rev-parse", "HEAD")
|
||||||
|
remote_head = None if offline else reader("rev-parse", "@{upstream}")
|
||||||
|
if offline:
|
||||||
|
reasons.append(f"{_OFFLINE_ENV} is set; parity commits were not read")
|
||||||
|
else:
|
||||||
|
if checkout_head is None:
|
||||||
|
reasons.append("local checkout HEAD could not be read")
|
||||||
|
if remote_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"no remote-tracking commit is known for the current branch; "
|
||||||
|
"remote staleness is indeterminate (no fetch is performed here)"
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_daemon = daemon_head if daemon_head is not None else checkout_head
|
||||||
|
if daemon_head is None:
|
||||||
|
reasons.append(
|
||||||
|
"the running MCP daemon's startup commit is not observable from the "
|
||||||
|
"web process; the checkout commit is reported in its place"
|
||||||
|
)
|
||||||
|
|
||||||
|
determinable = bool(checkout_head and remote_head and effective_daemon)
|
||||||
|
stale = bool(
|
||||||
|
determinable and len({checkout_head, remote_head, effective_daemon}) > 1
|
||||||
|
)
|
||||||
|
if stale:
|
||||||
|
reasons.append(
|
||||||
|
"runtime, checkout, and remote commits disagree; restart the MCP "
|
||||||
|
"server after updating the checkout before trusting capability gates"
|
||||||
|
)
|
||||||
|
|
||||||
|
return StaleRuntime(
|
||||||
|
daemon_head=effective_daemon,
|
||||||
|
checkout_head=checkout_head,
|
||||||
|
remote_head=remote_head,
|
||||||
|
stale=stale,
|
||||||
|
determinable=determinable,
|
||||||
|
mutation_safe=bool(determinable and not stale),
|
||||||
|
reasons=tuple(reasons),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snapshot assembly
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_host() -> str:
|
||||||
|
registry = load_registry()
|
||||||
|
if not registry.projects:
|
||||||
|
return ""
|
||||||
|
raw = registry.projects[0].remote_host
|
||||||
|
parts = urlsplit(raw.strip())
|
||||||
|
return parts.netloc or raw.strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate(
|
||||||
|
probes: tuple[DependencyProbe, ...],
|
||||||
|
) -> tuple[str, bool, bool, tuple[str, ...]]:
|
||||||
|
"""Fold probe results into overall status and readiness.
|
||||||
|
|
||||||
|
Required probes drive readiness; optional probes can only degrade status.
|
||||||
|
A probe that did not run leaves readiness incomplete rather than passing.
|
||||||
|
"""
|
||||||
|
reasons: list[str] = []
|
||||||
|
required = [probe for probe in probes if probe.required]
|
||||||
|
unrun_required = [probe for probe in required if not probe.ran]
|
||||||
|
failed_required = [probe for probe in required if probe.ran and not probe.healthy]
|
||||||
|
failed_optional = [
|
||||||
|
probe
|
||||||
|
for probe in probes
|
||||||
|
if not probe.required and probe.ran and not probe.healthy
|
||||||
|
]
|
||||||
|
|
||||||
|
for probe in unrun_required:
|
||||||
|
reasons.append(
|
||||||
|
f"required dependency '{probe.name}' was not probed: {probe.detail}"
|
||||||
|
)
|
||||||
|
for probe in failed_required:
|
||||||
|
reasons.append(
|
||||||
|
f"required dependency '{probe.name}' is {probe.status}: {probe.detail}"
|
||||||
|
)
|
||||||
|
for probe in failed_optional:
|
||||||
|
reasons.append(
|
||||||
|
f"optional dependency '{probe.name}' is {probe.status}: {probe.detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
readiness_complete = not unrun_required
|
||||||
|
ready = readiness_complete and not failed_required
|
||||||
|
|
||||||
|
if any(probe.status == STATUS_DOWN for probe in failed_required):
|
||||||
|
status = STATUS_DOWN
|
||||||
|
elif failed_required or failed_optional or unrun_required:
|
||||||
|
status = STATUS_DEGRADED
|
||||||
|
else:
|
||||||
|
status = STATUS_OK
|
||||||
|
return status, ready, readiness_complete, tuple(reasons)
|
||||||
|
|
||||||
|
|
||||||
|
def load_system_health(
|
||||||
|
*,
|
||||||
|
deep: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
|
probes: tuple[DependencyProbe, ...] | None = None,
|
||||||
|
daemon_head: str | None = None,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> SystemHealthSnapshot:
|
||||||
|
"""Assemble the read-only system-health snapshot.
|
||||||
|
|
||||||
|
``deep=True`` adds the network probe against Gitea; its result is cached for
|
||||||
|
a short TTL so repeated dashboard polls do not amplify into remote load.
|
||||||
|
"""
|
||||||
|
repo = _repo_root()
|
||||||
|
probe_errors: list[str] = []
|
||||||
|
|
||||||
|
if probes is None:
|
||||||
|
collected: list[DependencyProbe] = []
|
||||||
|
for probe_fn in (
|
||||||
|
lambda: probe_control_plane_db(),
|
||||||
|
lambda: probe_repository(repo),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
collected.append(probe_fn())
|
||||||
|
except Exception as exc: # noqa: BLE001 — a probe must not 500 the API
|
||||||
|
probe_errors.append(redact(f"probe raised: {exc}"))
|
||||||
|
resolved_host = host if host is not None else _default_host()
|
||||||
|
if deep and not _offline():
|
||||||
|
collected.append(_cached_gitea_probe(resolved_host, use_cache=use_cache))
|
||||||
|
else:
|
||||||
|
collected.append(_skipped_gitea(resolved_host))
|
||||||
|
probes = tuple(collected)
|
||||||
|
|
||||||
|
status, ready, readiness_complete, reasons = _aggregate(probes)
|
||||||
|
stale = assess_stale_runtime(repo, daemon_head=daemon_head)
|
||||||
|
if stale.stale:
|
||||||
|
if status == STATUS_OK:
|
||||||
|
status = STATUS_DEGRADED
|
||||||
|
reasons = reasons + (
|
||||||
|
"runtime is stale relative to its remote-tracking commit",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_probe = next((p for p in probes if p.name == "control_plane_db"), None)
|
||||||
|
schema_version = None
|
||||||
|
if db_probe and db_probe.metadata:
|
||||||
|
schema_version = db_probe.metadata.get("schema_version")
|
||||||
|
|
||||||
|
return SystemHealthSnapshot(
|
||||||
|
status=status,
|
||||||
|
ready=ready,
|
||||||
|
readiness_complete=readiness_complete,
|
||||||
|
readiness_reasons=reasons,
|
||||||
|
service=SERVICE_NAME,
|
||||||
|
mode="read-only",
|
||||||
|
version=_load_version(repo, schema_version=schema_version),
|
||||||
|
started_at=_STARTED_AT.isoformat(),
|
||||||
|
uptime_seconds=round(time.monotonic() - _STARTED_MONOTONIC, 3),
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
deep_probes_requested=deep,
|
||||||
|
dependencies=probes,
|
||||||
|
mcp_namespaces=namespace_summaries(),
|
||||||
|
stale_runtime=stale,
|
||||||
|
probe_errors=tuple(probe_errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_gitea_probe(host: str, *, use_cache: bool = True) -> DependencyProbe:
|
||||||
|
ttl = _deep_probe_ttl()
|
||||||
|
now = time.monotonic()
|
||||||
|
if use_cache and ttl > 0:
|
||||||
|
cached = _deep_cache.get(host)
|
||||||
|
if cached and (now - cached[0]) < ttl:
|
||||||
|
return cached[1]
|
||||||
|
probe = probe_gitea(host)
|
||||||
|
if use_cache and ttl > 0:
|
||||||
|
_deep_cache[host] = (now, probe)
|
||||||
|
return probe
|
||||||
|
|
||||||
|
|
||||||
|
def clear_probe_cache() -> None:
|
||||||
|
"""Drop cached deep-probe results (tests and operator-forced refresh)."""
|
||||||
|
_deep_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def probe_to_dict(probe: DependencyProbe) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": probe.name,
|
||||||
|
"kind": probe.kind,
|
||||||
|
"status": probe.status,
|
||||||
|
"detail": probe.detail,
|
||||||
|
"required": probe.required,
|
||||||
|
"healthy": probe.healthy,
|
||||||
|
"latency_ms": probe.latency_ms,
|
||||||
|
"metadata": dict(probe.metadata or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_to_dict(snapshot: SystemHealthSnapshot) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": snapshot.status,
|
||||||
|
"service": snapshot.service,
|
||||||
|
"mode": snapshot.mode,
|
||||||
|
"api": API_PATH,
|
||||||
|
"timestamp": snapshot.timestamp,
|
||||||
|
"readiness": {
|
||||||
|
"ready": snapshot.ready,
|
||||||
|
"complete": snapshot.readiness_complete,
|
||||||
|
"reasons": list(snapshot.readiness_reasons),
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"git_sha": snapshot.version.git_sha,
|
||||||
|
"git_describe": snapshot.version.git_describe,
|
||||||
|
"control_plane_schema_version": (
|
||||||
|
snapshot.version.control_plane_schema_version
|
||||||
|
),
|
||||||
|
"python_version": snapshot.version.python_version,
|
||||||
|
"known": snapshot.version.known,
|
||||||
|
},
|
||||||
|
"process": {
|
||||||
|
"started_at": snapshot.started_at,
|
||||||
|
"uptime_seconds": snapshot.uptime_seconds,
|
||||||
|
},
|
||||||
|
"deep_probes_requested": snapshot.deep_probes_requested,
|
||||||
|
"dependencies": [probe_to_dict(probe) for probe in snapshot.dependencies],
|
||||||
|
"mcp_namespaces": [dict(row) for row in snapshot.mcp_namespaces],
|
||||||
|
"stale_runtime": {
|
||||||
|
"daemon_head": snapshot.stale_runtime.daemon_head,
|
||||||
|
"checkout_head": snapshot.stale_runtime.checkout_head,
|
||||||
|
"remote_head": snapshot.stale_runtime.remote_head,
|
||||||
|
"stale": snapshot.stale_runtime.stale,
|
||||||
|
"determinable": snapshot.stale_runtime.determinable,
|
||||||
|
"mutation_safe": snapshot.stale_runtime.mutation_safe,
|
||||||
|
"reasons": list(snapshot.stale_runtime.reasons),
|
||||||
|
},
|
||||||
|
"probe_errors": list(snapshot.probe_errors),
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"""Declarative worker registry and configuration schema (#798, epic #797).
|
||||||
|
|
||||||
|
The registry is the single source of truth for the scheduled multi-LLM worker
|
||||||
|
fleet. It is a versioned JSON document holding two *separate* entity kinds:
|
||||||
|
|
||||||
|
* **Providers** — the LLM runtimes a worker can be built on (Claude, Grok,
|
||||||
|
Codex, AGY, Kimi K). A provider describes the runtime itself: vendor,
|
||||||
|
executable name, models it can serve, and whether it is available on this
|
||||||
|
machine. Providers exist whether or not any worker uses them.
|
||||||
|
* **Workers** — a configured *instance*: one provider, one model, one project,
|
||||||
|
one role, one MCP namespace/profile, one workflow, one schedule. Several
|
||||||
|
workers may share a provider; a worker naming an undeclared provider is
|
||||||
|
refused.
|
||||||
|
|
||||||
|
Keeping them separate is what lets #799 list all five providers even when a
|
||||||
|
provider currently has no configured worker, and it stops provider facts from
|
||||||
|
being copied into (and drifting across) every worker record.
|
||||||
|
|
||||||
|
Scope boundary. This module owns the data model, its validation, and its
|
||||||
|
persistence. It does **not** schedule anything, launch anything, probe provider
|
||||||
|
executables, or serve HTTP. Loading a registry never touches a process; the
|
||||||
|
live fields a dashboard wants (PID, elapsed time, next run) are derived
|
||||||
|
elsewhere (#799, #801, #803, #804) from these declarations.
|
||||||
|
|
||||||
|
Safety invariants:
|
||||||
|
|
||||||
|
* No credential may be stored (:mod:`webui.registry_safety`), so the registry
|
||||||
|
stays safe to render and to hand to a browser layer.
|
||||||
|
* Validation fails closed. Unknown fields are refused rather than ignored, so a
|
||||||
|
typo cannot silently disable a timeout or a role binding.
|
||||||
|
* Writes are atomic and every superseded document is retained as a numbered
|
||||||
|
revision, so a bad edit is recoverable by rollback rather than hand-repair.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from webui.registry_safety import reject_credential_keys
|
||||||
|
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
#: Roles a worker may hold. These mirror the sanctioned MCP role kinds; a
|
||||||
|
#: worker may not invent one, because the role selects the namespace/profile
|
||||||
|
#: whose capability gates constrain it.
|
||||||
|
ALLOWED_ROLES = ("author", "reviewer", "merger", "reconciler", "cleanup")
|
||||||
|
|
||||||
|
#: Scheduler backends the registry can describe. ``manual`` means the worker is
|
||||||
|
#: only ever started on request and has no recurring trigger.
|
||||||
|
ALLOWED_SCHEDULER_KINDS = ("launchd", "manual")
|
||||||
|
|
||||||
|
#: Schedule kinds. Next-run computation belongs to #803; this module only
|
||||||
|
#: guarantees the declaration is well formed.
|
||||||
|
ALLOWED_SCHEDULE_KINDS = ("interval", "cron", "manual")
|
||||||
|
|
||||||
|
_REQUIRED_PROVIDER_FIELDS = ("id", "display_name", "vendor", "executable", "available")
|
||||||
|
_OPTIONAL_PROVIDER_FIELDS = ("models", "notes")
|
||||||
|
|
||||||
|
_REQUIRED_WORKER_FIELDS = (
|
||||||
|
"id",
|
||||||
|
"display_name",
|
||||||
|
"provider",
|
||||||
|
"model",
|
||||||
|
"project",
|
||||||
|
"role",
|
||||||
|
"namespace",
|
||||||
|
"profile",
|
||||||
|
"workflow",
|
||||||
|
"schedule",
|
||||||
|
"timeout_seconds",
|
||||||
|
"enabled",
|
||||||
|
"scheduler",
|
||||||
|
)
|
||||||
|
_OPTIONAL_WORKER_FIELDS = ("notes",)
|
||||||
|
|
||||||
|
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||||
|
|
||||||
|
#: Guards against an operator writing a timeout that would let a worker hold a
|
||||||
|
#: lease effectively forever. 24h is far above any sanctioned cycle.
|
||||||
|
_MAX_TIMEOUT_SECONDS = 86_400
|
||||||
|
|
||||||
|
#: How many superseded revisions to retain beside the live file.
|
||||||
|
_HISTORY_LIMIT = 20
|
||||||
|
|
||||||
|
_TOP_LEVEL_FIELDS = frozenset({"version", "revision", "updated_at", "providers", "workers"})
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryValidationError(ValueError):
|
||||||
|
"""Raised when a registry document violates the schema."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProviderRecord:
|
||||||
|
id: str
|
||||||
|
display_name: str
|
||||||
|
vendor: str
|
||||||
|
executable: str
|
||||||
|
available: bool
|
||||||
|
models: tuple[str, ...]
|
||||||
|
notes: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScheduleSpec:
|
||||||
|
kind: str
|
||||||
|
#: Set for ``interval`` schedules.
|
||||||
|
seconds: int | None
|
||||||
|
#: Set for ``cron`` schedules — a five-field crontab expression.
|
||||||
|
expression: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SchedulerSpec:
|
||||||
|
kind: str
|
||||||
|
#: LaunchAgent label; required for ``launchd``, absent for ``manual``.
|
||||||
|
label: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerRecord:
|
||||||
|
id: str
|
||||||
|
display_name: str
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
project: str
|
||||||
|
role: str
|
||||||
|
namespace: str
|
||||||
|
profile: str
|
||||||
|
workflow: str
|
||||||
|
schedule: ScheduleSpec
|
||||||
|
timeout_seconds: int
|
||||||
|
enabled: bool
|
||||||
|
scheduler: SchedulerSpec
|
||||||
|
notes: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkerRegistry:
|
||||||
|
version: int
|
||||||
|
revision: int
|
||||||
|
updated_at: str
|
||||||
|
providers: tuple[ProviderRecord, ...]
|
||||||
|
workers: tuple[WorkerRecord, ...]
|
||||||
|
source_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
# ── paths ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def default_registry_path() -> Path:
|
||||||
|
"""Location of the packaged worker registry, overridable for tests/deploys."""
|
||||||
|
override = os.environ.get("WEBUI_WORKER_REGISTRY", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).expanduser().resolve()
|
||||||
|
return (Path(__file__).resolve().parent / "data" / "workers.registry.json").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def history_dir(path: Path | None = None) -> Path:
|
||||||
|
"""Directory holding superseded revisions of *path*."""
|
||||||
|
source = (path or default_registry_path()).resolve()
|
||||||
|
return source.parent / f"{source.name}.history"
|
||||||
|
|
||||||
|
|
||||||
|
# ── field helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _require_exact_fields(
|
||||||
|
raw: Any,
|
||||||
|
*,
|
||||||
|
required: tuple[str, ...],
|
||||||
|
optional: tuple[str, ...],
|
||||||
|
subject: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RegistryValidationError(f"{subject} must be an object")
|
||||||
|
missing = [field for field in required if field not in raw]
|
||||||
|
if missing:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject} missing required fields: {', '.join(sorted(missing))}"
|
||||||
|
)
|
||||||
|
unknown = sorted(set(raw) - set(required) - set(optional))
|
||||||
|
if unknown:
|
||||||
|
# Fail closed: silently dropping an unrecognized key is how a typo'd
|
||||||
|
# "timeout_second" ends up meaning "no timeout".
|
||||||
|
raise RegistryValidationError(f"{subject} has unknown fields: {', '.join(unknown)}")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _require_identifier(value: Any, *, subject: str) -> str:
|
||||||
|
text = str(value).strip()
|
||||||
|
if not _ID_RE.match(text):
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject} must be lowercase alphanumeric with '.', '_', or '-' (got {value!r})"
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _require_text(value: Any, *, subject: str) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise RegistryValidationError(f"{subject} must be a string (got {value!r})")
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
raise RegistryValidationError(f"{subject} must be a non-empty string")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _require_bool(value: Any, *, subject: str) -> bool:
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise RegistryValidationError(f"{subject} must be a boolean (got {value!r})")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_positive_int(value: Any, *, subject: str, maximum: int | None = None) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise RegistryValidationError(f"{subject} must be an integer (got {value!r})")
|
||||||
|
if value <= 0:
|
||||||
|
raise RegistryValidationError(f"{subject} must be greater than zero (got {value})")
|
||||||
|
if maximum is not None and value > maximum:
|
||||||
|
raise RegistryValidationError(f"{subject} must not exceed {maximum} (got {value})")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ── parsing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_provider(raw: Any) -> ProviderRecord:
|
||||||
|
data = _require_exact_fields(
|
||||||
|
raw,
|
||||||
|
required=_REQUIRED_PROVIDER_FIELDS,
|
||||||
|
optional=_OPTIONAL_PROVIDER_FIELDS,
|
||||||
|
subject="provider",
|
||||||
|
)
|
||||||
|
provider_id = _require_identifier(data["id"], subject="provider.id")
|
||||||
|
|
||||||
|
models_raw = data.get("models") or []
|
||||||
|
if not isinstance(models_raw, list):
|
||||||
|
raise RegistryValidationError(f"provider[{provider_id}].models must be an array")
|
||||||
|
models = tuple(
|
||||||
|
_require_text(item, subject=f"provider[{provider_id}].models[]") for item in models_raw
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProviderRecord(
|
||||||
|
id=provider_id,
|
||||||
|
display_name=_require_text(
|
||||||
|
data["display_name"], subject=f"provider[{provider_id}].display_name"
|
||||||
|
),
|
||||||
|
vendor=_require_text(data["vendor"], subject=f"provider[{provider_id}].vendor"),
|
||||||
|
executable=_require_text(data["executable"], subject=f"provider[{provider_id}].executable"),
|
||||||
|
available=_require_bool(data["available"], subject=f"provider[{provider_id}].available"),
|
||||||
|
models=models,
|
||||||
|
notes=str(data.get("notes") or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_schedule(raw: Any, *, subject: str) -> ScheduleSpec:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RegistryValidationError(f"{subject} must be an object")
|
||||||
|
kind = _require_text(raw.get("kind"), subject=f"{subject}.kind")
|
||||||
|
if kind not in ALLOWED_SCHEDULE_KINDS:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject}.kind must be one of {', '.join(ALLOWED_SCHEDULE_KINDS)} (got {kind!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
seconds: int | None = None
|
||||||
|
expression: str | None = None
|
||||||
|
|
||||||
|
if kind == "interval":
|
||||||
|
if "seconds" not in raw:
|
||||||
|
raise RegistryValidationError(f"{subject}.seconds is required for interval schedules")
|
||||||
|
seconds = _require_positive_int(raw["seconds"], subject=f"{subject}.seconds")
|
||||||
|
elif kind == "cron":
|
||||||
|
if "expression" not in raw:
|
||||||
|
raise RegistryValidationError(f"{subject}.expression is required for cron schedules")
|
||||||
|
expression = _require_text(raw["expression"], subject=f"{subject}.expression")
|
||||||
|
if len(expression.split()) != 5:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject}.expression must have five crontab fields (got {expression!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed = {"kind"}
|
||||||
|
if kind == "interval":
|
||||||
|
allowed.add("seconds")
|
||||||
|
elif kind == "cron":
|
||||||
|
allowed.add("expression")
|
||||||
|
unknown = sorted(set(raw) - allowed)
|
||||||
|
if unknown:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject} has fields not valid for kind {kind!r}: {', '.join(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduleSpec(kind=kind, seconds=seconds, expression=expression)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scheduler(raw: Any, *, subject: str) -> SchedulerSpec:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RegistryValidationError(f"{subject} must be an object")
|
||||||
|
kind = _require_text(raw.get("kind"), subject=f"{subject}.kind")
|
||||||
|
if kind not in ALLOWED_SCHEDULER_KINDS:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject}.kind must be one of {', '.join(ALLOWED_SCHEDULER_KINDS)} (got {kind!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
label: str | None = None
|
||||||
|
if kind == "launchd":
|
||||||
|
if "label" not in raw:
|
||||||
|
raise RegistryValidationError(f"{subject}.label is required for launchd schedulers")
|
||||||
|
label = _require_text(raw["label"], subject=f"{subject}.label")
|
||||||
|
|
||||||
|
allowed = {"kind"}
|
||||||
|
if kind == "launchd":
|
||||||
|
allowed.add("label")
|
||||||
|
unknown = sorted(set(raw) - allowed)
|
||||||
|
if unknown:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"{subject} has fields not valid for kind {kind!r}: {', '.join(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return SchedulerSpec(kind=kind, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_worker(raw: Any) -> WorkerRecord:
|
||||||
|
data = _require_exact_fields(
|
||||||
|
raw,
|
||||||
|
required=_REQUIRED_WORKER_FIELDS,
|
||||||
|
optional=_OPTIONAL_WORKER_FIELDS,
|
||||||
|
subject="worker",
|
||||||
|
)
|
||||||
|
worker_id = _require_identifier(data["id"], subject="worker.id")
|
||||||
|
|
||||||
|
role = _require_text(data["role"], subject=f"worker[{worker_id}].role")
|
||||||
|
if role not in ALLOWED_ROLES:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"worker[{worker_id}].role must be one of {', '.join(ALLOWED_ROLES)} (got {role!r})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return WorkerRecord(
|
||||||
|
id=worker_id,
|
||||||
|
display_name=_require_text(
|
||||||
|
data["display_name"], subject=f"worker[{worker_id}].display_name"
|
||||||
|
),
|
||||||
|
provider=_require_identifier(data["provider"], subject=f"worker[{worker_id}].provider"),
|
||||||
|
model=_require_text(data["model"], subject=f"worker[{worker_id}].model"),
|
||||||
|
project=_require_text(data["project"], subject=f"worker[{worker_id}].project"),
|
||||||
|
role=role,
|
||||||
|
namespace=_require_text(data["namespace"], subject=f"worker[{worker_id}].namespace"),
|
||||||
|
profile=_require_text(data["profile"], subject=f"worker[{worker_id}].profile"),
|
||||||
|
workflow=_require_text(data["workflow"], subject=f"worker[{worker_id}].workflow"),
|
||||||
|
schedule=_parse_schedule(data["schedule"], subject=f"worker[{worker_id}].schedule"),
|
||||||
|
timeout_seconds=_require_positive_int(
|
||||||
|
data["timeout_seconds"],
|
||||||
|
subject=f"worker[{worker_id}].timeout_seconds",
|
||||||
|
maximum=_MAX_TIMEOUT_SECONDS,
|
||||||
|
),
|
||||||
|
enabled=_require_bool(data["enabled"], subject=f"worker[{worker_id}].enabled"),
|
||||||
|
scheduler=_parse_scheduler(data["scheduler"], subject=f"worker[{worker_id}].scheduler"),
|
||||||
|
notes=str(data.get("notes") or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_unique(values: list[str], *, subject: str) -> None:
|
||||||
|
seen: set[str] = set()
|
||||||
|
for value in values:
|
||||||
|
if value in seen:
|
||||||
|
raise RegistryValidationError(f"duplicate {subject}: {value}")
|
||||||
|
seen.add(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_payload(payload: Any, *, source_path: Path) -> WorkerRegistry:
|
||||||
|
"""Validate a decoded registry document and return the typed registry.
|
||||||
|
|
||||||
|
Raises :class:`RegistryValidationError` on any violation; never partially
|
||||||
|
accepts a document.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RegistryValidationError("registry root must be an object")
|
||||||
|
|
||||||
|
version = payload.get("version")
|
||||||
|
if version != SCHEMA_VERSION:
|
||||||
|
raise RegistryValidationError(f"unsupported registry version: {version!r}")
|
||||||
|
|
||||||
|
reject_credential_keys(payload, subject="worker registry")
|
||||||
|
|
||||||
|
unknown = sorted(set(payload) - _TOP_LEVEL_FIELDS)
|
||||||
|
if unknown:
|
||||||
|
raise RegistryValidationError(f"registry has unknown fields: {', '.join(unknown)}")
|
||||||
|
|
||||||
|
revision = _require_positive_int(payload.get("revision"), subject="revision")
|
||||||
|
updated_at = _require_text(payload.get("updated_at"), subject="updated_at")
|
||||||
|
|
||||||
|
providers_raw = payload.get("providers")
|
||||||
|
if not isinstance(providers_raw, list) or not providers_raw:
|
||||||
|
raise RegistryValidationError("providers must be a non-empty array")
|
||||||
|
providers = tuple(_parse_provider(item) for item in providers_raw)
|
||||||
|
_require_unique([provider.id for provider in providers], subject="provider id")
|
||||||
|
|
||||||
|
workers_raw = payload.get("workers")
|
||||||
|
if not isinstance(workers_raw, list):
|
||||||
|
raise RegistryValidationError("workers must be an array")
|
||||||
|
workers = tuple(_parse_worker(item) for item in workers_raw)
|
||||||
|
_require_unique([worker.id for worker in workers], subject="worker id")
|
||||||
|
|
||||||
|
# Referential integrity: a worker naming an undeclared provider would look
|
||||||
|
# configured while being unrunnable, which is exactly the ambiguous
|
||||||
|
# ownership the epic requires to fail closed.
|
||||||
|
known_providers = {provider.id for provider in providers}
|
||||||
|
for worker in workers:
|
||||||
|
if worker.provider not in known_providers:
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"worker[{worker.id}].provider references unknown provider {worker.provider!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A LaunchAgent label identifies a job to launchd; two workers sharing one
|
||||||
|
# would silently overwrite each other's agent.
|
||||||
|
_require_unique(
|
||||||
|
[worker.scheduler.label for worker in workers if worker.scheduler.label],
|
||||||
|
subject="scheduler label",
|
||||||
|
)
|
||||||
|
|
||||||
|
return WorkerRegistry(
|
||||||
|
version=version,
|
||||||
|
revision=revision,
|
||||||
|
updated_at=updated_at,
|
||||||
|
providers=providers,
|
||||||
|
workers=workers,
|
||||||
|
source_path=source_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry(path: Path | None = None) -> WorkerRegistry:
|
||||||
|
"""Load and validate the worker registry from disk."""
|
||||||
|
source = (path or default_registry_path()).resolve()
|
||||||
|
payload = json.loads(source.read_text(encoding="utf-8"))
|
||||||
|
return validate_payload(payload, source_path=source)
|
||||||
|
|
||||||
|
|
||||||
|
# ── serialization ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def provider_to_dict(provider: ProviderRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": provider.id,
|
||||||
|
"display_name": provider.display_name,
|
||||||
|
"vendor": provider.vendor,
|
||||||
|
"executable": provider.executable,
|
||||||
|
"available": provider.available,
|
||||||
|
"models": list(provider.models),
|
||||||
|
"notes": provider.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_to_dict(schedule: ScheduleSpec) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {"kind": schedule.kind}
|
||||||
|
if schedule.kind == "interval":
|
||||||
|
payload["seconds"] = schedule.seconds
|
||||||
|
elif schedule.kind == "cron":
|
||||||
|
payload["expression"] = schedule.expression
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduler_to_dict(scheduler: SchedulerSpec) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {"kind": scheduler.kind}
|
||||||
|
if scheduler.kind == "launchd":
|
||||||
|
payload["label"] = scheduler.label
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def worker_to_dict(worker: WorkerRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": worker.id,
|
||||||
|
"display_name": worker.display_name,
|
||||||
|
"provider": worker.provider,
|
||||||
|
"model": worker.model,
|
||||||
|
"project": worker.project,
|
||||||
|
"role": worker.role,
|
||||||
|
"namespace": worker.namespace,
|
||||||
|
"profile": worker.profile,
|
||||||
|
"workflow": worker.workflow,
|
||||||
|
"schedule": _schedule_to_dict(worker.schedule),
|
||||||
|
"timeout_seconds": worker.timeout_seconds,
|
||||||
|
"enabled": worker.enabled,
|
||||||
|
"scheduler": _scheduler_to_dict(worker.scheduler),
|
||||||
|
"notes": worker.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def registry_to_document(registry: WorkerRegistry) -> dict[str, Any]:
|
||||||
|
"""Serialize to the on-disk document shape (no local paths embedded)."""
|
||||||
|
return {
|
||||||
|
"version": registry.version,
|
||||||
|
"revision": registry.revision,
|
||||||
|
"updated_at": registry.updated_at,
|
||||||
|
"providers": [provider_to_dict(provider) for provider in registry.providers],
|
||||||
|
"workers": [worker_to_dict(worker) for worker in registry.workers],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def registry_to_dict(registry: WorkerRegistry) -> dict[str, Any]:
|
||||||
|
"""Serialize for JSON API responses (adds the resolved source path)."""
|
||||||
|
document = registry_to_document(registry)
|
||||||
|
document["source_path"] = str(registry.source_path)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def find_worker(registry: WorkerRegistry, worker_id: str) -> WorkerRecord | None:
|
||||||
|
for worker in registry.workers:
|
||||||
|
if worker.id == worker_id:
|
||||||
|
return worker
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_provider(registry: WorkerRegistry, provider_id: str) -> ProviderRecord | None:
|
||||||
|
for provider in registry.providers:
|
||||||
|
if provider.id == provider_id:
|
||||||
|
return provider
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def workers_for_provider(registry: WorkerRegistry, provider_id: str) -> tuple[WorkerRecord, ...]:
|
||||||
|
return tuple(worker for worker in registry.workers if worker.provider == provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── persistence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, payload: str) -> None:
|
||||||
|
"""Write *payload* to *path* atomically: temp file in the same dir, fsync, replace."""
|
||||||
|
parent = path.parent
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temp_path = tempfile.mkstemp(prefix=f".{path.name}-", suffix=".tmp", dir=parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
try:
|
||||||
|
os.remove(temp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_path(directory: Path, revision: int) -> Path:
|
||||||
|
return directory / f"rev-{revision:06d}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_history(path: Path) -> None:
|
||||||
|
directory = history_dir(path)
|
||||||
|
revisions = list_revisions(path)
|
||||||
|
excess = len(revisions) - _HISTORY_LIMIT
|
||||||
|
for revision in revisions[: max(0, excess)]:
|
||||||
|
_revision_path(directory, revision).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_current(path: Path) -> int | None:
|
||||||
|
"""Copy the live document into the history dir under its own revision number."""
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
revision = int(existing.get("revision", 0))
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError, AttributeError):
|
||||||
|
# An unreadable live file has no trustworthy revision number to file it
|
||||||
|
# under, so it cannot join the history chain.
|
||||||
|
return None
|
||||||
|
if revision <= 0:
|
||||||
|
return None
|
||||||
|
_atomic_write(
|
||||||
|
_revision_path(history_dir(path), revision),
|
||||||
|
json.dumps(existing, indent=2, sort_keys=True) + "\n",
|
||||||
|
)
|
||||||
|
_prune_history(path)
|
||||||
|
return revision
|
||||||
|
|
||||||
|
|
||||||
|
def list_revisions(path: Path | None = None) -> tuple[int, ...]:
|
||||||
|
"""Revision numbers retained in history for *path*, oldest first."""
|
||||||
|
directory = history_dir(path)
|
||||||
|
if not directory.is_dir():
|
||||||
|
return ()
|
||||||
|
revisions: list[int] = []
|
||||||
|
for entry in directory.glob("rev-*.json"):
|
||||||
|
try:
|
||||||
|
revisions.append(int(entry.stem.split("-", 1)[1]))
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
continue
|
||||||
|
return tuple(sorted(revisions))
|
||||||
|
|
||||||
|
|
||||||
|
def save_registry(
|
||||||
|
registry: WorkerRegistry,
|
||||||
|
path: Path | None = None,
|
||||||
|
*,
|
||||||
|
updated_at: str | None = None,
|
||||||
|
) -> WorkerRegistry:
|
||||||
|
"""Validate, archive the superseded revision, then atomically persist a new one.
|
||||||
|
|
||||||
|
The stored revision is always the previous revision plus one, so a reader
|
||||||
|
can tell two documents apart even when their content is otherwise equal.
|
||||||
|
Returns the registry exactly as persisted.
|
||||||
|
"""
|
||||||
|
target = (path or registry.source_path or default_registry_path()).resolve()
|
||||||
|
|
||||||
|
document = registry_to_document(registry)
|
||||||
|
# Re-validate before writing: a registry assembled in memory has not
|
||||||
|
# necessarily been through the loader.
|
||||||
|
validate_payload(document, source_path=target)
|
||||||
|
|
||||||
|
archived = _archive_current(target)
|
||||||
|
document["revision"] = (archived + 1) if archived is not None else registry.revision
|
||||||
|
document["updated_at"] = updated_at or _utc_now()
|
||||||
|
|
||||||
|
persisted = validate_payload(document, source_path=target)
|
||||||
|
_atomic_write(target, json.dumps(document, indent=2, sort_keys=True) + "\n")
|
||||||
|
return persisted
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_to_revision(revision: int, path: Path | None = None) -> WorkerRegistry:
|
||||||
|
"""Restore a retained *revision* as a new head revision.
|
||||||
|
|
||||||
|
History is append-only: rolling back does not delete the revisions in
|
||||||
|
between, it republishes the chosen one under the next revision number, so a
|
||||||
|
rollback is itself reversible.
|
||||||
|
"""
|
||||||
|
target = (path or default_registry_path()).resolve()
|
||||||
|
snapshot_path = _revision_path(history_dir(target), revision)
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
available = ", ".join(str(item) for item in list_revisions(target)) or "(none)"
|
||||||
|
raise RegistryValidationError(
|
||||||
|
f"revision {revision} is not retained for {target.name}; available: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||||
|
restored = validate_payload(payload, source_path=target)
|
||||||
|
return save_registry(restored, target)
|
||||||
@@ -65,9 +65,11 @@ PROMPT_RECONCILER = (
|
|||||||
"reconciliation (already-landed / post-merge cleanup). Do not approve or merge."
|
"reconciliation (already-landed / post-merge cleanup). Do not approve or merge."
|
||||||
)
|
)
|
||||||
PROMPT_CONTROLLER = (
|
PROMPT_CONTROLLER = (
|
||||||
"CONTROLLER session: inspect gitea_workflow_dashboard + control-plane leases, "
|
"CONTROLLER session: call gitea_route_task_session(task_type='process_work_queue') "
|
||||||
"diagnose blocked/terminal-locked items for {remote}/{org}/{repo}, and schedule "
|
"then gitea_allocate_next_work (cross_role default) for {remote}/{org}/{repo}; "
|
||||||
"exactly one fresh role-scoped cycle. Do not implement, review, or merge in-band."
|
"use the returned required_role/profile/action to schedule exactly one downstream "
|
||||||
|
"role cycle. Dashboard is explanatory only and never replaces allocator selection. "
|
||||||
|
"Do not implement, review, approve, or merge in-band."
|
||||||
)
|
)
|
||||||
PROMPT_IDLE = (
|
PROMPT_IDLE = (
|
||||||
"IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. "
|
"IDLE: no safe assignable work for role '{role}' on {remote}/{org}/{repo}. "
|
||||||
|
|||||||
Reference in New Issue
Block a user