Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79334d4840 | ||
|
|
aab54d4825 | ||
|
|
a09c485fc0 | ||
|
|
8c1d22a658 | ||
|
|
6a56260768 | ||
|
|
97bc190fc2 | ||
|
|
7bf4f12584 | ||
|
|
2066623986 | ||
|
|
dc0bff764d | ||
|
|
dfe8d7c28d | ||
|
|
2b4e43042a | ||
|
|
0f9390aab4 | ||
|
|
d7ad2838ec | ||
|
|
c6d68dbc7b | ||
|
|
c83a10d7c2 | ||
|
|
ca22c326a4 | ||
|
|
3bbe6df6c7 | ||
|
|
71031c812e | ||
|
|
e43ddd3cbe | ||
|
|
a64ba08e27 | ||
|
|
26f54851d1 | ||
|
|
f02a2dc030 | ||
|
|
bb8c3a537b | ||
|
|
04ae3532cc | ||
|
|
7bb5ff4719 | ||
|
|
5b7ceefa9a | ||
|
|
4a2fae8495 | ||
|
|
461e1dac78 | ||
|
|
9c69bfcd80 | ||
|
|
9b8e315b49 | ||
|
|
9a01543477 | ||
|
|
59aab06fe1 | ||
|
|
4f06d30e07 | ||
|
|
211890f361 | ||
|
|
1c88b87ec5 | ||
|
|
b993ad1c64 | ||
|
|
d0006e9f71 | ||
|
|
6da68fffb8 | ||
|
|
53ce1b1a5e | ||
|
|
433f66add8 |
@@ -23,6 +23,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from control_plane_db import (
|
from control_plane_db import (
|
||||||
@@ -738,6 +739,46 @@ def normalize_exclude_issue_numbers(
|
|||||||
return sorted(out)
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_expires_at(claim: Any) -> datetime | None:
|
||||||
|
"""Parse a claim's ``expires_at``, or ``None`` when it is absent/malformed."""
|
||||||
|
if not isinstance(claim, Mapping):
|
||||||
|
return None
|
||||||
|
text = str(claim.get("expires_at") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = text[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_expired_claims(
|
||||||
|
claims: Mapping[tuple[str, int], dict[str, Any]],
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[tuple[str, int], dict[str, Any]]:
|
||||||
|
"""Claims minus those whose lease has already expired (#643).
|
||||||
|
|
||||||
|
The read-only mirror of ``expire_stale_leases``: the sweep marks such rows
|
||||||
|
``expired`` so they stop being returned as claims, and this reaches the same
|
||||||
|
view without writing. A claim with no parseable ``expires_at`` is **kept** —
|
||||||
|
an unreadable expiry is not evidence that work is free.
|
||||||
|
"""
|
||||||
|
moment = now or datetime.now(timezone.utc)
|
||||||
|
kept: dict[tuple[str, int], dict[str, Any]] = {}
|
||||||
|
for key, claim in (claims or {}).items():
|
||||||
|
expires_at = _claim_expires_at(claim)
|
||||||
|
if expires_at is not None and expires_at <= moment:
|
||||||
|
continue
|
||||||
|
kept[key] = claim
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def candidate_set_fingerprint(
|
def candidate_set_fingerprint(
|
||||||
candidates: Sequence[WorkCandidate],
|
candidates: Sequence[WorkCandidate],
|
||||||
*,
|
*,
|
||||||
@@ -826,12 +867,22 @@ def allocate_next_work(
|
|||||||
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,
|
allocation_mode: str | None = None,
|
||||||
|
side_effect_free: bool = False,
|
||||||
) -> 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.
|
||||||
|
|
||||||
|
*side_effect_free* (#643): a dry run that writes **nothing** to the
|
||||||
|
control-plane DB. A plain ``apply=False`` still registered a session row and
|
||||||
|
swept stale leases globally, so a caller advertising a read-only preview was
|
||||||
|
mutating on every call. Under this flag both writes are suppressed and stale
|
||||||
|
leases are instead filtered out of the claim map in memory, which yields the
|
||||||
|
same selection the sweep would have produced without persisting anything.
|
||||||
|
Incompatible with *apply* — the combination fails closed rather than
|
||||||
|
silently reserving.
|
||||||
|
|
||||||
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
|
*allocation_mode* (#840): ``cross_role`` (default for controller) inspects
|
||||||
the complete queue and returns one authoritative selection naming the
|
the complete queue and returns one authoritative selection naming the
|
||||||
required downstream role/profile/action. ``role_scoped`` keeps prior
|
required downstream role/profile/action. ``role_scoped`` keeps prior
|
||||||
@@ -885,7 +936,24 @@ def allocate_next_work(
|
|||||||
"allocation_mode": (allocation_mode or "").strip() or None,
|
"allocation_mode": (allocation_mode or "").strip() or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A side-effect-free run may never reserve: reserving is a write, and the
|
||||||
|
# flag is the caller's assertion that this call writes nothing (#643).
|
||||||
|
if side_effect_free and apply:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"outcome": OUTCOME_NO_SAFE,
|
||||||
|
"apply": True,
|
||||||
|
"reasons": [
|
||||||
|
"side_effect_free is incompatible with apply=True; an "
|
||||||
|
"assignment is a write (fail closed, #643)"
|
||||||
|
],
|
||||||
|
"skipped": [],
|
||||||
|
"assignment": None,
|
||||||
|
"substrate": "control_plane_db",
|
||||||
|
}
|
||||||
|
|
||||||
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]}"
|
||||||
|
if not side_effect_free:
|
||||||
try:
|
try:
|
||||||
db.upsert_session(
|
db.upsert_session(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -953,6 +1021,12 @@ def allocate_next_work(
|
|||||||
"assignment": None,
|
"assignment": None,
|
||||||
"substrate": "control_plane_db",
|
"substrate": "control_plane_db",
|
||||||
}
|
}
|
||||||
|
if side_effect_free:
|
||||||
|
# ``list_active_claims`` filters on status alone, so without the
|
||||||
|
# global sweep an already-expired lease would still read as a live
|
||||||
|
# claim and the preview would report work as taken that is free.
|
||||||
|
# Drop those in memory: same view the sweep produces, no write.
|
||||||
|
claims = _drop_expired_claims(claims)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
exclude_nums = normalize_exclude_issue_numbers(exclude_issue_numbers)
|
||||||
|
|||||||
+155
-44
@@ -386,6 +386,68 @@ def run_compensating_recovery(
|
|||||||
return recovery_info
|
return recovery_info
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sha(value: str | None) -> str | None:
|
||||||
|
"""Normalize a Git object id for comparison, or ``None`` when unknown."""
|
||||||
|
normalized = (value or "").strip().lower()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
|
||||||
|
def _author_bootstrap_assessment(
|
||||||
|
*,
|
||||||
|
not_applicable: bool,
|
||||||
|
allowed: bool,
|
||||||
|
block: bool,
|
||||||
|
reasons: list[str],
|
||||||
|
workspace: str,
|
||||||
|
root: str,
|
||||||
|
branch: str | None,
|
||||||
|
dirty: list[str],
|
||||||
|
under_branches: bool,
|
||||||
|
bootstrap_path: str | None = None,
|
||||||
|
local_head_sha: str | None = None,
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured author-bootstrap assessment consumable by bootstrap_permits (#892).
|
||||||
|
|
||||||
|
Field shape mirrors :func:`create_issue_bootstrap._result` so the shared
|
||||||
|
``bootstrap_permits_control_checkout`` predicate can prove control-checkout
|
||||||
|
eligibility for ``gitea_bootstrap_author_issue_worktree`` the same way it
|
||||||
|
does for ``create_issue``. Allowed control assessments must use empty
|
||||||
|
``reasons`` — narrative belongs in other fields, not the refusal list.
|
||||||
|
"""
|
||||||
|
local_tip = _normalize_sha(local_head_sha)
|
||||||
|
remote_tip = _normalize_sha(remote_master_sha)
|
||||||
|
base_tips_verified = bool(local_tip and remote_tip and local_tip == remote_tip)
|
||||||
|
return {
|
||||||
|
"not_applicable": not_applicable,
|
||||||
|
"allowed": allowed,
|
||||||
|
"block": block,
|
||||||
|
"proven": bool(allowed and not block and not not_applicable),
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"workspace_path": workspace,
|
||||||
|
"canonical_repo_root": root,
|
||||||
|
"current_branch": branch,
|
||||||
|
"dirty_files": list(dirty),
|
||||||
|
"under_branches": under_branches,
|
||||||
|
"exact_next_action": exact_next_action,
|
||||||
|
"bootstrap_path": bootstrap_path,
|
||||||
|
"task_scope": "author_issue_bootstrap",
|
||||||
|
"local_head_sha": local_tip,
|
||||||
|
"remote_master_sha": remote_tip,
|
||||||
|
"base_tips_verified": base_tips_verified,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP = (
|
||||||
|
"Restore the canonical control checkout to a clean accepted base branch "
|
||||||
|
"(master/main/dev) that matches live master, with no tracked local edits. "
|
||||||
|
"Re-resolve bootstrap_author_issue_worktree, then re-run "
|
||||||
|
"gitea_bootstrap_author_issue_worktree from that clean control checkout. "
|
||||||
|
"Do not use shell git worktree add as the primary path once bootstrap is healthy."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def assess_author_issue_bootstrap(
|
def assess_author_issue_bootstrap(
|
||||||
*,
|
*,
|
||||||
workspace_path: str,
|
workspace_path: str,
|
||||||
@@ -397,7 +459,13 @@ def assess_author_issue_bootstrap(
|
|||||||
remote_master_sha_error: str | None = None,
|
remote_master_sha_error: str | None = None,
|
||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root."""
|
"""Assess whether author issue worktree bootstrap may proceed from control or worktree root.
|
||||||
|
|
||||||
|
#892: control-checkout successes emit the full field set required by
|
||||||
|
``create_issue_bootstrap.bootstrap_permits_control_checkout`` (empty reasons,
|
||||||
|
task_scope, base tip proof, binding paths) so the #274/#604 guards can
|
||||||
|
waive control-checkout for this one sanctioned bootstrap task.
|
||||||
|
"""
|
||||||
root = os.path.realpath(canonical_repo_root or "")
|
root = os.path.realpath(canonical_repo_root or "")
|
||||||
workspace = os.path.realpath(workspace_path or root or ".")
|
workspace = os.path.realpath(workspace_path or root or ".")
|
||||||
branch = (current_branch or "").strip()
|
branch = (current_branch or "").strip()
|
||||||
@@ -407,34 +475,50 @@ def assess_author_issue_bootstrap(
|
|||||||
if root
|
if root
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
|
local_tip = _normalize_sha(head_sha)
|
||||||
|
remote_tip = _normalize_sha(remote_master_sha)
|
||||||
|
|
||||||
if not is_author_issue_bootstrap_task(task):
|
if not is_author_issue_bootstrap_task(task):
|
||||||
return {
|
return _author_bootstrap_assessment(
|
||||||
"not_applicable": True,
|
not_applicable=True,
|
||||||
"allowed": False,
|
allowed=False,
|
||||||
"block": False,
|
block=False,
|
||||||
"proven": False,
|
reasons=["task is not author_issue_bootstrap"],
|
||||||
"reasons": ["task is not author_issue_bootstrap"],
|
workspace=workspace,
|
||||||
}
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=under_branches,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Already under branches/: ordinary #274 path applies; not a control waiver.
|
||||||
if under_branches:
|
if under_branches:
|
||||||
return {
|
return _author_bootstrap_assessment(
|
||||||
"not_applicable": False,
|
not_applicable=True,
|
||||||
"allowed": True,
|
allowed=False,
|
||||||
"block": False,
|
block=False,
|
||||||
"proven": True,
|
reasons=["workspace is under branches/; ordinary #274 path applies"],
|
||||||
"bootstrap_path": "existing_branches_worktree",
|
workspace=workspace,
|
||||||
"reasons": [
|
root=root,
|
||||||
"workspace is already a registered worktree under branches/"
|
branch=branch or None,
|
||||||
],
|
dirty=dirty,
|
||||||
}
|
under_branches=True,
|
||||||
|
bootstrap_path="existing_branches_worktree",
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
)
|
||||||
|
|
||||||
reasons: list[str] = []
|
reasons: list[str] = []
|
||||||
if workspace != root:
|
if not root or workspace != root:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"bootstrap requires workspace to be canonical control checkout or branches/ worktree"
|
"bootstrap requires workspace to be canonical control checkout or branches/ worktree"
|
||||||
)
|
)
|
||||||
if branch not in author_mutation_worktree.BASE_BRANCHES:
|
if not branch:
|
||||||
|
reasons.append(
|
||||||
|
"control checkout is detached HEAD; expected an accepted base branch "
|
||||||
|
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
||||||
|
)
|
||||||
|
elif branch not in author_mutation_worktree.BASE_BRANCHES:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"control checkout branch '{branch}' is not an accepted base branch "
|
f"control checkout branch '{branch}' is not an accepted base branch "
|
||||||
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
f"({', '.join(sorted(author_mutation_worktree.BASE_BRANCHES))})"
|
||||||
@@ -444,37 +528,64 @@ def assess_author_issue_bootstrap(
|
|||||||
f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
|
f"control checkout has tracked local edits: {', '.join(dirty[:5])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if remote_master_sha_error:
|
# Fail closed on missing tip proof (same bar as create_issue bootstrap #757).
|
||||||
|
if not local_tip:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"could not verify live master tip: {remote_master_sha_error}"
|
"control checkout HEAD SHA is unknown; base equivalence to live "
|
||||||
|
"master cannot be proven (fail closed)"
|
||||||
)
|
)
|
||||||
elif remote_master_sha and head_sha:
|
resolver_error = (remote_master_sha_error or "").strip() or None
|
||||||
h = head_sha.strip().lower()
|
if resolver_error:
|
||||||
rm = remote_master_sha.strip().lower()
|
|
||||||
if h != rm:
|
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"control checkout HEAD ({h[:12]}) != live master tip ({rm[:12]})"
|
f"live master tip could not be resolved ({resolver_error}); "
|
||||||
|
"base equivalence cannot be proven (fail closed)"
|
||||||
|
)
|
||||||
|
elif not remote_tip:
|
||||||
|
reasons.append(
|
||||||
|
"live master tip is unknown; base equivalence cannot be proven "
|
||||||
|
"(fail closed)"
|
||||||
|
)
|
||||||
|
elif local_tip and remote_tip and local_tip != remote_tip:
|
||||||
|
reasons.append(
|
||||||
|
f"control checkout HEAD ({local_tip[:12]}) != live master tip "
|
||||||
|
f"({remote_tip[:12]})"
|
||||||
)
|
)
|
||||||
|
|
||||||
if reasons:
|
if reasons:
|
||||||
return {
|
return _author_bootstrap_assessment(
|
||||||
"not_applicable": False,
|
not_applicable=False,
|
||||||
"allowed": False,
|
allowed=False,
|
||||||
"block": True,
|
block=True,
|
||||||
"proven": False,
|
reasons=reasons,
|
||||||
"reasons": reasons,
|
workspace=workspace,
|
||||||
}
|
root=root,
|
||||||
|
branch=branch or None,
|
||||||
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
exact_next_action=EXACT_NEXT_ACTION_AUTHOR_BOOTSTRAP,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
# Allowed: empty reasons so bootstrap_permits_control_checkout can pass.
|
||||||
"not_applicable": False,
|
return _author_bootstrap_assessment(
|
||||||
"allowed": True,
|
not_applicable=False,
|
||||||
"block": False,
|
allowed=True,
|
||||||
"proven": True,
|
block=False,
|
||||||
"bootstrap_path": "clean_canonical_control_checkout",
|
reasons=[],
|
||||||
"reasons": [
|
workspace=workspace,
|
||||||
"control checkout is clean on accepted base branch matching live master"
|
root=root,
|
||||||
],
|
branch=branch or None,
|
||||||
}
|
dirty=dirty,
|
||||||
|
under_branches=False,
|
||||||
|
bootstrap_path="clean_canonical_control_checkout",
|
||||||
|
local_head_sha=local_tip,
|
||||||
|
remote_master_sha=remote_tip,
|
||||||
|
exact_next_action=(
|
||||||
|
"Call gitea_bootstrap_author_issue_worktree with the allocated "
|
||||||
|
"issue/lease pins; it will create the branches/ worktree and lock."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
|
|||||||
@@ -241,9 +241,14 @@ def bootstrap_permits_control_checkout(
|
|||||||
caller's ordinary block in force.
|
caller's ordinary block in force.
|
||||||
|
|
||||||
``assessment`` is server-derived only: it is produced by
|
``assessment`` is server-derived only: it is produced by
|
||||||
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
|
:func:`assess_create_issue_bootstrap` or
|
||||||
never accepted from an MCP tool argument, so no caller can assert
|
:func:`author_issue_bootstrap.assess_author_issue_bootstrap` from inspected
|
||||||
eligibility it has not proven.
|
repository state. It is never accepted from an MCP tool argument, so no
|
||||||
|
caller can assert eligibility it has not proven.
|
||||||
|
|
||||||
|
#892: author issue worktree bootstrap uses the same predicate with
|
||||||
|
``task_scope='author_issue_bootstrap'`` so a clean control checkout can
|
||||||
|
create the first ``branches/`` worktree without the lock↔worktree cycle.
|
||||||
"""
|
"""
|
||||||
if not isinstance(assessment, dict):
|
if not isinstance(assessment, dict):
|
||||||
return False
|
return False
|
||||||
@@ -264,9 +269,16 @@ def bootstrap_permits_control_checkout(
|
|||||||
if assessment.get("reasons"):
|
if assessment.get("reasons"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Scope proof: only the create_issue bootstrap, only via the clean
|
# Scope proof: create_issue (#749) or author issue bootstrap (#850/#892),
|
||||||
# canonical control checkout path.
|
# only via the clean canonical control checkout path.
|
||||||
if assessment.get("task_scope") != "create_issue_only":
|
task_scope = assessment.get("task_scope")
|
||||||
|
if is_create_issue_task(task):
|
||||||
|
if task_scope != "create_issue_only":
|
||||||
|
return False
|
||||||
|
elif author_issue_bootstrap.is_author_issue_bootstrap_task(task):
|
||||||
|
if task_scope != "author_issue_bootstrap":
|
||||||
|
return False
|
||||||
|
else:
|
||||||
return False
|
return False
|
||||||
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Incident #670: bare direct-to-master commit `2fa97c26` (retroactive audit)
|
||||||
|
|
||||||
|
Status: verified; disposition recommendation: **accept as-is, no revert** (final
|
||||||
|
disposition owned by controller per issue #670).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Commit `2fa97c26fbda555a1a83930ca5fdcea9d8e47b50`
|
||||||
|
(`fix(mcp): load dotenv relative to project root`) landed on `prgs/master`
|
||||||
|
as a single-parent commit with no PR wrapper and no review record, bypassing
|
||||||
|
the sanctioned issue → branch → PR → review → merge workflow. It was
|
||||||
|
discovered during the PR #654 post-merge audit. PR #654 itself merged
|
||||||
|
cleanly via the Gitea API and did **not** introduce this commit.
|
||||||
|
|
||||||
|
## Verification evidence (acceptance criteria 1–3)
|
||||||
|
|
||||||
|
- **AC1 — present on `prgs/master`: yes.**
|
||||||
|
`git merge-base --is-ancestor 2fa97c26fbda555a1a83930ca5fdcea9d8e47b50 prgs/master` → true.
|
||||||
|
- **AC2 — no PR or review record: confirmed.**
|
||||||
|
The commit is a single-parent, non-merge commit sitting directly on
|
||||||
|
first-parent master between the #629 merge (`5ab5fe85`) and the #654
|
||||||
|
merge (`ec903b0d`). A PR landing on master produces a merge commit (or a
|
||||||
|
PR-linked head); neither exists here. The controller audit at issue-create
|
||||||
|
time also found no PR wrapper and no review record for this SHA.
|
||||||
|
- **AC3 — changed files and diff summary: confirmed.**
|
||||||
|
`gitea_auth.py | 5 +++--` (+3/−2). Single parent
|
||||||
|
`5ab5fe8583c07134d55dadf09381aecb67df246e`. The change moves
|
||||||
|
`PROJECT_ROOT` derivation above `load_dotenv()` and loads
|
||||||
|
`.env` relative to the project root instead of the process CWD.
|
||||||
|
|
||||||
|
## AC4 — why no immediate revert
|
||||||
|
|
||||||
|
- The dotenv fix is intentional and required for correct runtime behavior:
|
||||||
|
without it, `load_dotenv()` resolves `.env` against the process working
|
||||||
|
directory, which breaks MCP server launches whose CWD is not the project
|
||||||
|
root.
|
||||||
|
- The change is small (+3/−2), self-contained in `gitea_auth.py`, and has
|
||||||
|
been running on master without incident since 2026-07-10.
|
||||||
|
- Reverting would re-introduce a real bug to remove a provenance defect —
|
||||||
|
the wrong trade. Provenance is repaired retroactively by this document,
|
||||||
|
issue #670, and the hardening landed under #671.
|
||||||
|
- If the controller later judges the change unsafe, a separate
|
||||||
|
revert/repair issue is the sanctioned path (issue #670, recommended
|
||||||
|
disposition option 4).
|
||||||
|
|
||||||
|
## AC5 — workflow-hardening linkage
|
||||||
|
|
||||||
|
Prevention already landed: **issue #671** (closed)
|
||||||
|
*“Block direct pushes to stable branches from MCP workflow sessions”*,
|
||||||
|
implemented by commit `5933d87647656643a67a50331c4c7b06ea751dad`
|
||||||
|
(`feat(guard): block direct stable-branch pushes from MCP workflow sessions`).
|
||||||
|
|
||||||
|
Shipped guardrails include:
|
||||||
|
|
||||||
|
- `gitea_record_stable_branch_push_attempt` — classifies proposed commands
|
||||||
|
for direct stable-branch push intent (`git push <remote> master`,
|
||||||
|
refspecs, `HEAD:master`, `--force`, dry-run intent, `:master` delete),
|
||||||
|
plus root/control-checkout local commits not carried by an issue branch,
|
||||||
|
and writes a durable `stable_branch_contamination` marker.
|
||||||
|
- `gitea_audit_stable_branch_contamination` — reconciler-only audit/clear
|
||||||
|
path; a contaminated worker session cannot self-clear.
|
||||||
|
- Review/merge/close/completion mutations fail closed while a
|
||||||
|
contamination marker is active.
|
||||||
|
|
||||||
|
## AC6 — PR #654 was not the source
|
||||||
|
|
||||||
|
- `2fa97c26` is the **first parent** of the #654 merge commit
|
||||||
|
`ec903b0d619e7a27d24aed272a890f4e5d381411`; it predates the #654 merge.
|
||||||
|
- First-parent history `5ab5fe8..ec903b0`:
|
||||||
|
`2fa97c2 fix(mcp): load dotenv relative to project root` followed by
|
||||||
|
`ec903b0 Merge pull request 'feat: lifecycle role/hazard labels ... (#603)' (#654)`.
|
||||||
|
- The #654 merger audit confirmed `ec903b0d` was a valid Gitea-API merge,
|
||||||
|
the `git push prgs master` attempt during that run was a no-op, and the
|
||||||
|
net change `2fa97c2..ec903b0` contained only the reviewed #603
|
||||||
|
lifecycle-label files.
|
||||||
|
- Conclusion: #654 merged reviewed content only; the unauthorized-path
|
||||||
|
defect is solely the earlier bare commit `2fa97c26`.
|
||||||
|
|
||||||
|
## Explicit non-actions (unchanged by this audit)
|
||||||
|
|
||||||
|
- No revert of `2fa97c26`.
|
||||||
|
- No force-push or history rewrite.
|
||||||
|
- No master mutation from the audit session.
|
||||||
@@ -153,7 +153,19 @@ not a tool argument: a session must never be able to authorize itself.
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
|
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
|
||||||
|
- #657 — restart-path inventory and daemon classification.
|
||||||
|
- #686 — manual server launch detection & fail-closed provenance gate.
|
||||||
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||||
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||||
- `docs/mcp-client-registration.md` — per-server registration contract.
|
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||||
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
|
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
|
||||||
|
|
||||||
|
## Sanctioned reconnect vs forbidden manual launch (#686)
|
||||||
|
|
||||||
|
In addition to manual process killing (#630), manually launching a duplicate role server from an ad hoc shell (`python3 mcp_server.py`) is forbidden and fail-closed:
|
||||||
|
|
||||||
|
- **Why manual launches are unsupported:** A terminal-launched `mcp_server.py` holds its own stdio transport; it can never bind to the IDE client's stdio pipes. It cannot restore a dropped IDE namespace, and a manual duplicate process masks stale client-managed runtimes for that profile, defeating stale-runtime gates.
|
||||||
|
- **Sanctioned path:** Supported recovery is IDE/client-managed reconnect only (`/mcp reconnect`, IDE restart, or sanctioned reconnect exposure).
|
||||||
|
- **Fail-closed enforcement (#686):** Mutating tools on a server lacking client-managed launch provenance (`GITEA_CLIENT_MANAGED=1`) refuse execution fail-closed with typed blocker `unsupported_manual_launch` and an exact next action. Unsupported `GITEA_*` env overrides (e.g. `GITEA_DUMMY`) are surfaced in diagnostics rather than silently ignored.
|
||||||
|
- **Inventory & staleness:** Staleness diagnostics ignore non-client-managed duplicates when evaluating runtime freshness and inventory duplicate processes per profile (#657, #686).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# MCP scoped recovery playbook (#669)
|
||||||
|
|
||||||
|
**Parent:** [#655](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/655)
|
||||||
|
**Vision / roadmap:** [#652](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/652) · [#653](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/653)
|
||||||
|
**Class matrix:** [#663](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/663) · `docs/mcp-restart-classes.md`
|
||||||
|
**Coordinator:** [#658](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/658) · `restart_coordinator.py`
|
||||||
|
**Audit lineage:** [#665](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/665)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Full-server MCP reset is a **last resort**. Prefer the narrowest recovery that
|
||||||
|
can clear the symptom. The coordinator **refuses** `rolling_mcp_restart`,
|
||||||
|
`full_mcp_restart`, and `host_restart` unless:
|
||||||
|
|
||||||
|
1. The inventory carries a prior **attempt log** of at least one *insufficient*
|
||||||
|
narrower recovery, **or**
|
||||||
|
2. **Break-glass** is authorized
|
||||||
|
(`request_break_glass` + `GITEA_BREAKGLASS_RESTART_AUTHORIZATION`).
|
||||||
|
|
||||||
|
Break-glass still never bypasses the #663 class matrix (role/permission).
|
||||||
|
|
||||||
|
## Ladder (narrow → broad)
|
||||||
|
|
||||||
|
| Rank | Action | Self-service | Implementation / delegation |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 0 | `client_reconnect` | yes | Host auto-reconnect / client reconnect · [#584](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/584) · `docs/mcp-namespace-eof-recovery.md` |
|
||||||
|
| 1 | `capability_refresh` | yes | `gitea_resolve_task_capability` + `gitea_whoami` · [#610](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/610) · [#685](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/685) |
|
||||||
|
| 2 | `session_reconnect` | yes | Runtime rebind + explicit `worktree_path` · [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) · [#618](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/618) |
|
||||||
|
| 3 | `configuration_reload` | no | Class `configuration_reload` · console reload · [#642](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/642) |
|
||||||
|
| 4 | `lease_recovery` | no | Lock/lease recovery paths · [#702](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/702) · [#753](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/753) · [#790](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/790) |
|
||||||
|
| 5 | `worker_restart` | no | Class `worker_restart` · #663 |
|
||||||
|
| 6 | `role_runtime_restart` | no | Class `role_runtime_restart` · console restart · #642/#663 |
|
||||||
|
| 7 | `connector_restart` | no | Class `connector_restart` · #663 |
|
||||||
|
| 8 | `rolling_mcp_restart` | no | Class `rolling_mcp_restart` · design [#668](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/668) · **attempt log required** |
|
||||||
|
| 9 | `full_mcp_restart` | no | Class `full_mcp_restart` · **attempt log required** |
|
||||||
|
| 10 | `host_restart` | no | Class `host_restart` · **attempt log required** |
|
||||||
|
|
||||||
|
Machine-readable source of truth: `recovery_playbook.RECOVERY_LADDER` and
|
||||||
|
`recovery_playbook.ladder_document()`.
|
||||||
|
|
||||||
|
## Attempt log shape
|
||||||
|
|
||||||
|
Each prior attempt is a mapping:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "client_reconnect",
|
||||||
|
"outcome": "insufficient",
|
||||||
|
"reason": "transport still closed after IDE reconnect",
|
||||||
|
"actor": "prgs-controller-12345",
|
||||||
|
"recorded_at": "2026-07-25T21:00:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Outcomes that count toward escalation: `failed`, `insufficient`, `denied`,
|
||||||
|
`unresolved`, `timeout`, `error`.
|
||||||
|
|
||||||
|
Pass attempts into the coordinator via inventory
|
||||||
|
`prior_recovery_attempts` or the MCP tool argument
|
||||||
|
`prior_recovery_attempts_json` on `gitea_request_mcp_restart`.
|
||||||
|
|
||||||
|
Helper: `recovery_playbook.build_attempt_record(...)`.
|
||||||
|
|
||||||
|
## Symptom → first rung
|
||||||
|
|
||||||
|
`recovery_playbook.recommend_actions(symptoms=[...])` maps symptoms such as
|
||||||
|
`transport_eof`, `stale_capability`, `stale_lease`, `daemon_corrupt` to the
|
||||||
|
narrowest recommended action, then walks the ladder. Soft recommendations
|
||||||
|
never replace the hard gate on broad restarts.
|
||||||
|
|
||||||
|
## Enforcement points
|
||||||
|
|
||||||
|
1. **`recovery_playbook.assess_escalation`** — pure gate.
|
||||||
|
2. **`restart_coordinator.evaluate_restart_impact`** — when `restart_class` is
|
||||||
|
set (policy-enforced path), broad classes require the gate; report fields
|
||||||
|
`attempt_log_satisfied`, `playbook_escalation`, `break_glass`.
|
||||||
|
3. **`gitea_request_mcp_restart`** — accepts attempt JSON and env-authorized
|
||||||
|
break-glass; never restarts a process.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
`recovery_playbook.recovery_metrics(attempts)` reports the fraction of
|
||||||
|
successful recoveries that avoided full/host restart
|
||||||
|
(`fraction_avoided_full_restart`).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
* HA multi-instance execution (#668 design only here).
|
||||||
|
* Normalizing `pkill` (#630 contamination stays forbidden).
|
||||||
|
* Silent mutation of leases or processes from the playbook itself.
|
||||||
|
|
||||||
|
## Manual process kills
|
||||||
|
|
||||||
|
Remain forbidden and contaminating (#630). The playbook never recommends them.
|
||||||
@@ -95,12 +95,18 @@ gitea_request_mcp_restart(remote, host, org, repo,
|
|||||||
target_session_id=None, target_role=None,
|
target_session_id=None, target_role=None,
|
||||||
target_connector=None,
|
target_connector=None,
|
||||||
drain_proof_json=None,
|
drain_proof_json=None,
|
||||||
request_break_glass=False)
|
request_break_glass=False,
|
||||||
|
prior_recovery_attempts_json=None)
|
||||||
```
|
```
|
||||||
|
|
||||||
It **never restarts anything**: `apply_supported` is always `false` and
|
It **never restarts anything**: `apply_supported` is always `false` and
|
||||||
`restart_performed` is always `false`.
|
`restart_performed` is always `false`.
|
||||||
|
|
||||||
|
`prior_recovery_attempts_json` (#669) is an optional JSON array of prior
|
||||||
|
narrow recovery attempts. Rolling / full / host classes require at least one
|
||||||
|
*insufficient* narrower attempt (or authorized break-glass). See
|
||||||
|
`docs/mcp-recovery-playbook.md`.
|
||||||
|
|
||||||
### Dry-run versus apply
|
### Dry-run versus apply
|
||||||
|
|
||||||
| Call | Behavior |
|
| Call | Behavior |
|
||||||
@@ -112,9 +118,10 @@ It **never restarts anything**: `apply_supported` is always `false` and
|
|||||||
|
|
||||||
An apply requires **both** authorizations, and they are independent:
|
An apply requires **both** authorizations, and they are independent:
|
||||||
|
|
||||||
1. **Restart-class authorization** (#663) — the requester's role and permissions
|
1. **Restart-class authorization** (#663 / #669) — the requester's role and
|
||||||
must allow the requested class, the class's approval requirement must be
|
permissions must allow the requested class, the class's approval requirement
|
||||||
satisfied, and any target-scoped class must name its target. Failing any of
|
must be satisfied, any target-scoped class must name its target, and broad
|
||||||
|
classes must satisfy the recovery-playbook attempt-log gate. Failing any of
|
||||||
these makes `allow_restart` `false`.
|
these makes `allow_restart` `false`.
|
||||||
2. **Drain-proof gate** (#661) — a valid, unexpired, clean proof bound to the
|
2. **Drain-proof gate** (#661) — a valid, unexpired, clean proof bound to the
|
||||||
current impact fingerprint, or an authorized break-glass.
|
current impact fingerprint, or an authorized break-glass.
|
||||||
@@ -127,7 +134,10 @@ the authorization that produced it.
|
|||||||
|
|
||||||
### Break-glass
|
### Break-glass
|
||||||
|
|
||||||
Break-glass bypasses the **drain proof only** — never the restart-class matrix.
|
Break-glass bypasses the **drain proof only** — never the restart-class matrix
|
||||||
|
(role/permission). Separately, authorized break-glass also satisfies the #669
|
||||||
|
attempt-log requirement for broad restarts (rolling/full/host), because that
|
||||||
|
gate is not a class-matrix permission check.
|
||||||
It is honoured solely when `request_break_glass` is set *and* the environment
|
It is honoured solely when `request_break_glass` is set *and* the environment
|
||||||
carries `GITEA_BREAKGLASS_RESTART_AUTHORIZATION`; like operator override, the
|
carries `GITEA_BREAKGLASS_RESTART_AUTHORIZATION`; like operator override, the
|
||||||
tool argument expresses caller intent and cannot be self-asserted by a worker
|
tool argument expresses caller intent and cannot be self-asserted by a worker
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Remote-MCP coupling inventory
|
||||||
|
|
||||||
|
Every place the Gitea MCP server depends on being a local, client-spawned, stdio-attached
|
||||||
|
process on the operator's machine.
|
||||||
|
|
||||||
|
- **Issue:** #930 (Remote-MCP 01), child 1 of epic #929.
|
||||||
|
- **Generated against commit:** `7bf4f1258451823a55b36d2157e74f8457165088` (`master`).
|
||||||
|
- **Anchors:** every `file:line` below resolves at the commit above and at the commit that
|
||||||
|
adds this document. This change adds one new file and edits no existing file, so no
|
||||||
|
existing line number shifts between the two.
|
||||||
|
- **Scope:** documentation only. No server behavior changes in this child.
|
||||||
|
|
||||||
|
## How to read an entry
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ----- | ------- |
|
||||||
|
| **Anchor** | `file:line` at the commit under review. |
|
||||||
|
| **Assumes today** | What the code takes for granted while running as a local stdio process. |
|
||||||
|
| **Observes remotely** | What the same code would actually see on a shared remote host. |
|
||||||
|
| **Class** | One of: *portable as written*, *needs a seam*, *needs a replacement*, *cannot be remote*. |
|
||||||
|
| **Owner** | Exactly one epic child (#931–#939) responsible for the fix. |
|
||||||
|
|
||||||
|
Classification meanings:
|
||||||
|
|
||||||
|
- **portable as written** — the code is already transport-, host-, and principal-neutral; it
|
||||||
|
moves unchanged once its inputs are supplied by a remote-aware caller.
|
||||||
|
- **needs a seam** — the logic is correct but is wired to a hard-coded local source. It needs
|
||||||
|
an injection point, not new semantics.
|
||||||
|
- **needs a replacement** — the semantics themselves are local-only. A remote deployment
|
||||||
|
needs a differently-defined mechanism, not the same mechanism relocated.
|
||||||
|
- **cannot be remote** — the operation is inherently about the operator's own machine
|
||||||
|
(its process table, its keychain, its checkout). It must either stay local behind an
|
||||||
|
explicit boundary or be deleted from the remote surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Transport bind
|
||||||
|
|
||||||
|
The transport is bound literally, once, at process start, and the bound value is the root of
|
||||||
|
the mutation-authorization chain.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| T1 | `gitea_mcp_server.py:23750` | The single production bind call passes the literal `transport="stdio"` immediately before the server loop. | The literal is wrong for any non-stdio deployment; there is no parameter to change it. | needs a seam | #931 |
|
||||||
|
| T2 | `mcp_daemon_guard.py:45` | `_PRODUCTION_TRANSPORTS = frozenset({"stdio"})` is the closed allowlist of production transports. | A remote transport name is rejected by the allowlist before any other check runs. | needs a seam | #931 |
|
||||||
|
| T3 | `mcp_daemon_guard.py:174` | `bind_native_mcp_transport` raises `UnsanctionedRuntimeError` for any transport outside `_PRODUCTION_TRANSPORTS` (raise at `mcp_daemon_guard.py:187`). | The remote server fails to start rather than degrading; the failure is correct, but the allowlist is the only thing that must change. | needs a seam | #931 |
|
||||||
|
| T4 | `mcp_daemon_guard.py:328` | `is_native_mcp_transport()` asserts a process-local runtime record whose `pid` matches `os.getpid()` and whose phase is `transport_bound`. The predicate itself names no transport. | Unchanged semantics: one server process that bound one transport. It stays true on a remote host. | portable as written | #931 |
|
||||||
|
| T5 | `mcp_daemon_guard.py:349` | `is_production_native_mcp_transport()` adds only a `mode == production` check on top of T4. | Unchanged. | portable as written | #931 |
|
||||||
|
| T6 | `irrecoverable_provenance.py:497` | `assess_transport_for_auth_mint()` requires production native transport before minting non-forgeable recovery authorization (#709 F1). | The gate is transport-agnostic in form, but its guarantee — "an ordinary Python process cannot reach this" — is currently underwritten by the stdio bind. Under a remote transport the guarantee must be re-derived from the authenticated session, not from the bind. | needs a seam | #931 |
|
||||||
|
| T7 | `gitea_mcp_server.py:8375` | Consumer: refuses to proceed unless `assess_transport_for_auth_mint()` allows. | Unchanged given a corrected T6. | portable as written | #931 |
|
||||||
|
| T8 | `gitea_mcp_server.py:8624` | Second consumer of the same gate on the confirmation path. | Unchanged given a corrected T6. | portable as written | #931 |
|
||||||
|
| T9 | `mcp_server.py:4` | Module docstring asserts "Runs over stdio." as a property of the server. | The stated contract becomes false on the remote deployment and is load-bearing documentation for operators. | needs a replacement | #931 |
|
||||||
|
|
||||||
|
## 2. Launch provenance
|
||||||
|
|
||||||
|
Mutations fail closed unless the process can prove a client launched it with real stdio pipes
|
||||||
|
and `GITEA_CLIENT_MANAGED` provenance. Every proof in this section is a statement about the
|
||||||
|
local operating system.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| P1 | `gitea_mcp_server.py:14588` | `_is_client_managed_process()` derives provenance from `GITEA_CLIENT_MANAGED` / `GITEA_MCP_CLIENT_MANAGED` / `GITEA_SERVER_PROVENANCE` / `GITEA_FORCE_CLIENT_MANAGED` on this process's own environment. | A long-lived remote process has one environment for all callers, so a per-process env var can no longer say anything about the caller that issued a request. | needs a replacement | #934 |
|
||||||
|
| P2 | `gitea_mcp_server.py:14606` | Falls back to `sys.stdin.isatty()`: an active TTY on stdin means a human launched it from a terminal, so refuse. | A remote server has no meaningful stdin. The signal is absent, not merely different. | cannot be remote | #934 |
|
||||||
|
| P3 | `gitea_mcp_server.py:14618` | `_provenance_mutation_block()` emits `blocker_kind: "unsupported_manual_launch"` and a "reconnect the IDE/client-managed MCP namespace" remediation. | The block shape is reusable; its predicate and its remediation text are both stdio-specific. | needs a seam | #934 |
|
||||||
|
| P4 | `gitea_mcp_server.py:20599` | `_check_mcp_runtimes_diagnostics()` shells `ps -o pid,lstart,command -ax` and greps for `mcp_server.py` to find peer role servers. | On a shared host the process table lists unrelated tenants' processes, or none at all under a container. Peer discovery by `ps` has no remote meaning. | cannot be remote | #934 |
|
||||||
|
| P5 | `gitea_mcp_server.py:20702` | More than one process per `GITEA_MCP_PROFILE` in the local process table is reported as a duplicate-launch fault. | A remote endpoint is expected to serve many concurrent sessions per role. "Two processes for one role" becomes the normal case, so the check inverts from a safety net into a false wall. | cannot be remote | #934 |
|
||||||
|
| P6 | `gitea_mcp_server.py:20715` | Processes lacking client-managed provenance are ignored for runtime freshness and reported as manual launches. | Same defect as P5: correctness depends on enumerating local peers. | cannot be remote | #934 |
|
||||||
|
| P7 | `gitea_config.py:1172` | `RECOGNIZED_GITEA_ENV_KEYS` is the allowlist of `GITEA_*` env vars a legitimately launched server may carry; anything else is contamination. | Configuration on a remote host arrives from deployment tooling, not from a client-authored env block. The allowlist keeps working mechanically but stops proving anything about provenance. | needs a replacement | #934 |
|
||||||
|
| P8 | `gitea_mcp_server.py:20683` | The unsupported-env scan applies `RECOGNIZED_GITEA_ENV_KEYS` to *other* processes' environments harvested via `ps eww <pid>`. | Reading another process's environment is unavailable or prohibited across tenants, and is not exposed in this form outside macOS/BSD `ps`. | cannot be remote | #934 |
|
||||||
|
| P9 | `mcp_daemon_guard.py:126` | `mark_sanctioned_daemon()` requires the claiming stack frame's resolved absolute path to be the canonical `mcp_server.py` / `gitea_mcp_server.py` next to the guard module; basename spoofing is rejected. | Entrypoint-path identity still exists on a remote host, but it authenticates the *deployment*, not the *caller*. It must be kept and demoted from "authorizes mutations" to "authorizes the process". | needs a seam | #934 |
|
||||||
|
| P10 | `gitea_config.py:1233` | The client-config generator emits `"GITEA_CLIENT_MANAGED": "1"` into each generated MCP client entry, alongside `GITEA_MCP_CONFIG` / `GITEA_MCP_PROFILE`. | A remote endpoint is addressed by URL and credential, not by a spawn command with an env block. This generator produces the wrong artifact entirely. | needs a replacement | #938 |
|
||||||
|
| P11 | `mcp_namespace_health.py:232` | Namespace health classifies a namespace as `client_managed` or `manual_launch` from the reported env summary. | During dual-run, local and remote namespaces coexist and must both be classifiable; a two-valued local/manual axis cannot express "remote endpoint, authenticated session". | needs a replacement | #939 |
|
||||||
|
| P12 | `gitea_mcp_server.py:18161` | The diagnostics payload reports `server_provenance` as exactly `"client_managed"` or `"manual_launch"`. | This is the field a cutover operator reads to confirm which deployment served a call. It must gain a remote value before dual-run parity can be validated. | needs a replacement | #939 |
|
||||||
|
|
||||||
|
## 3. Role binding
|
||||||
|
|
||||||
|
Role separation is currently enforced by *which process a call reaches*. The process is pinned
|
||||||
|
to one role for its lifetime by an environment variable.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| R1 | `gitea_config.py:54` | `ENV_PROFILE = "GITEA_MCP_PROFILE"` is the single source of the active profile, read from the process environment. | One shared process serves several principals; a process-wide profile cannot answer "who is calling now". This is the root of the coupling. | needs a replacement | #932 |
|
||||||
|
| R2 | `review_workflow_load.py:95` | Reads `GITEA_MCP_PROFILE` directly to decide the reviewer workflow binding. | Reads the deployment's profile, not the caller's, silently granting or denying the wrong role. | needs a replacement | #932 |
|
||||||
|
| R3 | `mcp_discoverability.py:152` | Reads `GITEA_MCP_PROFILE` to describe the namespace to the client. | Correct logic, wrong input source; it needs the request principal injected. | needs a seam | #932 |
|
||||||
|
| R4 | `webui/deployment_boundary.py:115` | Reads `GITEA_MCP_PROFILE` to classify the deployment boundary for the console. | Same as R3. | needs a seam | #932 |
|
||||||
|
| R5 | `gitea_mcp_server.py:21106` | Remediation text instructs the operator to "Relaunch the server with `GITEA_MCP_PROFILE` set to a profile that has the required permission". | Relaunching a shared remote endpoint to change one caller's role is not a valid instruction; it would re-role every other session. | needs a replacement | #932 |
|
||||||
|
| R6 | `native_mcp_preference.py:93` | Detects shell commands that override `GITEA_MCP_PROFILE` away from the session (`native_mcp_preference.py:223`) and flags them as CLI auth divergence. | The divergence check is genuinely useful and survives, but its notion of "the session's profile" must come from the request principal. | needs a seam | #932 |
|
||||||
|
| R7 | `gitea_mcp_server.py:20671` | Recovers a peer server's role by regexing `GITEA_MCP_PROFILE=` out of that process's environment. | Depends on P4/P8 process-table access; role discovery by peer-env scraping has no remote analogue. | cannot be remote | #932 |
|
||||||
|
|
||||||
|
## 4. Credentials
|
||||||
|
|
||||||
|
Every token resolves, directly or indirectly, from one human's macOS keychain.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| C1 | `gitea_config.py:956` | `_keychain_token()` shells `security find-generic-password -s <item> -w`. | `security(1)` is a macOS binary reading the calling user's login keychain. It does not exist on a Linux host and would be the wrong identity even on a shared Mac. | cannot be remote | #933 |
|
||||||
|
| C2 | `gitea_config.py:974` | `resolve_token(profile, keychain_lookup=_keychain_token)` dispatches on `auth.type` of `env` or `keychain`, defaulting the lookup to C1. | The injectable `keychain_lookup` parameter is the existing seam; a remote credential provider plugs in here without changing the dispatch. | needs a seam | #933 |
|
||||||
|
| C3 | `gitea_config.py:1015` | `keychain_auth(item_id)` constructs the `{"type": "keychain", "id": ...}` reference stored in profiles. | The reference type itself encodes "macOS keychain" into persisted config. A remote provider needs a new auth reference type, not a new value of this one. | needs a replacement | #933 |
|
||||||
|
| C4 | `mcp_daemon_guard.py:440` | `assert_keychain_access_allowed()` fails closed for git-credential keychain fill outside a sanctioned daemon, with an operator opt-out env var. | The gate protects a mechanism that will not exist remotely. Its replacement must gate the *credential provider* call, not the keychain call, or the protection silently lapses. | needs a replacement | #933 |
|
||||||
|
| C5 | `sentry_incident_bridge.py:190` | `resolve_token(env)` resolves the Sentry token from an injected env mapping with no keychain path. | Already host-neutral; it is the shape the Gitea credential path should converge on. | portable as written | #933 |
|
||||||
|
| C6 | `gitea_mcp_server.py:18469` | The profile-audit tool calls `gitea_config.resolve_token(p)` for every configured profile to report "credentials present" without networking. | On a remote host this would materialize every principal's credential inside one process — an audit surface that becomes a credential-aggregation risk. | needs a seam | #933 |
|
||||||
|
|
||||||
|
## 5. Runtime freshness
|
||||||
|
|
||||||
|
The mutation gate is defined as "the commit this process started at matches the checkout on
|
||||||
|
this disk, and both match live master". Two of those three terms are local-disk facts.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| F1 | `master_parity_gate.py:168` | `capture_startup_parity(root)` reads git `HEAD` from the server's own root once at startup and returns it as the baseline. | A remote host carries a deployed artifact, not the operator's checkout. Its `HEAD` says nothing about the operator's working tree, which is the thing the gate exists to protect. | cannot be remote | #935 |
|
||||||
|
| F2 | `master_parity_gate.py:255` | `mutation_safe = determinable and in_parity and live_known and not live_stale` — a conjunction of two local-HEAD comparisons and one live-remote comparison. | Two of the three conjuncts lose meaning, so the whole verdict does. A remote deployment needs a redefined, testable freshness predicate rather than this one relocated. | needs a replacement | #935 |
|
||||||
|
| F3 | `master_parity_gate.py:164` | The live-remote head is probed and cached per `(root, remote, branch)`, keyed on the local root. | The live-remote probe is the one conjunct that survives; it needs a key that is not the operator's filesystem path. | needs a seam | #935 |
|
||||||
|
| F4 | `gitea_mcp_server.py:18262` | `gitea_assess_master_parity` publishes `startup_head` / `local_head` / `live_remote_head` / `mutation_safe` as the authoritative mutation-safety verdict. | The tool's contract is consumed by every mutation caller and by the operator; it must keep its shape while its semantics are redefined, or every consumer breaks at once. | needs a replacement | #935 |
|
||||||
|
| F5 | `gitea_mcp_server.py:23054` | Falls back to `_process_boot_head_sha` — the commit this process booted at — when the parity payload has no `startup_head`. | Same defect as F1, in a fallback path that is easy to miss when F1 is fixed. | needs a seam | #935 |
|
||||||
|
| F6 | `gitea_mcp_server.py:20615` | Staleness is also inferred from `os.path.getmtime()` of `gitea_mcp_server.py` under `PROJECT_ROOT` (`gitea_mcp_server.py:20611`), compared against peer process start times. | File mtime on a deployed artifact tracks the deploy, not the operator's edits, and the peer start times it is compared against come from the unavailable process table (P4). | cannot be remote | #935 |
|
||||||
|
|
||||||
|
## 6. Local filesystem
|
||||||
|
|
||||||
|
Author and reviewer tools act directly on the operator's checkout.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| L1 | `gitea_mcp_server.py:10122` | `gitea_bootstrap_author_issue_worktree` creates and binds a git worktree on the server's own disk. | The remote host has no operator checkout to add a worktree to. Executing this remotely would act on the wrong disk while reporting success. | cannot be remote | #936 |
|
||||||
|
| L2 | `gitea_mcp_server.py:190` | `ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"` and `AUTHOR_WORKTREE_ENV` (`gitea_mcp_server.py:191`) carry the active workspace as process-wide environment. | Process-wide workspace state cannot represent per-session workspaces on a shared endpoint. | needs a replacement | #936 |
|
||||||
|
| L3 | `gitea_mcp_server.py:9801` | Binding a worktree writes `os.environ["GITEA_AUTHOR_WORKTREE"]` and `os.environ["GITEA_ACTIVE_WORKTREE"]` (`gitea_mcp_server.py:9802`), mutating global process state. | One session's bind would silently retarget every other concurrent session in the same process. This is a correctness bug the moment concurrency is real. | needs a replacement | #936 |
|
||||||
|
| L4 | `reviewer_inventory_worktree.py:48` | `_BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I)` requires review worktree paths to sit under `branches/`. | A path convention on the operator's machine, asserted as a validation rule. It needs to become a property of a declared workspace, not a substring test. | needs a seam | #936 |
|
||||||
|
| L5 | `stable_control_runtime.py:54` | `DEV_WORKTREE_SEGMENT = "branches"` classifies a process root as a development worktree by path segment. | Same class of assumption as L4, on the runtime-classification side. | needs a seam | #936 |
|
||||||
|
| L6 | `mcp_server.py:42` | `check_conflict_markers()` runs at import and `os.walk`s the install directory for unresolved conflict markers, `sys.exit(1)` on a hit. | On a remote host it scans a deployed artifact, which by construction never has conflict markers — so the guard passes trivially and stops protecting the thing it was written to protect. | needs a replacement | #936 |
|
||||||
|
| L7 | `role_session_router.py:487` | `check_mid_merge()` reports infra-stop from `.git/MERGE_HEAD`, `rebase-merge`, `rebase-apply` and a source conflict scan under the server's project root. | Same inversion as L6: it would report the deployment's git state, not the operator's. | needs a replacement | #936 |
|
||||||
|
| L8 | `author_issue_bootstrap.py:996` | Enumerates worktrees with `git -C <root> worktree list --porcelain`. | Requires a real local clone with real worktrees; there is nothing equivalent to enumerate remotely. | cannot be remote | #936 |
|
||||||
|
| L9 | `mcp_server.py:10` | Redirects `sys.stderr` to the fixed path `/tmp/mcp_server_stderr.log` outside pytest. | A single fixed `/tmp` path is shared by every concurrent server on a host and is not a deployment's logging surface. | needs a replacement | #938 |
|
||||||
|
| L10 | `gitea_mcp_server.py:2314` | `ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json"` — the legacy single global lock slot. | One global `/tmp` slot per host cannot represent concurrent remote sessions and is world-visible on a shared machine. | needs a replacement | #937 |
|
||||||
|
| L11 | `issue_lock_provenance.py:14` | `ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")` keeps the same `/tmp` default in the provenance path. | Same as L10; the env override is a local escape hatch, not a remote design. | needs a replacement | #937 |
|
||||||
|
|
||||||
|
## 7. Durable state
|
||||||
|
|
||||||
|
Locks, leases, session state, and the control-plane database live in the operator's home
|
||||||
|
directory and are keyed on local PIDs.
|
||||||
|
|
||||||
|
| ID | Anchor | Assumes today | Observes remotely | Class | Owner |
|
||||||
|
| -- | ------ | ------------- | ----------------- | ----- | ----- |
|
||||||
|
| S1 | `issue_lock_store.py:26` | `DEFAULT_LOCK_DIR = ~/.cache/gitea-tools/issue-locks` — per-issue lock files under one user's home. | A shared endpoint has no single operator home; per-user paths make locks invisible across sessions and hosts. | needs a replacement | #937 |
|
||||||
|
| S2 | `issue_lock_store.py:83` | `session_pointer_path()` names the session pointer file `session-<os.getpid()>.json`. | Many sessions share one PID on a remote server, so the pointer collapses to a single slot and sessions overwrite each other. | cannot be remote | #937 |
|
||||||
|
| S3 | `issue_lock_store.py:98` | `is_process_alive(pid)` decides lock liveness by probing the local process table. | A PID recorded by one host is meaningless on another, and may coincidentally match a live unrelated process. | cannot be remote | #937 |
|
||||||
|
| S4 | `issue_lock_store.py:213` | Lock records stamp `session_pid` and `pid` from `os.getpid()`. | The recorded identity no longer distinguishes sessions; ownership checks silently pass for the wrong caller. | needs a replacement | #937 |
|
||||||
|
| S5 | `mcp_session_state.py:27` | `DEFAULT_STATE_DIR = ~/.cache/gitea-tools/session-state`, mode `0o700`. | Same home-directory coupling as S1, for review decision locks and workflow proofs. | needs a replacement | #937 |
|
||||||
|
| S6 | `mcp_session_state.py:559` | Session bodies stamp `session_pid` and `writer_pid` from `os.getpid()` (`mcp_session_state.py:560`). | Writer attribution collapses across concurrent sessions in one process. | needs a replacement | #937 |
|
||||||
|
| S7 | `control_plane_db.py:47` | `DEFAULT_DB_PATH = ~/.cache/gitea-tools/control-plane/control_plane.sqlite3`. | A per-user SQLite file is not reachable by, or safe for, multiple remote sessions or multiple hosts. | needs a replacement | #937 |
|
||||||
|
| S8 | `control_plane_db.py:386` | `sqlite3.connect(self.db_path, timeout=30)` — single-writer file locking tuned for one local process. | SQLite's write lock does not extend across hosts and degrades sharply under real concurrency; the store needs a concurrency-safe backend. | needs a replacement | #937 |
|
||||||
|
| S9 | `control_plane_db.py:1145` | Lease rows record `owner_pid` defaulting to `os.getpid()` (also `control_plane_db.py:2039`). | PID-keyed lease ownership is unusable across hosts and ambiguous within one shared process. | cannot be remote | #937 |
|
||||||
|
| S10 | `mcp_daemon_guard.py:53` | `_DEFAULT_SESSION_STATE_DIR` is pinned once at transport bind so a later `GITEA_MCP_SESSION_STATE_DIR` change cannot manufacture a second authority domain (#695 AC2). | The single-authority-domain invariant is exactly right and must be preserved; only its backing location needs to move. | needs a seam | #937 |
|
||||||
|
| S11 | `gitea_mcp_server.py:11875` | Reviewer-lease reclaim reads `owner_pid_alive` from the lease freshness record to decide whether an owner is dead. | Consumes S3/S9; a false "owner alive" or "owner dead" here reclaims or refuses a live lease. This is the highest-consequence consumer of PID liveness. | cannot be remote | #937 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### Entries per category
|
||||||
|
|
||||||
|
| Category | Entries |
|
||||||
|
| -------- | ------: |
|
||||||
|
| 1. Transport bind | 9 |
|
||||||
|
| 2. Launch provenance | 12 |
|
||||||
|
| 3. Role binding | 7 |
|
||||||
|
| 4. Credentials | 6 |
|
||||||
|
| 5. Runtime freshness | 6 |
|
||||||
|
| 6. Local filesystem | 11 |
|
||||||
|
| 7. Durable state | 11 |
|
||||||
|
| **Total** | **62** |
|
||||||
|
|
||||||
|
No category is empty, so no "this category has no coupling" justification is required.
|
||||||
|
|
||||||
|
### Entries per classification
|
||||||
|
|
||||||
|
| Classification | Entries |
|
||||||
|
| -------------- | ------: |
|
||||||
|
| portable as written | 5 |
|
||||||
|
| needs a seam | 16 |
|
||||||
|
| needs a replacement | 26 |
|
||||||
|
| cannot be remote | 15 |
|
||||||
|
| **Total** | **62** |
|
||||||
|
|
||||||
|
### Category × classification
|
||||||
|
|
||||||
|
| Category | portable | seam | replacement | cannot | Total |
|
||||||
|
| -------- | -------: | ---: | ----------: | -----: | ----: |
|
||||||
|
| 1. Transport bind | 4 | 4 | 1 | 0 | 9 |
|
||||||
|
| 2. Launch provenance | 0 | 2 | 5 | 5 | 12 |
|
||||||
|
| 3. Role binding | 0 | 3 | 3 | 1 | 7 |
|
||||||
|
| 4. Credentials | 1 | 2 | 2 | 1 | 6 |
|
||||||
|
| 5. Runtime freshness | 0 | 2 | 2 | 2 | 6 |
|
||||||
|
| 6. Local filesystem | 0 | 2 | 7 | 2 | 11 |
|
||||||
|
| 7. Durable state | 0 | 1 | 6 | 4 | 11 |
|
||||||
|
| **Total** | **5** | **16** | **26** | **15** | **62** |
|
||||||
|
|
||||||
|
### Entries per epic child
|
||||||
|
|
||||||
|
Every child from 2 through 10 is named by at least one entry, and every entry names exactly
|
||||||
|
one child.
|
||||||
|
|
||||||
|
| Child | Issue | Title | Entries | IDs |
|
||||||
|
| ----: | ----- | ----- | ------: | --- |
|
||||||
|
| 2 | #931 | Transport-neutral bind seam | 9 | T1–T9 |
|
||||||
|
| 3 | #932 | Per-request principal resolution | 7 | R1–R7 |
|
||||||
|
| 4 | #933 | Server-side credential provider | 6 | C1–C6 |
|
||||||
|
| 5 | #934 | Remote-session provenance | 9 | P1–P9 |
|
||||||
|
| 6 | #935 | Redefined master-parity gate | 6 | F1–F6 |
|
||||||
|
| 7 | #936 | Local-filesystem vs remotable tool split | 8 | L1–L8 |
|
||||||
|
| 8 | #937 | Concurrency-safe session, lock, and lease state | 13 | L10, L11, S1–S11 |
|
||||||
|
| 9 | #938 | Authenticated remote MCP endpoint | 2 | P10, L9 |
|
||||||
|
| 10 | #939 | Dual-run cutover and rollback | 2 | P11, P12 |
|
||||||
|
| | | **Total** | **62** | |
|
||||||
|
|
||||||
|
## Notes for downstream children
|
||||||
|
|
||||||
|
- **The three highest-risk entries are P5, F2, and S11.** Each is a guard that does not
|
||||||
|
merely stop working remotely — it inverts. P5 turns concurrency into a reported fault,
|
||||||
|
F2 returns a verdict computed from terms that no longer mean anything, and S11 reclaims
|
||||||
|
or refuses leases on a PID-liveness answer that is wrong rather than unknown. A gate that
|
||||||
|
fails open while still reporting green is worse than one that fails to start.
|
||||||
|
- **T4, T5, T7, T8, and C5 are the portable core.** They show the target shape: predicates
|
||||||
|
over injected inputs, with no reference to the host, the process table, or the operator's
|
||||||
|
disk.
|
||||||
|
- **The keychain seam already exists** at C2 (`resolve_token`'s injectable `keychain_lookup`).
|
||||||
|
#933 should widen that seam rather than introduce a parallel path, and must remember C4 —
|
||||||
|
the guard protecting the old mechanism has to be re-pointed, or the protection lapses
|
||||||
|
silently when the mechanism is replaced.
|
||||||
|
- **`branches/` appears as a validation rule in at least two independent places** (L4, L5).
|
||||||
|
Path-substring conventions tend to have more copies than expected; #936 should re-grep
|
||||||
|
rather than trust this list to be exhaustive for that specific pattern.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Sanctioned Recovery Playbooks & Controls (Phase 2 #644)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Stale runtimes, worktree binding mismatches, and un-reconciled merged branches previously required expert manual shell recovery. Manual process kills (`pkill -f mcp_server.py`) are strictly forbidden and classified as runtime contamination ([#630](sanctioned-restart-controls.md)).
|
||||||
|
|
||||||
|
Phase 2 introduces **sanctioned recovery playbooks and controls** into the Web Console:
|
||||||
|
- **Diagnose**: Surface stale runtimes, worktree binding errors, contamination markers, and worktree anomalies via health & inventory APIs.
|
||||||
|
- **Preview**: Render mutation ledgers and exact confirmation phrases for recovery playbooks.
|
||||||
|
- **Confirm & Apply**: Execute sanctioned recovery actions through gated, audited paths.
|
||||||
|
- **Verify**: Revalidate control-plane state post-recovery before claiming clean status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recovery Playbook Taxonomy
|
||||||
|
|
||||||
|
| Playbook ID | Action ID | Minimum Role | Target / Scope | Description |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `clear_stale_binding` | `system.clear_stale_binding` | Operator | Active worktree binding | Clear provably missing or superseded `GITEA_ACTIVE_WORKTREE` binding ([#702](../stale_binding_recovery.py)). |
|
||||||
|
| `rebind_session_worktree` | `system.rebind_session_worktree` | Operator | Session worktree | Rebind or synchronize session worktree to verified lease worktree ([#864](../dirty_same_claimant_session_rebind.py)). |
|
||||||
|
| `reconcile_cleanups` | `system.reconcile_cleanups` | Controller | Worktree hygiene | Execute reconciler cleanup preview and apply for merged/superseded PR branches. |
|
||||||
|
| `sanctioned_restart` | `system.restart_namespace` | Admin | MCP Namespace | Restart MCP daemon gracefully via host supervisor ([#642](sanctioned-restart-controls.md)). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wizard Workflow (Diagnose → Preview → Confirm → Verify)
|
||||||
|
|
||||||
|
### 1. Diagnose (`GET /api/v1/system/recovery/diagnose`)
|
||||||
|
Runs control-plane diagnostics:
|
||||||
|
- **Stale Runtime**: Mismatch between running daemon HEAD, local checkout HEAD, and remote-tracking HEAD.
|
||||||
|
- **Worktree Binding**: Missing path (`provably_stale_missing_path`), unverified inherited binding (`unverified_inherited`), or superseded binding (`superseded_by_session_lease`).
|
||||||
|
- **Contamination**: Checks for live contamination markers from unmanaged process kills.
|
||||||
|
- **Worktree Anomalies**: Scans `branches/` directory for un-reconciled cleanups or missing preserved worktrees.
|
||||||
|
|
||||||
|
Returns `RecoveryDiagnosis` with eligible playbooks.
|
||||||
|
|
||||||
|
### 2. Preview (`POST /api/v1/system/recovery/preview`)
|
||||||
|
Takes `playbook_id` and optional `target`/`params`.
|
||||||
|
Returns:
|
||||||
|
- **Mutation Ledger**: Step-by-step sequence of actions.
|
||||||
|
- **Confirmation Phrase**: Exact phrase required to authorize execution (e.g., `confirm clear_stale_binding`).
|
||||||
|
- **Authorization Decision**: RBAC check against the operator's principal.
|
||||||
|
|
||||||
|
### 3. Apply (`POST /api/v1/system/recovery/apply`)
|
||||||
|
Requires `playbook_id` and matching `confirmation` phrase. Gates run in this order, and each fails closed before anything is mutated:
|
||||||
|
|
||||||
|
1. **RBAC and execution phase** (`console_authz.authorize(..., for_execution=True)`). The phase branch only applies when `for_execution` is set. While `ACTIVE_PHASE` is `1`, every phase-2 recovery action is refused with `phase_not_active`, so no recovery playbook writes yet. Preview reports the same decision under `execution_authorization` / `execution_blocked_reason`.
|
||||||
|
2. **Confirmation phrase** (`confirmation_matches`).
|
||||||
|
3. **Contamination rules** ([#630](sanctioned-restart-controls.md)): the live marker is read from the session inventory and assessed under the gated task key `console_recovery_apply`. A contaminated runtime must be cleared through the reconciler cleanup playbook, which is the one playbook exempted from this gate because it is the designated remedy. The marker is also forwarded to `sanctioned_restart.execute_restart`, so a restart cannot launder a contaminated runtime.
|
||||||
|
|
||||||
|
Apply then executes the sanctioned recovery logic against the **live** process environment — not a copy — and records an audit entry in `console_audit`. A playbook that leaves the binding unchanged reports `performed: false`; `binding_before`, `binding_after`, and `binding_changed` are returned so a no-op cannot read as success.
|
||||||
|
|
||||||
|
Apply does **not** enforce master parity. Parity is reported by Diagnose ([#610](../master_parity_gate.py)) as evidence for the operator; it is not a precondition of this endpoint.
|
||||||
|
|
||||||
|
### 4. Verify (`POST /api/v1/system/recovery/verify`)
|
||||||
|
Re-evaluates control-plane diagnostics post-recovery and **reports** `clean`, `stale_runtime_clean`, `binding_clean`, `binding_classification`, and `contamination_clean`. It reports; it does not assert or block. State is read fresh rather than from the mapping a mutation just wrote. An `unverified_inherited` binding is reported as not clean, because unproven is not clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety & Governance Principles
|
||||||
|
|
||||||
|
1. **No Manual `pkill`**: Direct process killing remains forbidden and is recorded as contamination.
|
||||||
|
2. **Auditability**: Every recovery preview and execution is logged in the console audit trail.
|
||||||
|
3. **Master Parity & Dual Control**: High-privilege recovery actions require controller/admin roles and explicit confirmation phrases.
|
||||||
@@ -94,6 +94,10 @@ already define, and a regression test asserts each mapping matches.
|
|||||||
| `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 |
|
| `record_analytics_usage` | operator | gated_write | `runtime.record_analytics_usage` | Yes | No | No | 2 |
|
||||||
| `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 |
|
| `system.reload_namespace` | controller | privileged | `runtime.reload_namespace` | Yes | No | No | 2 |
|
||||||
| `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 |
|
| `system.restart_namespace` | admin | destructive | `runtime.restart_namespace` | Yes | **Yes** | **Yes** | 2 |
|
||||||
|
| `system.clear_stale_binding` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
||||||
|
| `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
||||||
|
| `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 |
|
||||||
|
| `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
||||||
|
|
||||||
**Dual control** means the acting principal may not be the sole authority: a
|
**Dual control** means the acting principal may not be the sole authority: a
|
||||||
second distinct principal must confirm. **Break-glass** means the action is
|
second distinct principal must confirm. **Break-glass** means the action is
|
||||||
@@ -112,6 +116,12 @@ by the console — both hand off to a host supervisor, and neither exposes a raw
|
|||||||
process kill. See
|
process kill. See
|
||||||
[`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642).
|
[`sanctioned-restart-controls.md`](sanctioned-restart-controls.md) (#642).
|
||||||
|
|
||||||
|
`initiate_workflow` (#643) is operator-class because its outcome is a *claim*,
|
||||||
|
not a Gitea verdict. Requesting reviewer or merger work reserves that work
|
||||||
|
through the allocator; it does not grant the right to approve or merge, which
|
||||||
|
stays with the MCP role profile and its own capability gates. See
|
||||||
|
[`webui-requests.md`](webui-requests.md).
|
||||||
|
|
||||||
### Authorization decision
|
### Authorization decision
|
||||||
|
|
||||||
`authorize(action_id, principal, for_execution=False)` returns a decision
|
`authorize(action_id, principal, for_execution=False)` returns a decision
|
||||||
@@ -126,9 +136,24 @@ record and **denies by default**. The deny reasons are closed and enumerated:
|
|||||||
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
||||||
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
||||||
|
|
||||||
There is no implicit allow branch. Even the allow result reports
|
There is no implicit allow branch.
|
||||||
`execution_enabled: false` while the console is in Phase 1, so no caller can
|
|
||||||
read an allow as permission to mutate.
|
`execution_enabled` on the decision reports whether the action has a live
|
||||||
|
execution path at all, and is computed by `execution_wired(action)`. There are
|
||||||
|
exactly two ways to be wired:
|
||||||
|
|
||||||
|
1. the action's `phase` is at or below `ACTIVE_PHASE`; or
|
||||||
|
2. the action declares an `execution_env_flag` **and** that variable is set.
|
||||||
|
|
||||||
|
Every action that declares no flag therefore reports `execution_enabled: false`
|
||||||
|
while the console is in Phase 1, so no caller can read an allow as permission
|
||||||
|
to mutate. The per-action flag exists because raising `ACTIVE_PHASE` would
|
||||||
|
enable execution for every action of that phase at once, including ones whose
|
||||||
|
execution path is not implemented. One implemented action goes live on its own
|
||||||
|
flag instead of dragging its unimplemented phase-mates with it.
|
||||||
|
|
||||||
|
`initiate_workflow` is the only action that currently declares a flag
|
||||||
|
(`WEBUI_REQUESTS_EXECUTION`), and it stays denied until an operator sets it.
|
||||||
|
|
||||||
## Secret redaction
|
## Secret redaction
|
||||||
|
|
||||||
@@ -235,13 +260,22 @@ second one. The integration points are already wired and observable:
|
|||||||
instead of adding a parallel check.
|
instead of adding a parallel check.
|
||||||
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
||||||
policy, and audit policy as JSON for operators and tests.
|
policy, and audit policy as JSON for operators and tests.
|
||||||
|
- **`POST /api/v1/requests/preview` and `.../apply`** (#643) are the first
|
||||||
|
actions to use this model for a real execution path. Preview always returns a
|
||||||
|
decision and an audited `previewed` record; apply requires `confirm=true`,
|
||||||
|
emits `succeeded` or `denied`, and reserves work only through the allocator.
|
||||||
|
See [`webui-requests.md`](webui-requests.md).
|
||||||
|
|
||||||
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
|
A Phase 2 action must: use `execution_wired` rather than a private enable flag,
|
||||||
confirmation and dual-control flow the matrix already declares, emit a
|
implement the confirmation and dual-control flow the matrix already declares,
|
||||||
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
|
emit a `succeeded` or `failed` record alongside the `gitea_audit` mutation
|
||||||
keep `viewer` unable to reach any of it. Turning on execution without the
|
record, and keep `viewer` unable to reach any of it. Turning on execution
|
||||||
confirmation flow contradicts a declared requirement and is a review failure,
|
without the confirmation flow contradicts a declared requirement and is a
|
||||||
not a shortcut.
|
review failure, not a shortcut.
|
||||||
|
|
||||||
|
Raising `ACTIVE_PHASE` remains the way to open a whole phase at once, and is
|
||||||
|
deliberately *not* what #643 did: an action-scoped opt-in cannot enable an
|
||||||
|
action whose execution path nobody wrote.
|
||||||
|
|
||||||
## Local-dev mode
|
## Local-dev mode
|
||||||
|
|
||||||
@@ -294,6 +328,7 @@ Until Phase 2 wires it, probe protection rests on network placement alone, as
|
|||||||
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
||||||
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
||||||
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
||||||
|
| `WEBUI_REQUESTS_EXECUTION` | unset | Opt in to `initiate_workflow` execution (#643) |
|
||||||
|
|
||||||
All are read server-side only. None is ever rendered into a page or returned by
|
All are read server-side only. None is ever rendered into a page or returned by
|
||||||
an API.
|
an API.
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
|
|||||||
| `/sessions` | Runtime and session view (#641) — health + inventory sessions/namespaces/worktrees |
|
| `/sessions` | Runtime and session view (#641) — health + inventory sessions/namespaces/worktrees |
|
||||||
| `/api/sessions` | JSON export for the runtime/session view |
|
| `/api/sessions` | JSON export for the runtime/session view |
|
||||||
| `/api/v1/sessions` | Versioned alias of `/api/sessions` |
|
| `/api/v1/sessions` | Versioned alias of `/api/sessions` |
|
||||||
|
| `/gitea` | Gitea issue↔PR linkage console (#645) — both directions, with the evidence for each edge |
|
||||||
|
| `/api/v1/gitea/linkage` | JSON linkage export; `502` when the read could not be answered |
|
||||||
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
|
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
|
||||||
| `/timeline` | Phase 1 shell stub — workflow event timeline |
|
| `/timeline` | Phase 1 shell stub — workflow event timeline |
|
||||||
| `/policy` | Phase 1 shell stub — capability/role policy placeholder |
|
| `/policy` | Phase 1 shell stub — capability/role policy placeholder |
|
||||||
@@ -327,6 +329,68 @@ Honesty rules specific to this view:
|
|||||||
The write-time redactor is a narrow denylist and is not relied on. The field
|
The write-time redactor is a narrow denylist and is not relied on. The field
|
||||||
itself is kept — it is the `#630` evidence naming which daemon was killed.
|
itself is kept — it is the `#630` evidence naming which daemon was killed.
|
||||||
|
|
||||||
|
## Gitea issue/PR linkage (#645)
|
||||||
|
|
||||||
|
`/gitea` is the Phase 3 read-only linkage console: which PR carries which issue,
|
||||||
|
which issues are claimed by more than one PR, and what the latest Canonical
|
||||||
|
Thread Handoff on a thread said. Gitea remains the source of truth — this
|
||||||
|
surface reads it and never writes to it. There is no issue/PR editor, no review,
|
||||||
|
and no merge control.
|
||||||
|
|
||||||
|
Query parameters (all optional):
|
||||||
|
|
||||||
|
| Parameter | Meaning |
|
||||||
|
|-----------|---------|
|
||||||
|
| `project` | Registry project id to scope the read (default: first registry entry) |
|
||||||
|
| `state` | `open` (default) or `all`; `all` widens the window to merged/closed items, where a landed edge lives |
|
||||||
|
| `issue=N` / `pr=N` | Focus one thread and load *its* latest canonical handoff |
|
||||||
|
|
||||||
|
`GET /api/v1/gitea/linkage` returns the same model as JSON
|
||||||
|
(`schema_version: 1`). It answers `502` when the read could not be answered, so
|
||||||
|
an automated consumer cannot mistake a fail-closed payload for "no links exist".
|
||||||
|
The HTML page always answers `200` and renders the reason instead — an operator
|
||||||
|
view must show why a read failed rather than withhold the page.
|
||||||
|
|
||||||
|
### How an edge is found
|
||||||
|
|
||||||
|
Each edge carries the evidence that produced it, strongest first:
|
||||||
|
|
||||||
|
| Evidence | Meaning |
|
||||||
|
|----------|---------|
|
||||||
|
| `closes_keyword` | The PR title or body declares `closes/fixes/resolves #N`. Gitea itself acts on this keyword. |
|
||||||
|
| `branch_marker` | The PR head branch carries the canonical `(fix\|feat\|docs\|chore)/issue-N-…` marker minted by the issue lock. |
|
||||||
|
| `body_reference` | The PR body mentions `#N` with no closing keyword. A mention is not a claim to close. |
|
||||||
|
|
||||||
|
Only closing and branch-marker edges populate the **issue → PR** direction: a
|
||||||
|
bare mention is a cross-link, and counting it as ownership would invent
|
||||||
|
contested issues out of ordinary references. The mention stays visible on the
|
||||||
|
**PR → issue** side, labelled as such. A PR whose two strongest edges tie is
|
||||||
|
flagged `ambiguous`; an issue claimed by two PRs is flagged `contested`.
|
||||||
|
|
||||||
|
### Honesty rules specific to this view
|
||||||
|
|
||||||
|
* **A partial read never reads as an absence.** Linkage is a claim about the
|
||||||
|
loaded window only. When pagination did not complete, every empty edge cell
|
||||||
|
renders `none found (partial inventory)` rather than `none`, and the JSON
|
||||||
|
carries `inventory_complete: false` plus per-row `links_authoritative: false`.
|
||||||
|
* **A failed read renders no table at all.** Missing credentials, an unknown
|
||||||
|
project, or a fetch error produce `ok: false` with a reason. An empty linkage
|
||||||
|
table would assert that no issue is linked to any PR, which such a read is not
|
||||||
|
in a position to claim.
|
||||||
|
* **Handoffs are loaded, never assumed.** CTH comments are thread-scoped, so
|
||||||
|
only the focused issue or PR has its comments fetched. Every other row reports
|
||||||
|
`not_loaded` with the reason; a thread whose comments *were* loaded and carried
|
||||||
|
no CTH says exactly that. A comment-source failure degrades the handoff alone —
|
||||||
|
the linkage tables still render.
|
||||||
|
* **Unrecognised handoff headings are reported, not republished.** A `## CTH:`
|
||||||
|
heading outside `CTH_TYPES` renders as `unrecognized`.
|
||||||
|
* **Redaction precedes display.** Titles, labels, handoff fields, and error
|
||||||
|
reasons pass through `webui.console_redaction` before serialization, and the
|
||||||
|
page HTML-escapes everything it renders.
|
||||||
|
* **Deep links are opt-in.** A link out to the Gitea web UI appears only when
|
||||||
|
`GITEA_MCP_REVEAL_ENDPOINTS=1` is set server-side, matching how the MCP tools
|
||||||
|
gate URL exposure. Item numbers stay usable without it.
|
||||||
|
|
||||||
## System-health dashboard (#639)
|
## System-health dashboard (#639)
|
||||||
|
|
||||||
`/system-health` renders the same snapshot the `/api/v1/system/health` API
|
`/system-health` renders the same snapshot the `/api/v1/system/health` API
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Web Console: Notifications & Human-Attention Routing (#648)
|
||||||
|
|
||||||
|
- **Status:** Phase 3 Live
|
||||||
|
- **Tracking Issue:** [#648](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/648)
|
||||||
|
- **Parent Epic:** [#631](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/631)
|
||||||
|
- **Attention Boundary Reference:** [#628](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/628)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The **Notifications & Human-Attention Console** (`/notifications`, `/api/v1/notifications`) provides intelligent event classification and human-attention routing for autonomous workflow operations.
|
||||||
|
|
||||||
|
To prevent alert fatigue while ensuring critical escalation boundaries are never missed, events are classified into three distinct **Attention Classes**:
|
||||||
|
|
||||||
|
1. **`human-required`** (Urgent Escalation Boundary):
|
||||||
|
- Items requiring immediate human intervention or business decisions.
|
||||||
|
- Triggers: Auth failures, hard stops, irrecoverable state, decision locks, failed report validations, critical probe errors.
|
||||||
|
- Display: Highlighted in red (`badge-blocked`) with a `HUMAN REQUIRED` badge.
|
||||||
|
|
||||||
|
2. **`operator`** (Operational Inbox):
|
||||||
|
- Items requiring controller or operator review/triage during routine execution.
|
||||||
|
- Triggers: Blocked PRs (merge conflicts), stale leases, duplicate PRs on issues, unassigned ready work.
|
||||||
|
- Display: Displayed in orange/yellow (`badge-claimed`).
|
||||||
|
|
||||||
|
3. **`routine`** (Background Workflow Transitions):
|
||||||
|
- Normal, healthy workflow transitions and state progressions.
|
||||||
|
- Triggers: Active PRs/issues in standard state, clean branch creation, routine heartbeats.
|
||||||
|
- Display: Filtered out of default inbox views to eliminate notification spam; viewable on demand via the "Routine" or "All" tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API Endpoints
|
||||||
|
|
||||||
|
### `GET /api/v1/notifications`
|
||||||
|
*Compatibility Alias:* `GET /api/notifications`
|
||||||
|
|
||||||
|
#### Query Parameters:
|
||||||
|
- `project_id` (optional): Filter notifications by project ID.
|
||||||
|
- `attention_class` (optional): `inbox` (default: human-required + operator), `human-required`, `operator`, `routine`, `all`.
|
||||||
|
|
||||||
|
#### Example JSON Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project_id": "gitea-tools",
|
||||||
|
"repo_label": "Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"human_required_count": 0,
|
||||||
|
"operator_count": 2,
|
||||||
|
"routine_count": 5,
|
||||||
|
"total_count": 7,
|
||||||
|
"fetch_error": null,
|
||||||
|
"inbox_items": [
|
||||||
|
{
|
||||||
|
"id": "notif-pr-block-742",
|
||||||
|
"attention_class": "operator",
|
||||||
|
"category": "blocker",
|
||||||
|
"title": "Blocked PR #742",
|
||||||
|
"summary": "PR #742 requires merge conflict resolution.",
|
||||||
|
"work_kind": "pr",
|
||||||
|
"work_number": 742,
|
||||||
|
"project_id": "gitea-tools",
|
||||||
|
"repo_label": "Scaled-Tech-Consulting/Gitea-Tools",
|
||||||
|
"created_at": "2026-07-25T16:39:47Z",
|
||||||
|
"deep_link": "/traffic",
|
||||||
|
"requires_human": false,
|
||||||
|
"extra": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"all_items": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI Navigation
|
||||||
|
|
||||||
|
- Access via the **Traffic** navigation menu: **Traffic → Notifications**.
|
||||||
|
- The main view displays:
|
||||||
|
- **Metrics Summary Bar**: Highlighting counts for Human Required, Operator Inbox, and Routine items.
|
||||||
|
- **Attention Filter Tabs**: Toggle between Inbox (Human + Operator), Human Required, Operator, Routine, and All.
|
||||||
|
- **Structured Event Table**: Displays category, title, summary, work item links, and timestamps.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Web console requests: intent preview and workflow initiation (#643)
|
||||||
|
|
||||||
|
**Phase 2. Preview is always live and always read-only. Initiation is wired but
|
||||||
|
denied until an operator opts in.**
|
||||||
|
|
||||||
|
Before this surface, starting role work meant pasting a prompt into a terminal
|
||||||
|
and trusting the operator to have checked the allocator first. Nothing enforced
|
||||||
|
that check, so two sessions could reach for the same issue and each believe it
|
||||||
|
was theirs. This page replaces the paste with a *request*: a desired role, an
|
||||||
|
issue or PR, and a stated intent, answered by an authorization decision and —
|
||||||
|
on confirmation — an exclusive assignment from the allocator.
|
||||||
|
|
||||||
|
| Concern | Module |
|
||||||
|
|---------|--------|
|
||||||
|
| Request model, preview, initiation | `webui/request_service.py` |
|
||||||
|
| Form and preview rendering | `webui/request_views.py` |
|
||||||
|
| Authorization | `webui/console_authz.py` (`initiate_workflow`) |
|
||||||
|
| Audit | `webui/console_audit.py` |
|
||||||
|
| Ownership substrate | `allocator_service.py` + `control_plane_db.py` |
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `/requests` | GET | Request form |
|
||||||
|
| `/requests` | POST | Render an intent preview. **Never assigns.** |
|
||||||
|
| `/api/v1/requests/preview` | POST | Intent preview as JSON |
|
||||||
|
| `/api/v1/requests/apply` | POST | Initiate — confirmed, audited, allocator-owned |
|
||||||
|
|
||||||
|
The HTML form has no initiate button on purpose. Initiating requires a
|
||||||
|
confirmed POST to `/api/v1/requests/apply`, so a stray form submission cannot
|
||||||
|
reserve work as a side effect.
|
||||||
|
|
||||||
|
## The request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"desired_role": "author",
|
||||||
|
"work_kind": "issue",
|
||||||
|
"work_number": 643,
|
||||||
|
"intent_summary": "implement request preview and initiation",
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"expected_head_sha": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`desired_role` is one of `author`, `reviewer`, `merger`, `reconciler`,
|
||||||
|
`controller`. `work_kind` is `issue` or `pr`. `remote`/`org`/`repo` default to
|
||||||
|
the first project in the registry when omitted; when neither the request nor
|
||||||
|
the registry resolves them, the request is rejected rather than pointed at some
|
||||||
|
other repository. `intent_summary` is required — it is what the audit record
|
||||||
|
states as the reason — and is truncated to 500 characters.
|
||||||
|
|
||||||
|
Parsing rejects rather than corrects. An unknown role, an unknown work kind, a
|
||||||
|
non-positive number, or a missing intent each return `400` with a `reason_code`
|
||||||
|
and the offending `field`.
|
||||||
|
|
||||||
|
## Preview
|
||||||
|
|
||||||
|
Five checks, each with its own verdict, reason code, and detail:
|
||||||
|
|
||||||
|
| Check | Passes when |
|
||||||
|
|-------|-------------|
|
||||||
|
| `authorization` | The console principal holds `operator` or above |
|
||||||
|
| `capability` | The desired role maps to a declared profile and MCP namespace |
|
||||||
|
| `lease_availability` | No active claim holds the work unit |
|
||||||
|
| `next_safe_action` | The allocator would independently select this exact work unit |
|
||||||
|
| `head_pin` | PR work resolves to a head SHA, and a supplied SHA still matches |
|
||||||
|
|
||||||
|
A preview also returns the role's `allowed_actions` and `prohibited_actions`
|
||||||
|
(from `allocator_service.ROLE_ACTIONS`), the `required_profile` and
|
||||||
|
`required_namespace` the work must run under, and a `correlation_id` that ties
|
||||||
|
the preview to its audit record and to any assignment that follows.
|
||||||
|
|
||||||
|
Preview is read-only in the strict sense: it calls the allocator with
|
||||||
|
`apply=false` and writes nothing but an audit line. An unauthorized principal
|
||||||
|
never reaches the allocator or the control-plane DB at all, so a denial cannot
|
||||||
|
be used to enumerate the queue.
|
||||||
|
|
||||||
|
## Initiation
|
||||||
|
|
||||||
|
`POST /api/v1/requests/apply` refuses in this order, and every refusal returns
|
||||||
|
before any assignment is attempted:
|
||||||
|
|
||||||
|
| Condition | Outcome | Status |
|
||||||
|
|-----------|---------|--------|
|
||||||
|
| Unparseable request | `invalid_request` | 400 |
|
||||||
|
| Not authorized, or execution not wired | `denied` | 403 |
|
||||||
|
| `confirm` not set | `denied` / `confirmation_required` | 409 |
|
||||||
|
| Work unit already claimed | `blocked` / `duplicate_assignment` | 409 |
|
||||||
|
| Allocator would select other work | `wait` / `not_next_safe_work` | 409 |
|
||||||
|
| Allocator declines on apply | `blocked` or `wait` | 409 |
|
||||||
|
| Evidence unavailable | `wait` / `evidence_unavailable` | 503 |
|
||||||
|
| Assigned | `assigned_work` | 201 |
|
||||||
|
|
||||||
|
A success returns the assignment plus a `handoff` block naming the profile, the
|
||||||
|
namespace, and the actions that stay forbidden — enough for the operator to
|
||||||
|
continue in the right MCP namespace without guessing.
|
||||||
|
|
||||||
|
### Why apply runs the allocator twice
|
||||||
|
|
||||||
|
The allocator is the only source of exclusive ownership (#600 / #613), and it
|
||||||
|
selects work; it does not take orders. So `apply` runs a dry-run first and
|
||||||
|
proceeds only when the allocator would independently pick the requested work
|
||||||
|
unit. If it would not, the request reports `wait` and mutates nothing.
|
||||||
|
|
||||||
|
A request is therefore a *confirmation* of the allocator's decision, never an
|
||||||
|
override of it. The apply call carries the dry-run's
|
||||||
|
`candidate_set_fingerprint` as a CAS pin (#776), so a queue that changed
|
||||||
|
between the two calls fails closed rather than assigning against a stale view.
|
||||||
|
The result is checked again on the way out: an assignment naming a different
|
||||||
|
work unit is not read as success.
|
||||||
|
|
||||||
|
### Fail-closed defaults
|
||||||
|
|
||||||
|
- An unreadable control-plane DB denies. It is never treated as "nothing holds
|
||||||
|
this work unit".
|
||||||
|
- An incomplete queue inventory denies (#758). Ranking a partial candidate set
|
||||||
|
can select the wrong work.
|
||||||
|
- An allocator that raises denies.
|
||||||
|
- PR work with no resolvable head SHA denies; a supplied SHA that no longer
|
||||||
|
matches denies with `head_moved`.
|
||||||
|
|
||||||
|
## Enabling initiation
|
||||||
|
|
||||||
|
Execution is wired off. Set `WEBUI_REQUESTS_EXECUTION=1` to enable it for the
|
||||||
|
`initiate_workflow` action only — see
|
||||||
|
[`webui-authz-audit.md`](webui-authz-audit.md) for why this is an
|
||||||
|
action-scoped flag rather than a phase bump. With the variable unset, `apply`
|
||||||
|
returns `403` with `reason_code: unauthorized` no matter who asks.
|
||||||
|
|
||||||
|
Enabling execution does **not** enable approvals or merges. Those are phase 3
|
||||||
|
console actions and remain forbidden in every path here; the console reserves
|
||||||
|
work and hands off, and the MCP role profile enforces what that role may then
|
||||||
|
do.
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
Every preview and every apply emits a console audit record (schema in
|
||||||
|
[`webui-authz-audit.md`](webui-authz-audit.md)):
|
||||||
|
|
||||||
|
| Event | `result` |
|
||||||
|
|-------|----------|
|
||||||
|
| Preview | `previewed` |
|
||||||
|
| Refusal at any stage | `denied` |
|
||||||
|
| Assignment created | `succeeded` |
|
||||||
|
|
||||||
|
`correlation.request_id` carries the request's `correlation_id`, and a
|
||||||
|
successful record's `metadata` carries `assignment_id` and `lease_id`, so an
|
||||||
|
assignment can be traced back to the intent that produced it. The operator's
|
||||||
|
`intent_summary` travels in `metadata` and passes through the standard
|
||||||
|
redaction pass before persistence like every other field.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No browser-initiated approve or merge, in this phase or any other.
|
||||||
|
- No bypass of allocator exclusive ownership; no self-selection of work.
|
||||||
|
- No auto-start from raw monitoring incidents (#612 stays downstream).
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# Web Console: restart status, impact preview, and approval state (#667)
|
||||||
|
|
||||||
|
Phase 1 of the console restart surface. It consumes the #655 coordinator
|
||||||
|
substrate and displays it. It performs no restart, reload, drain, approval, or
|
||||||
|
process action, and it registers no write endpoint.
|
||||||
|
|
||||||
|
Issue #667's rollout is explicit — *status views first, write approval after the
|
||||||
|
backend gates are green* — and this change delivers only the status half.
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
| Path | Method | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `/runtime/restart` | GET | Restart status page |
|
||||||
|
| `/api/v1/system/restart/status` | GET | Same snapshot as JSON |
|
||||||
|
|
||||||
|
Both accept an optional `restart_class` query parameter (default
|
||||||
|
`full_mcp_restart`). An unrecognised class is not an error: the coordinator
|
||||||
|
resolves it as unknown and fails closed, and the page shows the resulting deny.
|
||||||
|
|
||||||
|
Neither path accepts `POST`; a write attempt returns `405`, and a test asserts
|
||||||
|
it.
|
||||||
|
|
||||||
|
## What it shows
|
||||||
|
|
||||||
|
* **Impact preview (#658)** — verdict, blast radius, affected sessions, leases,
|
||||||
|
critical sections, mutations, and the counts behind them, evaluated
|
||||||
|
`dry_run=True` against live control-plane state.
|
||||||
|
* **Drain proof (#661)** — verification of a supplied proof: valid, clean,
|
||||||
|
expired, tampered, and the reasons behind a refusal.
|
||||||
|
* **Post-restart reconcile (#662)** — the most recent completion proof, its
|
||||||
|
overall status, and which dimensions still require follow-up.
|
||||||
|
* **Restart classes (#663)** — the least-privilege matrix, with *you may
|
||||||
|
request* and *you may execute* computed for the viewing role rather than for a
|
||||||
|
generic operator.
|
||||||
|
* **Approval controls (#633)** — the authorization state of
|
||||||
|
`system.restart_namespace` and `system.reload_namespace`.
|
||||||
|
* **Break-glass (#664)** — declared and marked unavailable; see below.
|
||||||
|
|
||||||
|
## Three rules this surface holds itself to
|
||||||
|
|
||||||
|
A status page that is wrong is worse than one that is missing, because an
|
||||||
|
operator acts on it. Three properties are enforced by tests, and each was
|
||||||
|
verified by reverting the guard and watching a test fail.
|
||||||
|
|
||||||
|
### An unreadable source reports unavailable, never green
|
||||||
|
|
||||||
|
Every source carries its own `SourceStatus`. Nothing substitutes a default,
|
||||||
|
placeholder, or self-comparison for a reading that failed. An unreadable
|
||||||
|
control-plane database yields `inventory_complete: false`, which the coordinator
|
||||||
|
itself turns into a fail-closed verdict, and the page says the blast radius is
|
||||||
|
unknown rather than showing an empty affected-sessions table.
|
||||||
|
|
||||||
|
An absent drain proof is reported as absent — not as a pass. The #661 gate
|
||||||
|
authorizes a restart only against a valid, unexpired, clean proof, so no proof
|
||||||
|
is precisely the state that gate denies on.
|
||||||
|
|
||||||
|
### Authorization is asked the way execution would ask it
|
||||||
|
|
||||||
|
Every probe passes `for_execution=True`.
|
||||||
|
|
||||||
|
Asked without it, an admin is `allowed` for `system.restart_namespace`. On a
|
||||||
|
control surface that reads as a live button. Asked the way an execution attempt
|
||||||
|
would ask, the same principal is refused `phase_not_active`, because the console
|
||||||
|
is in Phase 1 and the action is Phase 2. This surface reports the second answer.
|
||||||
|
|
||||||
|
`execution_enabled` is therefore `false` for every action and every role today,
|
||||||
|
and a test asserts that across the whole role matrix.
|
||||||
|
|
||||||
|
### The control-plane database is opened read-only
|
||||||
|
|
||||||
|
`ControlPlaneDB()` creates directories and runs migrations on construction — a
|
||||||
|
write. This surface never constructs one. It opens the sqlite file with
|
||||||
|
`mode=ro`, exactly as `webui/inventory.py` does, and treats a missing file as
|
||||||
|
missing authority rather than as an empty inventory.
|
||||||
|
|
||||||
|
The test that protects this points at a path inside a directory that already
|
||||||
|
exists, so a read-write `connect` would really create the file. A nested
|
||||||
|
missing-directory path would have passed for the wrong reason.
|
||||||
|
|
||||||
|
## Break-glass is declared, not offered
|
||||||
|
|
||||||
|
The break-glass workflow (#664) is not available on this branch's base. The
|
||||||
|
panel is rendered to operator-class roles as **unavailable**, naming the issue
|
||||||
|
that tracks it. It is not silently omitted, because an operator who has been
|
||||||
|
told a governance path exists needs to see that it is not wired here; and it is
|
||||||
|
not rendered as a control, because there is nothing behind it.
|
||||||
|
|
||||||
|
Unprivileged viewers see only a note that the surface is operator-class.
|
||||||
|
|
||||||
|
## Redaction and escaping
|
||||||
|
|
||||||
|
Every interpolated value passes through `_esc` (`html.escape(..., quote=True)`).
|
||||||
|
Free-form text and anything that can carry a filesystem path additionally passes
|
||||||
|
through `webui.inventory.scrub_text`, which redacts credential-shaped tokens
|
||||||
|
inside a string rather than only at its start. The impact payload is passed
|
||||||
|
through `webui.inventory.scrub` before rendering.
|
||||||
|
|
||||||
|
## Linkage
|
||||||
|
|
||||||
|
Parent #655 · extends #642 · consumes #658, #661, #662, #663 · RBAC #633 ·
|
||||||
|
console #631 · vision #652 · roadmap #653 · break-glass #664.
|
||||||
+50
-1
@@ -1169,10 +1169,57 @@ def server_command():
|
|||||||
return python, [os.path.join(root, "mcp_server.py")]
|
return python, [os.path.join(root, "mcp_server.py")]
|
||||||
|
|
||||||
|
|
||||||
|
RECOGNIZED_GITEA_ENV_KEYS = frozenset({
|
||||||
|
"GITEA_MCP_CONFIG",
|
||||||
|
"GITEA_MCP_PROFILE",
|
||||||
|
"GITEA_PROFILE_NAME",
|
||||||
|
"GITEA_SERVICE",
|
||||||
|
"GITEA_EXECUTION_ROLE",
|
||||||
|
"GITEA_CLIENT_MANAGED",
|
||||||
|
"GITEA_MCP_CLIENT_MANAGED",
|
||||||
|
"GITEA_SERVER_PROVENANCE",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_DISABLE_KEYCHAIN",
|
||||||
|
"GITEA_CONTROL_PLANE_DB",
|
||||||
|
"GITEA_DB_PATH",
|
||||||
|
"GITEA_LOG_LEVEL",
|
||||||
|
"GITEA_DEBUG",
|
||||||
|
"GITEA_HMAC_SECRET",
|
||||||
|
"GITEA_IRRECOVERABLE_HMAC_SECRET",
|
||||||
|
"GITEA_FORCE_MCP_RUNTIME_CHECK",
|
||||||
|
"GITEA_FORCE_CLIENT_MANAGED",
|
||||||
|
})
|
||||||
|
|
||||||
|
RECOGNIZED_GITEA_ENV_PREFIXES = (
|
||||||
|
"GITEA_TOKEN_",
|
||||||
|
"GITEA_PASS_",
|
||||||
|
"GITEA_USER_",
|
||||||
|
"GITEA_URL_",
|
||||||
|
"GITEA_HOST_",
|
||||||
|
"GITEA_REMOTE_",
|
||||||
|
"GITEA_HTTP_HEADER_",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_unconsumed_gitea_env_overrides(env=None) -> dict[str, str]:
|
||||||
|
"""Find unsupported GITEA_* env vars present in *env* (defaults to os.environ)."""
|
||||||
|
target = os.environ if env is None else env
|
||||||
|
unconsumed = {}
|
||||||
|
for key, value in target.items():
|
||||||
|
if key.startswith("GITEA_"):
|
||||||
|
if key in RECOGNIZED_GITEA_ENV_KEYS:
|
||||||
|
continue
|
||||||
|
if any(key.startswith(p) for p in RECOGNIZED_GITEA_ENV_PREFIXES):
|
||||||
|
continue
|
||||||
|
unconsumed[key] = str(value)
|
||||||
|
return unconsumed
|
||||||
|
|
||||||
|
|
||||||
def launcher_entry(profile_name, config_path=None):
|
def launcher_entry(profile_name, config_path=None):
|
||||||
"""Return a thin MCP launcher entry for *profile_name*.
|
"""Return a thin MCP launcher entry for *profile_name*.
|
||||||
|
|
||||||
Contains only command/args and the two GITEA_MCP_* env vars — never a token
|
Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — never a token
|
||||||
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
|
or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks.
|
||||||
"""
|
"""
|
||||||
command, args = server_command()
|
command, args = server_command()
|
||||||
@@ -1183,11 +1230,13 @@ def launcher_entry(profile_name, config_path=None):
|
|||||||
"env": {
|
"env": {
|
||||||
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
|
"GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH,
|
||||||
"GITEA_MCP_PROFILE": profile_name,
|
"GITEA_MCP_PROFILE": profile_name,
|
||||||
|
"GITEA_CLIENT_MANAGED": "1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def keychain_set(item_id, token, account=None, runner=subprocess.run):
|
def keychain_set(item_id, token, account=None, runner=subprocess.run):
|
||||||
"""Store *token* in the macOS keychain under service *item_id*.
|
"""Store *token* in the macOS keychain under service *item_id*.
|
||||||
|
|
||||||
|
|||||||
+228
-21
@@ -1546,6 +1546,7 @@ def verify_preflight_purity(
|
|||||||
task=task,
|
task=task,
|
||||||
target_issue_number=target_issue_number,
|
target_issue_number=target_issue_number,
|
||||||
require_author_lock=require_author_lock,
|
require_author_lock=require_author_lock,
|
||||||
|
bootstrap_assessment=bootstrap_assessment,
|
||||||
)
|
)
|
||||||
# #604: common anti-stomp preflight after legacy + #683 enforcers.
|
# #604: common anti-stomp preflight after legacy + #683 enforcers.
|
||||||
_run_anti_stomp_preflight(
|
_run_anti_stomp_preflight(
|
||||||
@@ -1585,6 +1586,7 @@ def verify_preflight_purity(
|
|||||||
task=task,
|
task=task,
|
||||||
target_issue_number=target_issue_number,
|
target_issue_number=target_issue_number,
|
||||||
require_author_lock=require_author_lock,
|
require_author_lock=require_author_lock,
|
||||||
|
bootstrap_assessment=bootstrap_assessment,
|
||||||
)
|
)
|
||||||
if force_anti_stomp:
|
if force_anti_stomp:
|
||||||
_run_anti_stomp_preflight(
|
_run_anti_stomp_preflight(
|
||||||
@@ -1652,8 +1654,15 @@ def _enforce_issue_scope_guard(
|
|||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
target_issue_number: int | None = None,
|
target_issue_number: int | None = None,
|
||||||
require_author_lock: bool = False,
|
require_author_lock: bool = False,
|
||||||
|
bootstrap_assessment: object = _BOOTSTRAP_UNSET,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""#683: fail closed on missing/out-of-scope issue ownership for mutations."""
|
"""#683: fail closed on missing/out-of-scope issue ownership for mutations.
|
||||||
|
|
||||||
|
#941: the shared bootstrap assessment is threaded in so this guard judges
|
||||||
|
the author issue-worktree bootstrap on the same server-derived evidence as
|
||||||
|
the #274 branches-only and #604 anti-stomp guards. Callers that supply
|
||||||
|
none fall back to computing it here, which preserves behaviour.
|
||||||
|
"""
|
||||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||||
workspace = ctx["workspace_path"]
|
workspace = ctx["workspace_path"]
|
||||||
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
|
||||||
@@ -1703,11 +1712,32 @@ def _enforce_issue_scope_guard(
|
|||||||
import create_issue_bootstrap as _cib
|
import create_issue_bootstrap as _cib
|
||||||
|
|
||||||
is_create_issue = _cib.is_create_issue_task(task)
|
is_create_issue = _cib.is_create_issue_task(task)
|
||||||
|
# #941: consume the caller-computed bootstrap assessment when one was
|
||||||
|
# threaded in, so this guard and the #274/#604 guards judge identical
|
||||||
|
# evidence. Falling back preserves behaviour for callers that supply none.
|
||||||
|
bootstrap = (
|
||||||
|
_create_issue_bootstrap_assessment(task, worktree_path)
|
||||||
|
if bootstrap_assessment is _BOOTSTRAP_UNSET
|
||||||
|
else bootstrap_assessment
|
||||||
|
)
|
||||||
|
# #941: the author issue-worktree bootstrap is pre-ownership for the same
|
||||||
|
# reason create_issue is — it exists to break the lock<->worktree cycle, so
|
||||||
|
# no owning lock can exist yet. The exemption is granted by the canonical
|
||||||
|
# shared decision over server-derived evidence, never by a task-name list,
|
||||||
|
# and fails closed on missing, malformed, cross-scope, dirty, drifted, or
|
||||||
|
# wrongly bound evidence.
|
||||||
|
bootstrap_waives_ownership = _cib.bootstrap_permits_control_checkout(
|
||||||
|
bootstrap,
|
||||||
|
task=task,
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=ctx["canonical_repo_root"],
|
||||||
|
)
|
||||||
require_lock = bool(require_author_lock) or (
|
require_lock = bool(require_author_lock) or (
|
||||||
authorish
|
authorish
|
||||||
and workflow_scope_guard.production_guards_forced()
|
and workflow_scope_guard.production_guards_forced()
|
||||||
and role == "author"
|
and role == "author"
|
||||||
and not is_create_issue
|
and not is_create_issue
|
||||||
|
and not bootstrap_waives_ownership
|
||||||
)
|
)
|
||||||
assessment = workflow_scope_guard.assess_production_mutation_guards(
|
assessment = workflow_scope_guard.assess_production_mutation_guards(
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
@@ -1720,6 +1750,7 @@ def _enforce_issue_scope_guard(
|
|||||||
require_author_lock=require_lock,
|
require_author_lock=require_lock,
|
||||||
in_test_mode=_preflight_in_test_mode(),
|
in_test_mode=_preflight_in_test_mode(),
|
||||||
mutation_task=task,
|
mutation_task=task,
|
||||||
|
bootstrap_assessment=bootstrap,
|
||||||
)
|
)
|
||||||
workflow_scope_guard.raise_if_blocked(assessment)
|
workflow_scope_guard.raise_if_blocked(assessment)
|
||||||
|
|
||||||
@@ -2750,6 +2781,36 @@ def _collect_issue_duplicate_context(
|
|||||||
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
||||||
|
|
||||||
|
|
||||||
|
def _owning_pr_continuation_from_lock(lock_record: dict | None) -> dict | None:
|
||||||
|
"""Owning-PR continuation evidence a persisted lock still proves (#945).
|
||||||
|
|
||||||
|
``gitea_lock_issue`` grants the duplicate-work waiver from either a
|
||||||
|
sanctioned dead-session recovery (#755) or a sanctioned exact-owner renewal
|
||||||
|
(#760), in that precedence. Every later enforcement path — commit,
|
||||||
|
create-PR, push-ownership, and the read-only duplicate assessor — re-derives
|
||||||
|
ownership from the durable lock instead of that live assessment.
|
||||||
|
|
||||||
|
Until #945 only the recovery half was rebuilt there, so an ordinary
|
||||||
|
exact-owner renewal lost its waiver the moment ``gitea_lock_issue``
|
||||||
|
returned: the author renewed successfully and was then refused
|
||||||
|
``duplicate_commit_prevented`` with ``owning_pr_recovery_exempted: false``
|
||||||
|
on the very PR the renewal had just proved it owned.
|
||||||
|
|
||||||
|
Resolving both halves here, in the same precedence the lock path applies,
|
||||||
|
keeps the answer from drifting between the gate that grants the waiver and
|
||||||
|
the gates that enforce it. This only decides which server-written block the
|
||||||
|
token is rebuilt from — the token is still re-validated against live PR
|
||||||
|
state by ``issue_work_duplicate_gate._assess_owning_pr_exemption``, which
|
||||||
|
remains the single authoritative policy for whether an exemption applies.
|
||||||
|
"""
|
||||||
|
if not lock_record:
|
||||||
|
return None
|
||||||
|
recovered = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
|
||||||
|
if recovered:
|
||||||
|
return recovered
|
||||||
|
return issue_lock_renewal.owning_pr_renewal_from_lock(lock_record)
|
||||||
|
|
||||||
|
|
||||||
def _assess_issue_duplicate_gate(
|
def _assess_issue_duplicate_gate(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
*,
|
*,
|
||||||
@@ -2802,6 +2863,11 @@ def _enforce_locked_issue_duplicate_recheck(
|
|||||||
commit and create-PR phases run in their own calls, long after the recovery
|
commit and create-PR phases run in their own calls, long after the recovery
|
||||||
assessment ended, so without this they re-block the very PR the recovery
|
assessment ended, so without this they re-block the very PR the recovery
|
||||||
already proved belongs to this author.
|
already proved belongs to this author.
|
||||||
|
|
||||||
|
#945: an exact-owner *renewal* (#760) owns its open PR for exactly the same
|
||||||
|
reason, and ``gitea_lock_issue`` already waives the blocker for both. Both
|
||||||
|
halves are resolved together here so the renewal waiver survives past the
|
||||||
|
lock call instead of expiring with it.
|
||||||
"""
|
"""
|
||||||
lock_data = _load_existing_issue_lock()
|
lock_data = _load_existing_issue_lock()
|
||||||
if not lock_data:
|
if not lock_data:
|
||||||
@@ -2825,9 +2891,7 @@ def _enforce_locked_issue_duplicate_recheck(
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
locked_branch=locked_branch,
|
locked_branch=locked_branch,
|
||||||
phase=phase,
|
phase=phase,
|
||||||
recovered_owning_pr=issue_lock_recovery.recovered_owning_pr_from_lock(
|
recovered_owning_pr=_owning_pr_continuation_from_lock(lock_data),
|
||||||
lock_data
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if gate.get("block"):
|
if gate.get("block"):
|
||||||
return gate
|
return gate
|
||||||
@@ -5109,9 +5173,10 @@ def gitea_assess_work_issue_duplicate(
|
|||||||
recovered_owning_pr = None
|
recovered_owning_pr = None
|
||||||
lock_data = _load_existing_issue_lock()
|
lock_data = _load_existing_issue_lock()
|
||||||
if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number):
|
if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number):
|
||||||
recovered_owning_pr = issue_lock_recovery.recovered_owning_pr_from_lock(
|
# #945: rebuilt from a sanctioned recovery *or* a sanctioned exact-owner
|
||||||
lock_data
|
# renewal, so this read-only assessor reports the same disposition the
|
||||||
)
|
# commit and create-PR gates will enforce.
|
||||||
|
recovered_owning_pr = _owning_pr_continuation_from_lock(lock_data)
|
||||||
gate = _assess_issue_duplicate_gate(
|
gate = _assess_issue_duplicate_gate(
|
||||||
issue_number,
|
issue_number,
|
||||||
h=h,
|
h=h,
|
||||||
@@ -14585,6 +14650,56 @@ def _session_context_mutation_block(
|
|||||||
return blocked
|
return blocked
|
||||||
|
|
||||||
|
|
||||||
|
def _is_client_managed_process() -> bool:
|
||||||
|
"""Check whether the current MCP server process has client-managed launch provenance (#686)."""
|
||||||
|
val = (
|
||||||
|
os.environ.get("GITEA_CLIENT_MANAGED")
|
||||||
|
or os.environ.get("GITEA_MCP_CLIENT_MANAGED")
|
||||||
|
or os.environ.get("GITEA_SERVER_PROVENANCE")
|
||||||
|
or os.environ.get("GITEA_FORCE_CLIENT_MANAGED")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
|
||||||
|
if val in ("0", "false", "no", "manual", "manual_launch"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if val in ("1", "true", "yes", "client_managed"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# A terminal launch has an active TTY on stdin
|
||||||
|
try:
|
||||||
|
if sys.stdin and sys.stdin.isatty():
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Standard client launch or test runner with stdio pipe and profile env
|
||||||
|
if "GITEA_MCP_CONFIG" in os.environ or "GITEA_MCP_PROFILE" in os.environ or "GITEA_PROFILE_NAME" in os.environ:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _provenance_mutation_block(**extra_fields) -> dict | None:
|
||||||
|
"""Refuse mutating tool calls on processes lacking client-managed launch provenance (#686)."""
|
||||||
|
if _is_client_managed_process():
|
||||||
|
return None
|
||||||
|
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides()
|
||||||
|
blocked = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"blocker_kind": "unsupported_manual_launch",
|
||||||
|
"reasons": [
|
||||||
|
"mutation denied: server process was launched manually from a terminal without client-managed provenance (fail closed). Manually launched mcp_server.py processes cannot receive IDE stdio or serve workflow mutations."
|
||||||
|
],
|
||||||
|
"exact_next_action": "BLOCKED + RECONNECT: Reconnect the IDE/client-managed MCP server namespace instead of an ad hoc terminal launch. Hand-launched processes and mcp_config.json hand-edits are classified as workflow contamination.",
|
||||||
|
"provenance": "manual_launch",
|
||||||
|
"unconsumed_gitea_env": unconsumed,
|
||||||
|
}
|
||||||
|
blocked.update(extra_fields)
|
||||||
|
return blocked
|
||||||
|
|
||||||
|
|
||||||
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
|
def _profile_permission_block(required_operation: str, **extra_fields) -> dict | None:
|
||||||
"""Structured operation-gate denial for gated tools (#69, #142, #897).
|
"""Structured operation-gate denial for gated tools (#69, #142, #897).
|
||||||
|
|
||||||
@@ -14601,6 +14716,10 @@ def _profile_permission_block(required_operation: str, **extra_fields) -> dict |
|
|||||||
# #714: evaluate active profile only — never auto-switch.
|
# #714: evaluate active profile only — never auto-switch.
|
||||||
_ensure_matching_profile(required_operation, req_role, extra_fields.get("remote"))
|
_ensure_matching_profile(required_operation, req_role, extra_fields.get("remote"))
|
||||||
|
|
||||||
|
prov_block = _provenance_mutation_block(**extra_fields)
|
||||||
|
if prov_block is not None:
|
||||||
|
return prov_block
|
||||||
|
|
||||||
reasons = _profile_operation_gate(required_operation)
|
reasons = _profile_operation_gate(required_operation)
|
||||||
if reasons:
|
if reasons:
|
||||||
return _build_operation_gate_refusal(
|
return _build_operation_gate_refusal(
|
||||||
@@ -14634,6 +14753,10 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None
|
|||||||
# #714: evaluate active profile only — never auto-switch.
|
# #714: evaluate active profile only — never auto-switch.
|
||||||
_ensure_matching_profile(required_permission, required_role, extra_fields.get("remote"))
|
_ensure_matching_profile(required_permission, required_role, extra_fields.get("remote"))
|
||||||
|
|
||||||
|
prov_block = _provenance_mutation_block(**extra_fields)
|
||||||
|
if prov_block is not None:
|
||||||
|
return prov_block
|
||||||
|
|
||||||
try:
|
try:
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -18081,6 +18204,9 @@ def gitea_get_runtime_context(
|
|||||||
source="gitea_get_runtime_context",
|
source="gitea_get_runtime_context",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_client_managed = _is_client_managed_process()
|
||||||
|
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides()
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"active_profile": profile["profile_name"],
|
"active_profile": profile["profile_name"],
|
||||||
"authenticated_username": username,
|
"authenticated_username": username,
|
||||||
@@ -18097,6 +18223,9 @@ def gitea_get_runtime_context(
|
|||||||
"review_merge_blocked_reasons": blocked_reasons,
|
"review_merge_blocked_reasons": blocked_reasons,
|
||||||
"suggested_fix": suggested_fix,
|
"suggested_fix": suggested_fix,
|
||||||
"safe_next_action": safe_next_action,
|
"safe_next_action": safe_next_action,
|
||||||
|
"server_provenance": "client_managed" if is_client_managed else "manual_launch",
|
||||||
|
"is_client_managed": is_client_managed,
|
||||||
|
"unconsumed_gitea_env": unconsumed_env,
|
||||||
"preflight_ready": preflight["preflight_ready"],
|
"preflight_ready": preflight["preflight_ready"],
|
||||||
"preflight_block_reasons": preflight["preflight_block_reasons"],
|
"preflight_block_reasons": preflight["preflight_block_reasons"],
|
||||||
"preflight_workspace": preflight.get("preflight_workspace"),
|
"preflight_workspace": preflight.get("preflight_workspace"),
|
||||||
@@ -18110,6 +18239,13 @@ def gitea_get_runtime_context(
|
|||||||
PROJECT_ROOT),
|
PROJECT_ROOT),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not is_client_managed:
|
||||||
|
result["safe_next_action"] = (
|
||||||
|
"BLOCKED + RECONNECT: Serving process lacks client-managed launch provenance (manual launch). "
|
||||||
|
"Reconnect the IDE/client-managed MCP server namespace instead of an ad hoc terminal launch."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
|
# #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
|
||||||
# binding; recovery itself runs during capability resolution.
|
# binding; recovery itself runs during capability resolution.
|
||||||
try:
|
try:
|
||||||
@@ -19322,7 +19458,10 @@ def _prove_author_ownership_for_pr(
|
|||||||
# advance is the one recovery already sanctioned, not a foreign head.
|
# advance is the one recovery already sanctioned, not a foreign head.
|
||||||
recovered_owning_pr = None
|
recovered_owning_pr = None
|
||||||
if proven and lock_record:
|
if proven and lock_record:
|
||||||
candidate = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
|
# #945: a sanctioned exact-owner renewal proves the same ownership of
|
||||||
|
# the same PR, so the push gate resolves both halves rather than seeing
|
||||||
|
# only the recovery one.
|
||||||
|
candidate = _owning_pr_continuation_from_lock(lock_record)
|
||||||
if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
|
if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
|
||||||
recovered_owning_pr = candidate
|
recovered_owning_pr = candidate
|
||||||
return {
|
return {
|
||||||
@@ -20567,7 +20706,9 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
|||||||
self_pid = os.getpid()
|
self_pid = os.getpid()
|
||||||
self_stale = False
|
self_stale = False
|
||||||
|
|
||||||
running_profiles = {}
|
all_profile_procs: dict[str, list[dict]] = {}
|
||||||
|
unsupported_env_found = set()
|
||||||
|
|
||||||
for line in proc.stdout.splitlines()[1:]:
|
for line in proc.stdout.splitlines()[1:]:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or "mcp_server.py" not in line:
|
if not line or "mcp_server.py" not in line:
|
||||||
@@ -20599,16 +20740,55 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
|||||||
if match:
|
if match:
|
||||||
profile = match.group(1)
|
profile = match.group(1)
|
||||||
|
|
||||||
|
is_client_managed = bool(
|
||||||
|
re.search(r'\bGITEA_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE)
|
||||||
|
or re.search(r'\bGITEA_MCP_CLIENT_MANAGED=(1|true|yes|client_managed)\b', env_out, re.IGNORECASE)
|
||||||
|
or re.search(r'\bGITEA_SERVER_PROVENANCE=client_managed\b', env_out, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
|
||||||
|
for env_match in re.finditer(r'\b(GITEA_[A-Z0-9_]+)=([^\s]+)', env_out):
|
||||||
|
k, v = env_match.group(1), env_match.group(2)
|
||||||
|
if k not in gitea_config.RECOGNIZED_GITEA_ENV_KEYS and not any(k.startswith(p) for p in gitea_config.RECOGNIZED_GITEA_ENV_PREFIXES):
|
||||||
|
unsupported_env_found.add(f"{k}={v}")
|
||||||
|
|
||||||
is_stale = (start_time < code_mtime) or git_stale
|
is_stale = (start_time < code_mtime) or git_stale
|
||||||
if pid == self_pid and is_stale:
|
if pid == self_pid and is_stale:
|
||||||
self_stale = True
|
self_stale = True
|
||||||
|
|
||||||
if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]:
|
proc_info = {
|
||||||
running_profiles[profile] = {
|
|
||||||
"pid": pid,
|
"pid": pid,
|
||||||
"start_time": start_time,
|
"start_time": start_time,
|
||||||
"is_stale": is_stale
|
"is_stale": is_stale,
|
||||||
|
"is_client_managed": is_client_managed,
|
||||||
}
|
}
|
||||||
|
if profile not in all_profile_procs:
|
||||||
|
all_profile_procs[profile] = []
|
||||||
|
all_profile_procs[profile].append(proc_info)
|
||||||
|
|
||||||
|
running_profiles = {}
|
||||||
|
for profile, procs in all_profile_procs.items():
|
||||||
|
if len(procs) > 1:
|
||||||
|
pids_str = ", ".join(str(p["pid"]) for p in procs)
|
||||||
|
reasons.append(
|
||||||
|
f"stale-runtime: Duplicate MCP server process(es) detected for profile '{profile}' (PIDs: {pids_str}). "
|
||||||
|
"Manual or duplicate launches defeat staleness detection and cannot receive client stdio."
|
||||||
|
)
|
||||||
|
client_procs = [p for p in procs if p["is_client_managed"]]
|
||||||
|
if client_procs:
|
||||||
|
client_procs.sort(key=lambda p: p["start_time"], reverse=True)
|
||||||
|
running_profiles[profile] = client_procs[0]
|
||||||
|
else:
|
||||||
|
pids_str = ", ".join(str(p["pid"]) for p in procs)
|
||||||
|
reasons.append(
|
||||||
|
f"stale-runtime: Manually launched MCP process(es) detected without client-managed provenance for profile '{profile}' (PIDs: {pids_str}). "
|
||||||
|
"Manual launches cannot serve client stdio and are ignored for runtime freshness."
|
||||||
|
)
|
||||||
|
|
||||||
|
if unsupported_env_found:
|
||||||
|
reasons.append(
|
||||||
|
f"unsupported-env: Unsupported GITEA_* environment variable override(s) detected: {', '.join(sorted(unsupported_env_found))}. "
|
||||||
|
"Unknown env overrides are unsupported."
|
||||||
|
)
|
||||||
|
|
||||||
if self_stale:
|
if self_stale:
|
||||||
# #685: report-only — no config utime, no thread, no os._exit.
|
# #685: report-only — no config utime, no thread, no os._exit.
|
||||||
@@ -20643,6 +20823,7 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) ->
|
|||||||
return reasons
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_resolve_task_capability(
|
def gitea_resolve_task_capability(
|
||||||
task: str,
|
task: str,
|
||||||
@@ -22575,8 +22756,9 @@ def gitea_request_mcp_restart(
|
|||||||
target_connector: str | None = None,
|
target_connector: str | None = None,
|
||||||
drain_proof_json: str | None = None,
|
drain_proof_json: str | None = None,
|
||||||
request_break_glass: bool = False,
|
request_break_glass: bool = False,
|
||||||
|
prior_recovery_attempts_json: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Evaluate a proposed MCP restart and return an impact preview (#658).
|
"""Evaluate a proposed MCP restart and return an impact preview (#658/#669).
|
||||||
|
|
||||||
Central restart coordinator: resolves the requested restart class, gathers
|
Central restart coordinator: resolves the requested restart class, gathers
|
||||||
live control-plane state (sessions,
|
live control-plane state (sessions,
|
||||||
@@ -22600,10 +22782,16 @@ def gitea_request_mcp_restart(
|
|||||||
independent — the drain gate proves the blast radius was drained and knows
|
independent — the drain gate proves the blast radius was drained and knows
|
||||||
nothing about whether this requester may request this class — so a class the
|
nothing about whether this requester may request this class — so a class the
|
||||||
matrix denied never reports an authorized apply. Break-glass bypasses the
|
matrix denied never reports an authorized apply. Break-glass bypasses the
|
||||||
drain proof only; it never bypasses the class matrix. ``apply_gate`` carries
|
drain proof and, when env-authorized, the #669 attempt-log requirement for
|
||||||
|
broad restarts; it never bypasses the class matrix. ``apply_gate`` carries
|
||||||
``drain_gate_allow`` and ``restart_class_authorized`` so a denial is
|
``drain_gate_allow`` and ``restart_class_authorized`` so a denial is
|
||||||
attributable to the authorization that produced it.
|
attributable to the authorization that produced it.
|
||||||
|
|
||||||
|
``prior_recovery_attempts_json`` (#669) is an optional JSON array of prior
|
||||||
|
narrow recovery attempts ``{action, outcome, reason, ...}``. Rolling / full
|
||||||
|
/ host restart classes require at least one *insufficient* narrower attempt
|
||||||
|
unless break-glass is authorized.
|
||||||
|
|
||||||
Operator override authority is read from the process environment
|
Operator override authority is read from the process environment
|
||||||
(``GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION``), never self-asserted by
|
(``GITEA_OPERATOR_RESTART_OVERRIDE_AUTHORIZATION``), never self-asserted by
|
||||||
the requesting session: ``request_override`` only expresses caller intent
|
the requesting session: ``request_override`` only expresses caller intent
|
||||||
@@ -22709,12 +22897,37 @@ def gitea_request_mcp_restart(
|
|||||||
requester_role
|
requester_role
|
||||||
)
|
)
|
||||||
|
|
||||||
|
prior_recovery_attempts: list[dict] = []
|
||||||
|
if prior_recovery_attempts_json:
|
||||||
|
try:
|
||||||
|
parsed_attempts = json.loads(prior_recovery_attempts_json)
|
||||||
|
if isinstance(parsed_attempts, list):
|
||||||
|
prior_recovery_attempts = [
|
||||||
|
dict(a) for a in parsed_attempts if isinstance(a, dict)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
incomplete_reasons.append(
|
||||||
|
"prior_recovery_attempts_json must be a JSON array (#669)"
|
||||||
|
)
|
||||||
|
inventory_complete = False
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
incomplete_reasons.append(
|
||||||
|
f"invalid prior_recovery_attempts_json: {_redact(str(exc))}"
|
||||||
|
)
|
||||||
|
inventory_complete = False
|
||||||
|
|
||||||
|
break_glass_authorized = bool(
|
||||||
|
(os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "").strip()
|
||||||
|
)
|
||||||
|
break_glass = bool(request_break_glass and break_glass_authorized)
|
||||||
|
|
||||||
inventory = {
|
inventory = {
|
||||||
"sessions": sessions,
|
"sessions": sessions,
|
||||||
"leases": leases,
|
"leases": leases,
|
||||||
"terminal_lock": terminal_lock,
|
"terminal_lock": terminal_lock,
|
||||||
"inventory_complete": inventory_complete,
|
"inventory_complete": inventory_complete,
|
||||||
"incomplete_reasons": incomplete_reasons,
|
"incomplete_reasons": incomplete_reasons,
|
||||||
|
"prior_recovery_attempts": prior_recovery_attempts,
|
||||||
}
|
}
|
||||||
|
|
||||||
report = restart_coordinator.evaluate_restart_impact(
|
report = restart_coordinator.evaluate_restart_impact(
|
||||||
@@ -22730,6 +22943,7 @@ def gitea_request_mcp_restart(
|
|||||||
target_session_id=target_session_id,
|
target_session_id=target_session_id,
|
||||||
target_role=target_role,
|
target_role=target_role,
|
||||||
target_connector=target_connector,
|
target_connector=target_connector,
|
||||||
|
break_glass=break_glass,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = report.as_dict()
|
payload = report.as_dict()
|
||||||
@@ -22762,13 +22976,6 @@ def gitea_request_mcp_restart(
|
|||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
proof_parse_error = f"invalid drain_proof_json: {_redact(str(exc))}"
|
proof_parse_error = f"invalid drain_proof_json: {_redact(str(exc))}"
|
||||||
|
|
||||||
break_glass_authorized = bool(
|
|
||||||
(
|
|
||||||
os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or ""
|
|
||||||
).strip()
|
|
||||||
)
|
|
||||||
break_glass = bool(request_break_glass and break_glass_authorized)
|
|
||||||
|
|
||||||
expected_fp = drain_proof.impact_fingerprint(report.as_dict())
|
expected_fp = drain_proof.impact_fingerprint(report.as_dict())
|
||||||
gate = drain_proof.gate_apply_restart(
|
gate = drain_proof.gate_apply_restart(
|
||||||
proof=proof_obj,
|
proof=proof_obj,
|
||||||
|
|||||||
@@ -436,6 +436,90 @@ def owning_pr_renewal_evidence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def owning_pr_renewal_from_lock(
|
||||||
|
lock_record: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Rebuild owning-PR renewal evidence from a persisted lock (#945).
|
||||||
|
|
||||||
|
The renewal mirror of ``issue_lock_recovery.recovered_owning_pr_from_lock``.
|
||||||
|
``owning_pr_renewal_evidence`` supplies the waiver for the duration of the
|
||||||
|
``gitea_lock_issue`` call only. The commit, push, create-PR, and
|
||||||
|
duplicate-assessment gates run later in their own calls and re-derive
|
||||||
|
ownership from the durable lock instead — so without this the open PR that
|
||||||
|
renewal already proved belongs to this author reappears there as competing
|
||||||
|
duplicate work, and the exact owner is refused with
|
||||||
|
``duplicate_commit_prevented`` despite complete matching evidence.
|
||||||
|
|
||||||
|
This reads only the ``lease_renewal`` block that the server itself writes,
|
||||||
|
on a lock the caller must already own. Like the recovery mirror it is a
|
||||||
|
re-read of server-derived state, never a fresh assertion: a caller able to
|
||||||
|
forge it could equally forge the lock file every other ownership gate
|
||||||
|
already treats as authoritative.
|
||||||
|
|
||||||
|
Renewal has no descendant case — the assessor required the local, remote and
|
||||||
|
PR heads to be equal — so that equality is re-checked here, and the record
|
||||||
|
must still name the claimant the lock records.
|
||||||
|
"""
|
||||||
|
if not isinstance(lock_record, Mapping):
|
||||||
|
return None
|
||||||
|
record = lock_record.get("lease_renewal")
|
||||||
|
if not isinstance(record, Mapping) or not record.get("renewed"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
branch_name = _text(record.get("branch_name")) or _text(
|
||||||
|
lock_record.get("branch_name")
|
||||||
|
)
|
||||||
|
pr_head = _text(record.get("pr_head_sha"))
|
||||||
|
local_head = _text(record.get("head_sha"))
|
||||||
|
remote_head = _text(record.get("remote_head_sha"))
|
||||||
|
raw_pr_number = record.get("pr_number")
|
||||||
|
raw_issue_number = lock_record.get("issue_number")
|
||||||
|
|
||||||
|
if raw_pr_number is None or raw_issue_number is None:
|
||||||
|
return None
|
||||||
|
if not branch_name or not pr_head:
|
||||||
|
return None
|
||||||
|
# The assessor required all three heads to agree before it granted renewal.
|
||||||
|
# Re-check, so a truncated, drifted, or hand-built record cannot widen the
|
||||||
|
# exemption past the single head the renewal disposition actually proved.
|
||||||
|
if not local_head or not remote_head:
|
||||||
|
return None
|
||||||
|
if pr_head != local_head or pr_head != remote_head:
|
||||||
|
return None
|
||||||
|
# Renewal is refused outright unless the durable lock records both a
|
||||||
|
# claimant username and profile, so a sanctioned record always carries them.
|
||||||
|
# Requiring them to still agree keeps a renewal block from being reused
|
||||||
|
# under an identity or profile the lock no longer names.
|
||||||
|
claimant = lock_record.get("claimant")
|
||||||
|
if not isinstance(claimant, Mapping):
|
||||||
|
lease = lock_record.get("work_lease")
|
||||||
|
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
||||||
|
if not isinstance(claimant, Mapping):
|
||||||
|
return None
|
||||||
|
identity = _text(record.get("identity"))
|
||||||
|
profile = _text(record.get("profile"))
|
||||||
|
if not identity or identity != _text(claimant.get("username")):
|
||||||
|
return None
|
||||||
|
if not profile or profile != _text(claimant.get("profile")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
pr_number = int(raw_pr_number)
|
||||||
|
issue_number = int(raw_issue_number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"head_sha": pr_head,
|
||||||
|
"recorded_head": pr_head,
|
||||||
|
"accepted_head": pr_head,
|
||||||
|
"head_relation": "equal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_renewal_record(
|
def build_renewal_record(
|
||||||
assessment: Mapping[str, Any] | None,
|
assessment: Mapping[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -225,6 +225,16 @@ def classify_namespace_probe(
|
|||||||
# on bad data without treating success as IDE proof).
|
# on bad data without treating success as IDE proof).
|
||||||
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||||
|
|
||||||
|
import gitea_config
|
||||||
|
raw_env = process.get("env") if isinstance(process, dict) else None
|
||||||
|
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
|
||||||
|
is_client_managed = bool(
|
||||||
|
env_summary.get("GITEA_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
|
||||||
|
or env_summary.get("GITEA_MCP_CLIENT_MANAGED") in ("1", "true", "yes", "client_managed")
|
||||||
|
or env_summary.get("GITEA_SERVER_PROVENANCE") == "client_managed"
|
||||||
|
)
|
||||||
|
provenance = "client_managed" if is_client_managed else "manual_launch"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": healthy,
|
"success": healthy,
|
||||||
"healthy": healthy,
|
"healthy": healthy,
|
||||||
@@ -240,6 +250,9 @@ def classify_namespace_probe(
|
|||||||
"error_message": error_message or None,
|
"error_message": error_message or None,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"remediation": remediation,
|
"remediation": remediation,
|
||||||
|
"provenance": provenance,
|
||||||
|
"is_client_managed": is_client_managed,
|
||||||
|
"unconsumed_gitea_env": unconsumed_env,
|
||||||
"diagnostics": {
|
"diagnostics": {
|
||||||
"namespace": ns,
|
"namespace": ns,
|
||||||
"required_tool": tool,
|
"required_tool": tool,
|
||||||
@@ -248,6 +261,9 @@ def classify_namespace_probe(
|
|||||||
"env": env_summary,
|
"env": env_summary,
|
||||||
"config_path": config_path,
|
"config_path": config_path,
|
||||||
"probe_source": source,
|
"probe_source": source,
|
||||||
|
"provenance": provenance,
|
||||||
|
"is_client_managed": is_client_managed,
|
||||||
|
"unconsumed_gitea_env": unconsumed_env,
|
||||||
},
|
},
|
||||||
"blocks_merge_workflow": blocks,
|
"blocks_merge_workflow": blocks,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,583 @@
|
|||||||
|
"""Scoped MCP recovery playbook (#669).
|
||||||
|
|
||||||
|
Operational recovery must prefer the *narrowest* action that can fix the
|
||||||
|
symptom. Full MCP / host restarts are last-resort rungs on a documented
|
||||||
|
ladder; the coordinator refuses those rungs unless a prior attempt log
|
||||||
|
shows narrower recoveries already failed (or break-glass is authorized).
|
||||||
|
|
||||||
|
This module is pure classification and recommendation:
|
||||||
|
|
||||||
|
* No network, filesystem, or process I/O.
|
||||||
|
* Never restarts anything.
|
||||||
|
* Narrow recovery *execution* is delegated to existing tools/docs (linked
|
||||||
|
per rung) — the playbook records which rung to try next and whether
|
||||||
|
escalation to a broad restart is allowed.
|
||||||
|
|
||||||
|
Design lineage: umbrella #655, class matrix #663, coordinator #658,
|
||||||
|
auto-reconnect #584, stale-runtime #610, contamination #630, audit #665.
|
||||||
|
Vision #652 / roadmap #653.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
PLAYBOOK_VERSION = "1.0.0-issue-669"
|
||||||
|
|
||||||
|
# Attempt outcomes that count as "tried and insufficient" for escalation.
|
||||||
|
INSUFFICIENT_OUTCOMES = frozenset(
|
||||||
|
{
|
||||||
|
"failed",
|
||||||
|
"insufficient",
|
||||||
|
"denied",
|
||||||
|
"unresolved",
|
||||||
|
"timeout",
|
||||||
|
"error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Break-glass / operator override still records that the ladder was skipped.
|
||||||
|
OUTCOME_BREAK_GLASS = "break_glass"
|
||||||
|
OUTCOME_SUCCESS = "success"
|
||||||
|
OUTCOME_SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryAction(str, Enum):
|
||||||
|
"""Ordered recovery ladder (narrow → broad)."""
|
||||||
|
|
||||||
|
CLIENT_RECONNECT = "client_reconnect"
|
||||||
|
CAPABILITY_REFRESH = "capability_refresh"
|
||||||
|
SESSION_RECONNECT = "session_reconnect"
|
||||||
|
CONFIGURATION_RELOAD = "configuration_reload"
|
||||||
|
LEASE_RECOVERY = "lease_recovery"
|
||||||
|
WORKER_RESTART = "worker_restart"
|
||||||
|
ROLE_RUNTIME_RESTART = "role_runtime_restart"
|
||||||
|
CONNECTOR_RESTART = "connector_restart"
|
||||||
|
ROLLING_MCP_RESTART = "rolling_mcp_restart"
|
||||||
|
FULL_MCP_RESTART = "full_mcp_restart"
|
||||||
|
HOST_RESTART = "host_restart"
|
||||||
|
|
||||||
|
|
||||||
|
# Classes that require a prior narrow-attempt log (unless break-glass).
|
||||||
|
BROAD_RESTART_ACTIONS: frozenset[RecoveryAction] = frozenset(
|
||||||
|
{
|
||||||
|
RecoveryAction.ROLLING_MCP_RESTART,
|
||||||
|
RecoveryAction.FULL_MCP_RESTART,
|
||||||
|
RecoveryAction.HOST_RESTART,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map #663 restart_class strings onto playbook actions.
|
||||||
|
RESTART_CLASS_TO_ACTION: dict[str, RecoveryAction] = {
|
||||||
|
"client_reconnect": RecoveryAction.CLIENT_RECONNECT,
|
||||||
|
"session_reconnect": RecoveryAction.SESSION_RECONNECT,
|
||||||
|
"configuration_reload": RecoveryAction.CONFIGURATION_RELOAD,
|
||||||
|
"worker_restart": RecoveryAction.WORKER_RESTART,
|
||||||
|
"role_runtime_restart": RecoveryAction.ROLE_RUNTIME_RESTART,
|
||||||
|
"connector_restart": RecoveryAction.CONNECTOR_RESTART,
|
||||||
|
"rolling_mcp_restart": RecoveryAction.ROLLING_MCP_RESTART,
|
||||||
|
"full_mcp_restart": RecoveryAction.FULL_MCP_RESTART,
|
||||||
|
"host_restart": RecoveryAction.HOST_RESTART,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecoveryRung:
|
||||||
|
"""One rung on the recovery ladder."""
|
||||||
|
|
||||||
|
action: RecoveryAction
|
||||||
|
rank: int
|
||||||
|
summary: str
|
||||||
|
# Existing implementation or explicit delegation target.
|
||||||
|
implementation: str
|
||||||
|
issue_links: tuple[str, ...]
|
||||||
|
self_service: bool
|
||||||
|
# Restart-class permission when this rung is requested via coordinator.
|
||||||
|
restart_class: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"action": self.action.value,
|
||||||
|
"rank": self.rank,
|
||||||
|
"summary": self.summary,
|
||||||
|
"implementation": self.implementation,
|
||||||
|
"issue_links": list(self.issue_links),
|
||||||
|
"self_service": self.self_service,
|
||||||
|
"restart_class": self.restart_class,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical ladder. Rank 0 is narrowest.
|
||||||
|
RECOVERY_LADDER: tuple[RecoveryRung, ...] = (
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.CLIENT_RECONNECT,
|
||||||
|
0,
|
||||||
|
"Reconnect the IDE/client MCP transport (EOF / transport flap).",
|
||||||
|
"Host auto-reconnect or explicit client reconnect; "
|
||||||
|
"docs/mcp-namespace-eof-recovery.md",
|
||||||
|
("#584", "#655"),
|
||||||
|
True,
|
||||||
|
"client_reconnect",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.CAPABILITY_REFRESH,
|
||||||
|
1,
|
||||||
|
"Re-resolve task capability and clear stale permission context.",
|
||||||
|
"Delegated: gitea_resolve_task_capability + gitea_whoami "
|
||||||
|
"(no process change).",
|
||||||
|
("#610", "#685", "#655"),
|
||||||
|
True,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.SESSION_RECONNECT,
|
||||||
|
2,
|
||||||
|
"Rebind identity, workspace, and namespace for one session.",
|
||||||
|
"Delegated: gitea_get_runtime_context + explicit worktree_path "
|
||||||
|
"rebind (#618); docs/mcp-namespace-health.md",
|
||||||
|
("#543", "#618", "#655"),
|
||||||
|
True,
|
||||||
|
"session_reconnect",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.CONFIGURATION_RELOAD,
|
||||||
|
3,
|
||||||
|
"Gracefully reload configuration without replacing the daemon.",
|
||||||
|
"restart_coordinator class configuration_reload; console "
|
||||||
|
"system.reload_namespace (#642).",
|
||||||
|
("#642", "#663", "#655"),
|
||||||
|
False,
|
||||||
|
"configuration_reload",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.LEASE_RECOVERY,
|
||||||
|
4,
|
||||||
|
"Recover or rebind stale leases/locks without a process restart.",
|
||||||
|
"Delegated: issue lock recovery / lease lifecycle paths "
|
||||||
|
"(#702, #753, #790).",
|
||||||
|
("#702", "#753", "#790", "#655"),
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.WORKER_RESTART,
|
||||||
|
5,
|
||||||
|
"Restart one worker after its own lease and mutation scope drains.",
|
||||||
|
"restart_coordinator class worker_restart (#663).",
|
||||||
|
("#663", "#655"),
|
||||||
|
False,
|
||||||
|
"worker_restart",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.ROLE_RUNTIME_RESTART,
|
||||||
|
6,
|
||||||
|
"Restart one role runtime and re-probe that namespace only.",
|
||||||
|
"restart_coordinator class role_runtime_restart; console "
|
||||||
|
"system.restart_namespace (#642).",
|
||||||
|
("#642", "#663", "#655"),
|
||||||
|
False,
|
||||||
|
"role_runtime_restart",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.CONNECTOR_RESTART,
|
||||||
|
7,
|
||||||
|
"Restart one connector while unrelated runtimes stay available.",
|
||||||
|
"restart_coordinator class connector_restart (#663).",
|
||||||
|
("#663", "#655"),
|
||||||
|
False,
|
||||||
|
"connector_restart",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.ROLLING_MCP_RESTART,
|
||||||
|
8,
|
||||||
|
"Drain/restart/verify one instance at a time (HA path).",
|
||||||
|
"restart_coordinator class rolling_mcp_restart; design #668.",
|
||||||
|
("#668", "#663", "#655"),
|
||||||
|
False,
|
||||||
|
"rolling_mcp_restart",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.FULL_MCP_RESTART,
|
||||||
|
9,
|
||||||
|
"Full stable-control MCP process restart after verified full drain.",
|
||||||
|
"restart_coordinator class full_mcp_restart; requires attempt log "
|
||||||
|
"unless break-glass (#669).",
|
||||||
|
("#658", "#661", "#663", "#669", "#655"),
|
||||||
|
False,
|
||||||
|
"full_mcp_restart",
|
||||||
|
),
|
||||||
|
RecoveryRung(
|
||||||
|
RecoveryAction.HOST_RESTART,
|
||||||
|
10,
|
||||||
|
"Host/infrastructure restart — broadest last-resort action.",
|
||||||
|
"restart_coordinator class host_restart; operator-owned.",
|
||||||
|
("#663", "#669", "#655"),
|
||||||
|
False,
|
||||||
|
"host_restart",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_LADDER_BY_ACTION: dict[RecoveryAction, RecoveryRung] = {
|
||||||
|
rung.action: rung for rung in RECOVERY_LADDER
|
||||||
|
}
|
||||||
|
|
||||||
|
# Symptom tokens → preferred first rung (decision tree, #663 lineage).
|
||||||
|
SYMPTOM_TO_FIRST_ACTION: dict[str, RecoveryAction] = {
|
||||||
|
"transport_eof": RecoveryAction.CLIENT_RECONNECT,
|
||||||
|
"client_closing_eof": RecoveryAction.CLIENT_RECONNECT,
|
||||||
|
"transport_flap": RecoveryAction.CLIENT_RECONNECT,
|
||||||
|
"namespace_disconnected": RecoveryAction.CLIENT_RECONNECT,
|
||||||
|
"stale_capability": RecoveryAction.CAPABILITY_REFRESH,
|
||||||
|
"permission_stale": RecoveryAction.CAPABILITY_REFRESH,
|
||||||
|
"runtime_reconnect_required": RecoveryAction.CAPABILITY_REFRESH,
|
||||||
|
"stale_runtime": RecoveryAction.SESSION_RECONNECT,
|
||||||
|
"worktree_unbound": RecoveryAction.SESSION_RECONNECT,
|
||||||
|
"namespace_unhealthy": RecoveryAction.SESSION_RECONNECT,
|
||||||
|
"config_drift": RecoveryAction.CONFIGURATION_RELOAD,
|
||||||
|
"profile_misbound": RecoveryAction.CONFIGURATION_RELOAD,
|
||||||
|
"stale_lease": RecoveryAction.LEASE_RECOVERY,
|
||||||
|
"dead_pid_lock": RecoveryAction.LEASE_RECOVERY,
|
||||||
|
"orphan_worktree": RecoveryAction.LEASE_RECOVERY,
|
||||||
|
"single_worker_stuck": RecoveryAction.WORKER_RESTART,
|
||||||
|
"role_runtime_dead": RecoveryAction.ROLE_RUNTIME_RESTART,
|
||||||
|
"connector_dead": RecoveryAction.CONNECTOR_RESTART,
|
||||||
|
"ha_instance_unhealthy": RecoveryAction.ROLLING_MCP_RESTART,
|
||||||
|
"daemon_corrupt": RecoveryAction.FULL_MCP_RESTART,
|
||||||
|
"full_process_deadlock": RecoveryAction.FULL_MCP_RESTART,
|
||||||
|
"host_unresponsive": RecoveryAction.HOST_RESTART,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_action(value: RecoveryAction | str) -> RecoveryAction:
|
||||||
|
"""Resolve a recovery action or fail closed for unknown values."""
|
||||||
|
if isinstance(value, RecoveryAction):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip()
|
||||||
|
# Accept #663 restart_class aliases.
|
||||||
|
if text in RESTART_CLASS_TO_ACTION:
|
||||||
|
return RESTART_CLASS_TO_ACTION[text]
|
||||||
|
try:
|
||||||
|
return RecoveryAction(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown recovery action {value!r}; deny (fail closed, #669)"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def ladder_rank(action: RecoveryAction | str) -> int:
|
||||||
|
resolved = resolve_action(action)
|
||||||
|
return _LADDER_BY_ACTION[resolved].rank
|
||||||
|
|
||||||
|
|
||||||
|
def rung_for(action: RecoveryAction | str) -> RecoveryRung:
|
||||||
|
return _LADDER_BY_ACTION[resolve_action(action)]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_attempt(raw: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Normalize one prior-recovery attempt record; return None if unusable."""
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
return None
|
||||||
|
action_raw = raw.get("action") or raw.get("recovery_action") or raw.get(
|
||||||
|
"restart_class"
|
||||||
|
)
|
||||||
|
if not action_raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
action = resolve_action(str(action_raw))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
outcome = str(
|
||||||
|
raw.get("outcome") or raw.get("status") or raw.get("result") or ""
|
||||||
|
).strip().lower()
|
||||||
|
if not outcome:
|
||||||
|
return None
|
||||||
|
recorded_at = raw.get("recorded_at") or raw.get("at") or raw.get("timestamp")
|
||||||
|
reason = str(raw.get("reason") or raw.get("detail") or "").strip()
|
||||||
|
actor = str(raw.get("actor") or raw.get("session_id") or "").strip()
|
||||||
|
return {
|
||||||
|
"action": action.value,
|
||||||
|
"outcome": outcome,
|
||||||
|
"reason": reason,
|
||||||
|
"actor": actor,
|
||||||
|
"recorded_at": recorded_at,
|
||||||
|
"rank": ladder_rank(action),
|
||||||
|
"raw": dict(raw),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_attempt_log(
|
||||||
|
attempts: Sequence[Mapping[str, Any]] | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return usable attempt records in ladder order."""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for raw in attempts or ():
|
||||||
|
norm = normalize_attempt(raw)
|
||||||
|
if norm is not None:
|
||||||
|
out.append(norm)
|
||||||
|
out.sort(key=lambda a: (a["rank"], str(a.get("recorded_at") or "")))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def narrower_insufficient_attempts(
|
||||||
|
attempts: Sequence[Mapping[str, Any]] | None,
|
||||||
|
*,
|
||||||
|
requested: RecoveryAction | str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return prior attempts narrower than *requested* that were insufficient."""
|
||||||
|
target_rank = ladder_rank(requested)
|
||||||
|
usable = []
|
||||||
|
for attempt in normalize_attempt_log(attempts):
|
||||||
|
if attempt["rank"] >= target_rank:
|
||||||
|
continue
|
||||||
|
if attempt["outcome"] in INSUFFICIENT_OUTCOMES:
|
||||||
|
usable.append(attempt)
|
||||||
|
return usable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EscalationAssessment:
|
||||||
|
"""Whether a requested broad recovery may proceed given the attempt log."""
|
||||||
|
|
||||||
|
requested_action: str
|
||||||
|
allowed: bool
|
||||||
|
require_attempt_log: bool
|
||||||
|
break_glass: bool
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
qualifying_attempts: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
recommended_next: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
playbook_version: str = PLAYBOOK_VERSION
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"playbook_version": self.playbook_version,
|
||||||
|
"requested_action": self.requested_action,
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"require_attempt_log": self.require_attempt_log,
|
||||||
|
"break_glass": self.break_glass,
|
||||||
|
"reasons": list(self.reasons),
|
||||||
|
"qualifying_attempts": list(self.qualifying_attempts),
|
||||||
|
"recommended_next": list(self.recommended_next),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_escalation(
|
||||||
|
requested: RecoveryAction | str,
|
||||||
|
*,
|
||||||
|
prior_recovery_attempts: Sequence[Mapping[str, Any]] | None = None,
|
||||||
|
break_glass: bool = False,
|
||||||
|
) -> EscalationAssessment:
|
||||||
|
"""Gate broad restarts on a prior narrow-attempt log (#669 AC3).
|
||||||
|
|
||||||
|
Narrow / mid-ladder actions do not require a prior attempt log.
|
||||||
|
``full_mcp_restart``, ``host_restart``, and ``rolling_mcp_restart``
|
||||||
|
require at least one *insufficient* narrower attempt unless
|
||||||
|
``break_glass`` is true.
|
||||||
|
"""
|
||||||
|
action = resolve_action(requested)
|
||||||
|
require_log = action in BROAD_RESTART_ACTIONS
|
||||||
|
reasons: list[str] = []
|
||||||
|
qualifying = narrower_insufficient_attempts(
|
||||||
|
prior_recovery_attempts, requested=action
|
||||||
|
)
|
||||||
|
|
||||||
|
if not require_log:
|
||||||
|
return EscalationAssessment(
|
||||||
|
requested_action=action.value,
|
||||||
|
allowed=True,
|
||||||
|
require_attempt_log=False,
|
||||||
|
break_glass=bool(break_glass),
|
||||||
|
reasons=["narrow recovery; attempt log not required"],
|
||||||
|
qualifying_attempts=qualifying,
|
||||||
|
recommended_next=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if break_glass:
|
||||||
|
return EscalationAssessment(
|
||||||
|
requested_action=action.value,
|
||||||
|
allowed=True,
|
||||||
|
require_attempt_log=True,
|
||||||
|
break_glass=True,
|
||||||
|
reasons=[
|
||||||
|
"break-glass authorized; broad restart permitted without "
|
||||||
|
"narrow-attempt log (#669)"
|
||||||
|
],
|
||||||
|
qualifying_attempts=qualifying,
|
||||||
|
recommended_next=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if qualifying:
|
||||||
|
return EscalationAssessment(
|
||||||
|
requested_action=action.value,
|
||||||
|
allowed=True,
|
||||||
|
require_attempt_log=True,
|
||||||
|
break_glass=False,
|
||||||
|
reasons=[
|
||||||
|
f"{len(qualifying)} narrower recovery attempt(s) recorded as "
|
||||||
|
"insufficient; escalation permitted"
|
||||||
|
],
|
||||||
|
qualifying_attempts=qualifying,
|
||||||
|
recommended_next=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deny: recommend the next untried narrow rung(s).
|
||||||
|
recommended = recommend_actions(
|
||||||
|
symptoms=(),
|
||||||
|
prior_recovery_attempts=prior_recovery_attempts,
|
||||||
|
max_actions=3,
|
||||||
|
)
|
||||||
|
reasons.append(
|
||||||
|
f"{action.value} requires a prior attempt log of insufficient "
|
||||||
|
"narrower recoveries (or break-glass); none found — deny (fail "
|
||||||
|
"closed, #669)"
|
||||||
|
)
|
||||||
|
return EscalationAssessment(
|
||||||
|
requested_action=action.value,
|
||||||
|
allowed=False,
|
||||||
|
require_attempt_log=True,
|
||||||
|
break_glass=False,
|
||||||
|
reasons=reasons,
|
||||||
|
qualifying_attempts=[],
|
||||||
|
recommended_next=recommended.get("recommended_actions") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_actions(
|
||||||
|
*,
|
||||||
|
symptoms: Sequence[str] = (),
|
||||||
|
prior_recovery_attempts: Sequence[Mapping[str, Any]] | None = None,
|
||||||
|
max_actions: int = 5,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return ordered recommended recovery actions for the given symptoms.
|
||||||
|
|
||||||
|
Soft mode (rollout): recommendations only — callers decide whether to
|
||||||
|
hard-gate. Hard mode for broad restarts is :func:`assess_escalation`.
|
||||||
|
"""
|
||||||
|
attempted_success = {
|
||||||
|
a["action"]
|
||||||
|
for a in normalize_attempt_log(prior_recovery_attempts)
|
||||||
|
if a["outcome"] == OUTCOME_SUCCESS
|
||||||
|
}
|
||||||
|
attempted_any = {
|
||||||
|
a["action"] for a in normalize_attempt_log(prior_recovery_attempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
first_actions: list[RecoveryAction] = []
|
||||||
|
for symptom in symptoms:
|
||||||
|
key = str(symptom or "").strip().lower().replace(" ", "_").replace("-", "_")
|
||||||
|
mapped = SYMPTOM_TO_FIRST_ACTION.get(key)
|
||||||
|
if mapped is not None and mapped not in first_actions:
|
||||||
|
first_actions.append(mapped)
|
||||||
|
|
||||||
|
# Default entry: client reconnect then walk the ladder.
|
||||||
|
if not first_actions:
|
||||||
|
first_actions = [RecoveryAction.CLIENT_RECONNECT]
|
||||||
|
|
||||||
|
recommended: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
min_rank = min(ladder_rank(a) for a in first_actions)
|
||||||
|
|
||||||
|
for rung in RECOVERY_LADDER:
|
||||||
|
if rung.rank < min_rank:
|
||||||
|
continue
|
||||||
|
if rung.action.value in attempted_success:
|
||||||
|
continue
|
||||||
|
if rung.action.value in seen:
|
||||||
|
continue
|
||||||
|
# Prefer rungs not yet attempted; still list previously-failed ones
|
||||||
|
# only if nothing else remains.
|
||||||
|
entry = rung.as_dict()
|
||||||
|
entry["already_attempted"] = rung.action.value in attempted_any
|
||||||
|
recommended.append(entry)
|
||||||
|
seen.add(rung.action.value)
|
||||||
|
if len(recommended) >= max(1, int(max_actions)):
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"playbook_version": PLAYBOOK_VERSION,
|
||||||
|
"symptoms": [str(s) for s in symptoms],
|
||||||
|
"recommended_actions": recommended,
|
||||||
|
"ladder": [r.as_dict() for r in RECOVERY_LADDER],
|
||||||
|
"read_only": True,
|
||||||
|
"hard_gate_note": (
|
||||||
|
"Broad restarts (rolling/full/host) still require "
|
||||||
|
"assess_escalation / coordinator attempt-log enforcement."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_attempt_record(
|
||||||
|
action: RecoveryAction | str,
|
||||||
|
*,
|
||||||
|
outcome: str,
|
||||||
|
reason: str = "",
|
||||||
|
actor: str = "",
|
||||||
|
recorded_at: str | None = None,
|
||||||
|
extra: Mapping[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a durable-shaped attempt log entry for inventory/audit (#665)."""
|
||||||
|
resolved = resolve_action(action)
|
||||||
|
record = {
|
||||||
|
"action": resolved.value,
|
||||||
|
"outcome": str(outcome or "").strip().lower(),
|
||||||
|
"reason": str(reason or "").strip(),
|
||||||
|
"actor": str(actor or "").strip(),
|
||||||
|
"recorded_at": recorded_at or _utc_now().isoformat(),
|
||||||
|
"rank": ladder_rank(resolved),
|
||||||
|
"playbook_version": PLAYBOOK_VERSION,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
record["extra"] = dict(extra)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def recovery_metrics(
|
||||||
|
attempts: Sequence[Mapping[str, Any]] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute the fraction of recoveries that avoided full/host restart.
|
||||||
|
|
||||||
|
A recovery *episode* is approximated as one attempt with
|
||||||
|
``outcome=success``. Successes on non-broad rungs count as avoided full
|
||||||
|
restart; successes on full/host count as full-restart recoveries.
|
||||||
|
"""
|
||||||
|
norms = normalize_attempt_log(attempts)
|
||||||
|
successes = [a for a in norms if a["outcome"] == OUTCOME_SUCCESS]
|
||||||
|
broad_success = [
|
||||||
|
a
|
||||||
|
for a in successes
|
||||||
|
if resolve_action(a["action"])
|
||||||
|
in {RecoveryAction.FULL_MCP_RESTART, RecoveryAction.HOST_RESTART}
|
||||||
|
]
|
||||||
|
avoided = [a for a in successes if a not in broad_success]
|
||||||
|
total = len(successes)
|
||||||
|
fraction_avoided = (len(avoided) / total) if total else None
|
||||||
|
return {
|
||||||
|
"playbook_version": PLAYBOOK_VERSION,
|
||||||
|
"attempts_total": len(norms),
|
||||||
|
"successes_total": total,
|
||||||
|
"successes_avoided_full_restart": len(avoided),
|
||||||
|
"successes_full_or_host_restart": len(broad_success),
|
||||||
|
"fraction_avoided_full_restart": fraction_avoided,
|
||||||
|
"insufficient_attempts": sum(
|
||||||
|
1 for a in norms if a["outcome"] in INSUFFICIENT_OUTCOMES
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ladder_document() -> dict[str, Any]:
|
||||||
|
"""Machine-readable ladder for docs/tools inventory."""
|
||||||
|
return {
|
||||||
|
"playbook_version": PLAYBOOK_VERSION,
|
||||||
|
"parent_issues": ["#655", "#652", "#653"],
|
||||||
|
"enforcement_issue": "#669",
|
||||||
|
"ladder": [r.as_dict() for r in RECOVERY_LADDER],
|
||||||
|
"broad_restart_actions": [a.value for a in sorted(BROAD_RESTART_ACTIONS, key=lambda x: x.value)],
|
||||||
|
"insufficient_outcomes": sorted(INSUFFICIENT_OUTCOMES),
|
||||||
|
"symptom_map": {k: v.value for k, v in sorted(SYMPTOM_TO_FIRST_ACTION.items())},
|
||||||
|
}
|
||||||
+46
-2
@@ -1,4 +1,4 @@
|
|||||||
"""MCP restart coordinator and impact analysis (#658).
|
"""MCP restart coordinator and impact analysis (#658 / #669).
|
||||||
|
|
||||||
Before any sanctioned MCP restart, a central coordinator must evaluate the
|
Before any sanctioned MCP restart, a central coordinator must evaluate the
|
||||||
live control-plane state — active sessions, leases/locks, in-flight issue/PR
|
live control-plane state — active sessions, leases/locks, in-flight issue/PR
|
||||||
@@ -16,6 +16,9 @@ Design rules (mirrors the read-only posture of ``workflow_dashboard`` /
|
|||||||
a mutative apply path is a later child gated by a drain proof (non-goal here).
|
a mutative apply path is a later child gated by a drain proof (non-goal here).
|
||||||
* **Fail closed.** If the inventory is not explicitly complete, the verdict is
|
* **Fail closed.** If the inventory is not explicitly complete, the verdict is
|
||||||
``unsafe`` / deny — an incomplete evaluation must never green-light a restart.
|
``unsafe`` / deny — an incomplete evaluation must never green-light a restart.
|
||||||
|
* **Narrow-first (#669).** Broad classes (rolling / full / host) require a
|
||||||
|
prior attempt log of insufficient narrower recoveries unless break-glass is
|
||||||
|
authorized. See :mod:`recovery_playbook`.
|
||||||
* **No secrets.** Session ids, pids, and profiles are operational metadata, not
|
* **No secrets.** Session ids, pids, and profiles are operational metadata, not
|
||||||
credentials; nothing secret flows through this module.
|
credentials; nothing secret flows through this module.
|
||||||
|
|
||||||
@@ -32,8 +35,9 @@ from enum import Enum
|
|||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
import lease_lifecycle
|
import lease_lifecycle
|
||||||
|
import recovery_playbook
|
||||||
|
|
||||||
COORDINATOR_VERSION = "1.1.0-issue-663"
|
COORDINATOR_VERSION = "1.2.0-issue-669"
|
||||||
|
|
||||||
# Restart verdicts. Exactly the three the acceptance criteria name.
|
# Restart verdicts. Exactly the three the acceptance criteria name.
|
||||||
VERDICT_SAFE = "safe"
|
VERDICT_SAFE = "safe"
|
||||||
@@ -349,6 +353,10 @@ class RestartImpactReport:
|
|||||||
counts: dict[str, int]
|
counts: dict[str, int]
|
||||||
audit_record: dict[str, Any]
|
audit_record: dict[str, Any]
|
||||||
incomplete_reasons: list[str] = field(default_factory=list)
|
incomplete_reasons: list[str] = field(default_factory=list)
|
||||||
|
# #669 playbook escalation gate (attempt-log enforcement).
|
||||||
|
playbook_escalation: dict[str, Any] = field(default_factory=dict)
|
||||||
|
attempt_log_satisfied: bool = True
|
||||||
|
break_glass: bool = False
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -382,6 +390,9 @@ class RestartImpactReport:
|
|||||||
"prior_recovery_attempts": list(self.prior_recovery_attempts),
|
"prior_recovery_attempts": list(self.prior_recovery_attempts),
|
||||||
"counts": dict(self.counts),
|
"counts": dict(self.counts),
|
||||||
"audit_record": dict(self.audit_record),
|
"audit_record": dict(self.audit_record),
|
||||||
|
"playbook_escalation": dict(self.playbook_escalation),
|
||||||
|
"attempt_log_satisfied": self.attempt_log_satisfied,
|
||||||
|
"break_glass": self.break_glass,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -496,6 +507,7 @@ def evaluate_restart_impact(
|
|||||||
target_session_id: str | None = None,
|
target_session_id: str | None = None,
|
||||||
target_role: str | None = None,
|
target_role: str | None = None,
|
||||||
target_connector: str | None = None,
|
target_connector: str | None = None,
|
||||||
|
break_glass: bool = False,
|
||||||
) -> RestartImpactReport:
|
) -> RestartImpactReport:
|
||||||
"""Evaluate a proposed MCP restart and return an impact preview.
|
"""Evaluate a proposed MCP restart and return an impact preview.
|
||||||
|
|
||||||
@@ -584,6 +596,30 @@ def evaluate_restart_impact(
|
|||||||
dict(a) for a in (inventory.get("prior_recovery_attempts") or [])
|
dict(a) for a in (inventory.get("prior_recovery_attempts") or [])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# #669: broad restarts require a prior narrow-attempt log unless break-glass.
|
||||||
|
playbook_escalation: dict[str, Any] = {}
|
||||||
|
attempt_log_satisfied = True
|
||||||
|
if policy_enforced and resolved_class is not None:
|
||||||
|
try:
|
||||||
|
escalation = recovery_playbook.assess_escalation(
|
||||||
|
resolved_class.value,
|
||||||
|
prior_recovery_attempts=prior_recovery_attempts,
|
||||||
|
break_glass=bool(break_glass),
|
||||||
|
)
|
||||||
|
playbook_escalation = escalation.as_dict()
|
||||||
|
attempt_log_satisfied = bool(escalation.allowed)
|
||||||
|
if not attempt_log_satisfied:
|
||||||
|
authorization_reasons.extend(list(escalation.reasons))
|
||||||
|
except ValueError as exc:
|
||||||
|
# Unknown mapping should never happen for enum values; fail closed.
|
||||||
|
attempt_log_satisfied = False
|
||||||
|
playbook_escalation = {
|
||||||
|
"allowed": False,
|
||||||
|
"reasons": [str(exc)],
|
||||||
|
"playbook_version": recovery_playbook.PLAYBOOK_VERSION,
|
||||||
|
}
|
||||||
|
authorization_reasons.append(str(exc))
|
||||||
|
|
||||||
session_impacts = [
|
session_impacts = [
|
||||||
_classify_session(
|
_classify_session(
|
||||||
s,
|
s,
|
||||||
@@ -682,6 +718,7 @@ def evaluate_restart_impact(
|
|||||||
and role_authorized
|
and role_authorized
|
||||||
and approval_satisfied
|
and approval_satisfied
|
||||||
and target_complete
|
and target_complete
|
||||||
|
and attempt_log_satisfied
|
||||||
)
|
)
|
||||||
|
|
||||||
if policy_enforced and not authorization_ok:
|
if policy_enforced and not authorization_ok:
|
||||||
@@ -745,6 +782,7 @@ def evaluate_restart_impact(
|
|||||||
"affected_issues": len(affected_issues),
|
"affected_issues": len(affected_issues),
|
||||||
"affected_prs": len(affected_prs),
|
"affected_prs": len(affected_prs),
|
||||||
"prior_recovery_attempts": len(prior_recovery_attempts),
|
"prior_recovery_attempts": len(prior_recovery_attempts),
|
||||||
|
"attempt_log_satisfied": attempt_log_satisfied,
|
||||||
}
|
}
|
||||||
|
|
||||||
audit_record = {
|
audit_record = {
|
||||||
@@ -765,6 +803,9 @@ def evaluate_restart_impact(
|
|||||||
"allow_restart": allow_restart,
|
"allow_restart": allow_restart,
|
||||||
"blast_radius": blast_radius,
|
"blast_radius": blast_radius,
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
|
"attempt_log_satisfied": attempt_log_satisfied,
|
||||||
|
"break_glass": bool(break_glass),
|
||||||
|
"playbook_version": recovery_playbook.PLAYBOOK_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
return RestartImpactReport(
|
return RestartImpactReport(
|
||||||
@@ -804,4 +845,7 @@ def evaluate_restart_impact(
|
|||||||
counts=counts,
|
counts=counts,
|
||||||
audit_record=audit_record,
|
audit_record=audit_record,
|
||||||
incomplete_reasons=incomplete_reasons,
|
incomplete_reasons=incomplete_reasons,
|
||||||
|
playbook_escalation=playbook_escalation,
|
||||||
|
attempt_log_satisfied=attempt_log_satisfied,
|
||||||
|
break_glass=bool(break_glass),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ CONTAMINATION_GATED_TASKS = frozenset({
|
|||||||
"merge_pr",
|
"merge_pr",
|
||||||
"delete_branch",
|
"delete_branch",
|
||||||
"complete_issue",
|
"complete_issue",
|
||||||
|
# Web console recovery playbooks that write (#644). These mutate runtime
|
||||||
|
# binding and process state, so a live contamination marker must block them
|
||||||
|
# exactly as it blocks the Gitea-side mutations above. The reconciler
|
||||||
|
# cleanup playbook is the designated remedy and is exempted by its caller.
|
||||||
|
"console_recovery_apply",
|
||||||
})
|
})
|
||||||
|
|
||||||
CONTAMINATION_KIND = "stable_branch_push"
|
CONTAMINATION_KIND = "stable_branch_push"
|
||||||
|
|||||||
@@ -142,6 +142,23 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.read",
|
"permission": "gitea.read",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# #644: Phase 2 Web Console recovery tasks.
|
||||||
|
"clear_stale_binding": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"rebind_session_worktree": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
# The console playbook orchestrates gitea_reconcile_merged_cleanups, whose
|
||||||
|
# own gate is gitea.read (matching the existing reconcile_merged_cleanups
|
||||||
|
# entry). Declaring a stricter permission here stated a second, conflicting
|
||||||
|
# authority for one operation.
|
||||||
|
"reconcile_cleanups": {
|
||||||
|
"permission": "gitea.read",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
||||||
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
||||||
"assess_pr_sync_status": {
|
"assess_pr_sync_status": {
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
]:
|
]:
|
||||||
monkeypatch.delenv(env_key, raising=False)
|
monkeypatch.delenv(env_key, raising=False)
|
||||||
|
|
||||||
|
monkeypatch.setenv("GITEA_CLIENT_MANAGED", "1")
|
||||||
|
|
||||||
# Isolate durable session-state files so tests never share host cache (#559).
|
# Isolate durable session-state files so tests never share host cache (#559).
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from allocator_service import (
|
from allocator_service import (
|
||||||
OUTCOME_ASSIGNED,
|
OUTCOME_ASSIGNED,
|
||||||
@@ -15,6 +16,7 @@ from allocator_service import (
|
|||||||
OUTCOME_PREVIEW,
|
OUTCOME_PREVIEW,
|
||||||
OUTCOME_WAIT,
|
OUTCOME_WAIT,
|
||||||
WorkCandidate,
|
WorkCandidate,
|
||||||
|
_drop_expired_claims,
|
||||||
allocate_next_work,
|
allocate_next_work,
|
||||||
candidate_from_dict,
|
candidate_from_dict,
|
||||||
classify_skip,
|
classify_skip,
|
||||||
@@ -362,5 +364,161 @@ class AllocatorServiceTest(unittest.TestCase):
|
|||||||
self.assertIn("unavailable", res["reasons"][0].lower())
|
self.assertIn("unavailable", res["reasons"][0].lower())
|
||||||
|
|
||||||
|
|
||||||
|
class SideEffectFreeAllocationTest(unittest.TestCase):
|
||||||
|
"""``side_effect_free`` dry runs write nothing to the control plane (#643).
|
||||||
|
|
||||||
|
A plain ``apply=False`` still called ``upsert_session`` and
|
||||||
|
``expire_stale_leases`` before the apply branch was consulted, so a caller
|
||||||
|
advertising a read-only preview mutated on every call — one unreferenced
|
||||||
|
session row per preview, plus a global lease sweep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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="s-preview",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
candidates=[
|
||||||
|
WorkCandidate(kind="issue", number=643, labels=("status:ready",))
|
||||||
|
],
|
||||||
|
apply=False,
|
||||||
|
profile_name="prgs-author",
|
||||||
|
username="jcwalker3",
|
||||||
|
)
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return allocate_next_work(**defaults)
|
||||||
|
|
||||||
|
def _session_ids(self) -> set[str]:
|
||||||
|
return {str(r.get("session_id")) for r in self.db.list_sessions()}
|
||||||
|
|
||||||
|
def test_side_effect_free_preview_writes_no_session_row(self):
|
||||||
|
before = self._session_ids()
|
||||||
|
result = self._alloc(side_effect_free=True)
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(self._session_ids(), before)
|
||||||
|
self.assertNotIn("s-preview", self._session_ids())
|
||||||
|
|
||||||
|
def test_plain_dry_run_still_registers_a_session(self):
|
||||||
|
# The default is unchanged for every existing caller.
|
||||||
|
self._alloc()
|
||||||
|
self.assertIn("s-preview", self._session_ids())
|
||||||
|
|
||||||
|
def test_repeated_previews_do_not_accumulate_rows(self):
|
||||||
|
for index in range(5):
|
||||||
|
self._alloc(side_effect_free=True, session_id=f"s-{index}")
|
||||||
|
self.assertEqual(self._session_ids(), set())
|
||||||
|
|
||||||
|
def test_side_effect_free_does_not_sweep_stale_leases(self):
|
||||||
|
self.db.upsert_session(session_id="owner", role="author", pid=1)
|
||||||
|
assigned = self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=999,
|
||||||
|
lease_ttl_seconds=-60, # already expired
|
||||||
|
)
|
||||||
|
self.assertEqual(assigned.outcome, "assigned")
|
||||||
|
|
||||||
|
self._alloc(side_effect_free=True)
|
||||||
|
|
||||||
|
# The expired row is still 'active' in the DB: nothing swept it.
|
||||||
|
statuses = {
|
||||||
|
r["lease_id"]: r["status"]
|
||||||
|
for r in self.db.list_leases(
|
||||||
|
remote="prgs", org="org", repo="repo",
|
||||||
|
statuses=("active", "expired"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertEqual(statuses.get(assigned.lease_id), "active")
|
||||||
|
|
||||||
|
def test_expired_claims_are_filtered_in_memory_so_work_stays_selectable(self):
|
||||||
|
"""The read-only mirror of the sweep: expired claims must not block."""
|
||||||
|
self.db.upsert_session(session_id="owner", role="author", pid=1)
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=643,
|
||||||
|
lease_ttl_seconds=-60, # expired: must not withhold #643
|
||||||
|
)
|
||||||
|
result = self._alloc(side_effect_free=True)
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_PREVIEW)
|
||||||
|
self.assertEqual(result["selected"]["number"], 643)
|
||||||
|
|
||||||
|
def test_a_live_claim_still_withholds_the_work(self):
|
||||||
|
self.db.upsert_session(session_id="owner", role="author", pid=1)
|
||||||
|
self.db.assign_and_lease(
|
||||||
|
session_id="owner",
|
||||||
|
role="author",
|
||||||
|
remote="prgs",
|
||||||
|
org="org",
|
||||||
|
repo="repo",
|
||||||
|
kind="issue",
|
||||||
|
number=643,
|
||||||
|
lease_ttl_seconds=3600,
|
||||||
|
)
|
||||||
|
result = self._alloc(side_effect_free=True)
|
||||||
|
self.assertNotEqual(result["outcome"], OUTCOME_ASSIGNED)
|
||||||
|
self.assertNotEqual((result.get("selected") or {}).get("number"), 643)
|
||||||
|
|
||||||
|
def test_side_effect_free_with_apply_fails_closed(self):
|
||||||
|
result = self._alloc(side_effect_free=True, apply=True)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_NO_SAFE)
|
||||||
|
self.assertIsNone(result["assignment"])
|
||||||
|
self.assertIn("incompatible with apply", result["reasons"][0])
|
||||||
|
# And it reserved nothing.
|
||||||
|
self.assertEqual(
|
||||||
|
self.db.list_leases(remote="prgs", org="org", repo="repo"), []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DropExpiredClaimsTest(unittest.TestCase):
|
||||||
|
"""The in-memory expiry filter behind side-effect-free previews (#643)."""
|
||||||
|
|
||||||
|
def test_unparseable_expiry_is_kept_rather_than_assumed_free(self):
|
||||||
|
claims = {
|
||||||
|
("issue", 1): {"lease_id": "l1", "expires_at": "not-a-date"},
|
||||||
|
("issue", 2): {"lease_id": "l2"},
|
||||||
|
("issue", 3): {"lease_id": "l3", "expires_at": None},
|
||||||
|
}
|
||||||
|
self.assertEqual(_drop_expired_claims(claims), claims)
|
||||||
|
|
||||||
|
def test_expired_dropped_and_future_kept(self):
|
||||||
|
now = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc)
|
||||||
|
claims = {
|
||||||
|
("issue", 1): {"expires_at": "2026-07-25T11:59:59+00:00"},
|
||||||
|
("issue", 2): {"expires_at": "2026-07-25T12:00:01+00:00"},
|
||||||
|
("issue", 3): {"expires_at": "2026-07-25T12:00:00+00:00"}, # boundary
|
||||||
|
}
|
||||||
|
kept = _drop_expired_claims(claims, now=now)
|
||||||
|
self.assertEqual(set(kept), {("issue", 2)})
|
||||||
|
|
||||||
|
def test_naive_and_zulu_timestamps_are_treated_as_utc(self):
|
||||||
|
now = datetime(2026, 7, 25, 12, 0, tzinfo=timezone.utc)
|
||||||
|
claims = {
|
||||||
|
("issue", 1): {"expires_at": "2026-07-25T11:00:00"}, # naive, past
|
||||||
|
("issue", 2): {"expires_at": "2026-07-25T13:00:00Z"}, # zulu, future
|
||||||
|
}
|
||||||
|
kept = _drop_expired_claims(claims, now=now)
|
||||||
|
self.assertEqual(set(kept), {("issue", 2)})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ CONFIG = {
|
|||||||
],
|
],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"execution_profile": "full-author",
|
"execution_profile": "full-author",
|
||||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
"reviewer-no-commit": {
|
"reviewer-no-commit": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
@@ -50,7 +50,7 @@ CONFIG = {
|
|||||||
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
|
||||||
],
|
],
|
||||||
"execution_profile": "reviewer-no-commit",
|
"execution_profile": "reviewer-no-commit",
|
||||||
"allowed_repositories": ["Example-Org/Example-Repo"],
|
"allowed_repositories": ["Scaled-Tech-Consulting/Gitea-Tools", "Example-Org/Example-Repo"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"rules": {"allow_runtime_switching": False},
|
"rules": {"allow_runtime_switching": False},
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class TestLauncherSnippets(unittest.TestCase):
|
|||||||
def test_only_safe_keys_no_secrets(self):
|
def test_only_safe_keys_no_secrets(self):
|
||||||
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
|
entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"]
|
||||||
self.assertEqual(set(entry), {"command", "args", "env"})
|
self.assertEqual(set(entry), {"command", "args", "env"})
|
||||||
self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE"})
|
self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE", "GITEA_CLIENT_MANAGED"})
|
||||||
self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs")
|
self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs")
|
||||||
blob = json.dumps(entry).lower()
|
blob = json.dumps(entry).lower()
|
||||||
for word in ("token", "password", "secret"):
|
for word in ("token", "password", "secret"):
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Tests for Issue #686: Detect and reject manually launched duplicate MCP role servers."""
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import gitea_config
|
||||||
|
import gitea_mcp_server
|
||||||
|
import mcp_namespace_health
|
||||||
|
|
||||||
|
|
||||||
|
class TestIssue686ManualMcpProvenance(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_client_managed_process_detection(self):
|
||||||
|
"""Test _is_client_managed_process correctly detects provenance markers."""
|
||||||
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True):
|
||||||
|
self.assertTrue(gitea_mcp_server._is_client_managed_process())
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"GITEA_MCP_CLIENT_MANAGED": "true"}, clear=True):
|
||||||
|
self.assertTrue(gitea_mcp_server._is_client_managed_process())
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"GITEA_SERVER_PROVENANCE": "client_managed"}, clear=True):
|
||||||
|
self.assertTrue(gitea_mcp_server._is_client_managed_process())
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True):
|
||||||
|
self.assertFalse(gitea_mcp_server._is_client_managed_process())
|
||||||
|
|
||||||
|
def test_unconsumed_gitea_env_overrides(self):
|
||||||
|
"""Test surfacing of unsupported GITEA_* env overrides (e.g. GITEA_DUMMY)."""
|
||||||
|
env = {
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_CLIENT_MANAGED": "1",
|
||||||
|
"GITEA_DUMMY": "2",
|
||||||
|
"GITEA_UNKNOWN_FLAG": "abc",
|
||||||
|
}
|
||||||
|
unconsumed = gitea_config.get_unconsumed_gitea_env_overrides(env)
|
||||||
|
self.assertIn("GITEA_DUMMY", unconsumed)
|
||||||
|
self.assertEqual(unconsumed["GITEA_DUMMY"], "2")
|
||||||
|
self.assertIn("GITEA_UNKNOWN_FLAG", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_MCP_PROFILE", unconsumed)
|
||||||
|
self.assertNotIn("GITEA_CLIENT_MANAGED", unconsumed)
|
||||||
|
|
||||||
|
def test_manual_server_mutation_fail_closed(self):
|
||||||
|
"""AC 2: Mutating tools on a server without client-managed provenance fail closed with a typed blocker."""
|
||||||
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "0"}, clear=True):
|
||||||
|
block = gitea_mcp_server._provenance_mutation_block(task="create_issue")
|
||||||
|
self.assertIsNotNone(block)
|
||||||
|
self.assertFalse(block["success"])
|
||||||
|
self.assertFalse(block["performed"])
|
||||||
|
self.assertEqual(block["blocker_kind"], "unsupported_manual_launch")
|
||||||
|
self.assertEqual(block["provenance"], "manual_launch")
|
||||||
|
self.assertTrue(any("mutation denied: server process was launched manually" in r for r in block["reasons"]))
|
||||||
|
self.assertIn("BLOCKED + RECONNECT", block["exact_next_action"])
|
||||||
|
|
||||||
|
def test_client_managed_server_mutation_passes_provenance_gate(self):
|
||||||
|
"""AC 3: Clean client-managed baseline passes the provenance gate."""
|
||||||
|
with patch.dict(os.environ, {"GITEA_CLIENT_MANAGED": "1"}, clear=True):
|
||||||
|
block = gitea_mcp_server._provenance_mutation_block(task="create_issue")
|
||||||
|
self.assertIsNone(block)
|
||||||
|
|
||||||
|
@patch("subprocess.run")
|
||||||
|
@patch("os.path.getmtime")
|
||||||
|
@patch("os.path.exists")
|
||||||
|
@patch("os.getpid")
|
||||||
|
def test_manual_duplicate_does_not_mask_stale_runtime(
|
||||||
|
self, mock_getpid, mock_exists, mock_getmtime, mock_run
|
||||||
|
):
|
||||||
|
"""AC 1 & AC 3: Staleness detection ignores manual duplicates and reports stale supported runtimes."""
|
||||||
|
mock_getpid.return_value = 12345
|
||||||
|
mock_exists.return_value = True
|
||||||
|
|
||||||
|
code_time = datetime(2026, 7, 8, 14, 0, 0)
|
||||||
|
mock_getmtime.return_value = code_time.timestamp()
|
||||||
|
|
||||||
|
# PID 12345: stale client-managed process (started at 13:00)
|
||||||
|
# PID 99999: fresh manual duplicate process (started at 15:00, no GITEA_CLIENT_MANAGED)
|
||||||
|
ps_output = (
|
||||||
|
" PID LSTART COMMAND\n"
|
||||||
|
"12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
"99999 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_run_ps = MagicMock()
|
||||||
|
mock_run_ps.stdout = ps_output
|
||||||
|
|
||||||
|
mock_env_12345 = MagicMock()
|
||||||
|
mock_env_12345.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1"
|
||||||
|
|
||||||
|
mock_env_99999 = MagicMock()
|
||||||
|
mock_env_99999.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_DUMMY=2"
|
||||||
|
|
||||||
|
def side_effect(args, **kwargs):
|
||||||
|
if args[0] == "ps" and "eww" in args:
|
||||||
|
pid = args[2]
|
||||||
|
if pid == "12345":
|
||||||
|
return mock_env_12345
|
||||||
|
elif pid == "99999":
|
||||||
|
return mock_env_99999
|
||||||
|
elif args[0] == "ps":
|
||||||
|
return mock_run_ps
|
||||||
|
raise ValueError(f"Unexpected args: {args}")
|
||||||
|
|
||||||
|
mock_run.side_effect = side_effect
|
||||||
|
|
||||||
|
reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"])
|
||||||
|
|
||||||
|
# Manual duplicate process must be flagged
|
||||||
|
self.assertTrue(any("Duplicate MCP server process(es) detected" in r for r in reasons))
|
||||||
|
# Unsupported env override (GITEA_DUMMY=2) must be flagged
|
||||||
|
self.assertTrue(any("unsupported-env: Unsupported GITEA_* environment variable override(s) detected: GITEA_DUMMY=2" in r for r in reasons))
|
||||||
|
# Stale runtime must NOT be masked by fresh manual process 99999!
|
||||||
|
self.assertTrue(any("All matching profiles for task 'create_issue' (['prgs-author']) are running but stale" in r for r in reasons))
|
||||||
|
|
||||||
|
def test_namespace_health_classification_includes_provenance(self):
|
||||||
|
"""AC 1 & 4: mcp_namespace_health diagnostics include provenance and unconsumed_gitea_env."""
|
||||||
|
process = {
|
||||||
|
"pid": 5555,
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"env": {
|
||||||
|
"GITEA_MCP_PROFILE": "prgs-author",
|
||||||
|
"GITEA_DUMMY": "99",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res = mcp_namespace_health.classify_namespace_probe(
|
||||||
|
"gitea-author",
|
||||||
|
configured=True,
|
||||||
|
registered_tools=["gitea_whoami"],
|
||||||
|
probe_result={"success": True},
|
||||||
|
process=process,
|
||||||
|
probe_source="client_namespace",
|
||||||
|
)
|
||||||
|
self.assertEqual(res["provenance"], "manual_launch")
|
||||||
|
self.assertFalse(res["is_client_managed"])
|
||||||
|
self.assertEqual(res["unconsumed_gitea_env"], {"GITEA_DUMMY": "99"})
|
||||||
|
self.assertEqual(res["diagnostics"]["provenance"], "manual_launch")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -35,6 +35,17 @@ BREAK_GLASS_ENV = "GITEA_BREAKGLASS_RESTART_AUTHORIZATION"
|
|||||||
QUIET_SESSIONS: list[dict] = []
|
QUIET_SESSIONS: list[dict] = []
|
||||||
QUIET_LEASES: list[dict] = []
|
QUIET_LEASES: list[dict] = []
|
||||||
|
|
||||||
|
# #669: broad restarts need a prior narrow-attempt log (unless break-glass).
|
||||||
|
PRIOR_NARROW_ATTEMPTS_JSON = json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"action": "client_reconnect",
|
||||||
|
"outcome": "insufficient",
|
||||||
|
"reason": "still flapping after reconnect",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
class _FakeDB:
|
||||||
"""Minimal control-plane DB stand-in for the restart inventory."""
|
"""Minimal control-plane DB stand-in for the restart inventory."""
|
||||||
@@ -128,6 +139,7 @@ class TestConjunction(_RestartToolHarness):
|
|||||||
preview = self._call(
|
preview = self._call(
|
||||||
role="operator",
|
role="operator",
|
||||||
restart_class="full_mcp_restart",
|
restart_class="full_mcp_restart",
|
||||||
|
prior_recovery_attempts_json=PRIOR_NARROW_ATTEMPTS_JSON,
|
||||||
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
)
|
)
|
||||||
self.assertTrue(preview["allow_restart"],
|
self.assertTrue(preview["allow_restart"],
|
||||||
@@ -136,6 +148,7 @@ class TestConjunction(_RestartToolHarness):
|
|||||||
result = self._call(
|
result = self._call(
|
||||||
role="operator",
|
role="operator",
|
||||||
restart_class="full_mcp_restart",
|
restart_class="full_mcp_restart",
|
||||||
|
prior_recovery_attempts_json=PRIOR_NARROW_ATTEMPTS_JSON,
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
drain_proof_json=self._clean_proof_for(preview),
|
drain_proof_json=self._clean_proof_for(preview),
|
||||||
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
@@ -342,6 +355,7 @@ class TestExistingPathsStillWork(_RestartToolHarness):
|
|||||||
result = self._call(
|
result = self._call(
|
||||||
role="operator",
|
role="operator",
|
||||||
restart_class="full_mcp_restart",
|
restart_class="full_mcp_restart",
|
||||||
|
prior_recovery_attempts_json=PRIOR_NARROW_ATTEMPTS_JSON,
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
env={CONTROLLER_APPROVAL_ENV: "operator-approved"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Regression: author worktree bootstrap from clean control checkout (#892).
|
||||||
|
|
||||||
|
#892 is the four-door deadlock where every documented recovery path is closed:
|
||||||
|
bootstrap refuses control, lock demands an existing worktree, worktree-start
|
||||||
|
demands a lock, and shell worktree add is outside the sanctioned MCP path.
|
||||||
|
|
||||||
|
Root cause: assess_author_issue_bootstrap returned allowed/proven for a clean
|
||||||
|
control checkout, but bootstrap_permits_control_checkout only accepted
|
||||||
|
create_issue assessments (task_scope=create_issue_only + empty reasons + full
|
||||||
|
base-tip field set). Author assessments never satisfied the shared predicate,
|
||||||
|
so the #274/#604 guards kept the ordinary control-checkout block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import author_issue_bootstrap as aib
|
||||||
|
import create_issue_bootstrap as cib
|
||||||
|
|
||||||
|
|
||||||
|
CONTROL = "/repo/Gitea-Tools"
|
||||||
|
MASTER = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
OTHER = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
|
||||||
|
|
||||||
|
def _assess(
|
||||||
|
*,
|
||||||
|
workspace=CONTROL,
|
||||||
|
root=CONTROL,
|
||||||
|
branch="master",
|
||||||
|
head=MASTER,
|
||||||
|
porcelain="",
|
||||||
|
remote=MASTER,
|
||||||
|
remote_error=None,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
):
|
||||||
|
return aib.assess_author_issue_bootstrap(
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=root,
|
||||||
|
current_branch=branch,
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=remote,
|
||||||
|
remote_master_sha_error=remote_error,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorBootstrapAssessmentShape(unittest.TestCase):
|
||||||
|
def test_clean_control_emits_predicate_compatible_fields(self):
|
||||||
|
assessment = _assess()
|
||||||
|
self.assertTrue(assessment["allowed"])
|
||||||
|
self.assertTrue(assessment["proven"])
|
||||||
|
self.assertFalse(assessment["block"])
|
||||||
|
self.assertFalse(assessment["not_applicable"])
|
||||||
|
self.assertEqual(assessment["reasons"], [])
|
||||||
|
self.assertEqual(assessment["task_scope"], "author_issue_bootstrap")
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["bootstrap_path"], "clean_canonical_control_checkout"
|
||||||
|
)
|
||||||
|
self.assertEqual(assessment["dirty_files"], [])
|
||||||
|
self.assertIs(assessment["under_branches"], False)
|
||||||
|
self.assertTrue(assessment["base_tips_verified"])
|
||||||
|
self.assertEqual(assessment["local_head_sha"], MASTER)
|
||||||
|
self.assertEqual(assessment["remote_master_sha"], MASTER)
|
||||||
|
self.assertEqual(assessment["workspace_path"], os.path.realpath(CONTROL))
|
||||||
|
self.assertEqual(
|
||||||
|
assessment["canonical_repo_root"], os.path.realpath(CONTROL)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wrong_task_not_applicable(self):
|
||||||
|
assessment = _assess(task="lock_issue")
|
||||||
|
self.assertTrue(assessment["not_applicable"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
|
||||||
|
def test_branches_worktree_not_applicable_for_control_waiver(self):
|
||||||
|
branches = os.path.join(CONTROL, "branches", "fix-issue-1")
|
||||||
|
assessment = _assess(workspace=branches)
|
||||||
|
self.assertTrue(assessment["not_applicable"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertEqual(assessment["bootstrap_path"], "existing_branches_worktree")
|
||||||
|
|
||||||
|
def test_dirty_control_blocks(self):
|
||||||
|
assessment = _assess(porcelain=" M gitea_mcp_server.py\n")
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
self.assertTrue(any("tracked local edits" in r for r in assessment["reasons"]))
|
||||||
|
|
||||||
|
def test_head_remote_mismatch_blocks(self):
|
||||||
|
assessment = _assess(head=MASTER, remote=OTHER)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
|
||||||
|
def test_missing_remote_tip_blocks(self):
|
||||||
|
assessment = _assess(remote=None)
|
||||||
|
self.assertTrue(assessment["block"])
|
||||||
|
self.assertFalse(assessment["allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorBootstrapPredicate(unittest.TestCase):
|
||||||
|
def _permits(self, assessment, task="bootstrap_author_issue_worktree"):
|
||||||
|
return cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task=task,
|
||||||
|
workspace_path=os.path.realpath(CONTROL),
|
||||||
|
canonical_repo_root=os.path.realpath(CONTROL),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clean_author_bootstrap_permits(self):
|
||||||
|
self.assertTrue(self._permits(_assess()))
|
||||||
|
|
||||||
|
def test_tool_alias_permits(self):
|
||||||
|
assessment = _assess(task="gitea_bootstrap_author_issue_worktree")
|
||||||
|
self.assertTrue(
|
||||||
|
self._permits(assessment, task="gitea_bootstrap_author_issue_worktree")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_issue_scope_cannot_license_author_bootstrap(self):
|
||||||
|
# Cross-scope smuggling: a create_issue-shaped assessment must not
|
||||||
|
# authorize the author bootstrap task.
|
||||||
|
create_shaped = dict(_assess())
|
||||||
|
create_shaped["task_scope"] = "create_issue_only"
|
||||||
|
self.assertFalse(self._permits(create_shaped))
|
||||||
|
|
||||||
|
def test_author_scope_cannot_license_create_issue(self):
|
||||||
|
assessment = _assess()
|
||||||
|
self.assertFalse(
|
||||||
|
cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="create_issue",
|
||||||
|
workspace_path=os.path.realpath(CONTROL),
|
||||||
|
canonical_repo_root=os.path.realpath(CONTROL),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nonempty_reasons_fail_closed(self):
|
||||||
|
bad = dict(_assess(), reasons=["informational text must not be here"])
|
||||||
|
self.assertFalse(self._permits(bad))
|
||||||
|
|
||||||
|
def test_dirty_fails_closed(self):
|
||||||
|
self.assertFalse(self._permits(_assess(porcelain=" M x.py\n")))
|
||||||
|
|
||||||
|
def test_mismatch_fails_closed(self):
|
||||||
|
self.assertFalse(self._permits(_assess(remote=OTHER)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorBootstrapPreflightIntegration(unittest.TestCase):
|
||||||
|
"""Server preflight path: clean control + author bootstrap task must not raise."""
|
||||||
|
|
||||||
|
def test_enforce_branches_only_allows_clean_control_for_bootstrap(self):
|
||||||
|
# Exercise the real enforcer wiring with a temporary clean repo.
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = os.path.join(tmp, "repo")
|
||||||
|
os.makedirs(os.path.join(repo, "branches"))
|
||||||
|
# Minimal git repo on master at a known tip.
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
subprocess.check_call(["git", "init", "-b", "master", repo])
|
||||||
|
subprocess.check_call(
|
||||||
|
["git", "-C", repo, "commit", "--allow-empty", "-m", "init"]
|
||||||
|
)
|
||||||
|
head = subprocess.check_output(
|
||||||
|
["git", "-C", repo, "rev-parse", "HEAD"], text=True
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
assessment = aib.assess_author_issue_bootstrap(
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status="",
|
||||||
|
remote_master_sha=head,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate what _enforce_branches_only_author_mutation does when
|
||||||
|
# durable resolution blocks control: the shared predicate must waive.
|
||||||
|
durable_block = {
|
||||||
|
"block": True,
|
||||||
|
"workspace_path": repo,
|
||||||
|
"workspace_binding_source": "process_project_root",
|
||||||
|
"reasons": [
|
||||||
|
"author mutation blocked: workspace is the stable control checkout"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if cib.bootstrap_permits_control_checkout(
|
||||||
|
assessment,
|
||||||
|
task="bootstrap_author_issue_worktree",
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
):
|
||||||
|
waived = True
|
||||||
|
else:
|
||||||
|
waived = False
|
||||||
|
self.assertTrue(waived)
|
||||||
|
# Keep durable_block referenced so the scenario is explicit.
|
||||||
|
self.assertTrue(durable_block["block"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Regression: author bootstrap scope reaches workflow_scope_guard (#941).
|
||||||
|
|
||||||
|
PR #926 (#892) made ``bootstrap_permits_control_checkout`` accept
|
||||||
|
``task_scope=author_issue_bootstrap`` and wired that canonical decision into
|
||||||
|
the #274 branches-only enforcer and the #604 anti-stomp preflight. A third
|
||||||
|
enforcement path was left unwired.
|
||||||
|
|
||||||
|
``workflow_scope_guard.assess_root_source_mutation`` kept its own copy of the
|
||||||
|
clean-root author decision, gated on ``create_issue_bootstrap.is_create_issue_task``
|
||||||
|
— a task-name allowlist that never contained ``bootstrap_author_issue_worktree``.
|
||||||
|
So the real call path
|
||||||
|
|
||||||
|
gitea_bootstrap_author_issue_worktree
|
||||||
|
-> verify_preflight_purity
|
||||||
|
-> _enforce_issue_scope_guard
|
||||||
|
-> workflow_scope_guard.assess_production_mutation_guards
|
||||||
|
|
||||||
|
raised ProductionGuardError(missing_issue_worktree) before
|
||||||
|
``assess_author_issue_bootstrap`` was ever consulted.
|
||||||
|
|
||||||
|
These tests drive the real enforcer, not the authorization helper in
|
||||||
|
isolation. A helper-only test cannot observe this defect: #892's own predicate
|
||||||
|
tests all passed while the live bootstrap stayed blocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import author_issue_bootstrap as aib
|
||||||
|
import create_issue_bootstrap as cib
|
||||||
|
import workflow_scope_guard
|
||||||
|
|
||||||
|
BOOTSTRAP_TASK = "bootstrap_author_issue_worktree"
|
||||||
|
BOOTSTRAP_TOOL = "gitea_bootstrap_author_issue_worktree"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_control_repo(tmp: str) -> tuple[str, str]:
|
||||||
|
"""Create a clean control checkout on master and return (path, head)."""
|
||||||
|
repo = os.path.join(tmp, "repo")
|
||||||
|
os.makedirs(os.path.join(repo, "branches"))
|
||||||
|
subprocess.check_call(
|
||||||
|
["git", "init", "-b", "master", repo],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
subprocess.check_call(
|
||||||
|
[
|
||||||
|
"git", "-C", repo,
|
||||||
|
"-c", "user.email=t@t", "-c", "user.name=t",
|
||||||
|
"commit", "--allow-empty", "-m", "init",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
head = subprocess.check_output(
|
||||||
|
["git", "-C", repo, "rev-parse", "HEAD"], text=True
|
||||||
|
).strip()
|
||||||
|
return repo, head
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(
|
||||||
|
repo: str,
|
||||||
|
head: str,
|
||||||
|
*,
|
||||||
|
task: str = BOOTSTRAP_TASK,
|
||||||
|
porcelain: str = "",
|
||||||
|
remote: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return aib.assess_author_issue_bootstrap(
|
||||||
|
workspace_path=repo,
|
||||||
|
canonical_repo_root=repo,
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=head,
|
||||||
|
porcelain_status=porcelain,
|
||||||
|
remote_master_sha=head if remote is None else remote,
|
||||||
|
task=task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ControlCheckoutHarness(unittest.TestCase):
|
||||||
|
"""Drive the real server guard against a temporary clean control checkout."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.repo, self.head = _make_control_repo(self._tmp.name)
|
||||||
|
|
||||||
|
# #683 force-on: production guards must execute under pytest.
|
||||||
|
patcher = mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{workflow_scope_guard.FORCE_PRODUCTION_GUARDS_ENV: "1"},
|
||||||
|
)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def _enforce(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
*,
|
||||||
|
porcelain: str = "",
|
||||||
|
assessment: object = "auto",
|
||||||
|
role_kind: str = "author",
|
||||||
|
):
|
||||||
|
"""Call the real _enforce_issue_scope_guard for *task*."""
|
||||||
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
|
if assessment == "auto":
|
||||||
|
assessment = _assessment(
|
||||||
|
self.repo, self.head, task=task, porcelain=porcelain
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"workspace_path": self.repo,
|
||||||
|
"canonical_repo_root": self.repo,
|
||||||
|
"workspace_role_kind": role_kind,
|
||||||
|
"workspace_binding_source": "process_project_root",
|
||||||
|
}
|
||||||
|
git_state = {
|
||||||
|
"current_branch": "master",
|
||||||
|
"head_sha": self.head,
|
||||||
|
"porcelain_status": porcelain,
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
srv, "_resolve_namespace_mutation_context", return_value=ctx
|
||||||
|
), mock.patch.object(
|
||||||
|
srv.issue_lock_worktree,
|
||||||
|
"read_worktree_git_state",
|
||||||
|
return_value=git_state,
|
||||||
|
), mock.patch.object(
|
||||||
|
srv,
|
||||||
|
"_session_issue_lock_snapshot",
|
||||||
|
return_value={
|
||||||
|
"locked_issue_number": None,
|
||||||
|
"lock_branch_name": None,
|
||||||
|
"worktrees_match": False,
|
||||||
|
},
|
||||||
|
), mock.patch.object(
|
||||||
|
srv, "_actual_profile_role", return_value=role_kind
|
||||||
|
), mock.patch.object(
|
||||||
|
srv, "_effective_workspace_role", return_value=role_kind
|
||||||
|
), mock.patch.object(
|
||||||
|
srv, "_create_issue_bootstrap_assessment", return_value=assessment
|
||||||
|
):
|
||||||
|
srv._enforce_issue_scope_guard(None, task=task)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealPathBootstrapReachesGuard(_ControlCheckoutHarness):
|
||||||
|
"""The defect and its fix, observed through the real enforcer."""
|
||||||
|
|
||||||
|
def test_bootstrap_task_passes_scope_guard_from_clean_control(self):
|
||||||
|
# Pre-fix this raises ProductionGuardError(missing_issue_worktree)
|
||||||
|
# because the guard consulted a task-name allowlist instead of the
|
||||||
|
# canonical authorization decision.
|
||||||
|
self._enforce(BOOTSTRAP_TASK)
|
||||||
|
|
||||||
|
def test_bootstrap_tool_alias_passes_scope_guard(self):
|
||||||
|
self._enforce(BOOTSTRAP_TOOL)
|
||||||
|
|
||||||
|
def test_guard_consults_canonical_predicate(self):
|
||||||
|
"""The guard must reach bootstrap_permits_control_checkout, not a name list."""
|
||||||
|
real = cib.bootstrap_permits_control_checkout
|
||||||
|
seen: list[str | None] = []
|
||||||
|
|
||||||
|
def _spy(assessment, *, task, workspace_path, canonical_repo_root):
|
||||||
|
seen.append(task)
|
||||||
|
return real(
|
||||||
|
assessment,
|
||||||
|
task=task,
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
canonical_repo_root=canonical_repo_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
cib, "bootstrap_permits_control_checkout", side_effect=_spy
|
||||||
|
):
|
||||||
|
self._enforce(BOOTSTRAP_TASK)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
BOOTSTRAP_TASK,
|
||||||
|
seen,
|
||||||
|
"workflow_scope_guard did not consult the canonical bootstrap "
|
||||||
|
"authorization decision",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailClosedOnBadEvidence(_ControlCheckoutHarness):
|
||||||
|
"""Missing, malformed, or mismatched scope evidence must still block."""
|
||||||
|
|
||||||
|
def _assert_blocked(self, **kwargs):
|
||||||
|
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
|
||||||
|
self._enforce(BOOTSTRAP_TASK, **kwargs)
|
||||||
|
|
||||||
|
def test_missing_assessment_fails_closed(self):
|
||||||
|
self._assert_blocked(assessment=None)
|
||||||
|
|
||||||
|
def test_malformed_assessment_fails_closed(self):
|
||||||
|
self._assert_blocked(assessment={"allowed": True})
|
||||||
|
|
||||||
|
def test_non_dict_assessment_fails_closed(self):
|
||||||
|
self._assert_blocked(assessment="allowed")
|
||||||
|
|
||||||
|
def test_wrong_task_scope_fails_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head))
|
||||||
|
bad["task_scope"] = "create_issue_only"
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
def test_nonempty_reasons_fail_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head), reasons=["note"])
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
def test_mismatched_base_tips_fail_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head))
|
||||||
|
bad["remote_master_sha"] = "b" * 40
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
def test_unverified_base_tips_fail_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head), base_tips_verified=False)
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
def test_mismatched_workspace_binding_fails_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head))
|
||||||
|
bad["workspace_path"] = os.path.join(self.repo, "elsewhere")
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
def test_mismatched_repo_root_binding_fails_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head))
|
||||||
|
bad["canonical_repo_root"] = os.path.join(self.repo, "other-root")
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
def test_blocked_assessment_fails_closed(self):
|
||||||
|
bad = dict(_assessment(self.repo, self.head), block=True, allowed=False)
|
||||||
|
self._assert_blocked(assessment=bad)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrdinaryControlCheckoutMutationStillForbidden(_ControlCheckoutHarness):
|
||||||
|
"""The waiver must not leak to ordinary author work."""
|
||||||
|
|
||||||
|
def test_ordinary_author_task_still_blocked(self):
|
||||||
|
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
|
||||||
|
self._enforce("commit_files", assessment=None)
|
||||||
|
|
||||||
|
def test_lock_issue_still_blocked_from_control(self):
|
||||||
|
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
|
||||||
|
self._enforce("lock_issue", assessment=None)
|
||||||
|
|
||||||
|
def test_bootstrap_assessment_cannot_license_other_task(self):
|
||||||
|
# Cross-task smuggling: valid bootstrap evidence must not waive a
|
||||||
|
# different author mutation.
|
||||||
|
good = _assessment(self.repo, self.head)
|
||||||
|
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
|
||||||
|
self._enforce("commit_files", assessment=good)
|
||||||
|
|
||||||
|
def test_dirty_control_checkout_still_blocked_for_bootstrap(self):
|
||||||
|
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
|
||||||
|
self._enforce(BOOTSTRAP_TASK, porcelain=" M gitea_mcp_server.py\n")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateIssueBehaviorUnchanged(_ControlCheckoutHarness):
|
||||||
|
"""#749 create_issue keeps its own sanctioned path."""
|
||||||
|
|
||||||
|
def test_create_issue_still_allowed_from_clean_control(self):
|
||||||
|
self._enforce("create_issue", assessment=None)
|
||||||
|
|
||||||
|
def test_create_issue_tool_alias_still_allowed(self):
|
||||||
|
self._enforce("gitea_create_issue", assessment=None)
|
||||||
|
|
||||||
|
def test_create_issue_blocked_when_control_dirty(self):
|
||||||
|
with self.assertRaises(workflow_scope_guard.ProductionGuardError):
|
||||||
|
self._enforce(
|
||||||
|
"create_issue",
|
||||||
|
porcelain=" M gitea_mcp_server.py\n",
|
||||||
|
assessment=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuardUnitLevelWiring(unittest.TestCase):
|
||||||
|
"""assess_root_source_mutation itself must accept and honour the evidence."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self._tmp.cleanup)
|
||||||
|
self.repo, self.head = _make_control_repo(self._tmp.name)
|
||||||
|
patcher = mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{workflow_scope_guard.FORCE_PRODUCTION_GUARDS_ENV: "1"},
|
||||||
|
)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def _assess(self, *, task=BOOTSTRAP_TASK, bootstrap_assessment="auto"):
|
||||||
|
if bootstrap_assessment == "auto":
|
||||||
|
bootstrap_assessment = _assessment(self.repo, self.head, task=task)
|
||||||
|
return workflow_scope_guard.assess_root_source_mutation(
|
||||||
|
workspace_path=self.repo,
|
||||||
|
canonical_repo_root=self.repo,
|
||||||
|
porcelain_status="",
|
||||||
|
current_branch="master",
|
||||||
|
role_kind="author",
|
||||||
|
mutation_task=task,
|
||||||
|
bootstrap_assessment=bootstrap_assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valid_evidence_unblocks(self):
|
||||||
|
result = self._assess()
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertIsNone(result["blocker_kind"])
|
||||||
|
|
||||||
|
def test_absent_evidence_blocks(self):
|
||||||
|
result = self._assess(bootstrap_assessment=None)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["blocker_kind"], workflow_scope_guard.BLOCKER_MISSING_WORKTREE
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconciler_exemption_preserved(self):
|
||||||
|
result = workflow_scope_guard.assess_root_source_mutation(
|
||||||
|
workspace_path=self.repo,
|
||||||
|
canonical_repo_root=self.repo,
|
||||||
|
porcelain_status="",
|
||||||
|
current_branch="master",
|
||||||
|
role_kind="reconciler",
|
||||||
|
mutation_task=BOOTSTRAP_TASK,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
def test_signature_accepts_evidence_without_it_being_required(self):
|
||||||
|
# Callers that supply no evidence keep the pre-existing behaviour.
|
||||||
|
result = workflow_scope_guard.assess_root_source_mutation(
|
||||||
|
workspace_path=self.repo,
|
||||||
|
canonical_repo_root=self.repo,
|
||||||
|
porcelain_status="",
|
||||||
|
current_branch="master",
|
||||||
|
role_kind="author",
|
||||||
|
mutation_task="create_issue",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
import sys as _sys
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||||||
|
from mutation_profile_fixture import shared_mutation_env # noqa: F401,E402
|
||||||
|
"""Exact-owner renewal keeps its owning-PR waiver past lock_issue (#945).
|
||||||
|
|
||||||
|
#755 taught the duplicate-work gate that a sanctioned *dead-session recovery*
|
||||||
|
owns its open PR, and #768 taught the later gates to rebuild that proof from the
|
||||||
|
durable lock. #760 added the exact-owner *renewal* disposition and granted it
|
||||||
|
the same waiver inside ``gitea_lock_issue`` — but never added the matching
|
||||||
|
rebuild. So an ordinary renewal held the waiver only for the duration of the
|
||||||
|
lock call: ``_enforce_locked_issue_duplicate_recheck`` asked
|
||||||
|
``recovered_owning_pr_from_lock``, which reads only ``dead_session_recovery``,
|
||||||
|
and the very next commit was refused ``duplicate_commit_prevented`` with
|
||||||
|
``owning_pr_recovery_exempted: false`` on the PR the renewal had just proved.
|
||||||
|
|
||||||
|
``TestPreFixReproduction`` pins that defect directly: the recovery-only rebuild
|
||||||
|
still returns ``None`` for a renewal lock, which is exactly why the gates lost
|
||||||
|
the waiver. Everything else proves the renewal half now survives, that recovery
|
||||||
|
is unchanged, and that no path grants an exemption on weaker evidence.
|
||||||
|
|
||||||
|
Every fixture here is an in-memory mapping. Nothing writes a branch, worktree,
|
||||||
|
lock file, lease, comment, or PR (#945 AC18).
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import gitea_mcp_server # noqa: E402
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_renewal # noqa: E402
|
||||||
|
from issue_work_duplicate_gate import ( # noqa: E402
|
||||||
|
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
|
||||||
|
PHASE_COMMIT,
|
||||||
|
PHASE_CREATE_PR,
|
||||||
|
PHASE_LOCK,
|
||||||
|
PHASE_PUSH,
|
||||||
|
assess_work_issue_duplicate_gate,
|
||||||
|
)
|
||||||
|
|
||||||
|
ISSUE = 4945
|
||||||
|
OWNING_PR = 4946
|
||||||
|
OTHER_PR = 4947
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-owning-pr-renewal"
|
||||||
|
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
|
||||||
|
HEAD = "a" * 40
|
||||||
|
OTHER_HEAD = "b" * 40
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "test-author-prgs"
|
||||||
|
|
||||||
|
|
||||||
|
def renewal_record(**overrides):
|
||||||
|
"""The ``lease_renewal`` block ``build_renewal_record`` writes on success."""
|
||||||
|
record = {
|
||||||
|
"renewed": True,
|
||||||
|
"renewed_at": "2026-01-01T00:00:00Z",
|
||||||
|
"prior_pid": 4242,
|
||||||
|
"prior_pid_alive": True,
|
||||||
|
"prior_expires_at": "2026-01-01T00:00:00Z",
|
||||||
|
"replacement_pid": 4243,
|
||||||
|
"new_expires_at": "2026-01-01T00:10:00Z",
|
||||||
|
"identity": IDENTITY,
|
||||||
|
"profile": PROFILE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": f"branches/issue-{ISSUE}-owning-pr-renewal",
|
||||||
|
"head_sha": HEAD,
|
||||||
|
"remote_head_sha": HEAD,
|
||||||
|
"pr_head_sha": HEAD,
|
||||||
|
"pr_number": OWNING_PR,
|
||||||
|
"reason": "expired lease renewed by its exact recorded owner",
|
||||||
|
"proof": [],
|
||||||
|
}
|
||||||
|
record.update(overrides)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def renewal_lock(record=None, *, issue_number=ISSUE, claimant=True, **lock_overrides):
|
||||||
|
lock = {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"lease_renewal": renewal_record() if record is None else record,
|
||||||
|
}
|
||||||
|
if claimant:
|
||||||
|
lock["claimant"] = {"username": IDENTITY, "profile": PROFILE}
|
||||||
|
lock.update(lock_overrides)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def recovery_lock(pr_number=OWNING_PR, head=HEAD):
|
||||||
|
"""A lock carrying sanctioned dead-session recovery evidence (#755/#768)."""
|
||||||
|
return {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"dead_session_recovery": {
|
||||||
|
"recovered": True,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"pr_head": head,
|
||||||
|
"recorded_head": head,
|
||||||
|
"accepted_head": head,
|
||||||
|
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": f"fix: something (Closes #{issue})",
|
||||||
|
"body": f"Closes #{issue}.",
|
||||||
|
"head": {"ref": ref, "sha": sha},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gate(phase, *, token, open_prs=None, branch_names=None, locked_branch=BRANCH):
|
||||||
|
return assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[owning_pr()] if open_prs is None else open_prs,
|
||||||
|
branch_names=branch_names or [],
|
||||||
|
claim_entry={},
|
||||||
|
locked_branch=locked_branch,
|
||||||
|
phase=phase,
|
||||||
|
recovered_owning_pr=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────── the defect this issue exists to fix ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreFixReproduction(unittest.TestCase):
|
||||||
|
"""The exact wiring gap: renewal evidence was invisible to later gates."""
|
||||||
|
|
||||||
|
def test_recovery_only_rebuild_cannot_see_a_renewal_lock(self):
|
||||||
|
# This is the pre-fix behaviour of every enforcement path. It is correct
|
||||||
|
# for the recovery rebuild to ignore a renewal block -- the defect was
|
||||||
|
# that nothing else looked at it.
|
||||||
|
self.assertIsNone(
|
||||||
|
issue_lock_recovery.recovered_owning_pr_from_lock(renewal_lock())
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_renewal_lock_produced_no_exemption_before_the_fix(self):
|
||||||
|
# Feeding the gate what the pre-fix code fed it (recovery rebuild only)
|
||||||
|
# reproduces the reported refusal at the commit phase.
|
||||||
|
token = issue_lock_recovery.recovered_owning_pr_from_lock(renewal_lock())
|
||||||
|
result = gate(PHASE_COMMIT, token=token)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertEqual(result["outcome"], "duplicate_commit_prevented")
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["owning_pr_recovery_notes"], [])
|
||||||
|
|
||||||
|
def test_shared_resolver_now_sees_it(self):
|
||||||
|
self.assertIsNotNone(
|
||||||
|
gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── rebuild: the granted case ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenewalRebuildGranted(unittest.TestCase):
|
||||||
|
def test_sanctioned_renewal_rebuilds_owning_pr_evidence(self):
|
||||||
|
token = issue_lock_renewal.owning_pr_renewal_from_lock(renewal_lock())
|
||||||
|
self.assertEqual(
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"pr_number": OWNING_PR,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"head_sha": HEAD,
|
||||||
|
"recorded_head": HEAD,
|
||||||
|
"accepted_head": HEAD,
|
||||||
|
"head_relation": "equal",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_branch_falls_back_to_the_lock_branch(self):
|
||||||
|
lock = renewal_lock(renewal_record(branch_name=""))
|
||||||
|
token = issue_lock_renewal.owning_pr_renewal_from_lock(lock)
|
||||||
|
self.assertEqual(token["branch_name"], BRANCH)
|
||||||
|
|
||||||
|
def test_claimant_may_live_under_work_lease(self):
|
||||||
|
lock = renewal_lock(claimant=False)
|
||||||
|
lock["work_lease"] = {"claimant": {"username": IDENTITY, "profile": PROFILE}}
|
||||||
|
self.assertIsNotNone(issue_lock_renewal.owning_pr_renewal_from_lock(lock))
|
||||||
|
|
||||||
|
def test_rebuild_does_not_mutate_the_lock(self):
|
||||||
|
lock = renewal_lock()
|
||||||
|
before = copy.deepcopy(lock)
|
||||||
|
issue_lock_renewal.owning_pr_renewal_from_lock(lock)
|
||||||
|
self.assertEqual(lock, before)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── rebuild: fails closed ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenewalRebuildFailsClosed(unittest.TestCase):
|
||||||
|
def assertNoEvidence(self, lock):
|
||||||
|
self.assertIsNone(issue_lock_renewal.owning_pr_renewal_from_lock(lock))
|
||||||
|
|
||||||
|
def test_no_lock_at_all(self):
|
||||||
|
self.assertNoEvidence(None)
|
||||||
|
self.assertNoEvidence({})
|
||||||
|
self.assertNoEvidence("not-a-mapping")
|
||||||
|
|
||||||
|
def test_lock_without_renewal_block(self):
|
||||||
|
# A fresh claim, or a lock whose renewal block was replaced.
|
||||||
|
self.assertNoEvidence({"issue_number": ISSUE, "branch_name": BRANCH})
|
||||||
|
|
||||||
|
def test_renewal_not_granted(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(renewed=False)))
|
||||||
|
|
||||||
|
def test_renewal_flag_missing(self):
|
||||||
|
record = renewal_record()
|
||||||
|
del record["renewed"]
|
||||||
|
self.assertNoEvidence(renewal_lock(record))
|
||||||
|
|
||||||
|
def test_renewal_block_malformed(self):
|
||||||
|
self.assertNoEvidence(renewal_lock("not-a-mapping"))
|
||||||
|
|
||||||
|
def test_local_head_diverged_from_pr_head(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(head_sha=OTHER_HEAD)))
|
||||||
|
|
||||||
|
def test_remote_head_diverged_from_pr_head(self):
|
||||||
|
# Force-push or unrelated remote movement.
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(remote_head_sha=OTHER_HEAD)))
|
||||||
|
|
||||||
|
def test_local_head_missing(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(head_sha="")))
|
||||||
|
|
||||||
|
def test_remote_head_missing(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(remote_head_sha="")))
|
||||||
|
|
||||||
|
def test_pr_head_missing(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(pr_head_sha="")))
|
||||||
|
|
||||||
|
def test_pr_number_missing(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(pr_number=None)))
|
||||||
|
|
||||||
|
def test_pr_number_malformed(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(pr_number="not-a-number")))
|
||||||
|
|
||||||
|
def test_issue_number_missing_from_lock(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(issue_number=None))
|
||||||
|
|
||||||
|
def test_branch_unknown_everywhere(self):
|
||||||
|
lock = renewal_lock(renewal_record(branch_name=""))
|
||||||
|
lock["branch_name"] = ""
|
||||||
|
self.assertNoEvidence(lock)
|
||||||
|
|
||||||
|
def test_identity_mismatch(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(identity="someone-else")))
|
||||||
|
|
||||||
|
def test_profile_mismatch(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(profile="other-profile")))
|
||||||
|
|
||||||
|
def test_identity_missing(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(identity="")))
|
||||||
|
|
||||||
|
def test_profile_missing(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(renewal_record(profile="")))
|
||||||
|
|
||||||
|
def test_claimant_absent(self):
|
||||||
|
self.assertNoEvidence(renewal_lock(claimant=False))
|
||||||
|
|
||||||
|
def test_evidence_from_a_different_session_is_not_reusable(self):
|
||||||
|
# A renewal block left by another workflow session names another
|
||||||
|
# claimant, so the lock it is found on cannot inherit its authority.
|
||||||
|
lock = renewal_lock()
|
||||||
|
lock["claimant"] = {"username": "other-session-user", "profile": PROFILE}
|
||||||
|
self.assertNoEvidence(lock)
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── the shared resolver ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSharedResolver(unittest.TestCase):
|
||||||
|
def test_recovery_lock_resolves_to_recovery_evidence(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(recovery_lock())
|
||||||
|
self.assertEqual(token["pr_number"], OWNING_PR)
|
||||||
|
|
||||||
|
def test_renewal_lock_resolves_to_renewal_evidence(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
self.assertEqual(token["pr_number"], OWNING_PR)
|
||||||
|
|
||||||
|
def test_recovery_takes_precedence_over_renewal(self):
|
||||||
|
# Same precedence gitea_lock_issue applies when granting the waiver, so
|
||||||
|
# the answer cannot differ between the granting and enforcing paths.
|
||||||
|
lock = recovery_lock(pr_number=OTHER_PR, head=OTHER_HEAD)
|
||||||
|
lock["lease_renewal"] = renewal_record()
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(lock)
|
||||||
|
self.assertEqual(token["pr_number"], OTHER_PR)
|
||||||
|
|
||||||
|
def test_no_evidence_resolves_to_none(self):
|
||||||
|
self.assertIsNone(gitea_mcp_server._owning_pr_continuation_from_lock(None))
|
||||||
|
self.assertIsNone(gitea_mcp_server._owning_pr_continuation_from_lock({}))
|
||||||
|
self.assertIsNone(
|
||||||
|
gitea_mcp_server._owning_pr_continuation_from_lock(
|
||||||
|
{"issue_number": ISSUE, "branch_name": BRANCH}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────── every enforcement path uses the same decision ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnforcementPathsShareOneDecision(unittest.TestCase):
|
||||||
|
"""AC: commit, push and create-PR gates consume one authoritative token."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
|
||||||
|
def test_commit_phase_permits_continuation(self):
|
||||||
|
result = gate(PHASE_COMMIT, token=self.token)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
||||||
|
|
||||||
|
def test_create_pr_phase_permits_continuation(self):
|
||||||
|
result = gate(PHASE_CREATE_PR, token=self.token)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_push_phase_permits_continuation(self):
|
||||||
|
result = gate(PHASE_PUSH, token=self.token)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_lock_phase_permits_continuation(self):
|
||||||
|
result = gate(PHASE_LOCK, token=self.token)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
|
||||||
|
def test_all_phases_agree(self):
|
||||||
|
outcomes = {
|
||||||
|
phase: gate(phase, token=self.token)["block"]
|
||||||
|
for phase in (PHASE_LOCK, PHASE_COMMIT, PHASE_PUSH, PHASE_CREATE_PR)
|
||||||
|
}
|
||||||
|
self.assertEqual(set(outcomes.values()), {False}, outcomes)
|
||||||
|
|
||||||
|
def test_dead_session_recovery_still_permits_continuation(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(recovery_lock())
|
||||||
|
for phase in (PHASE_COMMIT, PHASE_PUSH, PHASE_CREATE_PR):
|
||||||
|
with self.subTest(phase=phase):
|
||||||
|
result = gate(phase, token=token)
|
||||||
|
self.assertFalse(result["block"])
|
||||||
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── the exemption cannot be widened ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestExemptionCannotBeWidened(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
|
||||||
|
def test_an_open_pr_alone_grants_nothing(self):
|
||||||
|
result = gate(PHASE_COMMIT, token=None)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_a_second_pr_is_refused(self):
|
||||||
|
result = gate(
|
||||||
|
PHASE_CREATE_PR,
|
||||||
|
token=self.token,
|
||||||
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_a_different_pr_is_refused(self):
|
||||||
|
result = gate(
|
||||||
|
PHASE_COMMIT, token=self.token, open_prs=[owning_pr(number=OTHER_PR)]
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_a_different_branch_is_refused(self):
|
||||||
|
result = gate(
|
||||||
|
PHASE_COMMIT, token=self.token, open_prs=[owning_pr(ref=OTHER_BRANCH)]
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_locked_branch_mismatch_is_refused(self):
|
||||||
|
result = gate(PHASE_COMMIT, token=self.token, locked_branch=OTHER_BRANCH)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_live_pr_head_divergence_is_refused(self):
|
||||||
|
# Force-push or unrelated remote movement after renewal.
|
||||||
|
result = gate(
|
||||||
|
PHASE_COMMIT, token=self.token, open_prs=[owning_pr(sha=OTHER_HEAD)]
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_evidence_for_another_issue_is_refused(self):
|
||||||
|
foreign = gitea_mcp_server._owning_pr_continuation_from_lock(
|
||||||
|
renewal_lock(issue_number=ISSUE + 1)
|
||||||
|
)
|
||||||
|
result = gate(PHASE_COMMIT, token=foreign)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_sequential_tasks_do_not_inherit_continuation(self):
|
||||||
|
# One daemon serves many tasks. A renewal proved for issue N must not
|
||||||
|
# authorize continuation for the next task's issue.
|
||||||
|
prior_task = gitea_mcp_server._owning_pr_continuation_from_lock(
|
||||||
|
renewal_lock(issue_number=ISSUE + 7)
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(prior_task)
|
||||||
|
self.assertTrue(gate(PHASE_COMMIT, token=prior_task)["block"])
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── ordinary duplicate prevention is intact ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicatePreventionRetained(unittest.TestCase):
|
||||||
|
def test_competing_branch_still_blocks(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
result = gate(
|
||||||
|
PHASE_COMMIT,
|
||||||
|
token=token,
|
||||||
|
open_prs=[],
|
||||||
|
branch_names=[BRANCH, OTHER_BRANCH],
|
||||||
|
)
|
||||||
|
self.assertTrue(result["block"])
|
||||||
|
|
||||||
|
def test_unrelated_work_without_a_lock_still_blocks(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(None)
|
||||||
|
self.assertIsNone(token)
|
||||||
|
self.assertTrue(gate(PHASE_COMMIT, token=token)["block"])
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────── refusals stay structured and auditable ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefusalShapePreserved(unittest.TestCase):
|
||||||
|
def test_blocked_result_keeps_its_audit_fields(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
result = gate(
|
||||||
|
PHASE_COMMIT, token=token, open_prs=[owning_pr(number=OTHER_PR)]
|
||||||
|
)
|
||||||
|
for field in (
|
||||||
|
"block",
|
||||||
|
"outcome",
|
||||||
|
"reasons",
|
||||||
|
"owning_pr_recovery_exempted",
|
||||||
|
"owning_pr_recovery_notes",
|
||||||
|
):
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.assertIn(field, result)
|
||||||
|
self.assertTrue(result["reasons"])
|
||||||
|
# A rejected token explains which element of ownership disagreed.
|
||||||
|
self.assertTrue(result["owning_pr_recovery_notes"])
|
||||||
|
|
||||||
|
def test_granted_result_records_why(self):
|
||||||
|
token = gitea_mcp_server._owning_pr_continuation_from_lock(renewal_lock())
|
||||||
|
result = gate(PHASE_COMMIT, token=token)
|
||||||
|
self.assertTrue(result["owning_pr_recovery_notes"])
|
||||||
|
self.assertIn(
|
||||||
|
f"#{OWNING_PR}", " ".join(result["owning_pr_recovery_notes"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""Concurrent-session MCP restart safety & dogfooding test suite (#666).
|
||||||
|
|
||||||
|
Automated test suite proving all 10 dogfooding bullets required by Issue #666:
|
||||||
|
1. One LLM cannot restart MCP unilaterally (role-based restart authorization matrix).
|
||||||
|
2. New work stops during drain (assignments_stopped gate enforcement).
|
||||||
|
3. Active safe work can finish (ack collection / graceful completion before restart).
|
||||||
|
4. Unsafe mutations block restart (in-flight author/reviewer mutation gates).
|
||||||
|
5. Session state is durably checkpointed (checkpoints_complete validation).
|
||||||
|
6. Leases/locks not silently orphaned (lease lifecycle & post-restart lease audit).
|
||||||
|
7. Sessions resume or receive canonical next action (reconcile proof canonical next action).
|
||||||
|
8. Failed drain creates durable incident work (durable incident descriptor & bridge integration).
|
||||||
|
9. Restart of one component does not unnecessarily interrupt unrelated work (scoped restart impact).
|
||||||
|
10. Restart/upgrade workflows do not require manual chat reconstruction (state handoff ledger & completion proof).
|
||||||
|
|
||||||
|
Links parent #655, vision #652, roadmap #653, #658, #659, #660, #661, #662, #663.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import drain_proof as dp
|
||||||
|
import mcp_restart_paths as rp
|
||||||
|
import post_restart_reconcile as prr
|
||||||
|
import restart_coordinator as rc
|
||||||
|
from restart_coordinator import RestartClass
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
SECRET = b"test-secret-dogfooding-issue-666-0123456789"
|
||||||
|
|
||||||
|
|
||||||
|
def _live_pid() -> int:
|
||||||
|
return os.getpid()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_drain_state() -> dict:
|
||||||
|
return {
|
||||||
|
"assignments_stopped": True,
|
||||||
|
"checkpoints_complete": True,
|
||||||
|
"handoffs_verified": True,
|
||||||
|
"leases_handled": True,
|
||||||
|
"acks": {},
|
||||||
|
"ack_timeout_policy_applied": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_inventory() -> dict:
|
||||||
|
return {
|
||||||
|
"service_health": {"healthy": True},
|
||||||
|
"clients": [],
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checkpoints": [],
|
||||||
|
"leases": [],
|
||||||
|
"capabilities": {},
|
||||||
|
"worktree_bindings": [],
|
||||||
|
"pending_mutations": [],
|
||||||
|
"inventory_complete": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet1UnilateralRestartForbidden(unittest.TestCase):
|
||||||
|
"""Bullet 1: One LLM cannot restart MCP unilaterally."""
|
||||||
|
|
||||||
|
def test_worker_role_unilateral_full_restart_denied(self):
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.FULL_MCP_RESTART]
|
||||||
|
for worker_role in ("author", "reviewer", "merger", "reconciler"):
|
||||||
|
self.assertNotIn(
|
||||||
|
worker_role,
|
||||||
|
policy.request_roles,
|
||||||
|
f"Worker role '{worker_role}' must not unilaterally authorize FULL_MCP_RESTART",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_privileged_role_full_restart_authorized(self):
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.FULL_MCP_RESTART]
|
||||||
|
for priv_role in ("controller", "operator", "admin"):
|
||||||
|
self.assertIn(
|
||||||
|
priv_role,
|
||||||
|
policy.request_roles,
|
||||||
|
f"Privileged role '{priv_role}' must be authorized for FULL_MCP_RESTART",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_evaluate_impact_records_unauthorized_worker_request(self):
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
restart_class=RestartClass.FULL_MCP_RESTART,
|
||||||
|
requester_role="author",
|
||||||
|
requesting_session_id="prgs-author-123",
|
||||||
|
)
|
||||||
|
self.assertFalse(report.role_authorized)
|
||||||
|
self.assertEqual(report.verdict, rc.VERDICT_UNSAFE)
|
||||||
|
self.assertTrue(any("may not request" in r.lower() or "authorization denied" in r.lower() for r in report.reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet2NewWorkStopsDuringDrain(unittest.TestCase):
|
||||||
|
"""Bullet 2: New work stops during drain."""
|
||||||
|
|
||||||
|
def test_assignments_stopped_false_blocks_drain_proof(self):
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["assignments_stopped"] = False
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_ASSIGNMENTS_STOPPED)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_DENY)
|
||||||
|
self.assertFalse(gate.allow)
|
||||||
|
self.assertTrue(any("drain proof invalid" in r.lower() or "assignments_stopped" in r.lower() for r in gate.reasons))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet3ActiveSafeWorkCanFinish(unittest.TestCase):
|
||||||
|
"""Bullet 3: Active safe work can finish."""
|
||||||
|
|
||||||
|
def test_active_safe_sessions_ack_allows_clean_drain(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-reviewer-42",
|
||||||
|
"role": "reviewer",
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
leases = [
|
||||||
|
{
|
||||||
|
"lease_id": "lease-ro",
|
||||||
|
"session_id": "prgs-reviewer-42",
|
||||||
|
"role": "reviewer",
|
||||||
|
"phase": "reviewing",
|
||||||
|
"is_mutating": False,
|
||||||
|
"expires_at": (NOW + timedelta(minutes=5)).isoformat(),
|
||||||
|
"pid": _live_pid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": leases, "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["acks"] = {"prgs-reviewer-42": "ack"}
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(proof.clean)
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertTrue(gate.allow)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_ALLOW)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet4UnsafeMutationsBlockRestart(unittest.TestCase):
|
||||||
|
"""Bullet 4: Unsafe mutations block restart."""
|
||||||
|
|
||||||
|
def test_inflight_unsafe_mutation_yields_unsafe_verdict(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-99",
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
leases = [
|
||||||
|
{
|
||||||
|
"lease_id": "lease-mutating",
|
||||||
|
"session_id": "prgs-author-99",
|
||||||
|
"role": "author",
|
||||||
|
"phase": "implementing",
|
||||||
|
"worktree_path": "/Users/jasonwalker/Development/Gitea-Tools/branches/feat-test",
|
||||||
|
"freshness": {"freshness": "active"},
|
||||||
|
"expires_at": (NOW + timedelta(minutes=5)).isoformat(),
|
||||||
|
"pid": _live_pid(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": leases, "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report.verdict, rc.VERDICT_UNSAFE)
|
||||||
|
self.assertFalse(report.allow_restart)
|
||||||
|
self.assertGreater(len(report.mutations), 0)
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=report.as_dict(),
|
||||||
|
drain_state=_clean_drain_state(),
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_NO_INFLIGHT_MUTATIONS)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_DENY)
|
||||||
|
self.assertFalse(gate.allow)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet5DurableSessionCheckpoints(unittest.TestCase):
|
||||||
|
"""Bullet 5: Session state is durably checkpointed."""
|
||||||
|
|
||||||
|
def test_incomplete_checkpoints_blocks_drain_proof(self):
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["checkpoints_complete"] = False
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_CHECKPOINTS_COMPLETE)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
def test_post_restart_reconcile_audits_checkpoint_dimension(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
inv["checkpoints_available"] = True
|
||||||
|
inv["checkpoints"] = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-99",
|
||||||
|
"checkpoint_id": "chk-1",
|
||||||
|
"stale": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_ENFORCE)
|
||||||
|
chk_item = next(i for i in proof.items if i.dimension == prr.DIM_CHECKPOINTS)
|
||||||
|
self.assertIn(chk_item.status, (prr.ITEM_UNRESOLVED, prr.ITEM_DEGRADED, prr.ITEM_SKIPPED))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet6LeasesNotSilentlyOrphaned(unittest.TestCase):
|
||||||
|
"""Bullet 6: Leases/locks not silently orphaned."""
|
||||||
|
|
||||||
|
def test_unhandled_leases_block_drain_proof(self):
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["leases_handled"] = False
|
||||||
|
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(proof.clean)
|
||||||
|
check = next(c for c in proof.checks if c.name == dp.CHECK_LEASES_HANDLED)
|
||||||
|
self.assertFalse(check.passed)
|
||||||
|
|
||||||
|
def test_post_restart_reconcile_audits_all_leases(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
inv["leases"] = [
|
||||||
|
{
|
||||||
|
"lease_id": "lease-orphaned-1",
|
||||||
|
"session_id": "prgs-author-dead",
|
||||||
|
"role": "author",
|
||||||
|
"status": "active",
|
||||||
|
"freshness": "expired",
|
||||||
|
"expires_at": (NOW - timedelta(minutes=10)).isoformat(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_LOG_ONLY)
|
||||||
|
lease_item = next(i for i in proof.items if i.dimension == prr.DIM_LEASES)
|
||||||
|
self.assertIsNotNone(lease_item)
|
||||||
|
self.assertTrue(lease_item.summary)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet7SessionsResumeOrReceiveNextAction(unittest.TestCase):
|
||||||
|
"""Bullet 7: Sessions resume or receive canonical next action."""
|
||||||
|
|
||||||
|
def test_reconcile_provides_canonical_next_action_for_unresolved(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
inv["pending_mutations"] = [
|
||||||
|
{
|
||||||
|
"mutation_id": "mut-404",
|
||||||
|
"session_id": "prgs-author-77",
|
||||||
|
"phase": "implementing",
|
||||||
|
"issue_number": 666,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_ENFORCE)
|
||||||
|
self.assertEqual(proof.overall_status, prr.STATUS_DEGRADED)
|
||||||
|
self.assertTrue(proof.mutation_hold)
|
||||||
|
self.assertTrue(proof.note)
|
||||||
|
self.assertGreater(len(proof.proposed_follow_ups), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet8FailedDrainCreatesIncidentWork(unittest.TestCase):
|
||||||
|
"""Bullet 8: Failed drain creates durable incident work."""
|
||||||
|
|
||||||
|
def test_denied_drain_gate_mints_durable_incident_descriptor(self):
|
||||||
|
impact = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": [], "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
).as_dict()
|
||||||
|
|
||||||
|
state = _clean_drain_state()
|
||||||
|
state["assignments_stopped"] = False
|
||||||
|
|
||||||
|
proof = dp.build_drain_proof(
|
||||||
|
secret=SECRET,
|
||||||
|
impact_report=impact,
|
||||||
|
drain_state=state,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
gate = dp.gate_apply_restart(proof=proof.as_dict(), secret=SECRET, now=NOW)
|
||||||
|
self.assertEqual(gate.verdict, dp.GATE_DENY)
|
||||||
|
|
||||||
|
incident = gate.incident
|
||||||
|
self.assertIsNotNone(incident)
|
||||||
|
self.assertEqual(incident["kind"], "restart_drain_gate_denied")
|
||||||
|
self.assertTrue(any("assignments_stopped" in r for r in incident["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet9ScopedRestartNonInterference(unittest.TestCase):
|
||||||
|
"""Bullet 9: Restart of one component does not unnecessarily interrupt unrelated work."""
|
||||||
|
|
||||||
|
def test_scoped_role_restart_impacts_only_target_role(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-controller-1",
|
||||||
|
"role": "controller",
|
||||||
|
"profile": "prgs-controller",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-10",
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-reviewer-20",
|
||||||
|
"role": "reviewer",
|
||||||
|
"profile": "prgs-reviewer",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.ROLE_RUNTIME_RESTART]
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
restart_class=RestartClass.ROLE_RUNTIME_RESTART,
|
||||||
|
target_role="reviewer",
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=list(policy.request_roles),
|
||||||
|
controller_approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(report.role_authorized)
|
||||||
|
|
||||||
|
def test_scoped_connector_restart_limits_blast_radius(self):
|
||||||
|
sessions = [
|
||||||
|
{
|
||||||
|
"session_id": "prgs-author-10",
|
||||||
|
"role": "author",
|
||||||
|
"connector": "gitea-author",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"session_id": "prgs-reviewer-20",
|
||||||
|
"role": "reviewer",
|
||||||
|
"connector": "gitea-reviewer",
|
||||||
|
"pid": _live_pid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": NOW.isoformat(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
policy = rc.RESTART_CLASS_POLICIES[RestartClass.CONNECTOR_RESTART]
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
{"sessions": sessions, "leases": [], "inventory_complete": True},
|
||||||
|
now=NOW,
|
||||||
|
restart_class=RestartClass.CONNECTOR_RESTART,
|
||||||
|
target_connector="gitea-author",
|
||||||
|
requesting_session_id="prgs-controller-1",
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=list(policy.request_roles),
|
||||||
|
controller_approved=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(report)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBullet10NoManualChatReconstruction(unittest.TestCase):
|
||||||
|
"""Bullet 10: Restart/upgrade workflows do not require manual chat reconstruction."""
|
||||||
|
|
||||||
|
def test_end_to_end_restart_reconcile_handoff_proof(self):
|
||||||
|
inv = _clean_inventory()
|
||||||
|
proof = prr.reconcile_after_restart(inv, now=NOW, mode=prr.MODE_LOG_ONLY)
|
||||||
|
|
||||||
|
proof_dict = proof.as_dict()
|
||||||
|
self.assertEqual(proof_dict["overall_status"], prr.STATUS_COMPLETE)
|
||||||
|
self.assertFalse(proof_dict["mutation_hold"])
|
||||||
|
self.assertTrue(proof_dict["note"])
|
||||||
|
self.assertIn("links", proof_dict)
|
||||||
|
self.assertEqual(proof_dict["links"]["umbrella"], 655)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -35,10 +35,10 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
|||||||
|
|
||||||
# Mock env output for ps eww
|
# Mock env output for ps eww
|
||||||
mock_run_env12345 = MagicMock()
|
mock_run_env12345 = MagicMock()
|
||||||
mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler"
|
mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler GITEA_CLIENT_MANAGED=1"
|
||||||
|
|
||||||
mock_run_env54321 = MagicMock()
|
mock_run_env54321 = MagicMock()
|
||||||
mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author"
|
mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1"
|
||||||
|
|
||||||
def side_effect(args, **kwargs):
|
def side_effect(args, **kwargs):
|
||||||
if args[0] == "ps" and "eww" in args:
|
if args[0] == "ps" and "eww" in args:
|
||||||
@@ -91,7 +91,7 @@ class TestMcpStaleRuntime(unittest.TestCase):
|
|||||||
mock_run_ps.stdout = ps_output
|
mock_run_ps.stdout = ps_output
|
||||||
|
|
||||||
mock_run_env = MagicMock()
|
mock_run_env = MagicMock()
|
||||||
mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author"
|
mock_run_env.stdout = "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1"
|
||||||
|
|
||||||
mock_run_git = MagicMock()
|
mock_run_git = MagicMock()
|
||||||
mock_run_git.stdout = "FAKE2" # different SHA
|
mock_run_git.stdout = "FAKE2" # different SHA
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""Unit tests for the scoped recovery playbook (#669)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import recovery_playbook as rp
|
||||||
|
import restart_coordinator as rc
|
||||||
|
|
||||||
|
|
||||||
|
def test_ladder_covers_eleven_ordered_rungs():
|
||||||
|
ranks = [r.rank for r in rp.RECOVERY_LADDER]
|
||||||
|
assert ranks == list(range(len(rp.RECOVERY_LADDER)))
|
||||||
|
assert len(rp.RECOVERY_LADDER) == 11
|
||||||
|
assert rp.RECOVERY_LADDER[0].action is rp.RecoveryAction.CLIENT_RECONNECT
|
||||||
|
assert rp.RECOVERY_LADDER[-1].action is rp.RecoveryAction.HOST_RESTART
|
||||||
|
|
||||||
|
|
||||||
|
def test_ladder_document_links_parent_issues():
|
||||||
|
doc = rp.ladder_document()
|
||||||
|
assert "#655" in doc["parent_issues"]
|
||||||
|
assert "#652" in doc["parent_issues"]
|
||||||
|
assert "#653" in doc["parent_issues"]
|
||||||
|
assert doc["enforcement_issue"] == "#669"
|
||||||
|
assert "full_mcp_restart" in doc["broad_restart_actions"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommend_transport_eof_starts_at_client_reconnect():
|
||||||
|
plan = rp.recommend_actions(symptoms=["transport_eof"])
|
||||||
|
assert plan["recommended_actions"][0]["action"] == "client_reconnect"
|
||||||
|
assert plan["recommended_actions"][0]["issue_links"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommend_skips_successful_prior_attempts():
|
||||||
|
attempts = [
|
||||||
|
rp.build_attempt_record(
|
||||||
|
"client_reconnect", outcome="success", reason="reconnected"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
plan = rp.recommend_actions(
|
||||||
|
symptoms=["transport_eof"], prior_recovery_attempts=attempts
|
||||||
|
)
|
||||||
|
actions = [a["action"] for a in plan["recommended_actions"]]
|
||||||
|
assert "client_reconnect" not in actions
|
||||||
|
assert actions[0] == "capability_refresh"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalation_denied_without_attempt_log():
|
||||||
|
result = rp.assess_escalation("full_mcp_restart", prior_recovery_attempts=[])
|
||||||
|
assert result.allowed is False
|
||||||
|
assert result.require_attempt_log is True
|
||||||
|
assert any("#669" in r for r in result.reasons)
|
||||||
|
assert result.recommended_next # soft recommendations still provided
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalation_allowed_after_insufficient_narrower():
|
||||||
|
attempts = [
|
||||||
|
rp.build_attempt_record(
|
||||||
|
"client_reconnect",
|
||||||
|
outcome="insufficient",
|
||||||
|
reason="still flapping",
|
||||||
|
),
|
||||||
|
rp.build_attempt_record(
|
||||||
|
"session_reconnect",
|
||||||
|
outcome="failed",
|
||||||
|
reason="namespace still dead",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
result = rp.assess_escalation(
|
||||||
|
"full_mcp_restart", prior_recovery_attempts=attempts
|
||||||
|
)
|
||||||
|
assert result.allowed is True
|
||||||
|
assert len(result.qualifying_attempts) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_escalation_break_glass_bypasses_attempt_log():
|
||||||
|
result = rp.assess_escalation(
|
||||||
|
"host_restart", prior_recovery_attempts=[], break_glass=True
|
||||||
|
)
|
||||||
|
assert result.allowed is True
|
||||||
|
assert result.break_glass is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_narrow_action_does_not_require_attempt_log():
|
||||||
|
result = rp.assess_escalation(
|
||||||
|
"client_reconnect", prior_recovery_attempts=[]
|
||||||
|
)
|
||||||
|
assert result.allowed is True
|
||||||
|
assert result.require_attempt_log is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_rank_attempt_does_not_qualify_for_escalation():
|
||||||
|
attempts = [
|
||||||
|
rp.build_attempt_record(
|
||||||
|
"full_mcp_restart", outcome="failed", reason="already failed full"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
result = rp.assess_escalation(
|
||||||
|
"full_mcp_restart", prior_recovery_attempts=attempts
|
||||||
|
)
|
||||||
|
assert result.allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_outcome_does_not_qualify_for_escalation():
|
||||||
|
attempts = [
|
||||||
|
rp.build_attempt_record(
|
||||||
|
"client_reconnect", outcome="success", reason="fixed"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
result = rp.assess_escalation(
|
||||||
|
"full_mcp_restart", prior_recovery_attempts=attempts
|
||||||
|
)
|
||||||
|
assert result.allowed is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_metrics_fraction_avoided():
|
||||||
|
attempts = [
|
||||||
|
rp.build_attempt_record("client_reconnect", outcome="success"),
|
||||||
|
rp.build_attempt_record("session_reconnect", outcome="success"),
|
||||||
|
rp.build_attempt_record("full_mcp_restart", outcome="success"),
|
||||||
|
]
|
||||||
|
metrics = rp.recovery_metrics(attempts)
|
||||||
|
assert metrics["successes_total"] == 3
|
||||||
|
assert metrics["successes_avoided_full_restart"] == 2
|
||||||
|
assert metrics["successes_full_or_host_restart"] == 1
|
||||||
|
assert abs(metrics["fraction_avoided_full_restart"] - (2 / 3)) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_denies_full_restart_without_attempt_log():
|
||||||
|
inv = {
|
||||||
|
"inventory_complete": True,
|
||||||
|
"sessions": [],
|
||||||
|
"leases": [],
|
||||||
|
"prior_recovery_attempts": [],
|
||||||
|
}
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
inv,
|
||||||
|
restart_class=rc.RestartClass.FULL_MCP_RESTART,
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=rc.permissions_for_role("controller"),
|
||||||
|
controller_approved=True,
|
||||||
|
operator_authorized=True,
|
||||||
|
)
|
||||||
|
assert report.allow_restart is False
|
||||||
|
assert report.attempt_log_satisfied is False
|
||||||
|
assert report.verdict == rc.VERDICT_UNSAFE
|
||||||
|
blob = " ".join(report.reasons + report.authorization_reasons)
|
||||||
|
assert "#669" in blob or "attempt log" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_allows_full_restart_with_attempt_log():
|
||||||
|
inv = {
|
||||||
|
"inventory_complete": True,
|
||||||
|
"sessions": [],
|
||||||
|
"leases": [],
|
||||||
|
"prior_recovery_attempts": [
|
||||||
|
{
|
||||||
|
"action": "client_reconnect",
|
||||||
|
"outcome": "insufficient",
|
||||||
|
"reason": "still broken",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
inv,
|
||||||
|
restart_class=rc.RestartClass.FULL_MCP_RESTART,
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=rc.permissions_for_role("controller"),
|
||||||
|
controller_approved=True,
|
||||||
|
operator_authorized=True,
|
||||||
|
)
|
||||||
|
assert report.attempt_log_satisfied is True
|
||||||
|
assert report.allow_restart is True
|
||||||
|
assert report.verdict == rc.VERDICT_SAFE
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_break_glass_allows_without_log():
|
||||||
|
inv = {
|
||||||
|
"inventory_complete": True,
|
||||||
|
"sessions": [],
|
||||||
|
"leases": [],
|
||||||
|
"prior_recovery_attempts": [],
|
||||||
|
}
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
inv,
|
||||||
|
restart_class=rc.RestartClass.FULL_MCP_RESTART,
|
||||||
|
requester_role="controller",
|
||||||
|
requester_permissions=rc.permissions_for_role("controller"),
|
||||||
|
controller_approved=True,
|
||||||
|
operator_authorized=True,
|
||||||
|
break_glass=True,
|
||||||
|
)
|
||||||
|
assert report.break_glass is True
|
||||||
|
assert report.attempt_log_satisfied is True
|
||||||
|
assert report.allow_restart is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinator_client_reconnect_unaffected():
|
||||||
|
inv = {
|
||||||
|
"inventory_complete": True,
|
||||||
|
"sessions": [],
|
||||||
|
"leases": [],
|
||||||
|
"prior_recovery_attempts": [],
|
||||||
|
}
|
||||||
|
report = rc.evaluate_restart_impact(
|
||||||
|
inv,
|
||||||
|
restart_class=rc.RestartClass.CLIENT_RECONNECT,
|
||||||
|
requester_role="author",
|
||||||
|
requester_permissions=rc.permissions_for_role("author"),
|
||||||
|
)
|
||||||
|
assert report.attempt_log_satisfied is True
|
||||||
|
assert report.allow_restart is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_class_alias_accepted():
|
||||||
|
assert (
|
||||||
|
rp.resolve_action("full_mcp_restart")
|
||||||
|
is rp.RecoveryAction.FULL_MCP_RESTART
|
||||||
|
)
|
||||||
@@ -243,9 +243,10 @@ class TestRuntimeClarity(unittest.TestCase):
|
|||||||
self.assertIn("switching is disabled", res["message"].lower())
|
self.assertIn("switching is disabled", res["message"].lower())
|
||||||
self.assertIsNone(gitea_config._active_profile_override)
|
self.assertIsNone(gitea_config._active_profile_override)
|
||||||
|
|
||||||
|
@patch("mcp_server._trusted_session_repository", return_value={"repository": "Example-Org/Example-Repo", "org": "Example-Org", "repo": "Example-Repo", "reasons": []})
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header")
|
@patch("mcp_server.get_auth_header")
|
||||||
def test_activate_profile_succeeds_when_enabled(self, mock_auth, mock_api):
|
def test_activate_profile_succeeds_when_enabled(self, mock_auth, mock_api, mock_trusted):
|
||||||
self._write_config(CONFIG_SWITCHING_ENABLED)
|
self._write_config(CONFIG_SWITCHING_ENABLED)
|
||||||
|
|
||||||
# Setup mock responses for whoami checks
|
# Setup mock responses for whoami checks
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""Unit and integration tests for Phase 2 Web Console recovery controls (#644)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import merged_cleanup_reconcile
|
||||||
|
import runtime_recovery_guard
|
||||||
|
import stable_branch_push_guard
|
||||||
|
import stale_binding_recovery
|
||||||
|
from webui import console_authz, console_recovery, system_health
|
||||||
|
from webui.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsoleRecovery(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_diagnose_recovery_healthy(self) -> None:
|
||||||
|
diag = console_recovery.diagnose_recovery()
|
||||||
|
self.assertIn(diag.status, {console_recovery.STATUS_HEALTHY, console_recovery.STATUS_ACTION_REQUIRED})
|
||||||
|
self.assertIsInstance(diag.playbooks, tuple)
|
||||||
|
self.assertGreaterEqual(len(diag.playbooks), 4)
|
||||||
|
|
||||||
|
playbook_ids = {pb.playbook_id for pb in diag.playbooks}
|
||||||
|
self.assertIn(console_recovery.PLAYBOOK_CLEAR_STALE_BINDING, playbook_ids)
|
||||||
|
self.assertIn(console_recovery.PLAYBOOK_REBIND_SESSION, playbook_ids)
|
||||||
|
self.assertIn(console_recovery.PLAYBOOK_RECONCILE_CLEANUPS, playbook_ids)
|
||||||
|
self.assertIn(console_recovery.PLAYBOOK_SANCTIONED_RESTART, playbook_ids)
|
||||||
|
|
||||||
|
def test_confirmation_phrase_generation_and_matching(self) -> None:
|
||||||
|
phrase = console_recovery.confirmation_phrase("clear_stale_binding")
|
||||||
|
self.assertEqual(phrase, "confirm clear_stale_binding")
|
||||||
|
self.assertTrue(console_recovery.confirmation_matches("clear_stale_binding", "confirm clear_stale_binding"))
|
||||||
|
self.assertFalse(console_recovery.confirmation_matches("clear_stale_binding", "wrong phrase"))
|
||||||
|
|
||||||
|
phrase_target = console_recovery.confirmation_phrase("sanctioned_restart", "gitea-author")
|
||||||
|
self.assertEqual(phrase_target, "confirm sanctioned_restart gitea-author")
|
||||||
|
self.assertTrue(console_recovery.confirmation_matches("sanctioned_restart", "confirm sanctioned_restart gitea-author", "gitea-author"))
|
||||||
|
|
||||||
|
def test_build_recovery_preview(self) -> None:
|
||||||
|
principal = console_authz.Principal("[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True)
|
||||||
|
preview = console_recovery.build_recovery_preview(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
target="test-worktree",
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
self.assertEqual(preview["playbook_id"], console_recovery.PLAYBOOK_CLEAR_STALE_BINDING)
|
||||||
|
self.assertEqual(preview["action_id"], console_recovery.ACTION_CLEAR_STALE_BINDING)
|
||||||
|
self.assertEqual(preview["confirmation_phrase"], "confirm clear_stale_binding test-worktree")
|
||||||
|
self.assertTrue(len(preview["mutation_ledger"]) >= 3)
|
||||||
|
self.assertTrue(preview["authorization"]["allowed"])
|
||||||
|
|
||||||
|
def test_build_recovery_preview_unknown_playbook(self) -> None:
|
||||||
|
preview = console_recovery.build_recovery_preview("unknown_playbook")
|
||||||
|
self.assertFalse(preview.get("allowed"))
|
||||||
|
self.assertEqual(preview.get("error"), "unknown_playbook")
|
||||||
|
|
||||||
|
def test_execute_recovery_playbook_confirmation_mismatch(self) -> None:
|
||||||
|
# Authorization is checked before confirmation, so the phase gate has to
|
||||||
|
# pass for this test to reach the branch it is about.
|
||||||
|
principal = console_authz.Principal("[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True)
|
||||||
|
with self._phase_two_enabled():
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
confirmation="invalid confirmation",
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["error"], "confirmation_mismatch")
|
||||||
|
|
||||||
|
def test_execute_recovery_playbook_unauthorized(self) -> None:
|
||||||
|
# Anonymous principal has viewer role -> should be denied
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
confirmation="confirm clear_stale_binding",
|
||||||
|
principal=console_authz.ANONYMOUS,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["error"], console_authz.DENY_UNAUTHENTICATED)
|
||||||
|
|
||||||
|
def test_execute_refuses_phase_two_write_while_console_is_phase_one(self) -> None:
|
||||||
|
"""B1: the apply path must arm the phase gate, not skip it.
|
||||||
|
|
||||||
|
``authorize`` only applies the phase branch when ``for_execution=True``.
|
||||||
|
The apply path used the default, so an operator executed a phase-2 write
|
||||||
|
while ``ACTIVE_PHASE`` was 1.
|
||||||
|
"""
|
||||||
|
self.assertGreater(
|
||||||
|
console_authz.get_action(console_recovery.ACTION_CLEAR_STALE_BINDING).phase,
|
||||||
|
console_authz.ACTIVE_PHASE,
|
||||||
|
"fixture assumes the recovery actions are ahead of the active phase",
|
||||||
|
)
|
||||||
|
principal = console_authz.Principal(
|
||||||
|
"[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True
|
||||||
|
)
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_CLEAR_STALE_BINDING
|
||||||
|
)
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
confirmation=phrase,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertFalse(result["allowed"])
|
||||||
|
self.assertEqual(result["error"], console_authz.DENY_PHASE_NOT_ACTIVE)
|
||||||
|
|
||||||
|
def test_preview_execution_enabled_matches_the_execution_decision(self) -> None:
|
||||||
|
"""B1: preview must not report a bare False it cannot explain."""
|
||||||
|
principal = console_authz.Principal(
|
||||||
|
"[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True
|
||||||
|
)
|
||||||
|
preview = console_recovery.build_recovery_preview(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION,
|
||||||
|
target="branches/feat-issue-644",
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
self.assertFalse(preview["execution_enabled"])
|
||||||
|
self.assertEqual(
|
||||||
|
preview["execution_blocked_reason"], console_authz.DENY_PHASE_NOT_ACTIVE
|
||||||
|
)
|
||||||
|
self.assertFalse(preview["execution_authorization"]["allowed"])
|
||||||
|
# The preview (non-execution) decision still allows, by role.
|
||||||
|
self.assertTrue(preview["authorization"]["allowed"])
|
||||||
|
|
||||||
|
def _phase_two_enabled(self):
|
||||||
|
"""Raise ACTIVE_PHASE so the execution branches are reachable in tests."""
|
||||||
|
return patch.object(console_authz, "ACTIVE_PHASE", 2)
|
||||||
|
|
||||||
|
def _operator(self) -> console_authz.Principal:
|
||||||
|
return console_authz.Principal(
|
||||||
|
"[email protected]", console_authz.OPERATOR, console_authz.IDENTITY_LOCAL_DEV, True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rebind_mutates_the_live_environment_not_a_copy(self) -> None:
|
||||||
|
"""B2: the playbook must change the mapping it claims to have changed."""
|
||||||
|
live_env = {stale_binding_recovery.ACTIVE_WORKTREE_ENV: "branches/stale-old"}
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_REBIND_SESSION, "branches/feat-issue-644"
|
||||||
|
)
|
||||||
|
with self._phase_two_enabled():
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION,
|
||||||
|
confirmation=phrase,
|
||||||
|
target="branches/feat-issue-644",
|
||||||
|
principal=self._operator(),
|
||||||
|
env=live_env,
|
||||||
|
)
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
self.assertEqual(
|
||||||
|
live_env[stale_binding_recovery.ACTIVE_WORKTREE_ENV],
|
||||||
|
"branches/feat-issue-644",
|
||||||
|
"rebind reported success without changing the caller's environment",
|
||||||
|
)
|
||||||
|
self.assertTrue(result["applied_result"]["binding_changed"])
|
||||||
|
self.assertEqual(result["applied_result"]["binding_before"], "branches/stale-old")
|
||||||
|
self.assertEqual(
|
||||||
|
result["applied_result"]["binding_after"], "branches/feat-issue-644"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clear_stale_binding_reports_failure_when_nothing_changed(self) -> None:
|
||||||
|
"""B2: a no-op recovery must never be reported as success."""
|
||||||
|
live_env: dict[str, str] = {}
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_CLEAR_STALE_BINDING
|
||||||
|
)
|
||||||
|
with self._phase_two_enabled():
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
confirmation=phrase,
|
||||||
|
principal=self._operator(),
|
||||||
|
env=live_env,
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
result["success"],
|
||||||
|
"a clear that changed no binding must not report success",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["applied_result"]["binding_changed"])
|
||||||
|
|
||||||
|
def test_clear_stale_binding_clears_the_live_binding(self) -> None:
|
||||||
|
"""B2: the sanctioned clear must reach the caller's environment."""
|
||||||
|
missing = "/nonexistent/branches/deleted-worktree"
|
||||||
|
live_env = {stale_binding_recovery.ACTIVE_WORKTREE_ENV: missing}
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_CLEAR_STALE_BINDING
|
||||||
|
)
|
||||||
|
with self._phase_two_enabled():
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
confirmation=phrase,
|
||||||
|
principal=self._operator(),
|
||||||
|
env=live_env,
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
self.assertNotIn(stale_binding_recovery.ACTIVE_WORKTREE_ENV, live_env)
|
||||||
|
self.assertEqual(result["applied_result"]["binding_before"], missing)
|
||||||
|
self.assertIsNone(result["applied_result"]["binding_after"])
|
||||||
|
else:
|
||||||
|
# Fail closed is acceptable; reporting a clear that did not happen
|
||||||
|
# is not. This is the invariant the blocker was about.
|
||||||
|
self.assertFalse(result["applied_result"]["binding_changed"])
|
||||||
|
self.assertEqual(
|
||||||
|
live_env.get(stale_binding_recovery.ACTIVE_WORKTREE_ENV), missing
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconcile_playbook_calls_an_entry_point_that_exists(self) -> None:
|
||||||
|
"""B3: the previous call named a function absent from the module."""
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_RECONCILE_CLEANUPS
|
||||||
|
)
|
||||||
|
fake_server = types.SimpleNamespace(
|
||||||
|
gitea_reconcile_merged_cleanups=lambda **kwargs: {
|
||||||
|
"success": True,
|
||||||
|
"entries": [{"issue_number": 100}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with self._phase_two_enabled(), patch.dict(
|
||||||
|
sys.modules, {"gitea_mcp_server": fake_server}
|
||||||
|
):
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_RECONCILE_CLEANUPS,
|
||||||
|
confirmation=phrase,
|
||||||
|
principal=console_authz.Principal(
|
||||||
|
"[email protected]",
|
||||||
|
console_authz.ADMIN,
|
||||||
|
console_authz.IDENTITY_LOCAL_DEV,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["success"], result.get("applied_result"))
|
||||||
|
self.assertNotIn("error_type", result["applied_result"])
|
||||||
|
self.assertEqual(result["applied_result"]["reconciled_count"], 1)
|
||||||
|
|
||||||
|
def test_reconcile_entry_point_exists_on_the_real_module(self) -> None:
|
||||||
|
"""B3 regression: guard the symbol itself, not just the call shape."""
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
hasattr(gitea_mcp_server, "gitea_reconcile_merged_cleanups"),
|
||||||
|
"console recovery depends on this reconciler entry point",
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
hasattr(merged_cleanup_reconcile, "reconcile_merged_cleanups"),
|
||||||
|
"if this module grows the orchestrator, point the playbook back at it",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contamination_gate_blocks_a_writing_playbook(self) -> None:
|
||||||
|
"""B4: a live marker plus a gated task key must actually block."""
|
||||||
|
marker = {
|
||||||
|
"kind": "manual_daemon_kill",
|
||||||
|
"reason_class": "manual_daemon_kill",
|
||||||
|
"command_summary": "pkill -f gitea_mcp_server",
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_REBIND_SESSION, "branches/feat-issue-644"
|
||||||
|
)
|
||||||
|
live_env = {stale_binding_recovery.ACTIVE_WORKTREE_ENV: "branches/stale-old"}
|
||||||
|
with self._phase_two_enabled(), patch.object(
|
||||||
|
console_recovery, "load_active_contamination_marker", return_value=marker
|
||||||
|
):
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_REBIND_SESSION,
|
||||||
|
confirmation=phrase,
|
||||||
|
target="branches/feat-issue-644",
|
||||||
|
principal=self._operator(),
|
||||||
|
env=live_env,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
self.assertEqual(result["error"], "contaminated_runtime")
|
||||||
|
self.assertEqual(
|
||||||
|
live_env[stale_binding_recovery.ACTIVE_WORKTREE_ENV],
|
||||||
|
"branches/stale-old",
|
||||||
|
"a blocked playbook must not have mutated anything",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_contamination_gate_exempts_the_reconciler_remedy(self) -> None:
|
||||||
|
"""B4: the designated remedy must stay reachable while contaminated."""
|
||||||
|
marker = {
|
||||||
|
"kind": "manual_daemon_kill",
|
||||||
|
"reason_class": "manual_daemon_kill",
|
||||||
|
"command_summary": "pkill -f gitea_mcp_server",
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
phrase = console_recovery.confirmation_phrase(
|
||||||
|
console_recovery.PLAYBOOK_RECONCILE_CLEANUPS
|
||||||
|
)
|
||||||
|
fake_server = types.SimpleNamespace(
|
||||||
|
gitea_reconcile_merged_cleanups=lambda **kwargs: {
|
||||||
|
"success": True,
|
||||||
|
"entries": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with self._phase_two_enabled(), patch.object(
|
||||||
|
console_recovery, "load_active_contamination_marker", return_value=marker
|
||||||
|
), patch.dict(sys.modules, {"gitea_mcp_server": fake_server}):
|
||||||
|
result = console_recovery.execute_recovery_playbook(
|
||||||
|
playbook_id=console_recovery.PLAYBOOK_RECONCILE_CLEANUPS,
|
||||||
|
confirmation=phrase,
|
||||||
|
principal=console_authz.Principal(
|
||||||
|
"[email protected]",
|
||||||
|
console_authz.ADMIN,
|
||||||
|
console_authz.IDENTITY_LOCAL_DEV,
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(result.get("error"), "contaminated_runtime")
|
||||||
|
|
||||||
|
def test_gated_task_key_is_actually_gated(self) -> None:
|
||||||
|
"""B4: the console action id was never a member of the gated set."""
|
||||||
|
self.assertIn(
|
||||||
|
console_recovery.CONTAMINATION_GATED_TASK,
|
||||||
|
stable_branch_push_guard.CONTAMINATION_GATED_TASKS,
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
console_recovery.ACTION_CLEAR_STALE_BINDING,
|
||||||
|
stable_branch_push_guard.CONTAMINATION_GATED_TASKS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_diagnosis_reads_the_key_the_gate_returns(self) -> None:
|
||||||
|
"""B4: ``contaminated`` is a key assess_contamination_gate never returns."""
|
||||||
|
gate = runtime_recovery_guard.assess_contamination_gate(
|
||||||
|
None, task=console_recovery.CONTAMINATION_GATED_TASK, actual_role="operator"
|
||||||
|
)
|
||||||
|
self.assertNotIn("contaminated", gate)
|
||||||
|
self.assertIn("block", gate)
|
||||||
|
|
||||||
|
def test_contaminated_runtime_is_reported_unclean(self) -> None:
|
||||||
|
"""B4: verify_post_recovery reported contamination_clean unconditionally."""
|
||||||
|
marker = {
|
||||||
|
"kind": "manual_daemon_kill",
|
||||||
|
"reason_class": "manual_daemon_kill",
|
||||||
|
"command_summary": "pkill -f gitea_mcp_server",
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
with patch.object(
|
||||||
|
console_recovery, "load_active_contamination_marker", return_value=marker
|
||||||
|
):
|
||||||
|
verification = console_recovery.verify_post_recovery()
|
||||||
|
diag = console_recovery.diagnose_recovery()
|
||||||
|
self.assertFalse(verification["contamination_clean"])
|
||||||
|
self.assertFalse(verification["clean"])
|
||||||
|
self.assertEqual(diag.status, console_recovery.STATUS_BLOCKED_CONTAMINATION)
|
||||||
|
|
||||||
|
def test_master_parity_baseline_is_not_the_head_it_is_compared_against(self) -> None:
|
||||||
|
"""B5: capture_startup_parity was fed the head it was then compared to."""
|
||||||
|
stale = system_health.StaleRuntime(
|
||||||
|
daemon_head="a" * 40,
|
||||||
|
checkout_head="b" * 40,
|
||||||
|
remote_head="b" * 40,
|
||||||
|
stale=True,
|
||||||
|
determinable=True,
|
||||||
|
mutation_safe=False,
|
||||||
|
reasons=("daemon is behind the checkout",),
|
||||||
|
)
|
||||||
|
with patch.object(system_health, "assess_stale_runtime", return_value=stale):
|
||||||
|
diag = console_recovery.diagnose_recovery()
|
||||||
|
parity = diag.master_parity
|
||||||
|
self.assertEqual(parity["startup_head"], "a" * 40)
|
||||||
|
self.assertEqual(parity["current_head"], "b" * 40)
|
||||||
|
self.assertNotEqual(parity["startup_head"], parity["current_head"])
|
||||||
|
self.assertFalse(parity["in_parity"])
|
||||||
|
|
||||||
|
def test_master_parity_carries_the_live_remote_dimension(self) -> None:
|
||||||
|
"""B5: live_remote_head was never passed, dropping the #610 dimension."""
|
||||||
|
stale = system_health.StaleRuntime(
|
||||||
|
daemon_head="c" * 40,
|
||||||
|
checkout_head="c" * 40,
|
||||||
|
remote_head="d" * 40,
|
||||||
|
stale=False,
|
||||||
|
determinable=True,
|
||||||
|
mutation_safe=False,
|
||||||
|
reasons=(),
|
||||||
|
)
|
||||||
|
with patch.object(system_health, "assess_stale_runtime", return_value=stale):
|
||||||
|
diag = console_recovery.diagnose_recovery()
|
||||||
|
self.assertEqual(diag.master_parity.get("live_remote_head"), "d" * 40)
|
||||||
|
|
||||||
|
def test_verify_post_recovery(self) -> None:
|
||||||
|
verification = console_recovery.verify_post_recovery()
|
||||||
|
self.assertIn("clean", verification)
|
||||||
|
self.assertIn("status", verification)
|
||||||
|
self.assertIn("reasons", verification)
|
||||||
|
|
||||||
|
def test_unverified_inherited_binding_is_not_reported_clean(self) -> None:
|
||||||
|
"""B2: ``not clear_eligible`` also read clean for unproven bindings."""
|
||||||
|
binding = {
|
||||||
|
"classification": stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
"clear_eligible": False,
|
||||||
|
}
|
||||||
|
diag = console_recovery.diagnose_recovery()
|
||||||
|
patched = console_recovery.RecoveryDiagnosis(
|
||||||
|
status=diag.status,
|
||||||
|
clean=diag.clean,
|
||||||
|
stale_runtime=diag.stale_runtime,
|
||||||
|
master_parity=diag.master_parity,
|
||||||
|
stale_binding=binding,
|
||||||
|
contamination=diag.contamination,
|
||||||
|
worktree_anomalies=diag.worktree_anomalies,
|
||||||
|
playbooks=diag.playbooks,
|
||||||
|
reasons=diag.reasons,
|
||||||
|
)
|
||||||
|
with patch.object(console_recovery, "diagnose_recovery", return_value=patched):
|
||||||
|
verification = console_recovery.verify_post_recovery()
|
||||||
|
self.assertFalse(verification["binding_clean"])
|
||||||
|
self.assertEqual(
|
||||||
|
verification["binding_classification"],
|
||||||
|
stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsoleRecoveryApi(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.app = create_app()
|
||||||
|
self.client = TestClient(self.app)
|
||||||
|
|
||||||
|
def test_api_recovery_diagnose(self) -> None:
|
||||||
|
res = self.client.get("/api/v1/system/recovery/diagnose")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = res.json()
|
||||||
|
self.assertIn("status", data)
|
||||||
|
self.assertIn("clean", data)
|
||||||
|
self.assertIn("playbooks", data)
|
||||||
|
self.assertTrue(len(data["playbooks"]) >= 4)
|
||||||
|
|
||||||
|
def test_api_recovery_preview(self) -> None:
|
||||||
|
res = self.client.post(
|
||||||
|
"/api/v1/system/recovery/preview",
|
||||||
|
json={"playbook_id": "clear_stale_binding", "target": "active"},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = res.json()
|
||||||
|
self.assertEqual(data["playbook_id"], "clear_stale_binding")
|
||||||
|
self.assertEqual(data["confirmation_phrase"], "confirm clear_stale_binding active")
|
||||||
|
self.assertIn("mutation_ledger", data)
|
||||||
|
|
||||||
|
def test_api_recovery_apply_denied_without_auth(self) -> None:
|
||||||
|
res = self.client.post(
|
||||||
|
"/api/v1/system/recovery/apply",
|
||||||
|
json={"playbook_id": "clear_stale_binding", "confirmation": "confirm clear_stale_binding"},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
data = res.json()
|
||||||
|
self.assertFalse(data["success"])
|
||||||
|
self.assertFalse(data["allowed"])
|
||||||
|
|
||||||
|
def test_api_recovery_apply_refuses_phase_two_write_with_dev_auth(self) -> None:
|
||||||
|
"""B1: this previously asserted the phase-gate bypass as intended.
|
||||||
|
|
||||||
|
An authenticated operator posting a valid confirmation still must not
|
||||||
|
execute a phase-2 write while the console is in phase 1. The refusal is
|
||||||
|
the contract; a 200 here means the gate is not armed.
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"WEBUI_AUTH_MODE": "local_dev",
|
||||||
|
"WEBUI_DEV_SUBJECT": "[email protected]",
|
||||||
|
"WEBUI_DEV_ROLE": "operator",
|
||||||
|
}
|
||||||
|
before = os.environ.get("GITEA_ACTIVE_WORKTREE")
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
res = self.client.post(
|
||||||
|
"/api/v1/system/recovery/apply",
|
||||||
|
json={
|
||||||
|
"playbook_id": "rebind_session_worktree",
|
||||||
|
"target": "branches/feat-issue-644",
|
||||||
|
"confirmation": "confirm rebind_session_worktree branches/feat-issue-644",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
data = res.json()
|
||||||
|
self.assertFalse(data["success"])
|
||||||
|
self.assertFalse(data["allowed"])
|
||||||
|
self.assertEqual(data["error"], console_authz.DENY_PHASE_NOT_ACTIVE)
|
||||||
|
self.assertEqual(
|
||||||
|
os.environ.get("GITEA_ACTIVE_WORKTREE"),
|
||||||
|
before,
|
||||||
|
"a refused apply must not have rebound the live process environment",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_recovery_preview_reports_why_execution_is_disabled(self) -> None:
|
||||||
|
res = self.client.post(
|
||||||
|
"/api/v1/system/recovery/preview",
|
||||||
|
json={"playbook_id": "rebind_session_worktree", "target": "active"},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = res.json()
|
||||||
|
self.assertFalse(data["execution_enabled"])
|
||||||
|
self.assertIn("execution_authorization", data)
|
||||||
|
|
||||||
|
def test_api_recovery_verify(self) -> None:
|
||||||
|
res = self.client.get("/api/v1/system/recovery/verify")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = res.json()
|
||||||
|
self.assertIn("clean", data)
|
||||||
|
self.assertIn("status", data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"""Tests for the Gitea issue↔PR linkage console (#645, Phase 3).
|
||||||
|
|
||||||
|
Covers the acceptance criteria of the issue:
|
||||||
|
|
||||||
|
* AC1 — issue↔PR linkage is visible for the selected project/repo, in both
|
||||||
|
directions, with the evidence that produced each edge.
|
||||||
|
* AC2 — the latest canonical handoff (CTH) is summarized for a focused thread.
|
||||||
|
* AC3 — an external Gitea link appears only under the admin reveal opt-in.
|
||||||
|
* AC4 — every case is driven by mocked Gitea payloads; no network.
|
||||||
|
|
||||||
|
Plus the invariants this console must not violate: a partial or failed read is
|
||||||
|
never rendered as "no link exists", an unfetched thread is never rendered as
|
||||||
|
"no handoff", redaction happens before display, and the surface stays read-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from tests.webui_testclient import TestClient
|
||||||
|
|
||||||
|
from canonical_thread_handoff import format_cth_body
|
||||||
|
from webui.app import create_app
|
||||||
|
from webui.linkage_loader import (
|
||||||
|
EVIDENCE_BRANCH,
|
||||||
|
EVIDENCE_CLOSES,
|
||||||
|
EVIDENCE_REFERENCE,
|
||||||
|
HANDOFF_LOADED,
|
||||||
|
HANDOFF_NOT_LOADED,
|
||||||
|
HANDOFF_UNAVAILABLE,
|
||||||
|
LinkageSnapshot,
|
||||||
|
load_linkage_snapshot,
|
||||||
|
resolve_linkage,
|
||||||
|
resolve_pr_links,
|
||||||
|
snapshot_to_dict,
|
||||||
|
summarize_handoff,
|
||||||
|
)
|
||||||
|
from webui.linkage_views import render_linkage_page
|
||||||
|
from webui.nav import nav_hrefs
|
||||||
|
from webui.queue_loader import PaginationMeta
|
||||||
|
|
||||||
|
|
||||||
|
def _pagination(*, complete: bool = True, count: int = 0) -> PaginationMeta:
|
||||||
|
return PaginationMeta(
|
||||||
|
page=1,
|
||||||
|
per_page=50,
|
||||||
|
returned_count=count,
|
||||||
|
has_more=not complete,
|
||||||
|
is_final_page=complete,
|
||||||
|
inventory_complete=complete,
|
||||||
|
pages_fetched=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pr(
|
||||||
|
number: int,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
body: str = "",
|
||||||
|
head: str = "",
|
||||||
|
state: str = "open",
|
||||||
|
labels: tuple[str, ...] = (),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": title or f"pr {number}",
|
||||||
|
"body": body,
|
||||||
|
"state": state,
|
||||||
|
"head": {"ref": head},
|
||||||
|
"labels": [{"name": name} for name in labels],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(
|
||||||
|
number: int,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
state: str = "open",
|
||||||
|
labels: tuple[str, ...] = (),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": title or f"issue {number}",
|
||||||
|
"state": state,
|
||||||
|
"labels": [{"name": name} for name in labels],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetcher(items: list[dict], *, complete: bool = True):
|
||||||
|
def _fetch(*_args, **_kwargs):
|
||||||
|
return items, _pagination(complete=complete, count=len(items))
|
||||||
|
|
||||||
|
return _fetch
|
||||||
|
|
||||||
|
|
||||||
|
def _load(
|
||||||
|
issues: list[dict],
|
||||||
|
prs: list[dict],
|
||||||
|
*,
|
||||||
|
complete: bool = True,
|
||||||
|
**kwargs,
|
||||||
|
) -> LinkageSnapshot:
|
||||||
|
return load_linkage_snapshot(
|
||||||
|
fetch_prs=_fetcher(prs, complete=complete),
|
||||||
|
fetch_issues=_fetcher(issues, complete=complete),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cth(comment_id: int, *, created_at: str, status: str, next_owner: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": comment_id,
|
||||||
|
"created_at": created_at,
|
||||||
|
"user": {"login": "jcwalker3"},
|
||||||
|
"body": format_cth_body(
|
||||||
|
cth_type="Author Handoff",
|
||||||
|
status=status,
|
||||||
|
next_owner=next_owner,
|
||||||
|
current_blocker="none",
|
||||||
|
decision="implemented",
|
||||||
|
proof="full suite green",
|
||||||
|
next_action="review PR",
|
||||||
|
ready_to_paste_prompt="Review PR #902 now.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinkageEvidence(unittest.TestCase):
|
||||||
|
"""AC1 — every edge records how it was found, and keeps all candidates."""
|
||||||
|
|
||||||
|
def test_closes_keyword_in_body_is_strongest_evidence(self):
|
||||||
|
links = resolve_pr_links(_pr(902, body="Closes #643"))
|
||||||
|
self.assertEqual([link.issue_number for link in links], [643])
|
||||||
|
self.assertEqual(links[0].evidence, (EVIDENCE_CLOSES,))
|
||||||
|
self.assertTrue(links[0].closes)
|
||||||
|
|
||||||
|
def test_closes_keyword_in_title_counts(self):
|
||||||
|
links = resolve_pr_links(_pr(902, title="feat(webui): preview (Closes #643)"))
|
||||||
|
self.assertEqual(links[0].evidence, (EVIDENCE_CLOSES,))
|
||||||
|
|
||||||
|
def test_canonical_branch_marker_links_without_a_keyword(self):
|
||||||
|
links = resolve_pr_links(_pr(902, head="feat/issue-643-request-preview"))
|
||||||
|
self.assertEqual([link.issue_number for link in links], [643])
|
||||||
|
self.assertEqual(links[0].evidence, (EVIDENCE_BRANCH,))
|
||||||
|
self.assertFalse(links[0].closes)
|
||||||
|
|
||||||
|
def test_non_canonical_branch_is_not_treated_as_a_marker(self):
|
||||||
|
self.assertEqual(resolve_pr_links(_pr(902, head="issue-643-preview")), ())
|
||||||
|
|
||||||
|
def test_bare_mention_is_recorded_as_the_weakest_evidence(self):
|
||||||
|
links = resolve_pr_links(_pr(902, body="context in #643"))
|
||||||
|
self.assertEqual(links[0].evidence, (EVIDENCE_REFERENCE,))
|
||||||
|
self.assertFalse(links[0].closes)
|
||||||
|
|
||||||
|
def test_several_evidence_kinds_merge_onto_one_edge(self):
|
||||||
|
links = resolve_pr_links(
|
||||||
|
_pr(902, body="Closes #643 — see #643", head="feat/issue-643-preview")
|
||||||
|
)
|
||||||
|
self.assertEqual(len(links), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
links[0].evidence,
|
||||||
|
(EVIDENCE_CLOSES, EVIDENCE_BRANCH, EVIDENCE_REFERENCE),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stronger_evidence_sorts_first(self):
|
||||||
|
links = resolve_pr_links(_pr(902, body="Closes #643, related #700"))
|
||||||
|
self.assertEqual([link.issue_number for link in links], [643, 700])
|
||||||
|
|
||||||
|
def test_self_reference_is_not_linkage(self):
|
||||||
|
links = resolve_pr_links(_pr(902, body="supersedes #902"))
|
||||||
|
self.assertEqual(links, ())
|
||||||
|
|
||||||
|
def test_every_candidate_is_kept_never_collapsed_to_a_guess(self):
|
||||||
|
links = resolve_pr_links(_pr(902, body="Closes #643\nCloses #644"))
|
||||||
|
self.assertEqual([link.issue_number for link in links], [643, 644])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinkageIndex(unittest.TestCase):
|
||||||
|
def test_issue_direction_ignores_mention_only_edges(self):
|
||||||
|
index = resolve_linkage([_pr(902, body="context in #643")])
|
||||||
|
self.assertIsNone(index.issue_prs.get(643))
|
||||||
|
self.assertEqual(index.pr_links[902][0].evidence, (EVIDENCE_REFERENCE,))
|
||||||
|
|
||||||
|
def test_contested_issue_is_reported_when_two_prs_claim_it(self):
|
||||||
|
index = resolve_linkage(
|
||||||
|
[_pr(902, body="Closes #643"), _pr(903, head="feat/issue-643-again")]
|
||||||
|
)
|
||||||
|
self.assertEqual(index.contested_issues(), (643,))
|
||||||
|
self.assertEqual(index.issue_prs[643], (902, 903))
|
||||||
|
|
||||||
|
def test_single_claim_is_not_contested(self):
|
||||||
|
index = resolve_linkage([_pr(902, body="Closes #643")])
|
||||||
|
self.assertEqual(index.contested_issues(), ())
|
||||||
|
|
||||||
|
def test_ambiguous_when_two_issues_tie_at_the_strongest_evidence(self):
|
||||||
|
index = resolve_linkage([_pr(902, body="Closes #643\nCloses #644")])
|
||||||
|
self.assertTrue(index.ambiguous(902))
|
||||||
|
|
||||||
|
def test_weaker_candidate_alongside_a_stronger_one_is_not_ambiguous(self):
|
||||||
|
index = resolve_linkage([_pr(902, body="Closes #643, see #700")])
|
||||||
|
self.assertFalse(index.ambiguous(902))
|
||||||
|
self.assertEqual(index.primary_issue(902).issue_number, 643)
|
||||||
|
|
||||||
|
def test_malformed_pr_row_is_skipped_not_raised_on(self):
|
||||||
|
index = resolve_linkage([{"title": "no number"}, _pr(902, body="Closes #643")])
|
||||||
|
self.assertEqual(sorted(index.pr_links), [902])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinkageSnapshot(unittest.TestCase):
|
||||||
|
"""AC1 — linkage is visible per project/repo, in both directions."""
|
||||||
|
|
||||||
|
def test_both_directions_are_populated(self):
|
||||||
|
snapshot = _load([_issue(643)], [_pr(902, body="Closes #643")])
|
||||||
|
self.assertTrue(snapshot.ok)
|
||||||
|
self.assertEqual([node.number for node in snapshot.issues], [643])
|
||||||
|
self.assertEqual(snapshot.issues[0].linked_prs, (902,))
|
||||||
|
self.assertEqual(snapshot.prs[0].links[0].issue_number, 643)
|
||||||
|
|
||||||
|
def test_repo_scope_comes_from_the_registry_project(self):
|
||||||
|
snapshot = _load([], [])
|
||||||
|
self.assertIn("/", snapshot.repo_label)
|
||||||
|
self.assertTrue(snapshot.project_id)
|
||||||
|
|
||||||
|
def test_unknown_project_fails_closed_with_a_reason(self):
|
||||||
|
snapshot = _load([_issue(643)], [], project_id="no-such-project")
|
||||||
|
self.assertFalse(snapshot.ok)
|
||||||
|
self.assertIn("not found in registry", snapshot.fetch_error)
|
||||||
|
self.assertEqual(snapshot.issues, ())
|
||||||
|
|
||||||
|
def test_orphan_pr_is_identifiable(self):
|
||||||
|
snapshot = _load([], [_pr(902), _pr(903, body="Closes #643")])
|
||||||
|
self.assertEqual([node.number for node in snapshot.orphan_prs], [902])
|
||||||
|
|
||||||
|
def test_state_scope_defaults_to_open_and_is_reported(self):
|
||||||
|
self.assertEqual(_load([], []).state_scope, "open")
|
||||||
|
self.assertEqual(_load([], [], state="all").state_scope, "all")
|
||||||
|
|
||||||
|
def test_unsupported_state_falls_back_to_open(self):
|
||||||
|
self.assertEqual(_load([], [], state="../etc").state_scope, "open")
|
||||||
|
|
||||||
|
def test_state_is_passed_through_to_the_fetchers(self):
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def _fetch(*_args, **kwargs):
|
||||||
|
seen.append(kwargs.get("state", ""))
|
||||||
|
return [], _pagination()
|
||||||
|
|
||||||
|
load_linkage_snapshot(state="all", fetch_prs=_fetch, fetch_issues=_fetch)
|
||||||
|
self.assertEqual(seen, ["all", "all"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPartialInventoryIsNotAnAbsenceClaim(unittest.TestCase):
|
||||||
|
"""An empty edge list from a partial read must never read as 'no link'."""
|
||||||
|
|
||||||
|
def test_incomplete_pagination_marks_links_non_authoritative(self):
|
||||||
|
snapshot = _load([_issue(643)], [], complete=False)
|
||||||
|
self.assertFalse(snapshot.inventory_complete)
|
||||||
|
self.assertFalse(snapshot.issues[0].links_authoritative)
|
||||||
|
|
||||||
|
def test_complete_pagination_marks_links_authoritative(self):
|
||||||
|
snapshot = _load([_issue(643)], [], complete=True)
|
||||||
|
self.assertTrue(snapshot.inventory_complete)
|
||||||
|
self.assertTrue(snapshot.issues[0].links_authoritative)
|
||||||
|
|
||||||
|
def test_partial_window_renders_a_qualified_empty_cell(self):
|
||||||
|
html = render_linkage_page(_load([_issue(643)], [], complete=False))
|
||||||
|
self.assertIn("none found (partial inventory)", html)
|
||||||
|
|
||||||
|
def test_complete_window_renders_a_plain_none(self):
|
||||||
|
html = render_linkage_page(_load([_issue(643)], [], complete=True))
|
||||||
|
self.assertNotIn("partial inventory", html)
|
||||||
|
self.assertIn(">none<", html)
|
||||||
|
|
||||||
|
def test_missing_credentials_fail_closed_without_a_table(self):
|
||||||
|
with mock.patch(
|
||||||
|
"webui.linkage_loader._offline_test_mode", return_value=False
|
||||||
|
), mock.patch("webui.linkage_loader.get_auth_header", return_value=""):
|
||||||
|
snapshot = load_linkage_snapshot()
|
||||||
|
self.assertFalse(snapshot.ok)
|
||||||
|
self.assertIn("credentials unavailable", snapshot.fetch_error)
|
||||||
|
html = render_linkage_page(snapshot)
|
||||||
|
self.assertIn("Linkage unavailable", html)
|
||||||
|
self.assertNotIn("Issues → pull requests", html)
|
||||||
|
|
||||||
|
def test_fetch_failure_is_reported_not_raised(self):
|
||||||
|
def _boom(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("gitea 502")
|
||||||
|
|
||||||
|
snapshot = load_linkage_snapshot(fetch_prs=_boom, fetch_issues=_boom)
|
||||||
|
self.assertFalse(snapshot.ok)
|
||||||
|
self.assertIn("Gitea fetch failed", snapshot.fetch_error)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandoffSummary(unittest.TestCase):
|
||||||
|
"""AC2 — the latest canonical handoff is summarized for a focused thread."""
|
||||||
|
|
||||||
|
def test_latest_cth_wins(self):
|
||||||
|
summary = summarize_handoff([
|
||||||
|
_cth(1, created_at="2026-07-24T10:00:00Z", status="in progress",
|
||||||
|
next_owner="author"),
|
||||||
|
_cth(2, created_at="2026-07-25T10:00:00Z", status="PR-open",
|
||||||
|
next_owner="reviewer"),
|
||||||
|
])
|
||||||
|
self.assertEqual(summary.comment_id, 2)
|
||||||
|
self.assertEqual(summary.status, "PR-open")
|
||||||
|
self.assertEqual(summary.next_owner, "reviewer")
|
||||||
|
self.assertTrue(summary.cth_type_known)
|
||||||
|
|
||||||
|
def test_thread_without_a_cth_summarizes_to_none(self):
|
||||||
|
self.assertIsNone(summarize_handoff([{"id": 1, "body": "ordinary comment"}]))
|
||||||
|
|
||||||
|
def test_unknown_heading_is_reported_not_republished(self):
|
||||||
|
summary = summarize_handoff([
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"created_at": "2026-07-25T10:00:00Z",
|
||||||
|
"user": {"login": "someone"},
|
||||||
|
"body": "<!-- cth:v1 -->\n## CTH: Totally Made Up\n\nStatus: odd\n",
|
||||||
|
}
|
||||||
|
])
|
||||||
|
self.assertFalse(summary.cth_type_known)
|
||||||
|
self.assertEqual(summary.cth_type, "unrecognized")
|
||||||
|
self.assertNotIn("Totally Made Up", json.dumps(summary.to_dict()))
|
||||||
|
|
||||||
|
def test_focused_pr_loads_its_handoff(self):
|
||||||
|
snapshot = _load(
|
||||||
|
[_issue(643)],
|
||||||
|
[_pr(902, body="Closes #643")],
|
||||||
|
pr=902,
|
||||||
|
comment_source=lambda kind, number: [
|
||||||
|
_cth(2, created_at="2026-07-25T10:00:00Z", status="PR-open",
|
||||||
|
next_owner="reviewer")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(snapshot.handoff_status.state, HANDOFF_LOADED)
|
||||||
|
self.assertEqual(snapshot.focus, ("pr", 902))
|
||||||
|
self.assertEqual(snapshot.prs[0].handoff.status, "PR-open")
|
||||||
|
|
||||||
|
def test_unfocused_rows_report_not_loaded_never_none(self):
|
||||||
|
snapshot = _load(
|
||||||
|
[_issue(643)],
|
||||||
|
[_pr(902, body="Closes #643"), _pr(903)],
|
||||||
|
pr=902,
|
||||||
|
comment_source=lambda kind, number: [],
|
||||||
|
)
|
||||||
|
other = next(node for node in snapshot.prs if node.number == 903)
|
||||||
|
self.assertIsNone(other.handoff)
|
||||||
|
self.assertEqual(other.handoff_status.state, HANDOFF_NOT_LOADED)
|
||||||
|
self.assertIn("not loaded", render_linkage_page(snapshot))
|
||||||
|
|
||||||
|
def test_no_focus_means_no_thread_is_claimed_handoff_free(self):
|
||||||
|
snapshot = _load([_issue(643)], [])
|
||||||
|
self.assertEqual(snapshot.handoff_status.state, HANDOFF_NOT_LOADED)
|
||||||
|
self.assertIn("thread-scoped", snapshot.handoff_status.reason)
|
||||||
|
|
||||||
|
def test_comment_source_failure_degrades_only_the_handoff(self):
|
||||||
|
def _boom(_kind, _number):
|
||||||
|
raise RuntimeError("comments 500")
|
||||||
|
|
||||||
|
snapshot = _load(
|
||||||
|
[_issue(643)], [_pr(902, body="Closes #643")], pr=902, comment_source=_boom
|
||||||
|
)
|
||||||
|
self.assertTrue(snapshot.ok)
|
||||||
|
self.assertEqual(snapshot.handoff_status.state, HANDOFF_UNAVAILABLE)
|
||||||
|
self.assertEqual(snapshot.issues[0].linked_prs, (902,))
|
||||||
|
self.assertIn("unavailable", render_linkage_page(snapshot))
|
||||||
|
|
||||||
|
def test_loaded_thread_with_no_cth_says_so_explicitly(self):
|
||||||
|
snapshot = _load(
|
||||||
|
[_issue(643)],
|
||||||
|
[_pr(902, body="Closes #643")],
|
||||||
|
pr=902,
|
||||||
|
comment_source=lambda kind, number: [{"id": 1, "body": "hi"}],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"no Canonical Thread Handoff comment found", render_linkage_page(snapshot)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeepLinks(unittest.TestCase):
|
||||||
|
"""AC3 — an external Gitea link is emitted only when permitted."""
|
||||||
|
|
||||||
|
def test_deep_links_are_withheld_by_default(self):
|
||||||
|
with mock.patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": ""}):
|
||||||
|
snapshot = _load([_issue(643)], [])
|
||||||
|
html = render_linkage_page(snapshot)
|
||||||
|
self.assertFalse(snapshot.deep_links_enabled)
|
||||||
|
self.assertIsNone(snapshot.issues[0].deep_link)
|
||||||
|
self.assertIn("Gitea deep links are withheld", html)
|
||||||
|
|
||||||
|
def test_reveal_opt_in_emits_the_link(self):
|
||||||
|
with mock.patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}):
|
||||||
|
snapshot = _load([_issue(643)], [_pr(902, body="Closes #643")])
|
||||||
|
html = render_linkage_page(snapshot)
|
||||||
|
self.assertTrue(snapshot.deep_links_enabled)
|
||||||
|
self.assertIn("/issues/643", snapshot.issues[0].deep_link)
|
||||||
|
self.assertIn("/pulls/902", snapshot.prs[0].deep_link)
|
||||||
|
self.assertIn(f'href="{snapshot.issues[0].deep_link}"', html)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedactionBoundary(unittest.TestCase):
|
||||||
|
def test_secret_shaped_title_is_redacted_before_display(self):
|
||||||
|
snapshot = _load(
|
||||||
|
[_issue(643, title="token=ghp_thisisnotarealsecretvalue0001")], []
|
||||||
|
)
|
||||||
|
payload = json.dumps(snapshot_to_dict(snapshot))
|
||||||
|
self.assertNotIn("ghp_thisisnotarealsecretvalue0001", payload)
|
||||||
|
self.assertNotIn(
|
||||||
|
"ghp_thisisnotarealsecretvalue0001", render_linkage_page(snapshot)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_handoff_fields_are_redacted(self):
|
||||||
|
comment = _cth(
|
||||||
|
2, created_at="2026-07-25T10:00:00Z", status="ok", next_owner="reviewer"
|
||||||
|
)
|
||||||
|
comment["body"] += "\nDecision: password=hunter2hunter2\n"
|
||||||
|
snapshot = _load(
|
||||||
|
[_issue(643)],
|
||||||
|
[_pr(902, body="Closes #643")],
|
||||||
|
pr=902,
|
||||||
|
comment_source=lambda kind, number: [comment],
|
||||||
|
)
|
||||||
|
self.assertNotIn("hunter2hunter2", json.dumps(snapshot_to_dict(snapshot)))
|
||||||
|
self.assertNotIn("hunter2hunter2", render_linkage_page(snapshot))
|
||||||
|
|
||||||
|
def test_html_escapes_markup_in_a_title(self):
|
||||||
|
snapshot = _load([_issue(643, title="<script>alert(1)</script>")], [])
|
||||||
|
html = render_linkage_page(snapshot)
|
||||||
|
self.assertNotIn("<script>alert(1)</script>", html)
|
||||||
|
self.assertIn("<script>", html)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLinkageRoutes(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.snapshot = _load(
|
||||||
|
[_issue(643, labels=("status:ready",))],
|
||||||
|
[_pr(902, body="Closes #643", labels=("status:pr-open",))],
|
||||||
|
)
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_page_renders_both_tables(self):
|
||||||
|
with mock.patch("webui.app.load_linkage_snapshot", return_value=self.snapshot):
|
||||||
|
response = self.client.get("/gitea")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("Issues → pull requests", response.text)
|
||||||
|
self.assertIn("Pull requests → issues", response.text)
|
||||||
|
self.assertIn("#643", response.text)
|
||||||
|
|
||||||
|
def test_api_exports_the_same_model(self):
|
||||||
|
with mock.patch("webui.app.load_linkage_snapshot", return_value=self.snapshot):
|
||||||
|
response = self.client.get("/api/v1/gitea/linkage")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertTrue(payload["ok"])
|
||||||
|
self.assertEqual(payload["issues"][0]["linked_prs"], [902])
|
||||||
|
self.assertEqual(payload["prs"][0]["links"][0]["issue_number"], 643)
|
||||||
|
self.assertEqual(payload["schema_version"], 1)
|
||||||
|
|
||||||
|
def test_api_declares_the_evidence_vocabulary(self):
|
||||||
|
with mock.patch("webui.app.load_linkage_snapshot", return_value=self.snapshot):
|
||||||
|
payload = self.client.get("/api/v1/gitea/linkage").json()
|
||||||
|
names = {entry["name"] for entry in payload["evidence_kinds"]}
|
||||||
|
self.assertEqual(names, {EVIDENCE_CLOSES, EVIDENCE_BRANCH, EVIDENCE_REFERENCE})
|
||||||
|
|
||||||
|
def test_api_fails_closed_with_a_non_200_when_the_read_failed(self):
|
||||||
|
failed = _load([], [], project_id="no-such-project")
|
||||||
|
with mock.patch("webui.app.load_linkage_snapshot", return_value=failed):
|
||||||
|
response = self.client.get("/api/v1/gitea/linkage")
|
||||||
|
self.assertEqual(response.status_code, 502)
|
||||||
|
self.assertFalse(response.json()["ok"])
|
||||||
|
|
||||||
|
def test_page_still_renders_when_the_read_failed(self):
|
||||||
|
failed = _load([], [], project_id="no-such-project")
|
||||||
|
with mock.patch("webui.app.load_linkage_snapshot", return_value=failed):
|
||||||
|
response = self.client.get("/gitea")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("Linkage unavailable", response.text)
|
||||||
|
|
||||||
|
def test_query_parameters_reach_the_loader(self):
|
||||||
|
with mock.patch(
|
||||||
|
"webui.app.load_linkage_snapshot", return_value=self.snapshot
|
||||||
|
) as loader:
|
||||||
|
self.client.get("/gitea?project=gitea-tools&state=all&pr=902")
|
||||||
|
loader.assert_called_once()
|
||||||
|
args, kwargs = loader.call_args
|
||||||
|
self.assertEqual(args[0], "gitea-tools")
|
||||||
|
self.assertEqual(kwargs["state"], "all")
|
||||||
|
self.assertEqual(kwargs["pr"], 902)
|
||||||
|
self.assertIsNone(kwargs["issue"])
|
||||||
|
|
||||||
|
def test_surface_stays_read_only(self):
|
||||||
|
for path in ("/gitea", "/api/v1/gitea/linkage"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self.client.post(path).status_code, 405)
|
||||||
|
|
||||||
|
def test_nav_exposes_the_linkage_page_as_live(self):
|
||||||
|
self.assertIn("/gitea", nav_hrefs())
|
||||||
|
home = self.client.get("/").text
|
||||||
|
self.assertIn('href="/gitea"', home)
|
||||||
|
self.assertIn(">Gitea<", home)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""Unit tests for Phase 3 Notifications and Human-Attention Console (#648)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from webui.app import create_app
|
||||||
|
from webui.notifications import (
|
||||||
|
ATTENTION_HUMAN_REQUIRED,
|
||||||
|
ATTENTION_OPERATOR,
|
||||||
|
ATTENTION_ROUTINE,
|
||||||
|
CATEGORY_AUTH,
|
||||||
|
CATEGORY_BLOCKER,
|
||||||
|
CATEGORY_LEASE,
|
||||||
|
CATEGORY_SYSTEM,
|
||||||
|
CATEGORY_VALIDATION,
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
NotificationItem,
|
||||||
|
NotificationSnapshot,
|
||||||
|
classify_attention_event,
|
||||||
|
load_notifications_snapshot,
|
||||||
|
snapshot_to_dict,
|
||||||
|
)
|
||||||
|
from webui.notification_views import render_notifications_page
|
||||||
|
from webui.project_registry import load_registry
|
||||||
|
from webui.queue_loader import QueueItem, QueueSnapshot
|
||||||
|
from webui.lease_loader import CollisionWarning, LeaseSnapshot
|
||||||
|
from webui.system_health import DependencyProbe, SystemHealthSnapshot, VersionInfo, StaleRuntime
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_attention_event_rules():
|
||||||
|
# 1. Critical escalation boundaries -> human-required
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_AUTH, "Auth error", "Unauthorized access attempt", is_auth_failure=True
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
||||||
|
assert req_human is True
|
||||||
|
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_SYSTEM, "Hard stop", "Hard stop triggered", is_hard_stop=True
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
||||||
|
assert req_human is True
|
||||||
|
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_VALIDATION, "Validation Error", "Report validation failed", is_validation_failure=True
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
||||||
|
assert req_human is True
|
||||||
|
|
||||||
|
# 2. Operational issues -> operator
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_BLOCKER, "PR Blocked", "Merge conflict detected", is_blocker=True
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_OPERATOR
|
||||||
|
assert req_human is False
|
||||||
|
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_LEASE, "Lease Expired", "Session lease expired", is_stale=True
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_OPERATOR
|
||||||
|
assert req_human is False
|
||||||
|
|
||||||
|
# 3. Routine workflow transitions -> routine
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_WORKFLOW, "PR Active", "PR in review"
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_ROUTINE
|
||||||
|
assert req_human is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_snapshot_aggregation():
|
||||||
|
reg = load_registry()
|
||||||
|
proj_id = reg.projects[0].id if reg.projects else "gitea-tools"
|
||||||
|
|
||||||
|
mock_queue = QueueSnapshot(
|
||||||
|
project_id=proj_id,
|
||||||
|
repo_label="org/repo",
|
||||||
|
prs=(
|
||||||
|
QueueItem(
|
||||||
|
number=101,
|
||||||
|
title="Blocked PR",
|
||||||
|
badges=("blocked",),
|
||||||
|
extra={},
|
||||||
|
),
|
||||||
|
QueueItem(
|
||||||
|
number=102,
|
||||||
|
title="Normal PR",
|
||||||
|
badges=("in-review",),
|
||||||
|
extra={},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
issues=(),
|
||||||
|
pr_pagination=None,
|
||||||
|
issue_pagination=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_leases = LeaseSnapshot(
|
||||||
|
project_id=proj_id,
|
||||||
|
repo_label="org/repo",
|
||||||
|
issue_lock=None,
|
||||||
|
claim_inventory={},
|
||||||
|
reviewer_leases=(
|
||||||
|
{
|
||||||
|
"pr_number": 101,
|
||||||
|
"status": "expired",
|
||||||
|
"is_expired": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
duplicate_prs=(
|
||||||
|
CollisionWarning(
|
||||||
|
kind="duplicate_pr",
|
||||||
|
message="Multiple open PRs for issue #101",
|
||||||
|
issue_number=101,
|
||||||
|
pr_numbers=(101, 103),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
duplicate_branches=(),
|
||||||
|
collision_history=(),
|
||||||
|
fetch_error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_version = VersionInfo(
|
||||||
|
git_sha="abc1234",
|
||||||
|
git_describe="v1.0.0",
|
||||||
|
control_plane_schema_version=1,
|
||||||
|
python_version="3.11",
|
||||||
|
known=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_stale = StaleRuntime(
|
||||||
|
daemon_head="abc1234",
|
||||||
|
checkout_head="abc1234",
|
||||||
|
remote_head="abc1234",
|
||||||
|
stale=False,
|
||||||
|
determinable=True,
|
||||||
|
mutation_safe=True,
|
||||||
|
reasons=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_health = SystemHealthSnapshot(
|
||||||
|
status="degraded",
|
||||||
|
ready=False,
|
||||||
|
readiness_complete=True,
|
||||||
|
readiness_reasons=("Auth failure",),
|
||||||
|
service="webui",
|
||||||
|
mode="test",
|
||||||
|
version=mock_version,
|
||||||
|
started_at="2026-07-25T00:00:00Z",
|
||||||
|
uptime_seconds=100.0,
|
||||||
|
timestamp="2026-07-25T00:00:00Z",
|
||||||
|
deep_probes_requested=True,
|
||||||
|
dependencies=(
|
||||||
|
DependencyProbe(
|
||||||
|
name="auth_service",
|
||||||
|
kind="auth",
|
||||||
|
status="unauthorized",
|
||||||
|
detail="Token expired",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mcp_namespaces=(),
|
||||||
|
stale_runtime=mock_stale,
|
||||||
|
probe_errors=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = load_notifications_snapshot(
|
||||||
|
proj_id,
|
||||||
|
load_queue=lambda _id: mock_queue,
|
||||||
|
load_leases=lambda **_kwargs: mock_leases,
|
||||||
|
load_health=lambda **_kwargs: mock_health,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert snapshot.project_id == proj_id
|
||||||
|
assert snapshot.total_count == 5
|
||||||
|
assert snapshot.human_required_count >= 1 # auth probe failure
|
||||||
|
assert snapshot.operator_count >= 3 # blocked PR + expired lease + duplicate PR collision
|
||||||
|
assert snapshot.routine_count >= 1 # normal PR
|
||||||
|
|
||||||
|
# Inbox items should include operator and human-required items only
|
||||||
|
inbox_classes = {item.attention_class for item in snapshot.inbox_items}
|
||||||
|
assert ATTENTION_ROUTINE not in inbox_classes
|
||||||
|
assert ATTENTION_OPERATOR in inbox_classes
|
||||||
|
assert ATTENTION_HUMAN_REQUIRED in inbox_classes
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_to_dict_and_redaction():
|
||||||
|
item = NotificationItem(
|
||||||
|
id="notif-1",
|
||||||
|
attention_class=ATTENTION_HUMAN_REQUIRED,
|
||||||
|
category=CATEGORY_AUTH,
|
||||||
|
title="Auth Error",
|
||||||
|
summary="Failed auth header: Bearer secret_token_12345",
|
||||||
|
work_kind="system",
|
||||||
|
work_number=None,
|
||||||
|
project_id="test-proj",
|
||||||
|
repo_label="org/repo",
|
||||||
|
created_at="2026-07-25T16:00:00Z",
|
||||||
|
requires_human=True,
|
||||||
|
)
|
||||||
|
snap = NotificationSnapshot(
|
||||||
|
project_id="test-proj",
|
||||||
|
repo_label="org/repo",
|
||||||
|
items=(item,),
|
||||||
|
human_required_count=1,
|
||||||
|
operator_count=0,
|
||||||
|
routine_count=0,
|
||||||
|
total_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = snapshot_to_dict(snap)
|
||||||
|
assert data["project_id"] == "test-proj"
|
||||||
|
assert data["human_required_count"] == 1
|
||||||
|
assert len(data["inbox_items"]) == 1
|
||||||
|
|
||||||
|
# Redaction test
|
||||||
|
summary = data["inbox_items"][0]["summary"]
|
||||||
|
assert "secret_token_12345" not in summary
|
||||||
|
assert "<redacted>" in summary or "Bearer" in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_html_views():
|
||||||
|
item = NotificationItem(
|
||||||
|
id="notif-1",
|
||||||
|
attention_class=ATTENTION_HUMAN_REQUIRED,
|
||||||
|
category=CATEGORY_AUTH,
|
||||||
|
title="Critical Auth Failure",
|
||||||
|
summary="Auth failure details",
|
||||||
|
work_kind="issue",
|
||||||
|
work_number=42,
|
||||||
|
project_id="test-proj",
|
||||||
|
repo_label="org/repo",
|
||||||
|
created_at="2026-07-25T16:00:00Z",
|
||||||
|
requires_human=True,
|
||||||
|
)
|
||||||
|
snap = NotificationSnapshot(
|
||||||
|
project_id="test-proj",
|
||||||
|
repo_label="org/repo",
|
||||||
|
items=(item,),
|
||||||
|
human_required_count=1,
|
||||||
|
operator_count=0,
|
||||||
|
routine_count=0,
|
||||||
|
total_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
html = render_notifications_page(snap, filter_class="inbox")
|
||||||
|
assert "Notifications & Attention Inbox" in html or "Notifications & Attention Inbox" in html
|
||||||
|
assert "Critical Auth Failure" in html
|
||||||
|
assert "HUMAN REQUIRED" in html
|
||||||
|
assert "Human Required" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_app_routes():
|
||||||
|
app = create_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
# 1. HTML Route
|
||||||
|
res = client.get("/notifications")
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert "Notifications" in res.text
|
||||||
|
assert "Attention Inbox" in res.text
|
||||||
|
|
||||||
|
# 2. API Route /api/v1/notifications
|
||||||
|
res_api = client.get("/api/v1/notifications")
|
||||||
|
assert res_api.status_code == 200
|
||||||
|
json_data = res_api.json()
|
||||||
|
assert "human_required_count" in json_data
|
||||||
|
assert "operator_count" in json_data
|
||||||
|
assert "routine_count" in json_data
|
||||||
|
assert "inbox_items" in json_data
|
||||||
|
|
||||||
|
# 3. Compatibility Alias /api/notifications
|
||||||
|
res_alias = client.get("/api/notifications")
|
||||||
|
assert res_alias.status_code == 200
|
||||||
|
assert res_alias.json()["project_id"] == json_data["project_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_ignores_human_authored_title_and_summary_keywords():
|
||||||
|
"""B1: keywords in human-authored titles must not escalate routine work (#905)."""
|
||||||
|
# Routine transition whose title/summary mention critical-boundary words
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
"record irrecoverable decision lock provenance",
|
||||||
|
"PR #999 'record irrecoverable decision lock provenance' is in routine state in-review.",
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_ROUTINE
|
||||||
|
assert req_human is False
|
||||||
|
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
"fix unauthorized token path",
|
||||||
|
"Issue #1 'fix unauthorized token path' state: claimed. hard stop docs only.",
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_ROUTINE
|
||||||
|
assert req_human is False
|
||||||
|
|
||||||
|
# Structured flags still escalate (machine-driven)
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_SYSTEM,
|
||||||
|
"anything",
|
||||||
|
"anything with hard stop in text",
|
||||||
|
is_hard_stop=True,
|
||||||
|
)
|
||||||
|
assert att_cls == ATTENTION_HUMAN_REQUIRED
|
||||||
|
assert req_human is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_ids_are_unique_across_probe_errors_and_collisions():
|
||||||
|
"""B2: published notification ids must be unique within a snapshot (#905)."""
|
||||||
|
reg = load_registry()
|
||||||
|
proj_id = reg.projects[0].id if reg.projects else "gitea-tools"
|
||||||
|
|
||||||
|
mock_queue = QueueSnapshot(
|
||||||
|
project_id=proj_id,
|
||||||
|
repo_label="org/repo",
|
||||||
|
prs=(),
|
||||||
|
issues=(),
|
||||||
|
pr_pagination=None,
|
||||||
|
issue_pagination=None,
|
||||||
|
)
|
||||||
|
mock_leases = LeaseSnapshot(
|
||||||
|
project_id=proj_id,
|
||||||
|
repo_label="org/repo",
|
||||||
|
issue_lock=None,
|
||||||
|
claim_inventory={},
|
||||||
|
reviewer_leases=(),
|
||||||
|
duplicate_prs=(
|
||||||
|
CollisionWarning(
|
||||||
|
kind="duplicate_pr",
|
||||||
|
message="Multiple open PRs for issue #10",
|
||||||
|
issue_number=10,
|
||||||
|
pr_numbers=(10, 11),
|
||||||
|
),
|
||||||
|
CollisionWarning(
|
||||||
|
kind="duplicate_branch",
|
||||||
|
message="Another collision without issue",
|
||||||
|
issue_number=None,
|
||||||
|
pr_numbers=(12, 13),
|
||||||
|
),
|
||||||
|
CollisionWarning(
|
||||||
|
kind="duplicate_pr",
|
||||||
|
message="Second issue collision",
|
||||||
|
issue_number=10,
|
||||||
|
pr_numbers=(14, 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
duplicate_branches=(),
|
||||||
|
collision_history=(),
|
||||||
|
fetch_error=None,
|
||||||
|
)
|
||||||
|
mock_version = VersionInfo(
|
||||||
|
git_sha="abc1234",
|
||||||
|
git_describe="v1.0.0",
|
||||||
|
control_plane_schema_version=1,
|
||||||
|
python_version="3.11",
|
||||||
|
known=True,
|
||||||
|
)
|
||||||
|
mock_stale = StaleRuntime(
|
||||||
|
daemon_head="abc1234",
|
||||||
|
checkout_head="abc1234",
|
||||||
|
remote_head="abc1234",
|
||||||
|
stale=False,
|
||||||
|
determinable=True,
|
||||||
|
mutation_safe=True,
|
||||||
|
reasons=(),
|
||||||
|
)
|
||||||
|
mock_health = SystemHealthSnapshot(
|
||||||
|
status="degraded",
|
||||||
|
ready=False,
|
||||||
|
readiness_complete=True,
|
||||||
|
readiness_reasons=(),
|
||||||
|
service="webui",
|
||||||
|
mode="test",
|
||||||
|
version=mock_version,
|
||||||
|
started_at="2026-07-25T00:00:00Z",
|
||||||
|
uptime_seconds=100.0,
|
||||||
|
timestamp="2026-07-25T00:00:00Z",
|
||||||
|
deep_probes_requested=True,
|
||||||
|
dependencies=(),
|
||||||
|
mcp_namespaces=(),
|
||||||
|
stale_runtime=mock_stale,
|
||||||
|
probe_errors=("error alpha", "error beta"),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = load_notifications_snapshot(
|
||||||
|
proj_id,
|
||||||
|
load_queue=lambda _id: mock_queue,
|
||||||
|
load_leases=lambda **_kwargs: mock_leases,
|
||||||
|
load_health=lambda **_kwargs: mock_health,
|
||||||
|
)
|
||||||
|
ids = [item.id for item in snapshot.items]
|
||||||
|
assert len(ids) == len(set(ids)), f"duplicate notification ids: {ids}"
|
||||||
|
assert any(i.startswith(f"notif-sys-err-{proj_id}-") for i in ids)
|
||||||
|
assert any(i.startswith("notif-collision-") for i in ids)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_errors_do_not_set_fetch_error():
|
||||||
|
"""B3: probe_errors must not be reported as fetch_error (#905)."""
|
||||||
|
reg = load_registry()
|
||||||
|
proj_id = reg.projects[0].id if reg.projects else "gitea-tools"
|
||||||
|
|
||||||
|
mock_queue = QueueSnapshot(
|
||||||
|
project_id=proj_id,
|
||||||
|
repo_label="org/repo",
|
||||||
|
prs=(),
|
||||||
|
issues=(),
|
||||||
|
pr_pagination=None,
|
||||||
|
issue_pagination=None,
|
||||||
|
fetch_error=None,
|
||||||
|
)
|
||||||
|
mock_leases = LeaseSnapshot(
|
||||||
|
project_id=proj_id,
|
||||||
|
repo_label="org/repo",
|
||||||
|
issue_lock=None,
|
||||||
|
claim_inventory={},
|
||||||
|
reviewer_leases=(),
|
||||||
|
duplicate_prs=(),
|
||||||
|
duplicate_branches=(),
|
||||||
|
collision_history=(),
|
||||||
|
fetch_error=None,
|
||||||
|
)
|
||||||
|
mock_version = VersionInfo(
|
||||||
|
git_sha="abc1234",
|
||||||
|
git_describe="v1.0.0",
|
||||||
|
control_plane_schema_version=1,
|
||||||
|
python_version="3.11",
|
||||||
|
known=True,
|
||||||
|
)
|
||||||
|
mock_stale = StaleRuntime(
|
||||||
|
daemon_head="abc1234",
|
||||||
|
checkout_head="abc1234",
|
||||||
|
remote_head="abc1234",
|
||||||
|
stale=False,
|
||||||
|
determinable=True,
|
||||||
|
mutation_safe=True,
|
||||||
|
reasons=(),
|
||||||
|
)
|
||||||
|
mock_health = SystemHealthSnapshot(
|
||||||
|
status="degraded",
|
||||||
|
ready=False,
|
||||||
|
readiness_complete=True,
|
||||||
|
readiness_reasons=(),
|
||||||
|
service="webui",
|
||||||
|
mode="test",
|
||||||
|
version=mock_version,
|
||||||
|
started_at="2026-07-25T00:00:00Z",
|
||||||
|
uptime_seconds=100.0,
|
||||||
|
timestamp="2026-07-25T00:00:00Z",
|
||||||
|
deep_probes_requested=True,
|
||||||
|
dependencies=(),
|
||||||
|
mcp_namespaces=(),
|
||||||
|
stale_runtime=mock_stale,
|
||||||
|
probe_errors=("probe blew up",),
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = load_notifications_snapshot(
|
||||||
|
proj_id,
|
||||||
|
load_queue=lambda _id: mock_queue,
|
||||||
|
load_leases=lambda **_kwargs: mock_leases,
|
||||||
|
load_health=lambda **_kwargs: mock_health,
|
||||||
|
)
|
||||||
|
assert snapshot.fetch_error is None
|
||||||
|
# probe errors still appear as items
|
||||||
|
assert any("probe blew up" in item.summary for item in snapshot.items)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
|||||||
|
"""Read-only restart console: views, gates, and honesty rules (#667).
|
||||||
|
|
||||||
|
The console consumes the #655 substrate. These tests hold it to the three
|
||||||
|
properties that make a status surface trustworthy:
|
||||||
|
|
||||||
|
* an unreadable source is reported unavailable, never rendered as green;
|
||||||
|
* authorization is probed the way execution would probe it, so an allow is
|
||||||
|
never shown for something that could not run;
|
||||||
|
* the surface performs no mutation, including no write to the control-plane DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from starlette.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
import restart_coordinator # noqa: E402
|
||||||
|
from webui import console_authz, restart_console, restart_views # noqa: E402
|
||||||
|
from webui.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 25, 21, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(role: str) -> console_authz.Principal:
|
||||||
|
return console_authz.Principal(
|
||||||
|
subject="[email protected]",
|
||||||
|
role=role,
|
||||||
|
identity_source=console_authz.IDENTITY_LOCAL_DEV,
|
||||||
|
authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inventory(*, complete: bool = True, sessions=(), leases=()):
|
||||||
|
def _read(**_kwargs):
|
||||||
|
return {
|
||||||
|
"sessions": list(sessions),
|
||||||
|
"leases": list(leases),
|
||||||
|
"terminal_lock": None,
|
||||||
|
"prior_recovery_attempts": [],
|
||||||
|
"inventory_complete": complete,
|
||||||
|
"incomplete_reasons": (
|
||||||
|
[] if complete else ["fixture: inventory withheld"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return _read
|
||||||
|
|
||||||
|
|
||||||
|
def _live_session(session_id: str = "prgs-author-1234-abcd") -> dict:
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"role": "author",
|
||||||
|
"profile": "prgs-author",
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"status": "active",
|
||||||
|
"last_heartbeat_at": (NOW - timedelta(seconds=30)).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def drain_proof_fixture() -> dict:
|
||||||
|
"""A structurally complete but unsigned drain proof."""
|
||||||
|
return {
|
||||||
|
"version": "drain-proof/v1",
|
||||||
|
"proof_id": "deadbeef" * 8,
|
||||||
|
"clean": True,
|
||||||
|
"issued_at": (NOW - timedelta(minutes=1)).isoformat(),
|
||||||
|
"expires_at": (NOW + timedelta(minutes=5)).isoformat(),
|
||||||
|
"requesting_session_id": "s-live",
|
||||||
|
"impact_fingerprint": "f" * 64,
|
||||||
|
"checks": [],
|
||||||
|
"failed_checks": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RestartClassMatrixTest(unittest.TestCase):
|
||||||
|
def test_every_policy_class_is_rendered(self) -> None:
|
||||||
|
views = restart_console.build_restart_class_views("operator")
|
||||||
|
self.assertEqual(len(views), len(restart_coordinator.RESTART_CLASS_POLICIES))
|
||||||
|
|
||||||
|
def test_viewer_capability_is_role_scoped_not_generic(self) -> None:
|
||||||
|
"""A worker role must not be shown as able to request a full restart."""
|
||||||
|
author = {
|
||||||
|
v.restart_class: v
|
||||||
|
for v in restart_console.build_restart_class_views("author")
|
||||||
|
}
|
||||||
|
operator = {
|
||||||
|
v.restart_class: v
|
||||||
|
for v in restart_console.build_restart_class_views("operator")
|
||||||
|
}
|
||||||
|
full = restart_coordinator.RestartClass.FULL_MCP_RESTART.value
|
||||||
|
|
||||||
|
self.assertFalse(author[full].viewer_may_request)
|
||||||
|
self.assertFalse(author[full].viewer_may_execute)
|
||||||
|
self.assertTrue(operator[full].viewer_may_request)
|
||||||
|
self.assertTrue(operator[full].viewer_may_execute)
|
||||||
|
|
||||||
|
def test_unknown_role_may_do_nothing(self) -> None:
|
||||||
|
views = restart_console.build_restart_class_views("not-a-role")
|
||||||
|
self.assertTrue(all(not v.viewer_may_request for v in views))
|
||||||
|
self.assertTrue(all(not v.viewer_may_execute for v in views))
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationProbeTest(unittest.TestCase):
|
||||||
|
def test_probe_asks_for_execution_so_phase_gate_is_reported(self) -> None:
|
||||||
|
"""An admin clears the role bar and still cannot execute in Phase 1.
|
||||||
|
|
||||||
|
This is the case that distinguishes the two probes. Asked without
|
||||||
|
``for_execution`` an admin is *allowed* for ``system.restart_namespace``,
|
||||||
|
which on a control surface reads as a live button. Asked the way
|
||||||
|
execution asks, the same principal is refused ``phase_not_active``. The
|
||||||
|
console must report the second answer.
|
||||||
|
"""
|
||||||
|
by_id = {
|
||||||
|
a.action_id: a
|
||||||
|
for a in restart_console.build_action_authorizations(
|
||||||
|
_principal(console_authz.ADMIN)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
restart = by_id["system.restart_namespace"]
|
||||||
|
|
||||||
|
self.assertFalse(restart.execution_enabled)
|
||||||
|
self.assertEqual(restart.reason_code, console_authz.DENY_PHASE_NOT_ACTIVE)
|
||||||
|
|
||||||
|
permissive = console_authz.authorize(
|
||||||
|
"system.restart_namespace", _principal(console_authz.ADMIN)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
permissive.allowed,
|
||||||
|
"guard precondition: without for_execution an admin is allowed, "
|
||||||
|
"which is exactly why the console must not probe that way",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_operator_is_refused_the_admin_only_restart_action(self) -> None:
|
||||||
|
"""Role refusal precedes the phase gate and is reported as such."""
|
||||||
|
by_id = {
|
||||||
|
a.action_id: a
|
||||||
|
for a in restart_console.build_action_authorizations(
|
||||||
|
_principal(console_authz.OPERATOR)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
by_id["system.restart_namespace"].reason_code,
|
||||||
|
console_authz.DENY_INSUFFICIENT_ROLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anonymous_is_denied_unauthenticated(self) -> None:
|
||||||
|
by_id = {
|
||||||
|
a.action_id: a for a in restart_console.build_action_authorizations(None)
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
by_id["system.restart_namespace"].reason_code,
|
||||||
|
console_authz.DENY_UNAUTHENTICATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_authorization_ever_reports_execution_enabled(self) -> None:
|
||||||
|
for role in (
|
||||||
|
console_authz.VIEWER,
|
||||||
|
console_authz.OPERATOR,
|
||||||
|
console_authz.CONTROLLER,
|
||||||
|
console_authz.ADMIN,
|
||||||
|
):
|
||||||
|
for auth in restart_console.build_action_authorizations(_principal(role)):
|
||||||
|
self.assertFalse(
|
||||||
|
auth.execution_enabled,
|
||||||
|
f"{role} reported execution_enabled for {auth.action_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ImpactPreviewTest(unittest.TestCase):
|
||||||
|
def test_impact_renders_from_coordinator_dto(self) -> None:
|
||||||
|
impact, source = restart_console.load_impact_report(
|
||||||
|
principal=_principal(console_authz.OPERATOR),
|
||||||
|
read_inventory=_inventory(sessions=[_live_session()]),
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertTrue(source.available)
|
||||||
|
self.assertIsNotNone(impact)
|
||||||
|
self.assertEqual(
|
||||||
|
impact["restart_class"],
|
||||||
|
restart_coordinator.RestartClass.FULL_MCP_RESTART.value,
|
||||||
|
)
|
||||||
|
self.assertIn("verdict", impact)
|
||||||
|
self.assertFalse(impact["restart_performed"])
|
||||||
|
self.assertTrue(impact["dry_run"])
|
||||||
|
|
||||||
|
def test_incomplete_inventory_is_surfaced_and_denies(self) -> None:
|
||||||
|
impact, source = restart_console.load_impact_report(
|
||||||
|
principal=_principal(console_authz.OPERATOR),
|
||||||
|
read_inventory=_inventory(complete=False),
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertFalse(impact["inventory_complete"])
|
||||||
|
self.assertFalse(impact["allow_restart"])
|
||||||
|
self.assertTrue(source.detail, "incomplete inventory must explain itself")
|
||||||
|
|
||||||
|
def test_inventory_reader_failure_is_unavailable_not_empty(self) -> None:
|
||||||
|
"""A reader that raises must not be rendered as 'no sessions affected'."""
|
||||||
|
|
||||||
|
def _boom(**_kwargs):
|
||||||
|
raise RuntimeError("control-plane unreachable")
|
||||||
|
|
||||||
|
impact, source = restart_console.load_impact_report(
|
||||||
|
principal=_principal(console_authz.OPERATOR),
|
||||||
|
read_inventory=_boom,
|
||||||
|
now=NOW,
|
||||||
|
)
|
||||||
|
self.assertIsNone(impact)
|
||||||
|
self.assertFalse(source.available)
|
||||||
|
self.assertIn("control-plane unreachable", source.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class ControlPlaneReadTest(unittest.TestCase):
|
||||||
|
def test_missing_database_is_incomplete_not_empty(self) -> None:
|
||||||
|
inventory = restart_console.read_control_plane_inventory(
|
||||||
|
db_path="/nonexistent/control-plane.sqlite3"
|
||||||
|
)
|
||||||
|
self.assertFalse(inventory["inventory_complete"])
|
||||||
|
self.assertEqual(inventory["sessions"], [])
|
||||||
|
self.assertTrue(inventory["incomplete_reasons"])
|
||||||
|
|
||||||
|
def test_reader_never_creates_the_database(self) -> None:
|
||||||
|
"""Reading status must not bring a control-plane DB into existence.
|
||||||
|
|
||||||
|
The path deliberately sits in a directory that already exists: a
|
||||||
|
read-write ``sqlite3.connect`` would happily create the file there, so
|
||||||
|
this fails if the reader ever stops opening the database ``mode=ro``.
|
||||||
|
A nested-missing-directory path would pass for the wrong reason,
|
||||||
|
because sqlite cannot create the parent directory either way.
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "control_plane.sqlite3")
|
||||||
|
self.assertTrue(os.path.isdir(os.path.dirname(path)))
|
||||||
|
|
||||||
|
inventory = restart_console.read_control_plane_inventory(db_path=path)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(path),
|
||||||
|
"reading restart status created a control-plane database",
|
||||||
|
)
|
||||||
|
self.assertFalse(inventory["inventory_complete"])
|
||||||
|
|
||||||
|
def test_reads_active_sessions_from_a_real_database(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "cp.sqlite3")
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE sessions (session_id TEXT, role TEXT, profile TEXT,"
|
||||||
|
" pid INTEGER, status TEXT, last_heartbeat_at TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE work_items (work_item_id INTEGER, kind TEXT,"
|
||||||
|
" number INTEGER)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE leases (lease_id TEXT, session_id TEXT, role TEXT,"
|
||||||
|
" phase TEXT, status TEXT, worktree_path TEXT,"
|
||||||
|
" work_item_id INTEGER, expires_at TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions VALUES (?,?,?,?,?,?)",
|
||||||
|
("s-live", "author", "prgs-author", 4242, "active", NOW.isoformat()),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions VALUES (?,?,?,?,?,?)",
|
||||||
|
("s-done", "author", "prgs-author", 11, "closed", NOW.isoformat()),
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO work_items VALUES (1, 'issue', 667)")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO leases VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
"l-1",
|
||||||
|
"s-live",
|
||||||
|
"author",
|
||||||
|
"allocated",
|
||||||
|
"active",
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
NOW.isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
inventory = restart_console.read_control_plane_inventory(db_path=path)
|
||||||
|
|
||||||
|
self.assertTrue(inventory["inventory_complete"])
|
||||||
|
self.assertEqual([s["session_id"] for s in inventory["sessions"]], ["s-live"])
|
||||||
|
self.assertEqual(inventory["leases"][0]["work_number"], 667)
|
||||||
|
|
||||||
|
|
||||||
|
class DrainAndReconcileTest(unittest.TestCase):
|
||||||
|
def test_absent_drain_proof_is_not_a_pass(self) -> None:
|
||||||
|
drain, source = restart_console.load_drain_status(proof=None, now=NOW)
|
||||||
|
self.assertIsNone(drain)
|
||||||
|
self.assertFalse(source.available)
|
||||||
|
self.assertIn("denies", source.detail)
|
||||||
|
|
||||||
|
def test_tampered_drain_proof_is_reported_invalid(self) -> None:
|
||||||
|
proof = drain_proof_fixture()
|
||||||
|
proof["clean"] = True
|
||||||
|
proof["proof_id"] = "0" * 64
|
||||||
|
drain, source = restart_console.load_drain_status(proof=proof, now=NOW)
|
||||||
|
self.assertTrue(source.available)
|
||||||
|
self.assertFalse(drain["valid"])
|
||||||
|
|
||||||
|
def test_absent_reconcile_proof_is_unavailable(self) -> None:
|
||||||
|
reconcile, source = restart_console.load_reconcile_status(load_proof=None)
|
||||||
|
self.assertIsNone(reconcile)
|
||||||
|
self.assertFalse(source.available)
|
||||||
|
|
||||||
|
def test_reconcile_proof_is_rendered_when_supplied(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"overall_status": "degraded",
|
||||||
|
"mode": "log_only",
|
||||||
|
"resolved_count": 3,
|
||||||
|
"unresolved_count": 2,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"dimension": "leases",
|
||||||
|
"status": "unresolved",
|
||||||
|
"summary": "2 orphaned leases",
|
||||||
|
"follow_up_required": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
reconcile, source = restart_console.load_reconcile_status(
|
||||||
|
load_proof=lambda: payload
|
||||||
|
)
|
||||||
|
self.assertTrue(source.available)
|
||||||
|
self.assertEqual(reconcile["unresolved_count"], 2)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderingTest(unittest.TestCase):
|
||||||
|
def _snapshot(self, **kwargs):
|
||||||
|
params = {
|
||||||
|
"principal": _principal(console_authz.OPERATOR),
|
||||||
|
"read_inventory": _inventory(sessions=[_live_session()]),
|
||||||
|
"now": NOW,
|
||||||
|
}
|
||||||
|
params.update(kwargs)
|
||||||
|
return restart_console.load_restart_console_snapshot(**params)
|
||||||
|
|
||||||
|
def test_page_renders_every_section(self) -> None:
|
||||||
|
html = restart_views.render_restart_console_page(self._snapshot())
|
||||||
|
for heading in (
|
||||||
|
"Impact preview",
|
||||||
|
"Drain proof",
|
||||||
|
"Post-restart reconcile",
|
||||||
|
"Restart classes",
|
||||||
|
"Approval controls",
|
||||||
|
"Break-glass",
|
||||||
|
):
|
||||||
|
self.assertIn(heading, html)
|
||||||
|
|
||||||
|
def test_hostile_session_id_is_escaped(self) -> None:
|
||||||
|
hostile = "<script>alert('x')</script>"
|
||||||
|
html = restart_views.render_restart_console_page(
|
||||||
|
self._snapshot(read_inventory=_inventory(sessions=[_live_session(hostile)]))
|
||||||
|
)
|
||||||
|
self.assertNotIn("<script>alert", html)
|
||||||
|
self.assertIn("<script>", html)
|
||||||
|
|
||||||
|
def test_unavailable_impact_says_unsafe_rather_than_clean(self) -> None:
|
||||||
|
def _boom(**_kwargs):
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
snapshot = self._snapshot(read_inventory=_boom)
|
||||||
|
html = restart_views.render_restart_console_page(snapshot)
|
||||||
|
self.assertIn("blast radius of a restart is unknown", html)
|
||||||
|
self.assertIn("unavailable", html)
|
||||||
|
|
||||||
|
def test_break_glass_is_hidden_from_unprivileged_viewers(self) -> None:
|
||||||
|
viewer_html = restart_views.render_restart_console_page(
|
||||||
|
self._snapshot(principal=_principal(console_authz.VIEWER))
|
||||||
|
)
|
||||||
|
self.assertIn("visible to operator-class", viewer_html)
|
||||||
|
self.assertNotIn(
|
||||||
|
f"#{restart_console.BREAK_GLASS_ISSUE}", viewer_html
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_break_glass_shown_to_operator_is_marked_unavailable(self) -> None:
|
||||||
|
html = restart_views.render_restart_console_page(self._snapshot())
|
||||||
|
self.assertIn("unavailable", html)
|
||||||
|
self.assertIn(f"#{restart_console.BREAK_GLASS_ISSUE}", html)
|
||||||
|
|
||||||
|
def test_snapshot_always_declares_itself_read_only(self) -> None:
|
||||||
|
self.assertTrue(self._snapshot().read_only)
|
||||||
|
|
||||||
|
|
||||||
|
class RestartConsoleRouteTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_page_route_renders(self) -> None:
|
||||||
|
res = self.client.get("/runtime/restart")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertIn("Restart status and impact", res.text)
|
||||||
|
|
||||||
|
def test_api_route_exports_snapshot(self) -> None:
|
||||||
|
res = self.client.get("/api/v1/system/restart/status")
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
payload = res.json()
|
||||||
|
self.assertTrue(payload["read_only"])
|
||||||
|
self.assertEqual(payload["links"]["issue"], 667)
|
||||||
|
self.assertEqual(
|
||||||
|
len(payload["restart_classes"]),
|
||||||
|
len(restart_coordinator.RESTART_CLASS_POLICIES),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_restart_class_is_selectable(self) -> None:
|
||||||
|
res = self.client.get(
|
||||||
|
"/api/v1/system/restart/status?restart_class=client_reconnect"
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
self.assertEqual(res.json()["impact"]["restart_class"], "client_reconnect")
|
||||||
|
|
||||||
|
def test_unknown_restart_class_fails_closed(self) -> None:
|
||||||
|
res = self.client.get(
|
||||||
|
"/api/v1/system/restart/status?restart_class=obliterate-everything"
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
impact = res.json()["impact"]
|
||||||
|
self.assertFalse(impact["allow_restart"])
|
||||||
|
|
||||||
|
def test_anonymous_api_reader_gets_no_execution_grant(self) -> None:
|
||||||
|
payload = self.client.get("/api/v1/system/restart/status").json()
|
||||||
|
self.assertFalse(payload["break_glass"]["available"])
|
||||||
|
for auth in payload["authorizations"]:
|
||||||
|
self.assertFalse(auth["execution_enabled"])
|
||||||
|
|
||||||
|
def test_route_is_registered_in_nav(self) -> None:
|
||||||
|
from webui.nav import nav_hrefs
|
||||||
|
|
||||||
|
self.assertIn("/runtime/restart", nav_hrefs())
|
||||||
|
|
||||||
|
def test_no_write_method_is_exposed(self) -> None:
|
||||||
|
"""The surface is read-only: nothing accepts a POST."""
|
||||||
|
for path in ("/runtime/restart", "/api/v1/system/restart/status"):
|
||||||
|
self.assertEqual(self.client.post(path).status_code, 405, path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+302
@@ -47,12 +47,20 @@ from webui.traffic_views import render_traffic_page
|
|||||||
from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict
|
from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as worktree_snapshot_to_dict
|
||||||
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
|
||||||
|
import restart_coordinator
|
||||||
|
from webui.restart_console import load_restart_console_snapshot
|
||||||
|
from webui.restart_views import render_restart_console_page
|
||||||
from webui.runtime_views import render_runtime_page
|
from webui.runtime_views import render_runtime_page
|
||||||
from webui.session_loader import (
|
from webui.session_loader import (
|
||||||
load_session_view_snapshot,
|
load_session_view_snapshot,
|
||||||
snapshot_to_dict as session_view_snapshot_to_dict,
|
snapshot_to_dict as session_view_snapshot_to_dict,
|
||||||
)
|
)
|
||||||
from webui.session_views import render_sessions_page
|
from webui.session_views import render_sessions_page
|
||||||
|
from webui.linkage_loader import (
|
||||||
|
load_linkage_snapshot,
|
||||||
|
snapshot_to_dict as linkage_snapshot_to_dict,
|
||||||
|
)
|
||||||
|
from webui.linkage_views import render_linkage_page
|
||||||
from webui.inventory import (
|
from webui.inventory import (
|
||||||
SECTION_NAMES as _INVENTORY_SECTIONS,
|
SECTION_NAMES as _INVENTORY_SECTIONS,
|
||||||
load_inventory_snapshot,
|
load_inventory_snapshot,
|
||||||
@@ -72,6 +80,13 @@ from webui.system_health import (
|
|||||||
snapshot_to_dict as system_health_to_dict,
|
snapshot_to_dict as system_health_to_dict,
|
||||||
)
|
)
|
||||||
from webui.system_health_views import render_system_health_page
|
from webui.system_health_views import render_system_health_page
|
||||||
|
from webui.notifications import (
|
||||||
|
load_notifications_snapshot,
|
||||||
|
snapshot_to_dict as notifications_snapshot_to_dict,
|
||||||
|
)
|
||||||
|
from webui.notification_views import render_notifications_page
|
||||||
|
from webui import request_service
|
||||||
|
from webui.request_views import render_requests_page
|
||||||
|
|
||||||
_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"})
|
||||||
@@ -200,6 +215,84 @@ async def system_health(request: Request) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_recovery_diagnose(_request: Request) -> JSONResponse:
|
||||||
|
from webui.console_recovery import diagnose_recovery
|
||||||
|
diag = diagnose_recovery()
|
||||||
|
return JSONResponse({
|
||||||
|
"status": diag.status,
|
||||||
|
"clean": diag.clean,
|
||||||
|
"stale_runtime": diag.stale_runtime,
|
||||||
|
"master_parity": diag.master_parity,
|
||||||
|
"stale_binding": diag.stale_binding,
|
||||||
|
"contamination": diag.contamination,
|
||||||
|
"worktree_anomalies": list(diag.worktree_anomalies),
|
||||||
|
"reasons": list(diag.reasons),
|
||||||
|
"playbooks": [
|
||||||
|
{
|
||||||
|
"playbook_id": pb.playbook_id,
|
||||||
|
"label": pb.label,
|
||||||
|
"action_id": pb.action_id,
|
||||||
|
"description": pb.description,
|
||||||
|
"eligible": pb.eligible,
|
||||||
|
"requires_confirmation": pb.requires_confirmation,
|
||||||
|
"reason": pb.reason,
|
||||||
|
"params_schema": pb.params_schema,
|
||||||
|
}
|
||||||
|
for pb in diag.playbooks
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_recovery_preview(request: Request) -> JSONResponse:
|
||||||
|
from webui.console_recovery import build_recovery_preview
|
||||||
|
body = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
playbook_id = body.get("playbook_id") or request.query_params.get("playbook_id") or ""
|
||||||
|
target = body.get("target") or request.query_params.get("target")
|
||||||
|
principal = resolve_principal(request.headers)
|
||||||
|
preview = build_recovery_preview(
|
||||||
|
playbook_id=playbook_id,
|
||||||
|
target=target,
|
||||||
|
params=body,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
status = 200 if preview.get("playbook_id") else 400
|
||||||
|
return JSONResponse(preview, status_code=status)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_recovery_apply(request: Request) -> JSONResponse:
|
||||||
|
from webui.console_recovery import execute_recovery_playbook
|
||||||
|
body = {}
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
playbook_id = body.get("playbook_id", "")
|
||||||
|
confirmation = body.get("confirmation", "")
|
||||||
|
target = body.get("target")
|
||||||
|
principal = resolve_principal(request.headers)
|
||||||
|
request_id = getattr(request.state, "request_id", None)
|
||||||
|
result = execute_recovery_playbook(
|
||||||
|
playbook_id=playbook_id,
|
||||||
|
confirmation=confirmation,
|
||||||
|
target=target,
|
||||||
|
params=body,
|
||||||
|
principal=principal,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
status_code = 200 if result.get("success") else 400
|
||||||
|
return JSONResponse(result, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_recovery_verify(_request: Request) -> JSONResponse:
|
||||||
|
from webui.console_recovery import verify_post_recovery
|
||||||
|
verification = verify_post_recovery()
|
||||||
|
return JSONResponse(verification, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
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)))
|
||||||
@@ -331,6 +424,33 @@ async def api_runtime(_request: Request) -> JSONResponse:
|
|||||||
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
|
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_console_snapshot(request: Request):
|
||||||
|
"""Build the read-only restart snapshot for the requesting principal (#667)."""
|
||||||
|
principal = resolve_principal(request.headers)
|
||||||
|
restart_class = (
|
||||||
|
request.query_params.get("restart_class")
|
||||||
|
or restart_coordinator.RestartClass.FULL_MCP_RESTART.value
|
||||||
|
)
|
||||||
|
return load_restart_console_snapshot(
|
||||||
|
principal=principal, restart_class=restart_class
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_console_page(request: Request) -> HTMLResponse:
|
||||||
|
"""Restart status, impact preview, and approval state (#667). Read-only."""
|
||||||
|
snapshot = _restart_console_snapshot(request)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_page(
|
||||||
|
title="Restart", body_html=render_restart_console_page(snapshot)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_restart_status(request: Request) -> JSONResponse:
|
||||||
|
"""JSON export of the read-only restart console snapshot (#667)."""
|
||||||
|
return JSONResponse(_restart_console_snapshot(request).as_dict())
|
||||||
|
|
||||||
|
|
||||||
async def sessions(_request: Request) -> HTMLResponse:
|
async def sessions(_request: Request) -> HTMLResponse:
|
||||||
"""Runtime and session view (#641) — read-only composition of health + inventory."""
|
"""Runtime and session view (#641) — read-only composition of health + inventory."""
|
||||||
snapshot = load_session_view_snapshot()
|
snapshot = load_session_view_snapshot()
|
||||||
@@ -342,6 +462,41 @@ async def api_sessions(_request: Request) -> JSONResponse:
|
|||||||
return JSONResponse(session_view_snapshot_to_dict(load_session_view_snapshot()))
|
return JSONResponse(session_view_snapshot_to_dict(load_session_view_snapshot()))
|
||||||
|
|
||||||
|
|
||||||
|
def _linkage_snapshot(request: Request):
|
||||||
|
"""Load one linkage snapshot from the request's scope and focus parameters."""
|
||||||
|
return load_linkage_snapshot(
|
||||||
|
request.query_params.get("project") or None,
|
||||||
|
state=request.query_params.get("state"),
|
||||||
|
issue=_query_int(request, "issue"),
|
||||||
|
pr=_query_int(request, "pr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def gitea_linkage(request: Request) -> HTMLResponse:
|
||||||
|
"""Gitea issue↔PR linkage console (#645) — read-only.
|
||||||
|
|
||||||
|
Always 200, including on a failed read: this is an operator view, and it
|
||||||
|
must render *why* linkage could not be loaded rather than withhold the page.
|
||||||
|
The snapshot itself carries ``ok=False`` and the page refuses to draw a
|
||||||
|
linkage table it cannot stand behind.
|
||||||
|
"""
|
||||||
|
return HTMLResponse(render_linkage_page(_linkage_snapshot(request)))
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_gitea_linkage(request: Request) -> JSONResponse:
|
||||||
|
"""JSON export of the issue↔PR linkage model (#645).
|
||||||
|
|
||||||
|
Unlike the HTML view, the API answers with 502 when the snapshot could not
|
||||||
|
be loaded, so an automated consumer cannot read a fail-closed payload as a
|
||||||
|
successful "no links exist" result.
|
||||||
|
"""
|
||||||
|
snapshot = _linkage_snapshot(request)
|
||||||
|
return JSONResponse(
|
||||||
|
linkage_snapshot_to_dict(snapshot),
|
||||||
|
status_code=200 if snapshot.ok else 502,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _parse_audit_form(request: Request) -> tuple[str, str | None]:
|
async def _parse_audit_form(request: Request) -> tuple[str, str | None]:
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
return "", None
|
return "", None
|
||||||
@@ -739,6 +894,125 @@ async def api_v1_analytics_ingest(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def notifications_route(request: Request) -> HTMLResponse:
|
||||||
|
project_id = request.query_params.get("project_id")
|
||||||
|
attention_class = request.query_params.get("attention_class") or "inbox"
|
||||||
|
snap = load_notifications_snapshot(project_id)
|
||||||
|
html = render_notifications_page(
|
||||||
|
snap, filter_class=attention_class, filter_project=project_id
|
||||||
|
)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_notifications(request: Request) -> JSONResponse:
|
||||||
|
project_id = request.query_params.get("project_id")
|
||||||
|
snap = load_notifications_snapshot(project_id)
|
||||||
|
data = notifications_snapshot_to_dict(snap)
|
||||||
|
return JSONResponse(data)
|
||||||
|
|
||||||
|
def _default_request_scope() -> dict[str, str]:
|
||||||
|
"""Resolve remote/org/repo from the project registry for request forms.
|
||||||
|
|
||||||
|
Returns an empty mapping when the registry cannot be read, which makes
|
||||||
|
``parse_request`` reject a request that did not name its own scope rather
|
||||||
|
than letting it default to some other repository.
|
||||||
|
"""
|
||||||
|
from webui.queue_loader import _host_from_url # host normalisation helper
|
||||||
|
|
||||||
|
registry, error = _load_project_registry()
|
||||||
|
if error is not None or not registry.projects:
|
||||||
|
return {}
|
||||||
|
project = registry.projects[0]
|
||||||
|
host = _host_from_url(project.remote_host)
|
||||||
|
return {
|
||||||
|
"remote": _derive_remote(host),
|
||||||
|
"org": project.gitea_owner or "",
|
||||||
|
"repo": project.repo_name or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_payload(request: Request) -> dict[str, object]:
|
||||||
|
"""Read a request body as JSON or form-encoded. Never raises."""
|
||||||
|
content_type = (request.headers.get("content-type") or "").lower()
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return dict(body) if isinstance(body, dict) else {}
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {key: form[key] for key in form}
|
||||||
|
|
||||||
|
|
||||||
|
async def requests_page(request: Request) -> HTMLResponse:
|
||||||
|
"""Operator request form and intent preview (#643).
|
||||||
|
|
||||||
|
POST here only ever *previews*. Initiation is a separate confirmed call to
|
||||||
|
``/api/v1/requests/apply`` so that submitting this form cannot reserve
|
||||||
|
work as a side effect.
|
||||||
|
"""
|
||||||
|
submitted: dict[str, object] = {}
|
||||||
|
preview = None
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
submitted = await _request_payload(request)
|
||||||
|
work_request, error = request_service.parse_request(
|
||||||
|
submitted, default_scope=_default_request_scope()
|
||||||
|
)
|
||||||
|
if work_request is not None:
|
||||||
|
preview = request_service.preview_request(
|
||||||
|
work_request,
|
||||||
|
principal=resolve_principal(headers=dict(request.headers)),
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
render_requests_page(
|
||||||
|
preview=preview, error=error, submitted=submitted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_request_preview(request: Request) -> JSONResponse:
|
||||||
|
"""Dry-run authorization and intent preview for a work request (#643)."""
|
||||||
|
payload = await _request_payload(request)
|
||||||
|
work_request, error = request_service.parse_request(
|
||||||
|
payload, default_scope=_default_request_scope()
|
||||||
|
)
|
||||||
|
if work_request is None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=400)
|
||||||
|
preview = request_service.preview_request(
|
||||||
|
work_request,
|
||||||
|
principal=resolve_principal(headers=dict(request.headers)),
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
preview.to_dict(), status_code=200 if preview.authorized else 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def api_v1_request_apply(request: Request) -> JSONResponse:
|
||||||
|
"""Initiate a previewed work request through the allocator (#643).
|
||||||
|
|
||||||
|
Fail-closed at every step: unauthorized, unconfirmed, not-next-safe, and
|
||||||
|
already-claimed all return without attempting an assignment.
|
||||||
|
"""
|
||||||
|
payload = await _request_payload(request)
|
||||||
|
work_request, error = request_service.parse_request(
|
||||||
|
payload, default_scope=_default_request_scope()
|
||||||
|
)
|
||||||
|
if work_request is None:
|
||||||
|
return JSONResponse(error.to_dict(), status_code=400)
|
||||||
|
confirm = _truthy_flag(str(payload.get("confirm") or ""))
|
||||||
|
result = request_service.apply_request(
|
||||||
|
work_request,
|
||||||
|
principal=resolve_principal(headers=dict(request.headers)),
|
||||||
|
confirm=confirm,
|
||||||
|
)
|
||||||
|
status = int(result.pop("status_code", 403))
|
||||||
|
return JSONResponse(result, status_code=status)
|
||||||
|
|
||||||
|
|
||||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
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":
|
||||||
@@ -767,6 +1041,9 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
Route("/api/queue", api_queue, methods=["GET"]),
|
Route("/api/queue", api_queue, methods=["GET"]),
|
||||||
Route("/traffic", traffic, methods=["GET"]),
|
Route("/traffic", traffic, methods=["GET"]),
|
||||||
Route("/api/traffic", api_traffic, methods=["GET"]),
|
Route("/api/traffic", api_traffic, methods=["GET"]),
|
||||||
|
Route("/notifications", notifications_route, methods=["GET"]),
|
||||||
|
Route("/api/notifications", api_notifications, methods=["GET"]),
|
||||||
|
Route("/api/v1/notifications", api_notifications, 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"]),
|
||||||
@@ -781,10 +1058,19 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||||
Route("/runtime", runtime, methods=["GET"]),
|
Route("/runtime", runtime, methods=["GET"]),
|
||||||
Route("/api/runtime", api_runtime, methods=["GET"]),
|
Route("/api/runtime", api_runtime, methods=["GET"]),
|
||||||
|
# #667 read-only restart status / impact preview / approval state.
|
||||||
|
Route("/runtime/restart", restart_console_page, methods=["GET"]),
|
||||||
|
Route(
|
||||||
|
"/api/v1/system/restart/status",
|
||||||
|
api_restart_status,
|
||||||
|
methods=["GET"],
|
||||||
|
),
|
||||||
Route("/sessions", sessions, methods=["GET"]),
|
Route("/sessions", sessions, methods=["GET"]),
|
||||||
Route("/api/sessions", api_sessions, methods=["GET"]),
|
Route("/api/sessions", api_sessions, methods=["GET"]),
|
||||||
Route("/api/v1/sessions", api_sessions, methods=["GET"]),
|
Route("/api/v1/sessions", api_sessions, methods=["GET"]),
|
||||||
Route("/api/v1/timeline", api_v1_timeline, methods=["GET"]),
|
Route("/api/v1/timeline", api_v1_timeline, methods=["GET"]),
|
||||||
|
Route("/gitea", gitea_linkage, methods=["GET"]),
|
||||||
|
Route("/api/v1/gitea/linkage", api_v1_gitea_linkage, methods=["GET"]),
|
||||||
Route("/analytics", analytics, methods=["GET"]),
|
Route("/analytics", analytics, methods=["GET"]),
|
||||||
Route("/api/analytics", api_v1_analytics, methods=["GET"]),
|
Route("/api/analytics", api_v1_analytics, methods=["GET"]),
|
||||||
Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]),
|
Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]),
|
||||||
@@ -806,6 +1092,17 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
api_action_attempt,
|
api_action_attempt,
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
),
|
),
|
||||||
|
Route("/requests", requests_page, methods=["GET", "POST"]),
|
||||||
|
Route(
|
||||||
|
"/api/v1/requests/preview",
|
||||||
|
api_v1_request_preview,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/api/v1/requests/apply",
|
||||||
|
api_v1_request_apply,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
Route("/api/leases", api_leases, methods=["GET"]),
|
Route("/api/leases", api_leases, methods=["GET"]),
|
||||||
Route("/api/v1/inventory", api_inventory, methods=["GET"]),
|
Route("/api/v1/inventory", api_inventory, methods=["GET"]),
|
||||||
Route(
|
Route(
|
||||||
@@ -818,6 +1115,11 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
api_console_security_model,
|
api_console_security_model,
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
),
|
),
|
||||||
|
# #644 Phase 2 Recovery API routes
|
||||||
|
Route("/api/v1/system/recovery/diagnose", api_recovery_diagnose, methods=["GET"]),
|
||||||
|
Route("/api/v1/system/recovery/preview", api_recovery_preview, methods=["POST", "GET"]),
|
||||||
|
Route("/api/v1/system/recovery/apply", api_recovery_apply, methods=["POST"]),
|
||||||
|
Route("/api/v1/system/recovery/verify", api_recovery_verify, methods=["POST", "GET"]),
|
||||||
*[
|
*[
|
||||||
Route(path, phase_stub, methods=["GET"])
|
Route(path, phase_stub, methods=["GET"])
|
||||||
for path in STUB_PAGES
|
for path in STUB_PAGES
|
||||||
|
|||||||
+105
-8
@@ -115,6 +115,12 @@ class ConsoleAction:
|
|||||||
break_glass: bool
|
break_glass: bool
|
||||||
phase: int
|
phase: int
|
||||||
summary: str
|
summary: str
|
||||||
|
# Opt-in switch for an action whose execution path is genuinely wired
|
||||||
|
# ahead of its phase becoming globally active (#643). Naming a variable
|
||||||
|
# here enables nothing on its own: the variable must also be set in the
|
||||||
|
# environment. An action that leaves this ``None`` can only execute once
|
||||||
|
# ACTIVE_PHASE reaches its phase, exactly as before.
|
||||||
|
execution_env_flag: str | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mcp_permission(self) -> str:
|
def mcp_permission(self) -> str:
|
||||||
@@ -277,6 +283,61 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
|
|||||||
phase=2,
|
phase=2,
|
||||||
summary="Restart one MCP namespace via the host supervisor.",
|
summary="Restart one MCP namespace via the host supervisor.",
|
||||||
),
|
),
|
||||||
|
# #644: Phase 2 recovery controls & playbooks.
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="system.clear_stale_binding",
|
||||||
|
task_key="clear_stale_binding",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Clear provably stale or superseded GITEA_ACTIVE_WORKTREE binding.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="system.rebind_session_worktree",
|
||||||
|
task_key="rebind_session_worktree",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Rebind session worktree context to verified lease worktree.",
|
||||||
|
),
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="system.reconcile_cleanups",
|
||||||
|
task_key="reconcile_cleanups",
|
||||||
|
action_class=CLASS_PRIVILEGED,
|
||||||
|
minimum_role=CONTROLLER,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary="Run reconciler cleanup for merged or superseded PR branches.",
|
||||||
|
),
|
||||||
|
# #643: submit a work request — desired role, issue/PR, intent — and let
|
||||||
|
# the allocator reserve it. This is the one Phase 2 action whose execution
|
||||||
|
# path is actually implemented (``webui.request_service``), so it carries
|
||||||
|
# the opt-in flag; it stays denied until an operator sets that variable.
|
||||||
|
# Authority is operator-class because the outcome is a claim, not a Gitea
|
||||||
|
# verdict: initiating reviewer or merger *work* does not grant the right
|
||||||
|
# to approve or merge, which stays with the MCP role profile.
|
||||||
|
ConsoleAction(
|
||||||
|
action_id="initiate_workflow",
|
||||||
|
task_key="allocate_next_work",
|
||||||
|
action_class=CLASS_WRITE,
|
||||||
|
minimum_role=OPERATOR,
|
||||||
|
requires_confirmation=True,
|
||||||
|
dual_control=False,
|
||||||
|
break_glass=False,
|
||||||
|
phase=2,
|
||||||
|
summary=(
|
||||||
|
"Preview and initiate allocator-owned workflow work for a role."
|
||||||
|
),
|
||||||
|
execution_env_flag="WEBUI_REQUESTS_EXECUTION",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
ACTIONS: dict[str, ConsoleAction] = {a.action_id: a for a in _ACTION_SPECS}
|
||||||
@@ -430,6 +491,33 @@ ALLOW_PREVIEW = "allowed_preview_only"
|
|||||||
# gated on this model landing; nothing here enables it.
|
# gated on this model landing; nothing here enables it.
|
||||||
ACTIVE_PHASE = 1
|
ACTIVE_PHASE = 1
|
||||||
|
|
||||||
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||||
|
|
||||||
|
|
||||||
|
def execution_wired(
|
||||||
|
action: ConsoleAction | None, env: dict[str, str] | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Whether *action* has a live execution path right now.
|
||||||
|
|
||||||
|
Two ways to be wired, and only two. The action's phase is active, or the
|
||||||
|
action declares an opt-in environment variable *and* that variable is set.
|
||||||
|
Everything else — including every action that never declares a flag — is
|
||||||
|
unwired, so the default across the registry stays deny.
|
||||||
|
|
||||||
|
Bumping ``ACTIVE_PHASE`` would enable execution for every action of that
|
||||||
|
phase at once. The per-action flag exists so a single implemented action
|
||||||
|
can go live without dragging its unimplemented phase-mates with it.
|
||||||
|
"""
|
||||||
|
if action is None:
|
||||||
|
return False
|
||||||
|
if action.phase <= ACTIVE_PHASE:
|
||||||
|
return True
|
||||||
|
flag = (action.execution_env_flag or "").strip()
|
||||||
|
if not flag:
|
||||||
|
return False
|
||||||
|
source = env if env is not None else os.environ
|
||||||
|
return (source.get(flag) or "").strip().lower() in _TRUTHY
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AuthorizationDecision:
|
class AuthorizationDecision:
|
||||||
@@ -469,16 +557,19 @@ def authorize(
|
|||||||
principal: Principal | None = None,
|
principal: Principal | None = None,
|
||||||
*,
|
*,
|
||||||
for_execution: bool = False,
|
for_execution: bool = False,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
) -> AuthorizationDecision:
|
) -> AuthorizationDecision:
|
||||||
"""Decide whether *principal* may invoke *action_id*. Deny by default.
|
"""Decide whether *principal* may invoke *action_id*. Deny by default.
|
||||||
|
|
||||||
``for_execution`` distinguishes a read-only preview from a real invocation.
|
``for_execution`` distinguishes a read-only preview from a real invocation.
|
||||||
Even an allowed decision reports ``execution_enabled=False`` while the
|
``execution_enabled`` reports whether the action has a live execution path
|
||||||
console is in Phase 1, so no caller can read an allow as permission to
|
at all (:func:`execution_wired`) — for every action without an explicit
|
||||||
mutate.
|
opt-in flag that stays ``False`` while the console is in Phase 1, so no
|
||||||
|
caller can read an allow as permission to mutate.
|
||||||
"""
|
"""
|
||||||
who = principal if principal is not None else ANONYMOUS
|
who = principal if principal is not None else ANONYMOUS
|
||||||
action = get_action(action_id)
|
action = get_action(action_id)
|
||||||
|
wired = execution_wired(action, env)
|
||||||
|
|
||||||
if action is None:
|
if action is None:
|
||||||
return AuthorizationDecision(
|
return AuthorizationDecision(
|
||||||
@@ -497,7 +588,7 @@ def authorize(
|
|||||||
"requires_confirmation": action.requires_confirmation,
|
"requires_confirmation": action.requires_confirmation,
|
||||||
"dual_control": action.dual_control,
|
"dual_control": action.dual_control,
|
||||||
"break_glass": action.break_glass,
|
"break_glass": action.break_glass,
|
||||||
"execution_enabled": False,
|
"execution_enabled": wired,
|
||||||
}
|
}
|
||||||
|
|
||||||
if not who.authenticated:
|
if not who.authenticated:
|
||||||
@@ -530,13 +621,19 @@ def authorize(
|
|||||||
**base,
|
**base,
|
||||||
)
|
)
|
||||||
|
|
||||||
if for_execution and action.phase > ACTIVE_PHASE:
|
if for_execution and not wired:
|
||||||
return AuthorizationDecision(
|
return AuthorizationDecision(
|
||||||
allowed=False,
|
allowed=False,
|
||||||
reason_code=DENY_PHASE_NOT_ACTIVE,
|
reason_code=DENY_PHASE_NOT_ACTIVE,
|
||||||
detail=(
|
detail=(
|
||||||
f"Action {action_id!r} belongs to phase {action.phase}; the "
|
f"Action {action_id!r} belongs to phase {action.phase}; the "
|
||||||
f"console is in phase {ACTIVE_PHASE}. Execution is not wired."
|
f"console is in phase {ACTIVE_PHASE}"
|
||||||
|
+ (
|
||||||
|
f" and {action.execution_env_flag} is not set"
|
||||||
|
if action.execution_env_flag
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ ". Execution is not wired."
|
||||||
),
|
),
|
||||||
**base,
|
**base,
|
||||||
)
|
)
|
||||||
@@ -545,8 +642,8 @@ def authorize(
|
|||||||
allowed=True,
|
allowed=True,
|
||||||
reason_code=ALLOW_PREVIEW,
|
reason_code=ALLOW_PREVIEW,
|
||||||
detail=(
|
detail=(
|
||||||
"Principal holds the required role. Preview only — execution "
|
"Principal holds the required role. Execution proceeds only for an "
|
||||||
"remains disabled until the Phase 2 action framework ships."
|
"action with a wired execution path; everything else is preview."
|
||||||
),
|
),
|
||||||
**base,
|
**base,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,721 @@
|
|||||||
|
"""Web Console Phase 2 Recovery Controls & Playbooks (#644).
|
||||||
|
|
||||||
|
Provides canonical recovery controls for the web console:
|
||||||
|
1. Diagnosis: Surfaces stale runtimes, worktree binding errors, contamination markers,
|
||||||
|
and un-reconciled cleanups.
|
||||||
|
2. Gated Actions & Playbooks: Guided recovery (rebind session worktree, clear stale
|
||||||
|
binding, trigger reconciler cleanups, sanctioned restart).
|
||||||
|
3. RBAC, Contamination (#630), and Master Parity (#610) integration.
|
||||||
|
4. Audit trail via ``console_audit`` and mandatory post-recovery revalidation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import master_parity_gate
|
||||||
|
import runtime_recovery_guard
|
||||||
|
import stale_binding_recovery
|
||||||
|
from webui import console_audit, console_authz, sanctioned_restart, system_health, worktree_scanner
|
||||||
|
|
||||||
|
# --- Recovery Statuses ------------------------------------------------------
|
||||||
|
STATUS_HEALTHY = "healthy"
|
||||||
|
STATUS_ACTION_REQUIRED = "action_required"
|
||||||
|
STATUS_BLOCKED_CONTAMINATION = "blocked_contamination"
|
||||||
|
STATUS_RECONNECT_REQUIRED = "reconnect_required"
|
||||||
|
|
||||||
|
# --- Playbook Identifiers ---------------------------------------------------
|
||||||
|
PLAYBOOK_CLEAR_STALE_BINDING = "clear_stale_binding"
|
||||||
|
PLAYBOOK_REBIND_SESSION = "rebind_session_worktree"
|
||||||
|
PLAYBOOK_RECONCILE_CLEANUPS = "reconcile_cleanups"
|
||||||
|
PLAYBOOK_SANCTIONED_RESTART = "sanctioned_restart"
|
||||||
|
|
||||||
|
KNOWN_PLAYBOOKS: tuple[str, ...] = (
|
||||||
|
PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
PLAYBOOK_REBIND_SESSION,
|
||||||
|
PLAYBOOK_RECONCILE_CLEANUPS,
|
||||||
|
PLAYBOOK_SANCTIONED_RESTART,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Console Action Mapping -------------------------------------------------
|
||||||
|
ACTION_CLEAR_STALE_BINDING = "system.clear_stale_binding"
|
||||||
|
ACTION_REBIND_SESSION = "system.rebind_session_worktree"
|
||||||
|
ACTION_RECONCILE_CLEANUPS = "system.reconcile_cleanups"
|
||||||
|
|
||||||
|
PLAYBOOK_ACTIONS: dict[str, str] = {
|
||||||
|
PLAYBOOK_CLEAR_STALE_BINDING: ACTION_CLEAR_STALE_BINDING,
|
||||||
|
PLAYBOOK_REBIND_SESSION: ACTION_REBIND_SESSION,
|
||||||
|
PLAYBOOK_RECONCILE_CLEANUPS: ACTION_RECONCILE_CLEANUPS,
|
||||||
|
PLAYBOOK_SANCTIONED_RESTART: sanctioned_restart.ACTION_RESTART_NAMESPACE,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Task key handed to :func:`runtime_recovery_guard.assess_contamination_gate`.
|
||||||
|
#: A console *action id* is not a task name and is not a member of
|
||||||
|
#: ``CONTAMINATION_GATED_TASKS``, so passing one left the #630 gate inert. Every
|
||||||
|
#: writing recovery playbook shares this one gated task key; the reconciler
|
||||||
|
#: cleanup playbook is exempted separately because it is the designated remedy.
|
||||||
|
CONTAMINATION_GATED_TASK = "console_recovery_apply"
|
||||||
|
|
||||||
|
#: Remote whose contamination markers govern this console. Markers are written
|
||||||
|
#: per remote, so reading the wrong one reports a contaminated runtime clean.
|
||||||
|
REMOTE_ENV = "WEBUI_GITEA_REMOTE"
|
||||||
|
DEFAULT_REMOTE = "prgs"
|
||||||
|
|
||||||
|
|
||||||
|
def _console_remote(env: dict[str, str] | None = None) -> str:
|
||||||
|
env_map = env if env is not None else os.environ
|
||||||
|
return (env_map.get(REMOTE_ENV) or "").strip() or DEFAULT_REMOTE
|
||||||
|
|
||||||
|
|
||||||
|
def load_active_contamination_marker(
|
||||||
|
remote: str | None = None, env: dict[str, str] | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return the live #630 contamination marker payload, or ``None``.
|
||||||
|
|
||||||
|
The gate is only meaningful when it is fed a real marker: with
|
||||||
|
``marker=None`` :func:`assess_contamination_gate` returns ``block: False``
|
||||||
|
on its first statement. The #641 session inventory already reads the durable
|
||||||
|
markers, so reuse that reader rather than adding a second source of truth.
|
||||||
|
Never raises into a diagnosis or execution path.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from webui import session_loader
|
||||||
|
except Exception: # noqa: BLE001 — never break recovery on an import problem
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
markers = session_loader._load_contamination_markers(
|
||||||
|
remote=remote or _console_remote(env)
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — fail soft; the caller degrades to no marker
|
||||||
|
return None
|
||||||
|
for marker in markers:
|
||||||
|
payload = marker.to_dict()
|
||||||
|
if payload.get("active"):
|
||||||
|
return payload
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _active_binding(env_map: Any) -> str | None:
|
||||||
|
"""Read the live worktree binding so a no-op recovery cannot report success."""
|
||||||
|
value = env_map.get(stale_binding_recovery.ACTIVE_WORKTREE_ENV)
|
||||||
|
return value if value else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecoveryLedgerEntry:
|
||||||
|
"""One planned recovery step displayed before execution."""
|
||||||
|
|
||||||
|
sequence: int
|
||||||
|
step: str
|
||||||
|
summary: str
|
||||||
|
executes_process_kill: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlaybookDescriptor:
|
||||||
|
"""Structured recovery playbook option returned during diagnosis."""
|
||||||
|
|
||||||
|
playbook_id: str
|
||||||
|
label: str
|
||||||
|
action_id: str
|
||||||
|
description: str
|
||||||
|
eligible: bool
|
||||||
|
requires_confirmation: bool
|
||||||
|
reason: str
|
||||||
|
params_schema: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RecoveryDiagnosis:
|
||||||
|
"""Complete diagnostic snapshot of control-plane recovery needs."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
clean: bool
|
||||||
|
stale_runtime: dict[str, Any]
|
||||||
|
master_parity: dict[str, Any]
|
||||||
|
stale_binding: dict[str, Any]
|
||||||
|
contamination: dict[str, Any]
|
||||||
|
worktree_anomalies: tuple[str, ...]
|
||||||
|
playbooks: tuple[PlaybookDescriptor, ...]
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root(custom_path: Path | str | None = None) -> Path:
|
||||||
|
if custom_path:
|
||||||
|
return Path(custom_path).resolve()
|
||||||
|
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).resolve()
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def confirmation_phrase(playbook_id: str, target: str | None = None) -> str:
|
||||||
|
"""Construct exact confirmation phrase required for a recovery playbook."""
|
||||||
|
clean_target = (target or "").strip()
|
||||||
|
if clean_target:
|
||||||
|
return f"confirm {playbook_id} {clean_target}"
|
||||||
|
return f"confirm {playbook_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def confirmation_matches(
|
||||||
|
playbook_id: str, confirmation: str | None, target: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
expected = confirmation_phrase(playbook_id, target)
|
||||||
|
return str(confirmation or "").strip() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ledger(
|
||||||
|
playbook_id: str, target: str | None = None
|
||||||
|
) -> tuple[RecoveryLedgerEntry, ...]:
|
||||||
|
if playbook_id == PLAYBOOK_CLEAR_STALE_BINDING:
|
||||||
|
return (
|
||||||
|
RecoveryLedgerEntry(1, "quiesce", "Stop admitting new gated mutations."),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
2,
|
||||||
|
"clear_env",
|
||||||
|
f"Remove stale env binding GITEA_ACTIVE_WORKTREE ({target or 'active'}).",
|
||||||
|
),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
3, "audit", "Record clear_stale_binding event in console audit log."
|
||||||
|
),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
4, "revalidate", "Re-run diagnosis to verify clean binding state."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if playbook_id == PLAYBOOK_REBIND_SESSION:
|
||||||
|
return (
|
||||||
|
RecoveryLedgerEntry(1, "quiesce", "Stop admitting new gated mutations."),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
2,
|
||||||
|
"rebind_worktree",
|
||||||
|
f"Rebind session worktree context safely to {target or 'target worktree'}.",
|
||||||
|
),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
3, "audit", "Record rebind_session_worktree event in console audit log."
|
||||||
|
),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
4, "revalidate", "Re-run diagnosis to verify worktree binding state."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if playbook_id == PLAYBOOK_RECONCILE_CLEANUPS:
|
||||||
|
return (
|
||||||
|
RecoveryLedgerEntry(1, "quiesce", "Stop admitting new gated mutations."),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
2,
|
||||||
|
"reconcile_cleanups",
|
||||||
|
"Execute sanctioned reconciler cleanup for merged or superseded PRs.",
|
||||||
|
),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
3, "audit", "Record reconcile_cleanups event in console audit log."
|
||||||
|
),
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
4, "revalidate", "Re-run worktree scanner to verify clean tree."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if playbook_id == PLAYBOOK_SANCTIONED_RESTART:
|
||||||
|
restart_ledger = sanctioned_restart._mutation_ledger(
|
||||||
|
target or "gitea-author", sanctioned_restart.MODE_RESTART
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
RecoveryLedgerEntry(
|
||||||
|
sequence=e.sequence,
|
||||||
|
step=e.step,
|
||||||
|
summary=e.summary,
|
||||||
|
executes_process_kill=e.executes_process_kill,
|
||||||
|
)
|
||||||
|
for e in restart_ledger
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
RecoveryLedgerEntry(1, "unspecified", f"Execute recovery playbook {playbook_id}."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose_recovery(
|
||||||
|
repo_path: Path | str | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
active_worktree_val: str | None = None,
|
||||||
|
session_lease_wt: str | None = None,
|
||||||
|
role_kind: str | None = None,
|
||||||
|
) -> RecoveryDiagnosis:
|
||||||
|
"""Run full control-plane diagnostics to determine recovery needs and options."""
|
||||||
|
root = _repo_root(repo_path)
|
||||||
|
source_env = dict(env) if env is not None else dict(os.environ)
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
# 1. Stale runtime assessment
|
||||||
|
stale_runtime_obj = system_health.assess_stale_runtime(root)
|
||||||
|
stale_runtime_dict = {
|
||||||
|
"daemon_head": stale_runtime_obj.daemon_head,
|
||||||
|
"checkout_head": stale_runtime_obj.checkout_head,
|
||||||
|
"remote_head": stale_runtime_obj.remote_head,
|
||||||
|
"stale": stale_runtime_obj.stale,
|
||||||
|
"determinable": stale_runtime_obj.determinable,
|
||||||
|
"mutation_safe": stale_runtime_obj.mutation_safe,
|
||||||
|
"reasons": list(stale_runtime_obj.reasons),
|
||||||
|
}
|
||||||
|
if stale_runtime_obj.stale:
|
||||||
|
reasons.append("Runtime HEAD disagrees with checkout/remote HEAD.")
|
||||||
|
|
||||||
|
# 2. Master parity assessment
|
||||||
|
#
|
||||||
|
# The baseline is the commit the *running process* started at, which is what
|
||||||
|
# the parity gate is about. Capturing it from ``checkout_head`` and then
|
||||||
|
# comparing it against that same value made ``in_parity`` structurally
|
||||||
|
# incapable of being false. ``live_remote_head`` restores the #610
|
||||||
|
# live-remote dimension, which was previously dropped.
|
||||||
|
checkout_head = stale_runtime_obj.checkout_head
|
||||||
|
startup_dict = master_parity_gate.capture_startup_parity(
|
||||||
|
str(root), head=stale_runtime_obj.daemon_head
|
||||||
|
)
|
||||||
|
parity_dict = master_parity_gate.assess_master_parity(
|
||||||
|
startup_dict, checkout_head, stale_runtime_obj.remote_head
|
||||||
|
)
|
||||||
|
if not parity_dict.get("in_parity", True):
|
||||||
|
reasons.append("Repository is not in master parity.")
|
||||||
|
|
||||||
|
# 3. Worktree binding classification
|
||||||
|
boot_bindings = stale_binding_recovery.snapshot_boot_bindings(source_env)
|
||||||
|
active_val = (
|
||||||
|
active_worktree_val
|
||||||
|
if active_worktree_val is not None
|
||||||
|
else source_env.get(stale_binding_recovery.ACTIVE_WORKTREE_ENV)
|
||||||
|
)
|
||||||
|
boot_inherited = bool(boot_bindings.get("active_worktree") and active_val == boot_bindings.get("active_worktree"))
|
||||||
|
|
||||||
|
path_exists = None
|
||||||
|
if active_val:
|
||||||
|
path_exists = os.path.exists(os.path.realpath(active_val))
|
||||||
|
|
||||||
|
binding_class = stale_binding_recovery.classify_active_worktree_binding(
|
||||||
|
active_value=active_val,
|
||||||
|
session_lease_worktree=session_lease_wt,
|
||||||
|
boot_inherited=boot_inherited,
|
||||||
|
path_exists=path_exists,
|
||||||
|
role_kind=role_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
if binding_class.get("clear_eligible"):
|
||||||
|
reasons.append(
|
||||||
|
f"Active worktree binding is stale ({binding_class.get('classification')})."
|
||||||
|
)
|
||||||
|
elif binding_class.get("classification") == stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED:
|
||||||
|
reasons.append("Inherited worktree binding is unverified.")
|
||||||
|
|
||||||
|
# 4. Contamination assessment (#630)
|
||||||
|
#
|
||||||
|
# A real marker and a task key the gate actually gates on: with marker=None
|
||||||
|
# the gate short-circuits to ``block: False``, and with a console action id
|
||||||
|
# the task is outside CONTAMINATION_GATED_TASKS, so it could never block.
|
||||||
|
contamination_marker = load_active_contamination_marker(env=source_env)
|
||||||
|
contamination_dict = runtime_recovery_guard.assess_contamination_gate(
|
||||||
|
contamination_marker,
|
||||||
|
task=CONTAMINATION_GATED_TASK,
|
||||||
|
actual_role=role_kind,
|
||||||
|
)
|
||||||
|
contaminated = bool(contamination_dict.get("block"))
|
||||||
|
if contaminated:
|
||||||
|
reasons.append("Runtime is contaminated by manual process kill (#630).")
|
||||||
|
|
||||||
|
# 5. Worktree scanner hygiene & anomalies
|
||||||
|
hygiene = worktree_scanner.load_hygiene_snapshot(project_root=str(root))
|
||||||
|
worktree_anomalies = hygiene.anomalies
|
||||||
|
|
||||||
|
# Determine status & eligible playbooks
|
||||||
|
playbooks: list[PlaybookDescriptor] = []
|
||||||
|
|
||||||
|
# Playbook 1: Clear Stale Binding
|
||||||
|
clear_eligible = bool(binding_class.get("clear_eligible"))
|
||||||
|
playbooks.append(
|
||||||
|
PlaybookDescriptor(
|
||||||
|
playbook_id=PLAYBOOK_CLEAR_STALE_BINDING,
|
||||||
|
label="Clear Stale Worktree Binding",
|
||||||
|
action_id=ACTION_CLEAR_STALE_BINDING,
|
||||||
|
description="Clear provably stale or superseded GITEA_ACTIVE_WORKTREE environment binding.",
|
||||||
|
eligible=clear_eligible,
|
||||||
|
requires_confirmation=True,
|
||||||
|
reason=(
|
||||||
|
f"Binding classified as {binding_class.get('classification')}; clear is authorized."
|
||||||
|
if clear_eligible
|
||||||
|
else "Active worktree binding is clean, corroborated, or absent."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Playbook 2: Rebind Session Worktree
|
||||||
|
rebind_eligible = bool(
|
||||||
|
active_val
|
||||||
|
or binding_class.get("classification") == stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||||
|
)
|
||||||
|
playbooks.append(
|
||||||
|
PlaybookDescriptor(
|
||||||
|
playbook_id=PLAYBOOK_REBIND_SESSION,
|
||||||
|
label="Rebind Session Worktree",
|
||||||
|
action_id=ACTION_REBIND_SESSION,
|
||||||
|
description="Rebind or synchronize session worktree binding safely with active lease.",
|
||||||
|
eligible=rebind_eligible,
|
||||||
|
requires_confirmation=True,
|
||||||
|
reason=(
|
||||||
|
"Session worktree binding can be rebound to verified lease worktree."
|
||||||
|
if rebind_eligible
|
||||||
|
else "Session worktree is properly bound."
|
||||||
|
),
|
||||||
|
params_schema={"target_worktree": "string"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Playbook 3: Reconcile Cleanups
|
||||||
|
reconcile_eligible = bool(hygiene.anomalies or any(e.classification in {"stale-clean", "detached-review"} for e in hygiene.entries))
|
||||||
|
playbooks.append(
|
||||||
|
PlaybookDescriptor(
|
||||||
|
playbook_id=PLAYBOOK_RECONCILE_CLEANUPS,
|
||||||
|
label="Trigger Reconciler Cleanups",
|
||||||
|
action_id=ACTION_RECONCILE_CLEANUPS,
|
||||||
|
description="Run sanctioned reconciler cleanup preview and apply for merged/superseded PR branches.",
|
||||||
|
eligible=reconcile_eligible,
|
||||||
|
requires_confirmation=True,
|
||||||
|
reason=(
|
||||||
|
f"Worktree hygiene scanner detected {len(hygiene.anomalies)} anomalies and cleanups needed."
|
||||||
|
if reconcile_eligible
|
||||||
|
else "No reconciler cleanups pending."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Playbook 4: Sanctioned Restart
|
||||||
|
restart_eligible = bool(stale_runtime_obj.stale or contaminated)
|
||||||
|
playbooks.append(
|
||||||
|
PlaybookDescriptor(
|
||||||
|
playbook_id=PLAYBOOK_SANCTIONED_RESTART,
|
||||||
|
label="Sanctioned MCP Restart",
|
||||||
|
action_id=sanctioned_restart.ACTION_RESTART_NAMESPACE,
|
||||||
|
description="Restart MCP daemon via configured host supervisor without manual process kill.",
|
||||||
|
eligible=restart_eligible,
|
||||||
|
requires_confirmation=True,
|
||||||
|
reason=(
|
||||||
|
"Stale runtime or contamination detected; host supervisor restart available."
|
||||||
|
if restart_eligible
|
||||||
|
else "Runtime is healthy and clean."
|
||||||
|
),
|
||||||
|
params_schema={"namespace": "string", "mode": "restart|reload"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
clean = not reasons and not contaminated
|
||||||
|
if contaminated:
|
||||||
|
status = STATUS_BLOCKED_CONTAMINATION
|
||||||
|
elif reasons:
|
||||||
|
status = STATUS_ACTION_REQUIRED
|
||||||
|
else:
|
||||||
|
status = STATUS_HEALTHY
|
||||||
|
|
||||||
|
return RecoveryDiagnosis(
|
||||||
|
status=status,
|
||||||
|
clean=clean,
|
||||||
|
stale_runtime=stale_runtime_dict,
|
||||||
|
master_parity=parity_dict,
|
||||||
|
stale_binding=binding_class,
|
||||||
|
contamination=contamination_dict,
|
||||||
|
worktree_anomalies=tuple(worktree_anomalies),
|
||||||
|
playbooks=tuple(playbooks),
|
||||||
|
reasons=tuple(reasons),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_recovery_preview(
|
||||||
|
playbook_id: str,
|
||||||
|
target: str | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
principal: console_authz.Principal | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Generate dry-run preview & mutation ledger for a recovery playbook."""
|
||||||
|
if playbook_id not in KNOWN_PLAYBOOKS:
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"error": "unknown_playbook",
|
||||||
|
"detail": f"Playbook {playbook_id!r} is not a registered recovery playbook.",
|
||||||
|
}
|
||||||
|
|
||||||
|
action_id = PLAYBOOK_ACTIONS[playbook_id]
|
||||||
|
action = console_authz.get_action(action_id)
|
||||||
|
decision = console_authz.authorize(action_id, principal)
|
||||||
|
# Preview and apply must answer the same question. ``execution_enabled`` was
|
||||||
|
# a hardcoded False beside an authorization decision taken without
|
||||||
|
# ``for_execution``, so the preview could not tell an operator *why*
|
||||||
|
# execution was disabled — and the apply path did not ask at all.
|
||||||
|
execution_decision = console_authz.authorize(
|
||||||
|
action_id, principal, for_execution=True
|
||||||
|
)
|
||||||
|
phrase = confirmation_phrase(playbook_id, target)
|
||||||
|
ledger = _build_ledger(playbook_id, target)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"playbook_id": playbook_id,
|
||||||
|
"action_id": action_id,
|
||||||
|
"target": target,
|
||||||
|
"required_role": action.minimum_role if action else console_authz.OPERATOR,
|
||||||
|
"required_permission": action.mcp_permission if action else "gitea.read",
|
||||||
|
"requires_confirmation": True,
|
||||||
|
"confirmation_phrase": phrase,
|
||||||
|
"mutation_ledger": [asdict(entry) for entry in ledger],
|
||||||
|
"authorization": decision.to_dict(),
|
||||||
|
"execution_authorization": execution_decision.to_dict(),
|
||||||
|
"params": dict(params or {}),
|
||||||
|
"execution_enabled": bool(execution_decision.allowed),
|
||||||
|
"execution_blocked_reason": (
|
||||||
|
None if execution_decision.allowed else execution_decision.reason_code
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_recovery_playbook(
|
||||||
|
playbook_id: str,
|
||||||
|
confirmation: str | None = None,
|
||||||
|
target: str | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
principal: console_authz.Principal | None = None,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Gated execution of a recovery playbook with audit logging and revalidation."""
|
||||||
|
if playbook_id not in KNOWN_PLAYBOOKS:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"allowed": False,
|
||||||
|
"error": "unknown_playbook",
|
||||||
|
"detail": f"Playbook {playbook_id!r} is not known.",
|
||||||
|
}
|
||||||
|
|
||||||
|
action_id = PLAYBOOK_ACTIONS[playbook_id]
|
||||||
|
# The mapping the playbooks actually mutate. ``dict(os.environ)`` produced a
|
||||||
|
# throwaway copy: every env playbook wrote to it, verified against it, and
|
||||||
|
# left the running daemon bound to the value it claimed to have fixed.
|
||||||
|
mutation_env: Any = env if env is not None else os.environ
|
||||||
|
|
||||||
|
# 1. Authorization check — ``for_execution=True`` is what arms the phase
|
||||||
|
# gate (console_authz.authorize only applies it in that branch). Without it
|
||||||
|
# a phase-2 write executed while the console was in phase 1.
|
||||||
|
decision = console_authz.authorize(action_id, principal, for_execution=True)
|
||||||
|
if not decision.allowed:
|
||||||
|
console_audit.record_event(
|
||||||
|
action_id=action_id,
|
||||||
|
result=console_audit.RESULT_DENIED,
|
||||||
|
principal=principal,
|
||||||
|
target={"playbook_id": playbook_id, "target": target},
|
||||||
|
reason_code=decision.reason_code,
|
||||||
|
detail=decision.detail,
|
||||||
|
request_id=request_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"allowed": False,
|
||||||
|
"error": decision.reason_code,
|
||||||
|
"detail": decision.detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Confirmation phrase check
|
||||||
|
if not confirmation_matches(playbook_id, confirmation, target):
|
||||||
|
expected = confirmation_phrase(playbook_id, target)
|
||||||
|
detail = f"Confirmation phrase mismatch. Expected: {expected!r}"
|
||||||
|
console_audit.record_event(
|
||||||
|
action_id=action_id,
|
||||||
|
result=console_audit.RESULT_DENIED,
|
||||||
|
principal=principal,
|
||||||
|
target={"playbook_id": playbook_id, "target": target},
|
||||||
|
reason_code="confirmation_mismatch",
|
||||||
|
detail=detail,
|
||||||
|
request_id=request_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"allowed": False,
|
||||||
|
"error": "confirmation_mismatch",
|
||||||
|
"detail": detail,
|
||||||
|
"expected_confirmation_phrase": expected,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. Contamination rule (#630) check
|
||||||
|
role_str = principal.role if principal else None
|
||||||
|
contamination_marker = load_active_contamination_marker(env=env)
|
||||||
|
contam = runtime_recovery_guard.assess_contamination_gate(
|
||||||
|
contamination_marker,
|
||||||
|
task=CONTAMINATION_GATED_TASK,
|
||||||
|
actual_role=role_str,
|
||||||
|
)
|
||||||
|
if contam.get("block"):
|
||||||
|
if playbook_id != PLAYBOOK_RECONCILE_CLEANUPS:
|
||||||
|
detail = "Runtime is contaminated by a manual process kill (#630). Run reconciler cleanup playbook first."
|
||||||
|
console_audit.record_event(
|
||||||
|
action_id=action_id,
|
||||||
|
result=console_audit.RESULT_DENIED,
|
||||||
|
principal=principal,
|
||||||
|
target={"playbook_id": playbook_id, "target": target},
|
||||||
|
reason_code="contaminated_runtime",
|
||||||
|
detail=detail,
|
||||||
|
request_id=request_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"allowed": False,
|
||||||
|
"error": "contaminated_runtime",
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Execute playbook action
|
||||||
|
applied_result: dict[str, Any] = {"performed": False}
|
||||||
|
if playbook_id == PLAYBOOK_CLEAR_STALE_BINDING:
|
||||||
|
binding_before = _active_binding(mutation_env)
|
||||||
|
diagnosis = diagnose_recovery(env=env)
|
||||||
|
plan = stale_binding_recovery.plan_recovery(diagnosis.stale_binding)
|
||||||
|
applied_result = stale_binding_recovery.apply_recovery(plan, env=mutation_env)
|
||||||
|
binding_after = _active_binding(mutation_env)
|
||||||
|
applied_result = {
|
||||||
|
**applied_result,
|
||||||
|
"binding_before": binding_before,
|
||||||
|
"binding_after": binding_after,
|
||||||
|
"binding_changed": binding_before != binding_after,
|
||||||
|
}
|
||||||
|
# A clear that did not clear is not a success, whatever the plan said.
|
||||||
|
if not applied_result["binding_changed"]:
|
||||||
|
applied_result["performed"] = False
|
||||||
|
applied_result.setdefault("reasons", []).append(
|
||||||
|
"clear_stale_binding did not change the live worktree binding"
|
||||||
|
)
|
||||||
|
elif playbook_id == PLAYBOOK_REBIND_SESSION:
|
||||||
|
target_wt = target or (params or {}).get("target_worktree")
|
||||||
|
if target_wt:
|
||||||
|
binding_before = _active_binding(mutation_env)
|
||||||
|
mutation_env[stale_binding_recovery.ACTIVE_WORKTREE_ENV] = target_wt
|
||||||
|
binding_after = _active_binding(mutation_env)
|
||||||
|
applied_result = {
|
||||||
|
"performed": binding_after == target_wt,
|
||||||
|
"rebound_worktree": target_wt,
|
||||||
|
"binding_before": binding_before,
|
||||||
|
"binding_after": binding_after,
|
||||||
|
"binding_changed": binding_before != binding_after,
|
||||||
|
"cleared_stale": binding_before != binding_after,
|
||||||
|
}
|
||||||
|
if binding_after != target_wt:
|
||||||
|
applied_result["reasons"] = [
|
||||||
|
"rebind_session_worktree did not take effect on the live "
|
||||||
|
"environment"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
applied_result = {
|
||||||
|
"performed": False,
|
||||||
|
"reason": "No target_worktree specified for rebind.",
|
||||||
|
}
|
||||||
|
elif playbook_id == PLAYBOOK_RECONCILE_CLEANUPS:
|
||||||
|
# ``merged_cleanup_reconcile`` exposes the building blocks only; the
|
||||||
|
# orchestrator is the MCP tool. The previous call named a function that
|
||||||
|
# does not exist, and a bare ``except`` turned the AttributeError into a
|
||||||
|
# generic failure, so this playbook could never succeed. Imported lazily
|
||||||
|
# because the MCP server module is large and binds FastMCP at import.
|
||||||
|
try:
|
||||||
|
import gitea_mcp_server
|
||||||
|
|
||||||
|
snapshot = gitea_mcp_server.gitea_reconcile_merged_cleanups(
|
||||||
|
dry_run=False,
|
||||||
|
execute_confirmed=True,
|
||||||
|
remote=(params or {}).get("remote") or _console_remote(env),
|
||||||
|
org=(params or {}).get("org"),
|
||||||
|
repo=(params or {}).get("repo"),
|
||||||
|
)
|
||||||
|
performed_reconcile = bool(snapshot.get("success"))
|
||||||
|
applied_result = {
|
||||||
|
"performed": performed_reconcile,
|
||||||
|
"reconciled_count": len(snapshot.get("entries") or []),
|
||||||
|
"snapshot": snapshot,
|
||||||
|
}
|
||||||
|
if not performed_reconcile:
|
||||||
|
applied_result["reasons"] = list(snapshot.get("reasons") or [])
|
||||||
|
except Exception as exc: # noqa: BLE001 — surfaced with its type
|
||||||
|
applied_result = {
|
||||||
|
"performed": False,
|
||||||
|
"error": str(exc),
|
||||||
|
"error_type": type(exc).__name__,
|
||||||
|
}
|
||||||
|
elif playbook_id == PLAYBOOK_SANCTIONED_RESTART:
|
||||||
|
ns = target or (params or {}).get("namespace", "gitea-author")
|
||||||
|
md = (params or {}).get("mode", sanctioned_restart.MODE_RESTART)
|
||||||
|
restart_res = sanctioned_restart.execute_restart(
|
||||||
|
namespace=ns,
|
||||||
|
mode=md,
|
||||||
|
principal=principal,
|
||||||
|
confirmation=f"{md} {ns}",
|
||||||
|
# Without the marker the stricter guard at sanctioned_restart.py:375
|
||||||
|
# never fires and a restart can launder a contaminated runtime.
|
||||||
|
contamination_marker=contamination_marker,
|
||||||
|
env=mutation_env,
|
||||||
|
request_id=request_id,
|
||||||
|
session_id=session_id,
|
||||||
|
)
|
||||||
|
applied_result = restart_res
|
||||||
|
|
||||||
|
# ``allowed`` is not ``performed``: execute_restart documents that success is
|
||||||
|
# False in both directions because the host supervisor still has to act.
|
||||||
|
performed = bool(applied_result.get("performed"))
|
||||||
|
|
||||||
|
# 5. Record Audit Log
|
||||||
|
audit_record = console_audit.record_event(
|
||||||
|
action_id=action_id,
|
||||||
|
result=console_audit.RESULT_ALLOWED if performed else console_audit.RESULT_DENIED,
|
||||||
|
principal=principal,
|
||||||
|
target={"playbook_id": playbook_id, "target": target},
|
||||||
|
reason_code="recovery_executed" if performed else "recovery_failed",
|
||||||
|
detail=f"Executed recovery playbook {playbook_id}",
|
||||||
|
request_id=request_id,
|
||||||
|
session_id=session_id,
|
||||||
|
metadata={"applied_result": applied_result},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Post-recovery verification recheck.
|
||||||
|
#
|
||||||
|
# Re-read state rather than re-reading the mapping the mutation just wrote:
|
||||||
|
# verifying the mutated copy confirmed changes that never reached the
|
||||||
|
# process. Passing ``env`` through means a caller-supplied mapping is the
|
||||||
|
# live one for that caller, and ``None`` re-reads ``os.environ`` fresh.
|
||||||
|
post_verification = verify_post_recovery(env=env)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": performed,
|
||||||
|
"allowed": True,
|
||||||
|
"playbook_id": playbook_id,
|
||||||
|
"action_id": action_id,
|
||||||
|
"applied_result": applied_result,
|
||||||
|
"audit": audit_record,
|
||||||
|
"post_recovery_verification": post_verification,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_post_recovery(
|
||||||
|
repo_path: Path | str | None = None, env: dict[str, str] | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Revalidate control-plane state post-recovery before clean status."""
|
||||||
|
diag = diagnose_recovery(repo_path, env)
|
||||||
|
classification = diag.stale_binding.get("classification")
|
||||||
|
# ``not clear_eligible`` also reads clean for every binding recovery is not
|
||||||
|
# allowed to touch — an unverified inherited binding is unproven, not clean.
|
||||||
|
binding_clean = (
|
||||||
|
not diag.stale_binding.get("clear_eligible")
|
||||||
|
and classification != stale_binding_recovery.CLASSIFICATION_UNVERIFIED_INHERITED
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"clean": diag.clean,
|
||||||
|
"status": diag.status,
|
||||||
|
"stale_runtime_clean": not diag.stale_runtime.get("stale"),
|
||||||
|
"binding_clean": binding_clean,
|
||||||
|
"binding_classification": classification,
|
||||||
|
# The gate returns ``block``; it has never returned ``contaminated``, so
|
||||||
|
# reading that key reported every runtime clean unconditionally.
|
||||||
|
"contamination_clean": not diag.contamination.get("block"),
|
||||||
|
"anomalies_count": len(diag.worktree_anomalies),
|
||||||
|
"reasons": list(diag.reasons),
|
||||||
|
}
|
||||||
@@ -178,6 +178,13 @@ def build_action_registry() -> ActionRegistry:
|
|||||||
("system.restart_namespace", "Restart MCP namespace",
|
("system.restart_namespace", "Restart MCP namespace",
|
||||||
"restart_namespace", "host.supervisor_restart",
|
"restart_namespace", "host.supervisor_restart",
|
||||||
"Restart one MCP namespace via the host supervisor."),
|
"Restart one MCP namespace via the host supervisor."),
|
||||||
|
# #644: Phase 2 recovery playbooks & controls.
|
||||||
|
("system.clear_stale_binding", "Clear stale binding", "clear_stale_binding",
|
||||||
|
"console.clear_stale_binding", "Clear provably stale or superseded env binding."),
|
||||||
|
("system.rebind_session_worktree", "Rebind session worktree", "rebind_session_worktree",
|
||||||
|
"console.rebind_session_worktree", "Rebind session worktree to verified lease."),
|
||||||
|
("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups",
|
||||||
|
"console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."),
|
||||||
)
|
)
|
||||||
actions = tuple(
|
actions = tuple(
|
||||||
GatedAction(
|
GatedAction(
|
||||||
|
|||||||
@@ -0,0 +1,771 @@
|
|||||||
|
"""Gitea issue↔PR linkage model for the console (#645, Phase 3).
|
||||||
|
|
||||||
|
Operators lose context between an issue and the PR that closes it: which PR
|
||||||
|
carries which issue, whether two PRs claim the same issue, and what the latest
|
||||||
|
canonical handoff on that thread said. The evidence exists in Gitea, but only
|
||||||
|
as free text scattered across PR titles, bodies, and branch names.
|
||||||
|
|
||||||
|
This module resolves that linkage into one read-only model:
|
||||||
|
|
||||||
|
* :func:`resolve_linkage` is a pure function from raw Gitea issue/PR payloads to
|
||||||
|
a :class:`LinkageIndex`. It records *how* each edge was found (a ``Closes #N``
|
||||||
|
keyword, the canonical ``feat/issue-N-…`` branch marker, or a bare ``#N``
|
||||||
|
body reference) and never collapses several candidates into one silent guess.
|
||||||
|
* :func:`load_linkage_snapshot` scopes that index to a registry project and
|
||||||
|
optionally attaches the latest Canonical Thread Handoff (CTH) summary for one
|
||||||
|
focused issue or PR.
|
||||||
|
|
||||||
|
Design rules, matching the rest of the console:
|
||||||
|
|
||||||
|
- **Read-only.** Gitea is read through the shared authenticated helpers. No
|
||||||
|
endpoint here mutates anything, and no write action is registered.
|
||||||
|
- **Qualified absence.** Linkage is a claim about a *loaded* window of Gitea.
|
||||||
|
When pagination did not complete, when credentials were unavailable, or when
|
||||||
|
only open items were fetched, the snapshot says so and every "no linked PR"
|
||||||
|
is marked non-authoritative. An empty edge list from a partial read is not
|
||||||
|
evidence that no link exists.
|
||||||
|
- **Handoff is loaded, never assumed.** CTH comments are thread-scoped, so they
|
||||||
|
are fetched only for an explicitly focused issue or PR. Every other row
|
||||||
|
reports ``not_loaded`` rather than rendering as "no handoff".
|
||||||
|
- **Redaction at the boundary.** Titles, labels, handoff fields, and error
|
||||||
|
reasons are free text from Gitea and cross :mod:`webui.console_redaction`
|
||||||
|
before they leave this module.
|
||||||
|
- **Deep links are opt-in.** A link to the Gitea web UI is emitted only under
|
||||||
|
the ``GITEA_MCP_REVEAL_ENDPOINTS`` admin opt-in, exactly as the MCP tools
|
||||||
|
gate their own URL exposure.
|
||||||
|
|
||||||
|
Non-goals (from the issue): no issue/PR editor, no browser review or merge, no
|
||||||
|
reimplementation of Gitea search.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Callable, Iterable, Sequence
|
||||||
|
|
||||||
|
from gitea_auth import api_fetch_page, get_auth_header, gitea_url, repo_api_url
|
||||||
|
|
||||||
|
from webui import console_redaction
|
||||||
|
from webui.project_registry import ProjectRecord, load_registry
|
||||||
|
from webui.queue_loader import (
|
||||||
|
PaginationMeta,
|
||||||
|
_fetch_issues,
|
||||||
|
_fetch_prs,
|
||||||
|
_host_from_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Version of the serialized linkage contract. Bump on any breaking change.
|
||||||
|
LINKAGE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
# --- Linkage evidence -------------------------------------------------------
|
||||||
|
# Ordered strongest to weakest. The strength ordering is what makes an
|
||||||
|
# ambiguous PR detectable: two candidates at the same strength are a genuine
|
||||||
|
# ambiguity, while a weaker candidate alongside a stronger one is not.
|
||||||
|
EVIDENCE_CLOSES = "closes_keyword"
|
||||||
|
EVIDENCE_BRANCH = "branch_marker"
|
||||||
|
EVIDENCE_REFERENCE = "body_reference"
|
||||||
|
|
||||||
|
EVIDENCE_ORDER: tuple[str, ...] = (
|
||||||
|
EVIDENCE_CLOSES,
|
||||||
|
EVIDENCE_BRANCH,
|
||||||
|
EVIDENCE_REFERENCE,
|
||||||
|
)
|
||||||
|
_EVIDENCE_RANK = {name: rank for rank, name in enumerate(EVIDENCE_ORDER)}
|
||||||
|
|
||||||
|
EVIDENCE_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
EVIDENCE_CLOSES: (
|
||||||
|
"the PR title or body declares 'closes/fixes/resolves #N' — Gitea itself "
|
||||||
|
"acts on this keyword, so it is the strongest available evidence"
|
||||||
|
),
|
||||||
|
EVIDENCE_BRANCH: (
|
||||||
|
"the PR head branch carries the canonical issue marker "
|
||||||
|
"'(fix|feat|docs|chore)/issue-N-…' minted by the issue lock"
|
||||||
|
),
|
||||||
|
EVIDENCE_REFERENCE: (
|
||||||
|
"the PR body mentions '#N' without a closing keyword; a mention is not "
|
||||||
|
"a claim that the PR closes that issue"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_CLOSES_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.IGNORECASE)
|
||||||
|
_REFERENCE_RE = re.compile(r"#(\d+)")
|
||||||
|
_BRANCH_MARKER_RE = re.compile(
|
||||||
|
r"^(?:fix|feat|docs|chore)/issue-(\d+)(?:[-/]|$)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handoff-source states. ``not_loaded`` is deliberately distinct from "none
|
||||||
|
# found": a row whose comments were never fetched proves nothing about whether
|
||||||
|
# a handoff exists on that thread.
|
||||||
|
HANDOFF_NOT_LOADED = "not_loaded"
|
||||||
|
HANDOFF_LOADED = "loaded"
|
||||||
|
HANDOFF_UNAVAILABLE = "unavailable"
|
||||||
|
|
||||||
|
# Which item states were fetched. Linkage claims are scoped to this window.
|
||||||
|
STATE_OPEN = "open"
|
||||||
|
STATE_ALL = "all"
|
||||||
|
_SUPPORTED_STATES = (STATE_OPEN, STATE_ALL)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(value: Any) -> Any:
|
||||||
|
"""Redact one free-text field, failing closed to the placeholder."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return console_redaction.redact_text(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def deep_links_enabled(env: dict[str, str] | None = None) -> bool:
|
||||||
|
"""Whether Gitea web-UI deep links may be emitted (admin/debug opt-in)."""
|
||||||
|
source = env if env is not None else os.environ
|
||||||
|
return (source.get("GITEA_MCP_REVEAL_ENDPOINTS") or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_link(host: str, org: str, repo: str, kind: str, number: int) -> str | None:
|
||||||
|
"""Build a Gitea web link for one item, or None when reveal is not enabled."""
|
||||||
|
if not deep_links_enabled() or not (host and org and repo):
|
||||||
|
return None
|
||||||
|
segment = "pulls" if kind == "pr" else "issues"
|
||||||
|
try:
|
||||||
|
return gitea_url(host, f"/{org}/{repo}/{segment}/{int(number)}")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pure linkage resolution -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IssueLink:
|
||||||
|
"""One resolved edge from a PR to an issue, with the evidence that found it."""
|
||||||
|
|
||||||
|
issue_number: int
|
||||||
|
evidence: tuple[str, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strength(self) -> int:
|
||||||
|
"""Rank of the strongest evidence backing this edge (lower is stronger)."""
|
||||||
|
return min(
|
||||||
|
(_EVIDENCE_RANK.get(name, len(EVIDENCE_ORDER)) for name in self.evidence),
|
||||||
|
default=len(EVIDENCE_ORDER),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def closes(self) -> bool:
|
||||||
|
"""True only when the PR *declares* it closes the issue."""
|
||||||
|
return EVIDENCE_CLOSES in self.evidence
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"issue_number": self.issue_number,
|
||||||
|
"evidence": list(self.evidence),
|
||||||
|
"closes": self.closes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_links(links: Iterable[IssueLink]) -> tuple[IssueLink, ...]:
|
||||||
|
return tuple(sorted(links, key=lambda link: (link.strength, link.issue_number)))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pr_links(pr: dict[str, Any]) -> tuple[IssueLink, ...]:
|
||||||
|
"""Resolve every issue a PR points at, strongest evidence first.
|
||||||
|
|
||||||
|
Every candidate is kept. Collapsing to a single "linked issue" is what makes
|
||||||
|
a mislinked or double-claimed PR invisible, so the caller decides what to do
|
||||||
|
with several candidates rather than being handed one guess.
|
||||||
|
"""
|
||||||
|
found: dict[int, set[str]] = {}
|
||||||
|
|
||||||
|
def _add(number: Any, evidence: str) -> None:
|
||||||
|
try:
|
||||||
|
issue_number = int(number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if issue_number <= 0:
|
||||||
|
return
|
||||||
|
found.setdefault(issue_number, set()).add(evidence)
|
||||||
|
|
||||||
|
title = str(pr.get("title") or "")
|
||||||
|
body = str(pr.get("body") or "")
|
||||||
|
for text in (title, body):
|
||||||
|
for match in _CLOSES_RE.finditer(text):
|
||||||
|
_add(match.group(1), EVIDENCE_CLOSES)
|
||||||
|
|
||||||
|
head_ref = str((pr.get("head") or {}).get("ref") or "")
|
||||||
|
branch_match = _BRANCH_MARKER_RE.match(head_ref.strip())
|
||||||
|
if branch_match:
|
||||||
|
_add(branch_match.group(1), EVIDENCE_BRANCH)
|
||||||
|
|
||||||
|
# The ``#N`` inside "Closes #N" is the *same* textual occurrence as the
|
||||||
|
# closing keyword, not a second, independent mention. Blanking the closing
|
||||||
|
# phrases first keeps "mention" meaning what the legend says it means: a
|
||||||
|
# reference the PR made without claiming to close anything.
|
||||||
|
for match in _REFERENCE_RE.finditer(_CLOSES_RE.sub(" ", body)):
|
||||||
|
_add(match.group(1), EVIDENCE_REFERENCE)
|
||||||
|
|
||||||
|
# A PR's own number appearing in its body is self-reference, not linkage.
|
||||||
|
try:
|
||||||
|
found.pop(int(pr.get("number")), None)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return _sorted_links(
|
||||||
|
IssueLink(
|
||||||
|
issue_number=number,
|
||||||
|
evidence=tuple(name for name in EVIDENCE_ORDER if name in evidence),
|
||||||
|
)
|
||||||
|
for number, evidence in found.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LinkageIndex:
|
||||||
|
"""Resolved linkage over one loaded window of issues and PRs."""
|
||||||
|
|
||||||
|
pr_links: dict[int, tuple[IssueLink, ...]]
|
||||||
|
issue_prs: dict[int, tuple[int, ...]]
|
||||||
|
|
||||||
|
def primary_issue(self, pr_number: int) -> IssueLink | None:
|
||||||
|
"""The strongest edge for a PR, or None when it points at no issue."""
|
||||||
|
links = self.pr_links.get(int(pr_number)) or ()
|
||||||
|
return links[0] if links else None
|
||||||
|
|
||||||
|
def ambiguous(self, pr_number: int) -> bool:
|
||||||
|
"""True when two or more issues tie at the PR's strongest evidence."""
|
||||||
|
links = self.pr_links.get(int(pr_number)) or ()
|
||||||
|
if len(links) < 2:
|
||||||
|
return False
|
||||||
|
best = links[0].strength
|
||||||
|
return sum(1 for link in links if link.strength == best) > 1
|
||||||
|
|
||||||
|
def contested_issues(self) -> tuple[int, ...]:
|
||||||
|
"""Issues claimed by more than one PR in the loaded window."""
|
||||||
|
return tuple(
|
||||||
|
number for number, prs in sorted(self.issue_prs.items()) if len(prs) > 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_linkage(prs: Sequence[dict[str, Any]]) -> LinkageIndex:
|
||||||
|
"""Build the bidirectional linkage index for a loaded window of PRs.
|
||||||
|
|
||||||
|
Only *closing* and *branch-marker* edges populate the issue→PR direction: a
|
||||||
|
bare ``#N`` mention is a reference, and treating it as "this PR is the work
|
||||||
|
for issue N" would invent contested issues out of ordinary cross-links. The
|
||||||
|
weaker edge stays visible on the PR→issue side, where it is labelled.
|
||||||
|
"""
|
||||||
|
pr_links: dict[int, tuple[IssueLink, ...]] = {}
|
||||||
|
issue_prs: dict[int, list[int]] = {}
|
||||||
|
for pr in prs or []:
|
||||||
|
try:
|
||||||
|
pr_number = int(pr["number"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
links = resolve_pr_links(pr)
|
||||||
|
pr_links[pr_number] = links
|
||||||
|
for link in links:
|
||||||
|
if link.evidence == (EVIDENCE_REFERENCE,):
|
||||||
|
continue
|
||||||
|
bucket = issue_prs.setdefault(link.issue_number, [])
|
||||||
|
if pr_number not in bucket:
|
||||||
|
bucket.append(pr_number)
|
||||||
|
return LinkageIndex(
|
||||||
|
pr_links=pr_links,
|
||||||
|
issue_prs={number: tuple(sorted(items)) for number, items in issue_prs.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Canonical handoff summary ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HandoffSummary:
|
||||||
|
"""The latest CTH comment on one thread, redacted for display."""
|
||||||
|
|
||||||
|
comment_id: int | None
|
||||||
|
created_at: str | None
|
||||||
|
author: str | None
|
||||||
|
cth_type: str
|
||||||
|
cth_type_known: bool
|
||||||
|
status: str | None
|
||||||
|
next_owner: str | None
|
||||||
|
current_blocker: str | None
|
||||||
|
decision: str | None
|
||||||
|
next_action: str | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"comment_id": self.comment_id,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"author": self.author,
|
||||||
|
"cth_type": self.cth_type,
|
||||||
|
"cth_type_known": self.cth_type_known,
|
||||||
|
"status": self.status,
|
||||||
|
"next_owner": self.next_owner,
|
||||||
|
"current_blocker": self.current_blocker,
|
||||||
|
"decision": self.decision,
|
||||||
|
"next_action": self.next_action,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HandoffStatus:
|
||||||
|
"""Why a thread's handoff summary is present, absent, or unknown."""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
reason: str | None = None
|
||||||
|
target: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def loaded(self) -> bool:
|
||||||
|
return self.state == HANDOFF_LOADED
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"state": self.state, "reason": self.reason, "target": self.target}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_handoff(comments: Sequence[dict[str, Any]]) -> HandoffSummary | None:
|
||||||
|
"""Summarize the newest CTH comment in *comments*, or None when there is none.
|
||||||
|
|
||||||
|
Every field is redacted before it is returned: a handoff body is operator
|
||||||
|
free text that regularly quotes commands, and it is rendered verbatim on the
|
||||||
|
page this feeds.
|
||||||
|
"""
|
||||||
|
from canonical_thread_handoff import find_latest_cth, is_known_cth_type
|
||||||
|
|
||||||
|
try:
|
||||||
|
latest = find_latest_cth(list(comments or []))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not latest:
|
||||||
|
return None
|
||||||
|
fields = latest.get("fields") or {}
|
||||||
|
cth_type = str(latest.get("cth_type") or "").strip()
|
||||||
|
known = is_known_cth_type(cth_type)
|
||||||
|
try:
|
||||||
|
comment_id: int | None = int(latest.get("comment_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
comment_id = None
|
||||||
|
return HandoffSummary(
|
||||||
|
comment_id=comment_id,
|
||||||
|
created_at=_redact(latest.get("created_at")),
|
||||||
|
author=_redact(latest.get("author")),
|
||||||
|
# An unrecognised heading is reported as such rather than republished:
|
||||||
|
# the heading is free text, and CTH_TYPES is the only authority for what
|
||||||
|
# a handoff type may be.
|
||||||
|
cth_type=cth_type if known else "unrecognized",
|
||||||
|
cth_type_known=known,
|
||||||
|
status=_redact(fields.get("status")),
|
||||||
|
next_owner=_redact(fields.get("next owner")),
|
||||||
|
current_blocker=_redact(fields.get("current blocker")),
|
||||||
|
decision=_redact(fields.get("decision")),
|
||||||
|
next_action=_redact(fields.get("next action")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CommentSource = Callable[[str, int], list[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
def build_comment_source(host: str, org: str, repo: str) -> CommentSource | None:
|
||||||
|
"""Build an authenticated ``(kind, number) -> comments`` fetcher, or None.
|
||||||
|
|
||||||
|
Returns None when the console is running in offline test mode or when no
|
||||||
|
credential is available for *host*, so the caller reports the handoff source
|
||||||
|
as unavailable instead of as an empty thread.
|
||||||
|
"""
|
||||||
|
if _offline_test_mode() or not (host and org and repo):
|
||||||
|
return None
|
||||||
|
auth = get_auth_header(host)
|
||||||
|
if not auth:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fetch(kind: str, number: int) -> list[dict[str, Any]]:
|
||||||
|
segment = "pulls" if kind == "pr" else "issues"
|
||||||
|
url = f"{repo_api_url(host, org, repo)}/{segment}/{int(number)}/comments"
|
||||||
|
comments: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
while page <= 20:
|
||||||
|
raw, meta = api_fetch_page(url, auth, page=page, limit=50)
|
||||||
|
comments.extend(raw)
|
||||||
|
if bool(meta["is_final_page"]):
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return comments
|
||||||
|
|
||||||
|
return _fetch
|
||||||
|
|
||||||
|
|
||||||
|
# --- Snapshot ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LinkageNode:
|
||||||
|
"""One issue or PR row with its resolved links and display metadata."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
number: int
|
||||||
|
title: str
|
||||||
|
state: str
|
||||||
|
labels: tuple[str, ...] = ()
|
||||||
|
links: tuple[IssueLink, ...] = ()
|
||||||
|
linked_prs: tuple[int, ...] = ()
|
||||||
|
ambiguous: bool = False
|
||||||
|
contested: bool = False
|
||||||
|
deep_link: str | None = None
|
||||||
|
handoff: HandoffSummary | None = None
|
||||||
|
handoff_status: HandoffStatus = HandoffStatus(HANDOFF_NOT_LOADED)
|
||||||
|
links_authoritative: bool = True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"number": self.number,
|
||||||
|
"title": self.title,
|
||||||
|
"state": self.state,
|
||||||
|
"labels": list(self.labels),
|
||||||
|
"links": [link.to_dict() for link in self.links],
|
||||||
|
"linked_prs": list(self.linked_prs),
|
||||||
|
"ambiguous": self.ambiguous,
|
||||||
|
"contested": self.contested,
|
||||||
|
"deep_link": self.deep_link,
|
||||||
|
"links_authoritative": self.links_authoritative,
|
||||||
|
"handoff": self.handoff.to_dict() if self.handoff else None,
|
||||||
|
"handoff_status": self.handoff_status.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LinkageSnapshot:
|
||||||
|
"""One answered linkage query over a scoped window of a Gitea repo."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
project_id: str
|
||||||
|
repo_label: str
|
||||||
|
host: str
|
||||||
|
state_scope: str
|
||||||
|
issues: tuple[LinkageNode, ...] = ()
|
||||||
|
prs: tuple[LinkageNode, ...] = ()
|
||||||
|
contested_issues: tuple[int, ...] = ()
|
||||||
|
focus: tuple[str, int] | None = None
|
||||||
|
inventory_complete: bool = False
|
||||||
|
deep_links_enabled: bool = False
|
||||||
|
handoff_status: HandoffStatus = HandoffStatus(HANDOFF_NOT_LOADED)
|
||||||
|
fetch_error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def orphan_prs(self) -> tuple[LinkageNode, ...]:
|
||||||
|
"""PRs in the loaded window that point at no issue at all."""
|
||||||
|
return tuple(node for node in self.prs if not node.links)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": self.ok,
|
||||||
|
"schema_version": LINKAGE_SCHEMA_VERSION,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"repo": self.repo_label,
|
||||||
|
"state_scope": self.state_scope,
|
||||||
|
"inventory_complete": self.inventory_complete,
|
||||||
|
"deep_links_enabled": self.deep_links_enabled,
|
||||||
|
"focus": (
|
||||||
|
None
|
||||||
|
if self.focus is None
|
||||||
|
else {"kind": self.focus[0], "number": self.focus[1]}
|
||||||
|
),
|
||||||
|
"handoff_source": self.handoff_status.to_dict(),
|
||||||
|
"fetch_error": self.fetch_error,
|
||||||
|
"contested_issues": list(self.contested_issues),
|
||||||
|
"issues": [node.to_dict() for node in self.issues],
|
||||||
|
"prs": [node.to_dict() for node in self.prs],
|
||||||
|
"evidence_kinds": [
|
||||||
|
{"name": name, "description": EVIDENCE_DESCRIPTIONS[name]}
|
||||||
|
for name in EVIDENCE_ORDER
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_to_dict(snapshot: LinkageSnapshot) -> dict[str, Any]:
|
||||||
|
"""JSON-serializable export for ``/api/v1/gitea/linkage``."""
|
||||||
|
return snapshot.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _offline_test_mode() -> bool:
|
||||||
|
return (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _labels_of(item: dict[str, Any]) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
str(_redact(label.get("name")))
|
||||||
|
for label in (item.get("labels") or [])
|
||||||
|
if label.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_snapshot(
|
||||||
|
*,
|
||||||
|
project_id: str,
|
||||||
|
repo_label: str,
|
||||||
|
host: str,
|
||||||
|
state_scope: str,
|
||||||
|
reason: str,
|
||||||
|
) -> LinkageSnapshot:
|
||||||
|
"""A read that could not be answered. Never an empty-and-healthy snapshot."""
|
||||||
|
return LinkageSnapshot(
|
||||||
|
ok=False,
|
||||||
|
project_id=project_id,
|
||||||
|
repo_label=repo_label,
|
||||||
|
host=host,
|
||||||
|
state_scope=state_scope,
|
||||||
|
inventory_complete=False,
|
||||||
|
deep_links_enabled=deep_links_enabled(),
|
||||||
|
handoff_status=HandoffStatus(
|
||||||
|
HANDOFF_UNAVAILABLE, reason="linkage inventory could not be loaded"
|
||||||
|
),
|
||||||
|
fetch_error=str(_redact(reason)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project(project_id: str | None) -> ProjectRecord | None:
|
||||||
|
registry = load_registry()
|
||||||
|
if project_id:
|
||||||
|
for entry in registry.projects:
|
||||||
|
if entry.id == project_id:
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
return registry.projects[0] if registry.projects else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_state(state: str | None) -> str:
|
||||||
|
text = (state or STATE_OPEN).strip().lower()
|
||||||
|
return text if text in _SUPPORTED_STATES else STATE_OPEN
|
||||||
|
|
||||||
|
|
||||||
|
def load_linkage_snapshot(
|
||||||
|
project_id: str | None = None,
|
||||||
|
*,
|
||||||
|
state: str | None = None,
|
||||||
|
issue: int | None = None,
|
||||||
|
pr: int | None = None,
|
||||||
|
fetch_prs: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
|
||||||
|
fetch_issues: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
|
||||||
|
comment_source: CommentSource | None = None,
|
||||||
|
) -> LinkageSnapshot:
|
||||||
|
"""Load issue↔PR linkage for a registry project.
|
||||||
|
|
||||||
|
``issue``/``pr`` focus one thread: the focused row is the only one whose
|
||||||
|
Canonical Thread Handoff comments are fetched, because handoff comments are
|
||||||
|
thread-scoped and loading them for a whole queue would be one request per
|
||||||
|
row. Every unfocused row reports its handoff as ``not_loaded``.
|
||||||
|
"""
|
||||||
|
state_scope = _normalize_state(state)
|
||||||
|
try:
|
||||||
|
project = _resolve_project(project_id)
|
||||||
|
except Exception as exc: # registry invalid — fail closed with the reason
|
||||||
|
return _failed_snapshot(
|
||||||
|
project_id=project_id or "",
|
||||||
|
repo_label="",
|
||||||
|
host="",
|
||||||
|
state_scope=state_scope,
|
||||||
|
reason=f"project registry unavailable: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if project is None:
|
||||||
|
return _failed_snapshot(
|
||||||
|
project_id=project_id or "",
|
||||||
|
repo_label="",
|
||||||
|
host="",
|
||||||
|
state_scope=state_scope,
|
||||||
|
reason=(
|
||||||
|
f"project {project_id!r} not found in registry"
|
||||||
|
if project_id
|
||||||
|
else "no projects registered"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
host = _host_from_url(project.remote_host)
|
||||||
|
repo_label = f"{project.gitea_owner}/{project.repo_name}"
|
||||||
|
offline_test = _offline_test_mode()
|
||||||
|
|
||||||
|
def _empty_fetch(*_args, **_kwargs):
|
||||||
|
return [], PaginationMeta(
|
||||||
|
page=1,
|
||||||
|
per_page=50,
|
||||||
|
returned_count=0,
|
||||||
|
has_more=False,
|
||||||
|
is_final_page=True,
|
||||||
|
# An offline stub loaded nothing; claiming a complete inventory here
|
||||||
|
# would let the page assert that no issue has a linked PR.
|
||||||
|
inventory_complete=False,
|
||||||
|
pages_fetched=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
pr_fetch = fetch_prs or (_empty_fetch if offline_test else _fetch_prs)
|
||||||
|
issue_fetch = fetch_issues or (_empty_fetch if offline_test else _fetch_issues)
|
||||||
|
using_live_fetch = not offline_test and (fetch_prs is None or fetch_issues is None)
|
||||||
|
auth = get_auth_header(host) if using_live_fetch else "test-auth"
|
||||||
|
if using_live_fetch and not auth:
|
||||||
|
return _failed_snapshot(
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=repo_label,
|
||||||
|
host=host,
|
||||||
|
state_scope=state_scope,
|
||||||
|
reason=(
|
||||||
|
f"Gitea credentials unavailable for {host}; linkage cannot be "
|
||||||
|
"loaded (fail closed — not rendering an empty linkage table)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_prs, pr_pagination = pr_fetch(
|
||||||
|
host, project.gitea_owner, project.repo_name, auth, state=state_scope
|
||||||
|
)
|
||||||
|
raw_issues, issue_pagination = issue_fetch(
|
||||||
|
host, project.gitea_owner, project.repo_name, auth, state=state_scope
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — operator-visible fetch failure
|
||||||
|
return _failed_snapshot(
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=repo_label,
|
||||||
|
host=host,
|
||||||
|
state_scope=state_scope,
|
||||||
|
reason=f"Gitea fetch failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
inventory_complete = bool(
|
||||||
|
getattr(pr_pagination, "inventory_complete", False)
|
||||||
|
and getattr(issue_pagination, "inventory_complete", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
index = resolve_linkage(raw_prs)
|
||||||
|
contested = index.contested_issues()
|
||||||
|
|
||||||
|
focus: tuple[str, int] | None = None
|
||||||
|
if pr is not None:
|
||||||
|
focus = ("pr", int(pr))
|
||||||
|
elif issue is not None:
|
||||||
|
focus = ("issue", int(issue))
|
||||||
|
|
||||||
|
unfocused_reason = (
|
||||||
|
"canonical handoff comments are thread-scoped; focus one issue or PR "
|
||||||
|
"to load its latest handoff"
|
||||||
|
)
|
||||||
|
handoff_status = HandoffStatus(HANDOFF_NOT_LOADED, reason=unfocused_reason)
|
||||||
|
focus_handoff: HandoffSummary | None = None
|
||||||
|
if focus is not None:
|
||||||
|
source = comment_source
|
||||||
|
if source is None and not offline_test:
|
||||||
|
source = build_comment_source(host, project.gitea_owner, project.repo_name)
|
||||||
|
target = f"{focus[0]}#{focus[1]}"
|
||||||
|
if source is None:
|
||||||
|
handoff_status = HandoffStatus(
|
||||||
|
HANDOFF_UNAVAILABLE,
|
||||||
|
reason="no authenticated comment source available for this read",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
focus_handoff = summarize_handoff(source(focus[0], focus[1]) or [])
|
||||||
|
handoff_status = HandoffStatus(HANDOFF_LOADED, target=target)
|
||||||
|
except Exception as exc: # fail soft: degrade this source only
|
||||||
|
handoff_status = HandoffStatus(
|
||||||
|
HANDOFF_UNAVAILABLE,
|
||||||
|
reason=str(_redact(f"handoff fetch failed: {exc}")),
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _node_handoff(
|
||||||
|
kind: str, number: int
|
||||||
|
) -> tuple[HandoffSummary | None, HandoffStatus]:
|
||||||
|
"""Attach the handoff only to the focused row; qualify every other row."""
|
||||||
|
if focus == (kind, number):
|
||||||
|
return (focus_handoff, handoff_status)
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
HandoffStatus(
|
||||||
|
HANDOFF_NOT_LOADED,
|
||||||
|
reason=unfocused_reason if focus is None else "not the focused thread",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _number_of(raw: dict[str, Any]) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(raw["number"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sort_key(raw: dict[str, Any]) -> int:
|
||||||
|
number = _number_of(raw)
|
||||||
|
return -1 if number is None else number
|
||||||
|
|
||||||
|
issue_nodes: list[LinkageNode] = []
|
||||||
|
for raw in sorted(raw_issues or [], key=_sort_key, reverse=True):
|
||||||
|
number = _number_of(raw)
|
||||||
|
if number is None:
|
||||||
|
continue
|
||||||
|
node_handoff, node_status = _node_handoff("issue", number)
|
||||||
|
linked_prs = index.issue_prs.get(number, ())
|
||||||
|
issue_nodes.append(
|
||||||
|
LinkageNode(
|
||||||
|
kind="issue",
|
||||||
|
number=number,
|
||||||
|
title=str(_redact(raw.get("title")) or ""),
|
||||||
|
state=str(raw.get("state") or ""),
|
||||||
|
labels=_labels_of(raw),
|
||||||
|
linked_prs=linked_prs,
|
||||||
|
contested=len(linked_prs) > 1,
|
||||||
|
deep_link=_deep_link(
|
||||||
|
host, project.gitea_owner, project.repo_name, "issue", number
|
||||||
|
),
|
||||||
|
handoff=node_handoff,
|
||||||
|
handoff_status=node_status,
|
||||||
|
links_authoritative=inventory_complete,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pr_nodes: list[LinkageNode] = []
|
||||||
|
for raw in sorted(raw_prs or [], key=_sort_key, reverse=True):
|
||||||
|
number = _number_of(raw)
|
||||||
|
if number is None:
|
||||||
|
continue
|
||||||
|
node_handoff, node_status = _node_handoff("pr", number)
|
||||||
|
links = index.pr_links.get(number, ())
|
||||||
|
pr_nodes.append(
|
||||||
|
LinkageNode(
|
||||||
|
kind="pr",
|
||||||
|
number=number,
|
||||||
|
title=str(_redact(raw.get("title")) or ""),
|
||||||
|
state=str(raw.get("state") or ""),
|
||||||
|
labels=_labels_of(raw),
|
||||||
|
links=links,
|
||||||
|
ambiguous=index.ambiguous(number),
|
||||||
|
contested=any(link.issue_number in contested for link in links),
|
||||||
|
deep_link=_deep_link(
|
||||||
|
host, project.gitea_owner, project.repo_name, "pr", number
|
||||||
|
),
|
||||||
|
handoff=node_handoff,
|
||||||
|
handoff_status=node_status,
|
||||||
|
links_authoritative=inventory_complete,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return LinkageSnapshot(
|
||||||
|
ok=True,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=repo_label,
|
||||||
|
host=host,
|
||||||
|
state_scope=state_scope,
|
||||||
|
issues=tuple(issue_nodes),
|
||||||
|
prs=tuple(pr_nodes),
|
||||||
|
contested_issues=contested,
|
||||||
|
focus=focus,
|
||||||
|
inventory_complete=inventory_complete,
|
||||||
|
deep_links_enabled=deep_links_enabled(),
|
||||||
|
handoff_status=handoff_status,
|
||||||
|
)
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""HTML views for the Gitea issue↔PR linkage console (#645, Phase 3).
|
||||||
|
|
||||||
|
Read-only renderer over :mod:`webui.linkage_loader`. The page's job is to make
|
||||||
|
three things impossible to misread:
|
||||||
|
|
||||||
|
* **why** an edge exists — every link carries its evidence badge, so a bare
|
||||||
|
``#N`` mention never looks like a closing claim;
|
||||||
|
* **what was not loaded** — a partial inventory, an unfocused thread, or an
|
||||||
|
unavailable handoff source renders as an explicit qualifier, never as an
|
||||||
|
affirmative "none";
|
||||||
|
* **that nothing here mutates** — there is no review, merge, or edit control,
|
||||||
|
and the deep link out to Gitea appears only under the admin reveal opt-in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from webui.layout import render_page
|
||||||
|
from webui.linkage_loader import (
|
||||||
|
EVIDENCE_BRANCH,
|
||||||
|
EVIDENCE_CLOSES,
|
||||||
|
EVIDENCE_DESCRIPTIONS,
|
||||||
|
EVIDENCE_ORDER,
|
||||||
|
EVIDENCE_REFERENCE,
|
||||||
|
HANDOFF_LOADED,
|
||||||
|
HANDOFF_NOT_LOADED,
|
||||||
|
HandoffSummary,
|
||||||
|
LinkageNode,
|
||||||
|
LinkageSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
_EVIDENCE_CSS = {
|
||||||
|
EVIDENCE_CLOSES: "badge-health-ok",
|
||||||
|
EVIDENCE_BRANCH: "badge-health-skipped",
|
||||||
|
EVIDENCE_REFERENCE: "badge-health-unproven",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EVIDENCE_LABEL = {
|
||||||
|
EVIDENCE_CLOSES: "closes",
|
||||||
|
EVIDENCE_BRANCH: "branch",
|
||||||
|
EVIDENCE_REFERENCE: "mention",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _badge(text: str, css: str) -> str:
|
||||||
|
return f'<span class="badge {css}">{escape(text)}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _labels(names: Sequence[str]) -> str:
|
||||||
|
if not names:
|
||||||
|
return '<span class="muted">—</span>'
|
||||||
|
return " ".join(_badge(name, "badge-health-skipped") for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def _ref(node: LinkageNode) -> str:
|
||||||
|
"""Render an item reference, hyperlinked only when deep links are revealed."""
|
||||||
|
label = f"#{node.number}"
|
||||||
|
if node.deep_link:
|
||||||
|
return f'<a href="{escape(node.deep_link)}"><code>{escape(label)}</code></a>'
|
||||||
|
return f"<code>{escape(label)}</code>"
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_card(snapshot: LinkageSnapshot) -> str:
|
||||||
|
focus = (
|
||||||
|
"none"
|
||||||
|
if snapshot.focus is None
|
||||||
|
else f"{snapshot.focus[0]}#{snapshot.focus[1]}"
|
||||||
|
)
|
||||||
|
completeness = (
|
||||||
|
_badge("complete", "badge-health-ok")
|
||||||
|
if snapshot.inventory_complete
|
||||||
|
else _badge("partial", "badge-health-degraded")
|
||||||
|
)
|
||||||
|
links_note = (
|
||||||
|
"Every linkage edge below is a claim about this loaded window only."
|
||||||
|
if snapshot.inventory_complete
|
||||||
|
else (
|
||||||
|
"Pagination did not complete for this window, so an empty link list "
|
||||||
|
"means <em>none found in what was loaded</em> — not that no link exists."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
deep_links = (
|
||||||
|
_badge("enabled", "badge-health-ok")
|
||||||
|
if snapshot.deep_links_enabled
|
||||||
|
else _badge("hidden", "badge-health-skipped")
|
||||||
|
)
|
||||||
|
return f"""<div class="health-card">
|
||||||
|
<h3>Scope</h3>
|
||||||
|
<table class="detail">
|
||||||
|
<tr><th>Project</th><td><code>{escape(snapshot.project_id or "—")}</code></td></tr>
|
||||||
|
<tr><th>Repository</th><td><code>{escape(snapshot.repo_label or "—")}</code></td></tr>
|
||||||
|
<tr><th>Item state</th><td><code>{escape(snapshot.state_scope)}</code></td></tr>
|
||||||
|
<tr><th>Focused thread</th><td><code>{escape(focus)}</code></td></tr>
|
||||||
|
<tr><th>Inventory</th><td>{completeness}</td></tr>
|
||||||
|
<tr><th>Gitea deep links</th><td>{deep_links}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted">{links_note}</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _error_card(snapshot: LinkageSnapshot) -> str:
|
||||||
|
if snapshot.ok and not snapshot.fetch_error:
|
||||||
|
return ""
|
||||||
|
return (
|
||||||
|
'<div class="health-card health-stale"><strong>Linkage unavailable:</strong> '
|
||||||
|
f"{escape(snapshot.fetch_error or 'the linkage read did not complete')}. "
|
||||||
|
"No linkage table is rendered: an empty table would read as "
|
||||||
|
"<em>no issue is linked to any PR</em>, which this read cannot claim."
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _contested_card(snapshot: LinkageSnapshot) -> str:
|
||||||
|
if not snapshot.contested_issues:
|
||||||
|
return ""
|
||||||
|
refs = ", ".join(f"<code>#{number}</code>" for number in snapshot.contested_issues)
|
||||||
|
return (
|
||||||
|
'<div class="health-card health-stale">'
|
||||||
|
f"<strong>Contested issues:</strong> {refs}. More than one PR in this "
|
||||||
|
"window claims each of these — duplicate work or a superseded PR. "
|
||||||
|
"Resolution stays in Gitea and the workflow; this console only reports it."
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_badges(evidence: Sequence[str]) -> str:
|
||||||
|
return " ".join(
|
||||||
|
_badge(
|
||||||
|
_EVIDENCE_LABEL.get(name, name),
|
||||||
|
_EVIDENCE_CSS.get(name, "badge-health-skipped"),
|
||||||
|
)
|
||||||
|
for name in EVIDENCE_ORDER
|
||||||
|
if name in evidence
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_inline(handoff: HandoffSummary) -> str:
|
||||||
|
type_css = "badge-health-ok" if handoff.cth_type_known else "badge-health-degraded"
|
||||||
|
return (
|
||||||
|
f'{_badge(handoff.cth_type or "—", type_css)}'
|
||||||
|
f'<div class="muted" style="font-size:0.82rem;">'
|
||||||
|
f'{escape(handoff.status or "—")} → {escape(handoff.next_owner or "—")}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_cell(node: LinkageNode) -> str:
|
||||||
|
"""Render the handoff column, distinguishing 'none found' from 'not loaded'."""
|
||||||
|
status = node.handoff_status
|
||||||
|
if status.state == HANDOFF_LOADED:
|
||||||
|
if node.handoff is None:
|
||||||
|
return '<span class="muted">no canonical handoff on this thread</span>'
|
||||||
|
return _handoff_inline(node.handoff)
|
||||||
|
if status.state == HANDOFF_NOT_LOADED:
|
||||||
|
return (
|
||||||
|
f'{_badge("not loaded", "badge-health-skipped")}'
|
||||||
|
f'<div class="muted" style="font-size:0.82rem;">'
|
||||||
|
f'{escape(status.reason or "")}</div>'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'{_badge("unavailable", "badge-health-degraded")}'
|
||||||
|
f'<div class="muted" style="font-size:0.82rem;">'
|
||||||
|
f'{escape(status.reason or "")}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_rows(snapshot: LinkageSnapshot) -> str:
|
||||||
|
rows = []
|
||||||
|
for node in snapshot.issues:
|
||||||
|
if node.linked_prs:
|
||||||
|
linked = ", ".join(f"<code>#{number}</code>" for number in node.linked_prs)
|
||||||
|
if node.contested:
|
||||||
|
linked += " " + _badge("contested", "badge-blocked")
|
||||||
|
elif node.links_authoritative:
|
||||||
|
linked = '<span class="muted">none</span>'
|
||||||
|
else:
|
||||||
|
# The distinction an operator needs: nothing found in a window that
|
||||||
|
# was not fully loaded is not the same as nothing existing.
|
||||||
|
linked = '<span class="muted">none found (partial inventory)</span>'
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{_ref(node)}</td>"
|
||||||
|
f"<td>{escape(node.title)}</td>"
|
||||||
|
f"<td>{escape(node.state or '—')}</td>"
|
||||||
|
f"<td>{_labels(node.labels)}</td>"
|
||||||
|
f"<td>{linked}</td>"
|
||||||
|
f"<td>{_handoff_cell(node)}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return '<tr><td colspan="6" class="muted">No issues in the loaded window.</td></tr>'
|
||||||
|
return "".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _pr_rows(snapshot: LinkageSnapshot) -> str:
|
||||||
|
rows = []
|
||||||
|
for node in snapshot.prs:
|
||||||
|
if node.links:
|
||||||
|
linked = "".join(
|
||||||
|
f"<div><code>#{link.issue_number}</code> "
|
||||||
|
f"{_evidence_badges(link.evidence)}</div>"
|
||||||
|
for link in node.links
|
||||||
|
)
|
||||||
|
if node.ambiguous:
|
||||||
|
linked += _badge("ambiguous", "badge-blocked")
|
||||||
|
if node.contested:
|
||||||
|
linked += " " + _badge("contested", "badge-blocked")
|
||||||
|
elif node.links_authoritative:
|
||||||
|
linked = '<span class="muted">no issue reference</span>'
|
||||||
|
else:
|
||||||
|
linked = '<span class="muted">none found (partial inventory)</span>'
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{_ref(node)}</td>"
|
||||||
|
f"<td>{escape(node.title)}</td>"
|
||||||
|
f"<td>{escape(node.state or '—')}</td>"
|
||||||
|
f"<td>{_labels(node.labels)}</td>"
|
||||||
|
f"<td>{linked}</td>"
|
||||||
|
f"<td>{_handoff_cell(node)}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return (
|
||||||
|
'<tr><td colspan="6" class="muted">No pull requests in the loaded '
|
||||||
|
"window.</td></tr>"
|
||||||
|
)
|
||||||
|
return "".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _focus_card(snapshot: LinkageSnapshot) -> str:
|
||||||
|
"""Render the focused thread's latest canonical handoff, when one was loaded."""
|
||||||
|
if snapshot.focus is None:
|
||||||
|
return f"""<div class="prompt-card">
|
||||||
|
<h3>Canonical handoff</h3>
|
||||||
|
<p class="muted">{escape(snapshot.handoff_status.reason or "")}
|
||||||
|
Add <code>?issue=N</code> or <code>?pr=N</code> to load the latest
|
||||||
|
Canonical Thread Handoff for one thread.</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
kind, number = snapshot.focus
|
||||||
|
target = f"{kind} #{number}"
|
||||||
|
if not snapshot.handoff_status.loaded:
|
||||||
|
return f"""<div class="prompt-card">
|
||||||
|
<h3>Canonical handoff — {escape(target)}</h3>
|
||||||
|
<p class="muted">{_badge("unavailable", "badge-health-degraded")}
|
||||||
|
{escape(snapshot.handoff_status.reason or "handoff source did not run")}.
|
||||||
|
This is not evidence that the thread carries no handoff.</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
handoff = next(
|
||||||
|
(
|
||||||
|
node.handoff
|
||||||
|
for node in (snapshot.issues + snapshot.prs)
|
||||||
|
if node.kind == kind and node.number == number and node.handoff
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if handoff is None:
|
||||||
|
return f"""<div class="prompt-card">
|
||||||
|
<h3>Canonical handoff — {escape(target)}</h3>
|
||||||
|
<p class="muted">Comments loaded; no Canonical Thread Handoff comment found on
|
||||||
|
this thread.</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
type_css = "badge-health-ok" if handoff.cth_type_known else "badge-health-degraded"
|
||||||
|
unknown_note = (
|
||||||
|
""
|
||||||
|
if handoff.cth_type_known
|
||||||
|
else (
|
||||||
|
'<p class="muted">The comment\'s heading is not a declared CTH type, '
|
||||||
|
"so it is reported as unrecognized rather than republished.</p>"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return f"""<div class="prompt-card">
|
||||||
|
<h3>Canonical handoff — {escape(target)}</h3>
|
||||||
|
<p class="meta">{_badge(handoff.cth_type or "—", type_css)}
|
||||||
|
by <code>{escape(handoff.author or "unknown")}</code>
|
||||||
|
at <code>{escape(handoff.created_at or "unknown")}</code></p>
|
||||||
|
{unknown_note}
|
||||||
|
<table class="detail">
|
||||||
|
<tr><th>Status</th><td>{escape(handoff.status or "—")}</td></tr>
|
||||||
|
<tr><th>Next owner</th><td>{escape(handoff.next_owner or "—")}</td></tr>
|
||||||
|
<tr><th>Current blocker</th><td>{escape(handoff.current_blocker or "—")}</td></tr>
|
||||||
|
<tr><th>Decision</th><td>{escape(handoff.decision or "—")}</td></tr>
|
||||||
|
<tr><th>Next action</th><td>{escape(handoff.next_action or "—")}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="muted">Full event history:
|
||||||
|
<a href="/api/v1/timeline?{escape(kind)}={number}"><code>/api/v1/timeline</code></a></p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _legend_card(snapshot: LinkageSnapshot) -> str:
|
||||||
|
items = "".join(
|
||||||
|
f"<li>{_badge(_EVIDENCE_LABEL[name], _EVIDENCE_CSS[name])} — "
|
||||||
|
f"{escape(EVIDENCE_DESCRIPTIONS[name])}</li>"
|
||||||
|
for name in EVIDENCE_ORDER
|
||||||
|
)
|
||||||
|
reveal_note = (
|
||||||
|
"Gitea deep links are shown because the "
|
||||||
|
"<code>GITEA_MCP_REVEAL_ENDPOINTS</code> admin opt-in is set."
|
||||||
|
if snapshot.deep_links_enabled
|
||||||
|
else (
|
||||||
|
"Gitea deep links are withheld. Set "
|
||||||
|
"<code>GITEA_MCP_REVEAL_ENDPOINTS=1</code> server-side to reveal "
|
||||||
|
"them; item numbers stay usable without them."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return f"""<div class="prompt-card">
|
||||||
|
<h3>How an edge was found</h3>
|
||||||
|
<ul class="reasons">{items}</ul>
|
||||||
|
<p class="muted">{reveal_note}</p>
|
||||||
|
<p class="muted">Read-only surface: no issue or PR editing, no review, and no merge.
|
||||||
|
JSON export: <a href="/api/v1/gitea/linkage"><code>/api/v1/gitea/linkage</code></a></p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_linkage_page(snapshot: LinkageSnapshot) -> str:
|
||||||
|
"""Render the full HTML page for the Gitea linkage console."""
|
||||||
|
if not snapshot.ok:
|
||||||
|
return render_page(
|
||||||
|
title="Gitea linkage",
|
||||||
|
body_html=f"""<h2>Gitea issue and PR linkage</h2>
|
||||||
|
<p class="meta">Phase 3 read-only linkage console (#645).</p>
|
||||||
|
{_error_card(snapshot)}
|
||||||
|
{_scope_card(snapshot)}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
body = f"""<h2>Gitea issue and PR linkage</h2>
|
||||||
|
<p class="meta">Phase 3 read-only linkage console (#645). Gitea remains the source of
|
||||||
|
truth; this page reads it and never writes to it.</p>
|
||||||
|
{_scope_card(snapshot)}
|
||||||
|
{_contested_card(snapshot)}
|
||||||
|
|
||||||
|
<div class="prompt-card">
|
||||||
|
<h3>Issues → pull requests</h3>
|
||||||
|
<table class="registry">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Issue</th><th>Title</th><th>State</th><th>Labels</th>
|
||||||
|
<th>Linked PRs</th><th>Latest handoff</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{_issue_rows(snapshot)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prompt-card">
|
||||||
|
<h3>Pull requests → issues</h3>
|
||||||
|
<table class="registry">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PR</th><th>Title</th><th>State</th><th>Labels</th>
|
||||||
|
<th>Linked issues</th><th>Latest handoff</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{_pr_rows(snapshot)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{_focus_card(snapshot)}
|
||||||
|
{_legend_card(snapshot)}
|
||||||
|
"""
|
||||||
|
return render_page(title="Gitea linkage", body_html=body)
|
||||||
+8
-1
@@ -6,7 +6,8 @@ destination is a GET view or a Phase 1 placeholder. No mutation links.
|
|||||||
|
|
||||||
Nav groups follow the #631 Phase 1 information architecture: Health, Traffic,
|
Nav groups follow the #631 Phase 1 information architecture: Health, Traffic,
|
||||||
Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and
|
Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and
|
||||||
Insights (placeholder). Later-phase surfaces are declared as ``stub`` items and
|
Insights (placeholder), joined by the Phase 3 Gitea linkage group (#645).
|
||||||
|
Later-phase surfaces are declared as ``stub`` items and
|
||||||
backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder
|
backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder
|
||||||
instead of a 404.
|
instead of a 404.
|
||||||
"""
|
"""
|
||||||
@@ -45,9 +46,12 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
|||||||
NavItem("/queue", "Queue"),
|
NavItem("/queue", "Queue"),
|
||||||
NavItem("/leases", "Leases"),
|
NavItem("/leases", "Leases"),
|
||||||
NavItem("/actions", "Actions"),
|
NavItem("/actions", "Actions"),
|
||||||
|
NavItem("/notifications", "Notifications"),
|
||||||
|
NavItem("/requests", "Requests"),
|
||||||
)),
|
)),
|
||||||
NavGroup("Runtime/Sessions", (
|
NavGroup("Runtime/Sessions", (
|
||||||
NavItem("/runtime", "Runtime health"),
|
NavItem("/runtime", "Runtime health"),
|
||||||
|
NavItem("/runtime/restart", "Restart status"),
|
||||||
NavItem("/sessions", "Sessions"),
|
NavItem("/sessions", "Sessions"),
|
||||||
)),
|
)),
|
||||||
NavGroup("Projects", (
|
NavGroup("Projects", (
|
||||||
@@ -60,6 +64,9 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
|||||||
NavGroup("Timeline", (
|
NavGroup("Timeline", (
|
||||||
NavItem("/timeline", "Timeline", "stub"),
|
NavItem("/timeline", "Timeline", "stub"),
|
||||||
)),
|
)),
|
||||||
|
NavGroup("Gitea", (
|
||||||
|
NavItem("/gitea", "Issue/PR linkage"),
|
||||||
|
)),
|
||||||
NavGroup("Policy", (
|
NavGroup("Policy", (
|
||||||
NavItem("/policy", "Policy", "stub"),
|
NavItem("/policy", "Policy", "stub"),
|
||||||
NavItem("/prompts", "Prompts"),
|
NavItem("/prompts", "Prompts"),
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""HTML rendering for Phase 3 Notifications and Human-Attention Console (#648)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from webui.layout import render_page
|
||||||
|
from webui.notifications import (
|
||||||
|
ATTENTION_HUMAN_REQUIRED,
|
||||||
|
ATTENTION_OPERATOR,
|
||||||
|
ATTENTION_ROUTINE,
|
||||||
|
NotificationItem,
|
||||||
|
NotificationSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_attention_badge(attention_class: str) -> str:
|
||||||
|
cls = "badge"
|
||||||
|
if attention_class == ATTENTION_HUMAN_REQUIRED:
|
||||||
|
cls += " badge-blocked"
|
||||||
|
elif attention_class == ATTENTION_OPERATOR:
|
||||||
|
cls += " badge-claimed"
|
||||||
|
else:
|
||||||
|
cls += " muted"
|
||||||
|
return f'<span class="{cls}">{escape(attention_class)}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_notification_row(item: NotificationItem) -> str:
|
||||||
|
category_label = escape(item.category.upper())
|
||||||
|
id_str = escape(item.id)
|
||||||
|
title_str = escape(item.title)
|
||||||
|
summary_str = escape(item.summary)
|
||||||
|
att_badge = _render_attention_badge(item.attention_class)
|
||||||
|
|
||||||
|
work_item_html = "—"
|
||||||
|
if item.work_number and item.work_kind:
|
||||||
|
kind_label = escape(item.work_kind.upper())
|
||||||
|
num_str = f"#{item.work_number}"
|
||||||
|
link = item.deep_link or "#"
|
||||||
|
work_item_html = f'<a href="{escape(link)}"><code>{kind_label} {num_str}</code></a>'
|
||||||
|
|
||||||
|
requires_human_label = (
|
||||||
|
'<span class="badge badge-blocked" style="font-size:0.75rem;">HUMAN REQUIRED</span>'
|
||||||
|
if item.requires_human
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"""<tr>
|
||||||
|
<td><code>{category_label}</code><br><span class="muted" style="font-size:0.75rem;">{id_str}</span></td>
|
||||||
|
<td>
|
||||||
|
<div><strong>{title_str}</strong> {att_badge} {requires_human_label}</div>
|
||||||
|
<div class="muted" style="font-size:0.85rem; margin-top:0.25rem;">{summary_str}</div>
|
||||||
|
</td>
|
||||||
|
<td>{work_item_html}</td>
|
||||||
|
<td><span class="muted" style="font-size:0.8rem;">{escape(item.created_at[:19])}</span></td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_notifications_table(items: Sequence[NotificationItem], empty_message: str) -> str:
|
||||||
|
if not items:
|
||||||
|
return f'<p class="muted" style="padding:1rem 0;">{escape(empty_message)}</p>'
|
||||||
|
|
||||||
|
rows = "".join(_render_notification_row(item) for item in items)
|
||||||
|
return f"""<table class="registry">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 18%;">Category & ID</th>
|
||||||
|
<th style="width: 52%;">Title & Attention Summary</th>
|
||||||
|
<th style="width: 15%;">Work Item</th>
|
||||||
|
<th style="width: 15%;">Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows}
|
||||||
|
</tbody>
|
||||||
|
</table>"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_notifications_page(
|
||||||
|
snapshot: NotificationSnapshot,
|
||||||
|
*,
|
||||||
|
filter_class: str = "inbox",
|
||||||
|
filter_project: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render the notifications and attention inbox page."""
|
||||||
|
title = "Notifications & Attention Inbox"
|
||||||
|
|
||||||
|
err_html = ""
|
||||||
|
if snapshot.fetch_error:
|
||||||
|
err_html = f'<div class="stub" style="border-color:#e53e3e; background:#fff5f5; color:#c53030; margin-bottom:1rem;"><p><strong>Fetch Warning:</strong> {escape(snapshot.fetch_error)}</p></div>'
|
||||||
|
|
||||||
|
# Determine items to render based on filter_class
|
||||||
|
if filter_class == ATTENTION_HUMAN_REQUIRED:
|
||||||
|
display_items = snapshot.human_required_items
|
||||||
|
active_tab_title = "Human-Required Escalations"
|
||||||
|
elif filter_class == ATTENTION_OPERATOR:
|
||||||
|
display_items = snapshot.operator_items
|
||||||
|
active_tab_title = "Operator Inbox Items"
|
||||||
|
elif filter_class == ATTENTION_ROUTINE:
|
||||||
|
display_items = snapshot.routine_items
|
||||||
|
active_tab_title = "Routine Workflow Transitions"
|
||||||
|
elif filter_class == "all":
|
||||||
|
display_items = snapshot.items
|
||||||
|
active_tab_title = "All Events (including Routine)"
|
||||||
|
else: # "inbox" default
|
||||||
|
display_items = snapshot.inbox_items
|
||||||
|
active_tab_title = "Attention Inbox (Human + Operator)"
|
||||||
|
|
||||||
|
hr_cls = "badge-blocked" if snapshot.human_required_count > 0 else "muted"
|
||||||
|
op_cls = "badge-claimed" if snapshot.operator_count > 0 else "muted"
|
||||||
|
|
||||||
|
metrics_html = f"""<div style="display:flex; gap:1rem; margin-bottom:1.5rem;">
|
||||||
|
<div class="health-card" style="flex:1;">
|
||||||
|
<span class="muted" style="font-size:0.85rem;">Human Required</span>
|
||||||
|
<h2 style="margin:0.2rem 0;"><span class="badge {hr_cls}" style="font-size:1.4rem;">{snapshot.human_required_count}</span></h2>
|
||||||
|
<p class="muted" style="font-size:0.8rem; margin:0;">Critical escalation boundary</p>
|
||||||
|
</div>
|
||||||
|
<div class="health-card" style="flex:1;">
|
||||||
|
<span class="muted" style="font-size:0.85rem;">Operator Inbox</span>
|
||||||
|
<h2 style="margin:0.2rem 0;"><span class="badge {op_cls}" style="font-size:1.4rem;">{snapshot.operator_count}</span></h2>
|
||||||
|
<p class="muted" style="font-size:0.8rem; margin:0;">Operational items needing review</p>
|
||||||
|
</div>
|
||||||
|
<div class="health-card" style="flex:1;">
|
||||||
|
<span class="muted" style="font-size:0.85rem;">Routine Transitions</span>
|
||||||
|
<h2 style="margin:0.2rem 0;"><span class="badge muted" style="font-size:1.4rem;">{snapshot.routine_count}</span></h2>
|
||||||
|
<p class="muted" style="font-size:0.8rem; margin:0;">Background transitions (filtered)</p>
|
||||||
|
</div>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
# Filter navigation links
|
||||||
|
def _tab_link(target_class: str, label: str) -> str:
|
||||||
|
is_active = (filter_class == target_class)
|
||||||
|
style = "font-weight:bold; border-bottom:2px solid currentColor;" if is_active else "color:#4a5568;"
|
||||||
|
return f'<a href="/notifications?attention_class={target_class}" style="margin-right:1.25rem; text-decoration:none; padding-bottom:0.25rem; {style}">{label}</a>'
|
||||||
|
|
||||||
|
tabs_html = f"""<div style="margin-bottom:1.25rem; border-bottom:1px solid #e2e8f0; padding-bottom:0.5rem;">
|
||||||
|
{_tab_link("inbox", f"Attention Inbox ({snapshot.human_required_count + snapshot.operator_count})")}
|
||||||
|
{_tab_link("human-required", f"Human Required ({snapshot.human_required_count})")}
|
||||||
|
{_tab_link("operator", f"Operator ({snapshot.operator_count})")}
|
||||||
|
{_tab_link("routine", f"Routine ({snapshot.routine_count})")}
|
||||||
|
{_tab_link("all", f"All Events ({snapshot.total_count})")}
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
table_html = _render_notifications_table(
|
||||||
|
display_items,
|
||||||
|
f"No items match attention filter '{filter_class}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
body = f"""<h2>{escape(title)}</h2>
|
||||||
|
<p class="muted">Phase 3 console surface for human-attention routing (#648). Routine workflow transitions are filtered by default to eliminate notification fatigue.</p>
|
||||||
|
{err_html}
|
||||||
|
{metrics_html}
|
||||||
|
{tabs_html}
|
||||||
|
<h3>{escape(active_tab_title)}</h3>
|
||||||
|
{table_html}"""
|
||||||
|
|
||||||
|
return render_page(title=title, body_html=body)
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""Notifications and human-attention routing module for Phase 3 web console (#648).
|
||||||
|
|
||||||
|
Defines attention classes, event classification rules, and inbox aggregation so
|
||||||
|
operators receive direct alerts only for human-required escalation boundaries
|
||||||
|
(#628) while routine workflow transitions remain available for pull-based review.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from webui import console_redaction
|
||||||
|
from webui.project_registry import load_registry
|
||||||
|
from webui.queue_loader import QueueSnapshot, load_queue_snapshot
|
||||||
|
from webui.lease_loader import LeaseSnapshot, load_lease_snapshot
|
||||||
|
from webui.system_health import SystemHealthSnapshot, load_system_health
|
||||||
|
|
||||||
|
# Attention class definitions (#628, #648)
|
||||||
|
ATTENTION_ROUTINE = "routine"
|
||||||
|
ATTENTION_OPERATOR = "operator"
|
||||||
|
ATTENTION_HUMAN_REQUIRED = "human-required"
|
||||||
|
|
||||||
|
ATTENTION_CLASSES = (
|
||||||
|
ATTENTION_ROUTINE,
|
||||||
|
ATTENTION_OPERATOR,
|
||||||
|
ATTENTION_HUMAN_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification categories
|
||||||
|
CATEGORY_AUTH = "auth"
|
||||||
|
CATEGORY_BLOCKER = "blocker"
|
||||||
|
CATEGORY_LEASE = "lease"
|
||||||
|
CATEGORY_VALIDATION = "validation"
|
||||||
|
CATEGORY_WORKFLOW = "workflow"
|
||||||
|
CATEGORY_SYSTEM = "system"
|
||||||
|
|
||||||
|
CATEGORIES = (
|
||||||
|
CATEGORY_AUTH,
|
||||||
|
CATEGORY_BLOCKER,
|
||||||
|
CATEGORY_LEASE,
|
||||||
|
CATEGORY_VALIDATION,
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
CATEGORY_SYSTEM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NotificationItem:
|
||||||
|
"""A single notification or inbox event."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
attention_class: str # "routine", "operator", "human-required"
|
||||||
|
category: str # "auth", "blocker", "lease", "validation", etc.
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
work_kind: str | None # "issue", "pr", "session", "system"
|
||||||
|
work_number: int | None
|
||||||
|
project_id: str
|
||||||
|
repo_label: str
|
||||||
|
created_at: str
|
||||||
|
deep_link: str | None = None
|
||||||
|
requires_human: bool = False
|
||||||
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"attention_class": self.attention_class,
|
||||||
|
"category": self.category,
|
||||||
|
"title": self.title,
|
||||||
|
"summary": console_redaction.redact_text(self.summary),
|
||||||
|
"work_kind": self.work_kind,
|
||||||
|
"work_number": self.work_number,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"repo_label": self.repo_label,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"deep_link": self.deep_link,
|
||||||
|
"requires_human": self.requires_human,
|
||||||
|
"extra": self.extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NotificationSnapshot:
|
||||||
|
"""Snapshot of notifications and attention inbox state."""
|
||||||
|
|
||||||
|
project_id: str
|
||||||
|
repo_label: str
|
||||||
|
items: tuple[NotificationItem, ...]
|
||||||
|
human_required_count: int
|
||||||
|
operator_count: int
|
||||||
|
routine_count: int
|
||||||
|
total_count: int
|
||||||
|
fetch_error: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inbox_items(self) -> tuple[NotificationItem, ...]:
|
||||||
|
"""Items requiring operator or human attention (excluding routine)."""
|
||||||
|
return tuple(
|
||||||
|
item
|
||||||
|
for item in self.items
|
||||||
|
if item.attention_class in {ATTENTION_OPERATOR, ATTENTION_HUMAN_REQUIRED}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def human_required_items(self) -> tuple[NotificationItem, ...]:
|
||||||
|
return tuple(
|
||||||
|
item for item in self.items if item.attention_class == ATTENTION_HUMAN_REQUIRED
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def operator_items(self) -> tuple[NotificationItem, ...]:
|
||||||
|
return tuple(
|
||||||
|
item for item in self.items if item.attention_class == ATTENTION_OPERATOR
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def routine_items(self) -> tuple[NotificationItem, ...]:
|
||||||
|
return tuple(
|
||||||
|
item for item in self.items if item.attention_class == ATTENTION_ROUTINE
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"repo_label": self.repo_label,
|
||||||
|
"human_required_count": self.human_required_count,
|
||||||
|
"operator_count": self.operator_count,
|
||||||
|
"routine_count": self.routine_count,
|
||||||
|
"total_count": self.total_count,
|
||||||
|
"fetch_error": self.fetch_error,
|
||||||
|
"inbox_items": [item.as_dict() for item in self.inbox_items],
|
||||||
|
"all_items": [item.as_dict() for item in self.items],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_attention_event(
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
summary: str,
|
||||||
|
*,
|
||||||
|
is_hard_stop: bool = False,
|
||||||
|
is_auth_failure: bool = False,
|
||||||
|
is_irrecoverable: bool = False,
|
||||||
|
is_decision_lock: bool = False,
|
||||||
|
is_validation_failure: bool = False,
|
||||||
|
is_stale: bool = False,
|
||||||
|
is_blocker: bool = False,
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Classify an event into an attention class and human requirement flag.
|
||||||
|
|
||||||
|
Rules (#628, #648):
|
||||||
|
1. Critical boundaries (hard stop, auth failure, irrecoverable state,
|
||||||
|
decision lock, validation failure) -> ATTENTION_HUMAN_REQUIRED (requires_human=True).
|
||||||
|
2. Operational queues (blocker, stale lease, unassigned ready work, queue collision)
|
||||||
|
-> ATTENTION_OPERATOR (requires_human=False).
|
||||||
|
3. Routine state transitions (clean progression, healthy heartbeats) -> ATTENTION_ROUTINE (requires_human=False).
|
||||||
|
|
||||||
|
Classification uses structured flags and category only. Human-authored
|
||||||
|
``title`` / ``summary`` text is never substring-matched for escalation
|
||||||
|
(PR #905 review B1) — callers that need text signals must set flags from
|
||||||
|
machine-generated status/detail fields before calling this function.
|
||||||
|
"""
|
||||||
|
del title, summary # kept for API stability; never used for classification
|
||||||
|
if (
|
||||||
|
is_hard_stop
|
||||||
|
or is_auth_failure
|
||||||
|
or is_irrecoverable
|
||||||
|
or is_decision_lock
|
||||||
|
or is_validation_failure
|
||||||
|
or category in {CATEGORY_AUTH, CATEGORY_VALIDATION}
|
||||||
|
):
|
||||||
|
return ATTENTION_HUMAN_REQUIRED, True
|
||||||
|
|
||||||
|
if is_stale or is_blocker or category in {CATEGORY_BLOCKER, CATEGORY_LEASE}:
|
||||||
|
return ATTENTION_OPERATOR, False
|
||||||
|
|
||||||
|
return ATTENTION_ROUTINE, False
|
||||||
|
|
||||||
|
|
||||||
|
def load_notifications_snapshot(
|
||||||
|
project_id: str | None = None,
|
||||||
|
*,
|
||||||
|
load_queue: Callable[..., QueueSnapshot] | None = None,
|
||||||
|
load_leases: Callable[..., LeaseSnapshot] | None = None,
|
||||||
|
load_health: Callable[..., SystemHealthSnapshot] | None = None,
|
||||||
|
) -> NotificationSnapshot:
|
||||||
|
"""Load and classify attention notifications across queue, leases, and system health."""
|
||||||
|
registry = load_registry()
|
||||||
|
project = None
|
||||||
|
if project_id:
|
||||||
|
for entry in registry.projects:
|
||||||
|
if entry.id == project_id:
|
||||||
|
project = entry
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
project = registry.projects[0] if registry.projects else None
|
||||||
|
|
||||||
|
if project is None:
|
||||||
|
return NotificationSnapshot(
|
||||||
|
project_id=project_id or "",
|
||||||
|
repo_label="",
|
||||||
|
items=(),
|
||||||
|
human_required_count=0,
|
||||||
|
operator_count=0,
|
||||||
|
routine_count=0,
|
||||||
|
total_count=0,
|
||||||
|
fetch_error="project not found in registry",
|
||||||
|
)
|
||||||
|
|
||||||
|
queue_loader_fn = load_queue or load_queue_snapshot
|
||||||
|
lease_loader_fn = load_leases or load_lease_snapshot
|
||||||
|
health_loader_fn = load_health or load_system_health
|
||||||
|
|
||||||
|
try:
|
||||||
|
queue_snap = queue_loader_fn(project.id)
|
||||||
|
except TypeError:
|
||||||
|
queue_snap = queue_loader_fn(project_id=project.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lease_snap = lease_loader_fn(project_id=project.id)
|
||||||
|
except TypeError:
|
||||||
|
lease_snap = lease_loader_fn(project.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
health_snap = health_loader_fn(project_id=project.id)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
health_snap = health_loader_fn(project.id)
|
||||||
|
except TypeError:
|
||||||
|
health_snap = health_loader_fn()
|
||||||
|
|
||||||
|
items: list[NotificationItem] = []
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# 1. System health alerts (highest priority)
|
||||||
|
for err_idx, probe_err in enumerate(getattr(health_snap, "probe_errors", ())):
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_SYSTEM,
|
||||||
|
"System Health Probe Error",
|
||||||
|
probe_err,
|
||||||
|
is_blocker=True,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-sys-err-{project.id}-{err_idx}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_SYSTEM,
|
||||||
|
title="System Health Error",
|
||||||
|
summary=f"System health error: {probe_err}",
|
||||||
|
work_kind="system",
|
||||||
|
work_number=None,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link="/system",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for probe in getattr(health_snap, "dependencies", ()):
|
||||||
|
if probe.status not in ("ok", "healthy"):
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_SYSTEM,
|
||||||
|
f"Probe Failure: {probe.name}",
|
||||||
|
probe.detail or probe.status,
|
||||||
|
is_hard_stop=("stop" in probe.status or "fatal" in probe.status),
|
||||||
|
is_auth_failure=("auth" in probe.name.lower() or "unauthorized" in probe.status.lower()),
|
||||||
|
is_blocker=True,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-probe-{probe.name}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_AUTH if "auth" in probe.name.lower() else CATEGORY_SYSTEM,
|
||||||
|
title=f"Health Probe Alert: {probe.name}",
|
||||||
|
summary=f"Probe '{probe.name}' reported status '{probe.status}': {probe.detail}",
|
||||||
|
work_kind="system",
|
||||||
|
work_number=None,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link="/system",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Queue items (PRs and Issues)
|
||||||
|
for pr in queue_snap.prs:
|
||||||
|
if "blocked" in pr.badges:
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_BLOCKER,
|
||||||
|
f"PR #{pr.number} Blocked",
|
||||||
|
f"PR #{pr.number} '{pr.title}' is blocked or has merge conflicts.",
|
||||||
|
is_blocker=True,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-pr-block-{pr.number}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_BLOCKER,
|
||||||
|
title=f"Blocked PR #{pr.number}",
|
||||||
|
summary=f"PR #{pr.number} ({pr.title}) requires merge conflict resolution.",
|
||||||
|
work_kind="pr",
|
||||||
|
work_number=pr.number,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link=f"/traffic",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif "stale" in pr.badges:
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
f"PR #{pr.number} Stale",
|
||||||
|
f"PR #{pr.number} '{pr.title}' has had no activity for over 14 days.",
|
||||||
|
is_stale=True,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-pr-stale-{pr.number}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_WORKFLOW,
|
||||||
|
title=f"Stale PR #{pr.number}",
|
||||||
|
summary=f"PR #{pr.number} ({pr.title}) is stale.",
|
||||||
|
work_kind="pr",
|
||||||
|
work_number=pr.number,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link=f"/queue",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Routine PR transition
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
f"PR #{pr.number} Active",
|
||||||
|
f"PR #{pr.number} '{pr.title}' is in routine state {', '.join(pr.badges)}.",
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-pr-routine-{pr.number}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_WORKFLOW,
|
||||||
|
title=f"Routine PR #{pr.number}",
|
||||||
|
summary=f"PR #{pr.number} ({pr.title}) state: {', '.join(pr.badges)}.",
|
||||||
|
work_kind="pr",
|
||||||
|
work_number=pr.number,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link=f"/queue",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for issue in queue_snap.issues:
|
||||||
|
if "duplicate" in issue.badges:
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_BLOCKER,
|
||||||
|
f"Issue #{issue.number} Duplicate PRs",
|
||||||
|
f"Issue #{issue.number} has multiple linked PRs.",
|
||||||
|
is_blocker=True,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-issue-dup-{issue.number}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_BLOCKER,
|
||||||
|
title=f"Duplicate PRs on Issue #{issue.number}",
|
||||||
|
summary=f"Issue #{issue.number} ({issue.title}) linked to multiple PRs.",
|
||||||
|
work_kind="issue",
|
||||||
|
work_number=issue.number,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link=f"/traffic",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif "claimed" in issue.badges or "in-review" in issue.badges:
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_WORKFLOW,
|
||||||
|
f"Issue #{issue.number} Active",
|
||||||
|
f"Issue #{issue.number} '{issue.title}' in state {', '.join(issue.badges)}.",
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-issue-routine-{issue.number}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_WORKFLOW,
|
||||||
|
title=f"Routine Issue #{issue.number}",
|
||||||
|
summary=f"Issue #{issue.number} ({issue.title}) state: {', '.join(issue.badges)}.",
|
||||||
|
work_kind="issue",
|
||||||
|
work_number=issue.number,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link=f"/queue",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Leases / Collisions
|
||||||
|
for lease in lease_snap.reviewer_leases:
|
||||||
|
if lease.get("is_expired") or lease.get("status") == "expired":
|
||||||
|
pr_num = lease.get("pr_number") or lease.get("work_item_number")
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_LEASE,
|
||||||
|
f"Reviewer Lease Expired for PR #{pr_num}",
|
||||||
|
f"Reviewer lease for PR #{pr_num} has expired.",
|
||||||
|
is_stale=True,
|
||||||
|
)
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-lease-exp-pr-{pr_num}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_LEASE,
|
||||||
|
title=f"Expired Reviewer Lease (PR #{pr_num})",
|
||||||
|
summary=f"Reviewer lease for PR #{pr_num} expired.",
|
||||||
|
work_kind="pr",
|
||||||
|
work_number=pr_num,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link="/leases",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for col_idx, collision in enumerate(lease_snap.duplicate_prs):
|
||||||
|
att_cls, req_human = classify_attention_event(
|
||||||
|
CATEGORY_BLOCKER,
|
||||||
|
f"Duplicate PR Collision ({collision.kind})",
|
||||||
|
collision.message,
|
||||||
|
is_blocker=True,
|
||||||
|
)
|
||||||
|
issue_part = collision.issue_number if collision.issue_number is not None else "none"
|
||||||
|
kind_part = (collision.kind or "unknown").replace(" ", "-")
|
||||||
|
items.append(
|
||||||
|
NotificationItem(
|
||||||
|
id=f"notif-collision-{kind_part}-{issue_part}-{col_idx}",
|
||||||
|
attention_class=att_cls,
|
||||||
|
category=CATEGORY_BLOCKER,
|
||||||
|
title=f"Collision Alert ({collision.kind})",
|
||||||
|
summary=collision.message,
|
||||||
|
work_kind="issue" if collision.issue_number else "pr",
|
||||||
|
work_number=collision.issue_number,
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
created_at=now_iso,
|
||||||
|
deep_link="/leases",
|
||||||
|
requires_human=req_human,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
human_req_count = sum(1 for i in items if i.attention_class == ATTENTION_HUMAN_REQUIRED)
|
||||||
|
operator_count = sum(1 for i in items if i.attention_class == ATTENTION_OPERATOR)
|
||||||
|
routine_count = sum(1 for i in items if i.attention_class == ATTENTION_ROUTINE)
|
||||||
|
|
||||||
|
# Fetch errors are transport/load failures only — not probe results that
|
||||||
|
# already surface as first-class notification items (PR #905 review B3).
|
||||||
|
fetch_err = queue_snap.fetch_error or lease_snap.fetch_error
|
||||||
|
if isinstance(fetch_err, (tuple, list)):
|
||||||
|
fetch_err = "; ".join(fetch_err) if fetch_err else None
|
||||||
|
|
||||||
|
return NotificationSnapshot(
|
||||||
|
project_id=project.id,
|
||||||
|
repo_label=f"{project.gitea_owner}/{project.repo_name}",
|
||||||
|
items=tuple(items),
|
||||||
|
human_required_count=human_req_count,
|
||||||
|
operator_count=operator_count,
|
||||||
|
routine_count=routine_count,
|
||||||
|
total_count=len(items),
|
||||||
|
fetch_error=fetch_err,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_to_dict(snapshot: NotificationSnapshot) -> dict[str, Any]:
|
||||||
|
"""JSON-serializable export for /api/v1/notifications."""
|
||||||
|
return snapshot.as_dict()
|
||||||
+27
-2
@@ -211,6 +211,19 @@ def _pagination_from_pages(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_SUPPORTED_FETCH_STATES = ("open", "closed", "all")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_state(state: str | None) -> str:
|
||||||
|
"""Constrain a caller-supplied item state before it reaches a query string.
|
||||||
|
|
||||||
|
The value is interpolated into the Gitea URL, so an unrecognised state falls
|
||||||
|
back to ``open`` rather than being passed through.
|
||||||
|
"""
|
||||||
|
text = (state or "open").strip().lower()
|
||||||
|
return text if text in _SUPPORTED_FETCH_STATES else "open"
|
||||||
|
|
||||||
|
|
||||||
def _fetch_prs(
|
def _fetch_prs(
|
||||||
host: str,
|
host: str,
|
||||||
org: str,
|
org: str,
|
||||||
@@ -218,8 +231,15 @@ def _fetch_prs(
|
|||||||
auth: str,
|
auth: str,
|
||||||
*,
|
*,
|
||||||
per_page: int = 50,
|
per_page: int = 50,
|
||||||
|
state: str = "open",
|
||||||
) -> tuple[list[dict], PaginationMeta]:
|
) -> tuple[list[dict], PaginationMeta]:
|
||||||
url = f"{repo_api_url(host, org, repo)}/pulls?state=open"
|
"""Fetch PRs in *state* (``open``, ``closed``, or ``all``).
|
||||||
|
|
||||||
|
The queue dashboard only ever wants the open window, so ``open`` stays the
|
||||||
|
default. The linkage console (#645) widens it, because a landed issue↔PR
|
||||||
|
edge lives on a merged PR.
|
||||||
|
"""
|
||||||
|
url = f"{repo_api_url(host, org, repo)}/pulls?state={_safe_state(state)}"
|
||||||
all_raw: list[dict] = []
|
all_raw: list[dict] = []
|
||||||
pages_fetched = 0
|
pages_fetched = 0
|
||||||
is_final = False
|
is_final = False
|
||||||
@@ -248,8 +268,13 @@ def _fetch_issues(
|
|||||||
auth: str,
|
auth: str,
|
||||||
*,
|
*,
|
||||||
per_page: int = 50,
|
per_page: int = 50,
|
||||||
|
state: str = "open",
|
||||||
) -> tuple[list[dict], PaginationMeta]:
|
) -> tuple[list[dict], PaginationMeta]:
|
||||||
url = f"{repo_api_url(host, org, repo)}/issues?state=open&type=issues"
|
"""Fetch issues in *state* (``open``, ``closed``, or ``all``); see :func:`_fetch_prs`."""
|
||||||
|
url = (
|
||||||
|
f"{repo_api_url(host, org, repo)}/issues"
|
||||||
|
f"?state={_safe_state(state)}&type=issues"
|
||||||
|
)
|
||||||
all_raw: list[dict] = []
|
all_raw: list[dict] = []
|
||||||
page = 1
|
page = 1
|
||||||
pages_fetched = 0
|
pages_fetched = 0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
|||||||
|
"""HTML views for the operator request surface (#643).
|
||||||
|
|
||||||
|
The form is deliberately a *preview* form. It has no initiate button, because
|
||||||
|
initiating requires a confirmed POST to ``/api/v1/requests/apply`` and a stray
|
||||||
|
form submission must not be able to produce one by accident.
|
||||||
|
|
||||||
|
Nothing rendered here is trusted input: every interpolated value is escaped,
|
||||||
|
and the page renders only values the service already produced rather than
|
||||||
|
echoing a raw request body back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from webui.layout import render_page
|
||||||
|
from webui.request_service import (
|
||||||
|
REQUESTABLE_ROLES,
|
||||||
|
WORK_KINDS,
|
||||||
|
RequestError,
|
||||||
|
RequestPreview,
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUESTS_PATH = "/requests"
|
||||||
|
PREVIEW_API_PATH = "/api/v1/requests/preview"
|
||||||
|
APPLY_API_PATH = "/api/v1/requests/apply"
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(text: Any) -> str:
|
||||||
|
return html.escape(str(text if text is not None else ""), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
REQUEST_PAGE_STYLES = """
|
||||||
|
<style>
|
||||||
|
.request-form { display: grid; gap: 0.75rem; max-width: 44rem; }
|
||||||
|
.request-form label { display: grid; gap: 0.25rem; font-size: 0.9rem; }
|
||||||
|
.request-check { margin: 0.35rem 0; }
|
||||||
|
.request-check .verdict-ok { color: var(--accent); }
|
||||||
|
.request-check .verdict-fail { color: #d14; }
|
||||||
|
.request-prohibited code { margin-right: 0.4rem; }
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _options(values: tuple[str, ...], selected: Any) -> str:
|
||||||
|
return "".join(
|
||||||
|
f"<option value='{_escape(value)}'"
|
||||||
|
+ (" selected" if selected == value else "")
|
||||||
|
+ f">{_escape(value)}</option>"
|
||||||
|
for value in values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _form(values: dict[str, Any] | None = None) -> str:
|
||||||
|
current = dict(values or {})
|
||||||
|
number = current.get("work_number")
|
||||||
|
return (
|
||||||
|
f"<form class='request-form' method='post' action='{REQUESTS_PATH}'>"
|
||||||
|
"<label>Desired role<select name='desired_role'>"
|
||||||
|
f"{_options(REQUESTABLE_ROLES, current.get('desired_role'))}"
|
||||||
|
"</select></label>"
|
||||||
|
"<label>Work kind<select name='work_kind'>"
|
||||||
|
f"{_options(WORK_KINDS, current.get('work_kind'))}"
|
||||||
|
"</select></label>"
|
||||||
|
"<label>Issue or PR number"
|
||||||
|
"<input type='number' name='work_number' min='1' required "
|
||||||
|
f"value='{_escape(number) if number else ''}'></label>"
|
||||||
|
"<label>Intent summary"
|
||||||
|
"<input type='text' name='intent_summary' maxlength='500' required "
|
||||||
|
f"value='{_escape(current.get('intent_summary'))}'></label>"
|
||||||
|
"<label>Expected head SHA <span class='muted'>(PR work only)</span>"
|
||||||
|
"<input type='text' name='expected_head_sha' "
|
||||||
|
f"value='{_escape(current.get('expected_head_sha'))}'></label>"
|
||||||
|
"<button type='submit' class='copy-btn'>Preview request</button>"
|
||||||
|
"<p class='muted meta'>Preview is read-only and creates no assignment. "
|
||||||
|
f"Initiating requires a confirmed POST to <code>{APPLY_API_PATH}</code>."
|
||||||
|
"</p>"
|
||||||
|
"</form>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _checks_block(preview: RequestPreview) -> str:
|
||||||
|
rows = []
|
||||||
|
for check in preview.checks:
|
||||||
|
verdict = "PASS" if check.ok else "FAIL"
|
||||||
|
css = "verdict-ok" if check.ok else "verdict-fail"
|
||||||
|
rows.append(
|
||||||
|
"<li class='request-check'>"
|
||||||
|
f"<span class='{css}'><strong>{verdict}</strong></span> "
|
||||||
|
f"<code>{_escape(check.name)}</code> — {_escape(check.detail)} "
|
||||||
|
f"<span class='muted meta'>({_escape(check.reason_code)})</span>"
|
||||||
|
"</li>"
|
||||||
|
)
|
||||||
|
return "<ul>" + "".join(rows) + "</ul>"
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_block(preview: RequestPreview) -> str:
|
||||||
|
verdict = "AUTHORIZED" if preview.authorized else "DENIED"
|
||||||
|
prohibited = "".join(
|
||||||
|
f"<code>{_escape(action)}</code>" for action in preview.prohibited_actions
|
||||||
|
)
|
||||||
|
request = preview.request
|
||||||
|
evidence = json.dumps(preview.allocator_evidence, indent=2, default=str)
|
||||||
|
return (
|
||||||
|
"<h3>Intent preview</h3>"
|
||||||
|
f"<p><strong>{verdict}</strong> — {_escape(preview.detail)}</p>"
|
||||||
|
"<p class='meta'>"
|
||||||
|
f"Role <code>{_escape(request.desired_role)}</code> · "
|
||||||
|
f"{_escape(request.work_kind)} <code>{_escape(request.display_ref)}</code>"
|
||||||
|
f" · profile <code>{_escape(preview.required_profile)}</code> · "
|
||||||
|
f"namespace <code>{_escape(preview.required_namespace)}</code> · "
|
||||||
|
f"permission <code>{_escape(preview.required_permission)}</code>"
|
||||||
|
"</p>"
|
||||||
|
f"<p>Intent: {_escape(request.intent_summary)}</p>"
|
||||||
|
f"{_checks_block(preview)}"
|
||||||
|
f"<p><strong>Next safe action:</strong> "
|
||||||
|
f"{_escape(preview.next_safe_action)}</p>"
|
||||||
|
"<p class='request-prohibited'><strong>Prohibited for this role:</strong> "
|
||||||
|
+ (prohibited or "<span class='muted'>none declared</span>")
|
||||||
|
+ "</p>"
|
||||||
|
"<p class='muted meta'>Correlation id "
|
||||||
|
f"<code>{_escape(preview.correlation_id)}</code></p>"
|
||||||
|
"<details><summary>Allocator evidence</summary>"
|
||||||
|
f"<pre class='prompt-text'>{_escape(evidence)}</pre>"
|
||||||
|
"</details>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _error_block(error: RequestError) -> str:
|
||||||
|
field = (
|
||||||
|
f"<p class='meta'>Field: <code>{_escape(error.field_name)}</code></p>"
|
||||||
|
if error.field_name
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"<h3>Request rejected</h3>"
|
||||||
|
f"<p><strong>{_escape(error.reason_code)}</strong> — "
|
||||||
|
f"{_escape(error.detail)}</p>{field}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_requests_page(
|
||||||
|
*,
|
||||||
|
preview: RequestPreview | None = None,
|
||||||
|
error: RequestError | None = None,
|
||||||
|
submitted: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render the request form, plus a preview or rejection when one exists."""
|
||||||
|
body = (
|
||||||
|
"<h2>Requests</h2>"
|
||||||
|
"<p>Submit a work request — desired role, issue or PR, and intent — "
|
||||||
|
"and see whether it would be authorized before anything is reserved. "
|
||||||
|
"Initiation goes through the allocator (#600/#613); this console never "
|
||||||
|
"self-selects work, never approves, and never merges.</p>"
|
||||||
|
+ _form(submitted)
|
||||||
|
+ (_error_block(error) if error is not None else "")
|
||||||
|
+ (_preview_block(preview) if preview is not None else "")
|
||||||
|
+ f"<p class='meta'><a href='{PREVIEW_API_PATH}'>Preview API</a> · "
|
||||||
|
"<a href='/api/console/security-model'>RBAC model</a></p>"
|
||||||
|
+ REQUEST_PAGE_STYLES
|
||||||
|
)
|
||||||
|
return render_page(title="Requests", body_html=body)
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
"""Read-only restart status, impact preview, and approval state (#667).
|
||||||
|
|
||||||
|
Phase 1 of the console restart surface. It *consumes* the #655 coordinator
|
||||||
|
substrate and renders it; it never restarts, reloads, drains, approves, or kills
|
||||||
|
anything. There is no apply path in this module, so there is no execution gate
|
||||||
|
here to arm incorrectly — the only writes the console could perform are the ones
|
||||||
|
it does not implement.
|
||||||
|
|
||||||
|
Sources, each independently fail-soft and each reported with its own
|
||||||
|
:class:`SourceStatus`:
|
||||||
|
|
||||||
|
* :mod:`restart_coordinator` — restart-class policy matrix (#663) and the
|
||||||
|
blast-radius impact report (#658).
|
||||||
|
* :mod:`drain_proof` — drain checklist and gate verdict (#661), verified
|
||||||
|
read-only against a caller-supplied proof.
|
||||||
|
* :mod:`post_restart_reconcile` — post-restart completion proof (#662).
|
||||||
|
* :mod:`webui.console_authz` — role authorization for the approval controls
|
||||||
|
(#633).
|
||||||
|
|
||||||
|
Three rules this module holds itself to, because a status surface that lies is
|
||||||
|
worse than one that is absent:
|
||||||
|
|
||||||
|
**A source that could not be read is reported unavailable, never green.** No
|
||||||
|
default, placeholder, or self-comparison is substituted for a reading that
|
||||||
|
failed. An unreadable control-plane DB yields ``inventory_complete=False``,
|
||||||
|
which the coordinator itself turns into a fail-closed verdict.
|
||||||
|
|
||||||
|
**Authorization is asked the way execution would ask it.** Every authorization
|
||||||
|
probe passes ``for_execution=True``, so the console reports whether the action
|
||||||
|
could actually run rather than the weaker "this principal is the right role".
|
||||||
|
While the console is in Phase 1 that answer is ``phase_not_active`` for every
|
||||||
|
phase-2 action, and the surface says so plainly instead of showing an allow.
|
||||||
|
|
||||||
|
**The database is opened read-only.** ``ControlPlaneDB()`` creates directories
|
||||||
|
and runs migrations on construction, which is a write; this module opens the
|
||||||
|
sqlite file with ``mode=ro`` exactly as :mod:`webui.inventory` does, and treats
|
||||||
|
a missing file as missing authority rather than an empty inventory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
|
import control_plane_db
|
||||||
|
import drain_proof
|
||||||
|
import restart_coordinator
|
||||||
|
from webui import console_authz
|
||||||
|
from webui.inventory import redact_path, scrub
|
||||||
|
|
||||||
|
# --- Source status ----------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_OK = "ok"
|
||||||
|
STATUS_UNAVAILABLE = "unavailable"
|
||||||
|
|
||||||
|
#: Console actions whose authorization state this surface reports. Both are
|
||||||
|
#: pre-existing #642 actions; this module adds no new console action because it
|
||||||
|
#: performs no console action.
|
||||||
|
REPORTED_ACTIONS: tuple[str, ...] = (
|
||||||
|
"system.restart_namespace",
|
||||||
|
"system.reload_namespace",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The break-glass workflow (#664) is not consumed here. It is declared so the
|
||||||
|
#: surface is honest about the gap rather than silently omitting a governance
|
||||||
|
#: path the operator has been told exists.
|
||||||
|
BREAK_GLASS_ISSUE = 664
|
||||||
|
BREAK_GLASS_PENDING_REASON = (
|
||||||
|
"The break-glass workflow (#664) is not yet available on this branch's "
|
||||||
|
"base; no break-glass control is offered and none is implied."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceStatus:
|
||||||
|
"""Whether one backing source could be read, and why not when it could not."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
detail: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self.status == STATUS_OK
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"status": self.status,
|
||||||
|
"available": self.available,
|
||||||
|
"detail": self.detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestartClassView:
|
||||||
|
"""One row of the #663 restart-class matrix, scoped to the viewer's role."""
|
||||||
|
|
||||||
|
restart_class: str
|
||||||
|
required_permission: str
|
||||||
|
expected_blast_radius: str
|
||||||
|
drain_requirement: str
|
||||||
|
full_drain_required: bool
|
||||||
|
approval_requirement: str
|
||||||
|
request_roles: tuple[str, ...]
|
||||||
|
execution_roles: tuple[str, ...]
|
||||||
|
viewer_may_request: bool
|
||||||
|
viewer_may_execute: bool
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"restart_class": self.restart_class,
|
||||||
|
"required_permission": self.required_permission,
|
||||||
|
"expected_blast_radius": self.expected_blast_radius,
|
||||||
|
"drain_requirement": self.drain_requirement,
|
||||||
|
"full_drain_required": self.full_drain_required,
|
||||||
|
"approval_requirement": self.approval_requirement,
|
||||||
|
"request_roles": list(self.request_roles),
|
||||||
|
"execution_roles": list(self.execution_roles),
|
||||||
|
"viewer_may_request": self.viewer_may_request,
|
||||||
|
"viewer_may_execute": self.viewer_may_execute,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionAuthorization:
|
||||||
|
"""Authorization state for one console action, asked as execution would."""
|
||||||
|
|
||||||
|
action_id: str
|
||||||
|
summary: str
|
||||||
|
required_role: str
|
||||||
|
allowed: bool
|
||||||
|
execution_enabled: bool
|
||||||
|
reason_code: str
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"action_id": self.action_id,
|
||||||
|
"summary": self.summary,
|
||||||
|
"required_role": self.required_role,
|
||||||
|
"allowed": self.allowed,
|
||||||
|
"execution_enabled": self.execution_enabled,
|
||||||
|
"reason_code": self.reason_code,
|
||||||
|
"detail": self.detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BreakGlassSurface:
|
||||||
|
"""Declared-but-unavailable break-glass panel (#664 is not on this base)."""
|
||||||
|
|
||||||
|
available: bool
|
||||||
|
issue: int
|
||||||
|
reason: str
|
||||||
|
viewer_is_privileged: bool
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"available": self.available,
|
||||||
|
"issue": self.issue,
|
||||||
|
"reason": self.reason,
|
||||||
|
"viewer_is_privileged": self.viewer_is_privileged,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RestartConsoleSnapshot:
|
||||||
|
"""Everything the read-only restart console renders."""
|
||||||
|
|
||||||
|
generated_at: str
|
||||||
|
viewer_role: str
|
||||||
|
viewer_authenticated: bool
|
||||||
|
read_only: bool
|
||||||
|
impact: dict[str, Any] | None
|
||||||
|
impact_source: SourceStatus
|
||||||
|
drain: dict[str, Any] | None
|
||||||
|
drain_source: SourceStatus
|
||||||
|
reconcile: dict[str, Any] | None
|
||||||
|
reconcile_source: SourceStatus
|
||||||
|
restart_classes: tuple[RestartClassView, ...]
|
||||||
|
authorizations: tuple[ActionAuthorization, ...]
|
||||||
|
break_glass: BreakGlassSurface
|
||||||
|
notes: tuple[str, ...] = field(default_factory=tuple)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"generated_at": self.generated_at,
|
||||||
|
"viewer_role": self.viewer_role,
|
||||||
|
"viewer_authenticated": self.viewer_authenticated,
|
||||||
|
"read_only": self.read_only,
|
||||||
|
"impact": self.impact,
|
||||||
|
"impact_source": self.impact_source.as_dict(),
|
||||||
|
"drain": self.drain,
|
||||||
|
"drain_source": self.drain_source.as_dict(),
|
||||||
|
"reconcile": self.reconcile,
|
||||||
|
"reconcile_source": self.reconcile_source.as_dict(),
|
||||||
|
"restart_classes": [c.as_dict() for c in self.restart_classes],
|
||||||
|
"authorizations": [a.as_dict() for a in self.authorizations],
|
||||||
|
"break_glass": self.break_glass.as_dict(),
|
||||||
|
"notes": list(self.notes),
|
||||||
|
"links": {
|
||||||
|
"issue": 667,
|
||||||
|
"extends": 642,
|
||||||
|
"umbrella": 655,
|
||||||
|
"coordinator": 658,
|
||||||
|
"drain_proof": 661,
|
||||||
|
"reconcile": 662,
|
||||||
|
"restart_classes": 663,
|
||||||
|
"break_glass": BREAK_GLASS_ISSUE,
|
||||||
|
"vision": 652,
|
||||||
|
"roadmap": 653,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Control-plane inventory (read-only) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def read_control_plane_inventory(
|
||||||
|
*,
|
||||||
|
db_path: str | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Read sessions and leases for an impact evaluation, read-only.
|
||||||
|
|
||||||
|
Returns the inventory mapping
|
||||||
|
:func:`restart_coordinator.evaluate_restart_impact` expects.
|
||||||
|
``inventory_complete`` is True only when every read succeeded, so a partial
|
||||||
|
read denies rather than under-reporting the blast radius.
|
||||||
|
|
||||||
|
The database is never created, migrated, or written: a missing file means
|
||||||
|
the console has no session authority, which is not the same as there being
|
||||||
|
no sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = (db_path or control_plane_db.default_db_path() or "").strip()
|
||||||
|
incomplete: list[str] = []
|
||||||
|
|
||||||
|
def _incomplete(reason: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"sessions": [],
|
||||||
|
"leases": [],
|
||||||
|
"terminal_lock": None,
|
||||||
|
"prior_recovery_attempts": [],
|
||||||
|
"inventory_complete": False,
|
||||||
|
"incomplete_reasons": [reason],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
return _incomplete("control-plane database path is not configured")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return _incomplete(
|
||||||
|
f"control-plane database not present at {redact_path(path)}; "
|
||||||
|
"no session or lease authority available"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
return _incomplete(f"control-plane database could not be opened: {exc}")
|
||||||
|
|
||||||
|
sessions: list[dict[str, Any]] = []
|
||||||
|
leases: list[dict[str, Any]] = []
|
||||||
|
capped = max(1, int(limit))
|
||||||
|
try:
|
||||||
|
tables = {
|
||||||
|
str(row[0])
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if "sessions" not in tables:
|
||||||
|
incomplete.append("control-plane database has no sessions table")
|
||||||
|
else:
|
||||||
|
sessions = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT session_id, role, profile, pid, status,"
|
||||||
|
" last_heartbeat_at FROM sessions"
|
||||||
|
" WHERE status = 'active'"
|
||||||
|
" ORDER BY last_heartbeat_at DESC LIMIT ?",
|
||||||
|
(capped,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
if "leases" not in tables:
|
||||||
|
incomplete.append("control-plane database has no leases table")
|
||||||
|
elif "work_items" not in tables:
|
||||||
|
incomplete.append(
|
||||||
|
"control-plane database has no work_items table; lease work "
|
||||||
|
"identity cannot be resolved"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
leases = [
|
||||||
|
dict(row)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT l.lease_id, l.session_id, l.role, l.phase,"
|
||||||
|
" l.status AS freshness, l.worktree_path,"
|
||||||
|
" w.kind AS work_kind, w.number AS work_number"
|
||||||
|
" FROM leases l"
|
||||||
|
" JOIN work_items w ON w.work_item_id = l.work_item_id"
|
||||||
|
" WHERE l.status = 'active'"
|
||||||
|
" ORDER BY l.expires_at DESC LIMIT ?",
|
||||||
|
(capped,),
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
return _incomplete(f"control-plane database read failed: {exc}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sessions": sessions,
|
||||||
|
"leases": leases,
|
||||||
|
"terminal_lock": None,
|
||||||
|
"prior_recovery_attempts": [],
|
||||||
|
"inventory_complete": not incomplete,
|
||||||
|
"incomplete_reasons": incomplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Composition ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_restart_class_views(viewer_role: str | None) -> tuple[RestartClassView, ...]:
|
||||||
|
"""Render the #663 class matrix, marking what this viewer may request."""
|
||||||
|
|
||||||
|
normalized = str(viewer_role or "").strip().lower()
|
||||||
|
views: list[RestartClassView] = []
|
||||||
|
for policy in restart_coordinator.RESTART_CLASS_POLICIES.values():
|
||||||
|
views.append(
|
||||||
|
RestartClassView(
|
||||||
|
restart_class=policy.restart_class.value,
|
||||||
|
required_permission=policy.required_permission,
|
||||||
|
expected_blast_radius=policy.expected_blast_radius,
|
||||||
|
drain_requirement=policy.drain_requirement,
|
||||||
|
full_drain_required=policy.full_drain_required,
|
||||||
|
approval_requirement=policy.approval_requirement,
|
||||||
|
request_roles=tuple(policy.request_roles),
|
||||||
|
execution_roles=tuple(policy.execution_roles),
|
||||||
|
viewer_may_request=normalized in policy.request_roles,
|
||||||
|
viewer_may_execute=normalized in policy.execution_roles,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(views)
|
||||||
|
|
||||||
|
|
||||||
|
def build_action_authorizations(
|
||||||
|
principal: console_authz.Principal | None,
|
||||||
|
) -> tuple[ActionAuthorization, ...]:
|
||||||
|
"""Authorization state for the approval controls, asked as execution.
|
||||||
|
|
||||||
|
``for_execution=True`` is deliberate. Asking without it answers "is this
|
||||||
|
principal senior enough", which is not the question an operator looking at a
|
||||||
|
control needs answered; asking with it answers "would this run", and while
|
||||||
|
the console is in Phase 1 the honest answer is no.
|
||||||
|
"""
|
||||||
|
|
||||||
|
results: list[ActionAuthorization] = []
|
||||||
|
for action_id in REPORTED_ACTIONS:
|
||||||
|
action = console_authz.get_action(action_id)
|
||||||
|
decision = console_authz.authorize(action_id, principal, for_execution=True)
|
||||||
|
results.append(
|
||||||
|
ActionAuthorization(
|
||||||
|
action_id=action_id,
|
||||||
|
summary=action.summary if action else "",
|
||||||
|
required_role=(
|
||||||
|
action.minimum_role if action else console_authz.OPERATOR
|
||||||
|
),
|
||||||
|
allowed=bool(decision.allowed),
|
||||||
|
execution_enabled=bool(decision.execution_enabled),
|
||||||
|
reason_code=str(decision.reason_code or ""),
|
||||||
|
detail=str(decision.detail or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
|
||||||
|
def viewer_is_privileged(principal: console_authz.Principal | None) -> bool:
|
||||||
|
"""True when the viewer holds at least the operator role."""
|
||||||
|
|
||||||
|
who = principal if principal is not None else console_authz.ANONYMOUS
|
||||||
|
if not who.authenticated:
|
||||||
|
return False
|
||||||
|
return who.rank >= console_authz.ROLE_ORDER.index(console_authz.OPERATOR)
|
||||||
|
|
||||||
|
|
||||||
|
def load_impact_report(
|
||||||
|
*,
|
||||||
|
principal: console_authz.Principal | None = None,
|
||||||
|
restart_class: str = restart_coordinator.RestartClass.FULL_MCP_RESTART.value,
|
||||||
|
db_path: str | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
read_inventory: Callable[..., Mapping[str, Any]] | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> tuple[dict[str, Any] | None, SourceStatus]:
|
||||||
|
"""Evaluate the blast radius for *restart_class*, always dry-run."""
|
||||||
|
|
||||||
|
reader = read_inventory or read_control_plane_inventory
|
||||||
|
try:
|
||||||
|
inventory = dict(reader(db_path=db_path, limit=limit))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, SourceStatus(
|
||||||
|
"impact",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
f"control-plane inventory failed: {type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
who = principal if principal is not None else console_authz.ANONYMOUS
|
||||||
|
viewer_role = str(who.role or "").strip().lower()
|
||||||
|
try:
|
||||||
|
report = restart_coordinator.evaluate_restart_impact(
|
||||||
|
inventory,
|
||||||
|
now=now,
|
||||||
|
dry_run=True,
|
||||||
|
restart_class=restart_class,
|
||||||
|
requester_role=viewer_role,
|
||||||
|
requester_permissions=restart_coordinator.permissions_for_role(
|
||||||
|
viewer_role
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, SourceStatus(
|
||||||
|
"impact",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
f"impact evaluation failed: {type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = scrub(report.as_dict())
|
||||||
|
detail = ""
|
||||||
|
if not report.inventory_complete:
|
||||||
|
detail = "; ".join(report.incomplete_reasons) or "inventory incomplete"
|
||||||
|
return payload, SourceStatus("impact", STATUS_OK, detail)
|
||||||
|
|
||||||
|
|
||||||
|
def load_drain_status(
|
||||||
|
*,
|
||||||
|
proof: Mapping[str, Any] | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
expected_impact_fingerprint: str | None = None,
|
||||||
|
) -> tuple[dict[str, Any] | None, SourceStatus]:
|
||||||
|
"""Verify a supplied drain proof read-only and report the verdict.
|
||||||
|
|
||||||
|
No proof supplied is not a failure and not a pass: it is reported as the
|
||||||
|
absence of a proof, which is exactly what the #661 gate would deny on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if proof is None:
|
||||||
|
return None, SourceStatus(
|
||||||
|
"drain",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
"no drain proof supplied; the #661 gate denies a restart without a "
|
||||||
|
"valid unexpired clean proof",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
verified = drain_proof.verify_drain_proof(
|
||||||
|
proof,
|
||||||
|
now=now,
|
||||||
|
expected_impact_fingerprint=expected_impact_fingerprint,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, SourceStatus(
|
||||||
|
"drain",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
f"drain proof verification failed: {type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
return scrub(verified.as_dict()), SourceStatus("drain", STATUS_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def load_reconcile_status(
|
||||||
|
*,
|
||||||
|
load_proof: Callable[[], Any] | None = None,
|
||||||
|
) -> tuple[dict[str, Any] | None, SourceStatus]:
|
||||||
|
"""Report the most recent post-restart completion proof (#662)."""
|
||||||
|
|
||||||
|
if load_proof is None:
|
||||||
|
return None, SourceStatus(
|
||||||
|
"reconcile",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
"no post-restart completion proof source is wired into this view",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
proof = load_proof()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return None, SourceStatus(
|
||||||
|
"reconcile",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
f"reconcile proof unavailable: {type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
if proof is None:
|
||||||
|
return None, SourceStatus(
|
||||||
|
"reconcile",
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
"no post-restart reconcile has been recorded",
|
||||||
|
)
|
||||||
|
payload = proof.as_dict() if hasattr(proof, "as_dict") else dict(proof)
|
||||||
|
return scrub(payload), SourceStatus("reconcile", STATUS_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def load_restart_console_snapshot(
|
||||||
|
*,
|
||||||
|
principal: console_authz.Principal | None = None,
|
||||||
|
restart_class: str = restart_coordinator.RestartClass.FULL_MCP_RESTART.value,
|
||||||
|
db_path: str | None = None,
|
||||||
|
limit: int = 200,
|
||||||
|
drain_proof_payload: Mapping[str, Any] | None = None,
|
||||||
|
read_inventory: Callable[..., Mapping[str, Any]] | None = None,
|
||||||
|
load_reconcile_proof: Callable[[], Any] | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> RestartConsoleSnapshot:
|
||||||
|
"""Compose the read-only restart console snapshot."""
|
||||||
|
|
||||||
|
who = principal if principal is not None else console_authz.ANONYMOUS
|
||||||
|
moment = now or _utc_now()
|
||||||
|
|
||||||
|
impact, impact_source = load_impact_report(
|
||||||
|
principal=who,
|
||||||
|
restart_class=restart_class,
|
||||||
|
db_path=db_path,
|
||||||
|
limit=limit,
|
||||||
|
read_inventory=read_inventory,
|
||||||
|
now=moment,
|
||||||
|
)
|
||||||
|
fingerprint = None
|
||||||
|
if impact is not None:
|
||||||
|
try:
|
||||||
|
fingerprint = drain_proof.impact_fingerprint(impact)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
fingerprint = None
|
||||||
|
|
||||||
|
drain, drain_source = load_drain_status(
|
||||||
|
proof=drain_proof_payload,
|
||||||
|
now=moment,
|
||||||
|
expected_impact_fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
reconcile, reconcile_source = load_reconcile_status(
|
||||||
|
load_proof=load_reconcile_proof
|
||||||
|
)
|
||||||
|
|
||||||
|
notes: list[str] = [
|
||||||
|
"This surface is read-only: it evaluates and displays, and performs no "
|
||||||
|
"restart, reload, drain, approval, or process action.",
|
||||||
|
]
|
||||||
|
if not impact_source.available:
|
||||||
|
notes.append(
|
||||||
|
"Impact preview unavailable — a restart decision must not be made "
|
||||||
|
"from this page while the blast radius is unknown."
|
||||||
|
)
|
||||||
|
|
||||||
|
return RestartConsoleSnapshot(
|
||||||
|
generated_at=moment.isoformat(),
|
||||||
|
viewer_role=str(who.role or "anonymous"),
|
||||||
|
viewer_authenticated=bool(who.authenticated),
|
||||||
|
read_only=True,
|
||||||
|
impact=impact,
|
||||||
|
impact_source=impact_source,
|
||||||
|
drain=drain,
|
||||||
|
drain_source=drain_source,
|
||||||
|
reconcile=reconcile,
|
||||||
|
reconcile_source=reconcile_source,
|
||||||
|
restart_classes=build_restart_class_views(who.role),
|
||||||
|
authorizations=build_action_authorizations(who),
|
||||||
|
break_glass=BreakGlassSurface(
|
||||||
|
available=False,
|
||||||
|
issue=BREAK_GLASS_ISSUE,
|
||||||
|
reason=BREAK_GLASS_PENDING_REASON,
|
||||||
|
viewer_is_privileged=viewer_is_privileged(who),
|
||||||
|
),
|
||||||
|
notes=tuple(notes),
|
||||||
|
)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""HTML views for the read-only restart console (#667).
|
||||||
|
|
||||||
|
Every interpolated value passes through :func:`_esc`. Values that can carry a
|
||||||
|
filesystem path or free-form operator text additionally pass through
|
||||||
|
:func:`webui.inventory.scrub_text`, which redacts credential-shaped tokens
|
||||||
|
*inside* a string rather than only at its start.
|
||||||
|
|
||||||
|
The page renders state and never offers a control that would mutate anything:
|
||||||
|
the approval and break-glass panels report authorization and availability, and
|
||||||
|
there is no form, button, or endpoint behind them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
|
||||||
|
from webui.inventory import scrub_text
|
||||||
|
from webui.restart_console import RestartConsoleSnapshot, SourceStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(value: object) -> str:
|
||||||
|
"""Escape any value for HTML text or a quoted attribute."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return html.escape(str(value), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _esc_text(value: object) -> str:
|
||||||
|
"""Escape free-form text after redacting secrets embedded inside it."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return _esc(scrub_text(str(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_badge(
|
||||||
|
value: bool, *, true_label: str = "yes", false_label: str = "no"
|
||||||
|
) -> str:
|
||||||
|
css = "badge-ok" if value else "badge-blocked"
|
||||||
|
label = true_label if value else false_label
|
||||||
|
return f'<span class="badge {css}">{_esc(label)}</span>'
|
||||||
|
|
||||||
|
|
||||||
|
def _source_badge(source: SourceStatus) -> str:
|
||||||
|
css = "badge-ok" if source.available else "badge-blocked"
|
||||||
|
badge = f'<span class="badge {css}">{_esc(source.status)}</span>'
|
||||||
|
if source.detail:
|
||||||
|
badge += f' <span class="muted">{_esc_text(source.detail)}</span>'
|
||||||
|
return badge
|
||||||
|
|
||||||
|
|
||||||
|
def _notes_block(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
if not snapshot.notes:
|
||||||
|
return ""
|
||||||
|
items = "".join(f"<li>{_esc_text(note)}</li>" for note in snapshot.notes)
|
||||||
|
return f"<ul class='reasons'>{items}</ul>"
|
||||||
|
|
||||||
|
|
||||||
|
def _impact_section(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
head = (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
f"<h3>Impact preview {_source_badge(snapshot.impact_source)}</h3>"
|
||||||
|
)
|
||||||
|
impact = snapshot.impact
|
||||||
|
if impact is None:
|
||||||
|
return (
|
||||||
|
head
|
||||||
|
+ "<p class='muted'>No impact preview is available, so the blast "
|
||||||
|
"radius of a restart is unknown. Treat this as unsafe.</p></section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = impact.get("counts") or {}
|
||||||
|
verdict = str(impact.get("verdict") or "unknown")
|
||||||
|
verdict_css = "badge-ok" if verdict == "safe" else "badge-blocked"
|
||||||
|
rows = "".join(
|
||||||
|
f"<tr><th>{_esc(key.replace('_', ' '))}</th><td>{_esc(value)}</td></tr>"
|
||||||
|
for key, value in sorted(counts.items())
|
||||||
|
)
|
||||||
|
reasons = "".join(
|
||||||
|
f"<li>{_esc_text(reason)}</li>" for reason in (impact.get("reasons") or [])
|
||||||
|
)
|
||||||
|
incomplete = ""
|
||||||
|
if not impact.get("inventory_complete", False):
|
||||||
|
detail = "; ".join(str(r) for r in (impact.get("incomplete_reasons") or []))
|
||||||
|
incomplete = (
|
||||||
|
"<p class='error'><strong>Inventory incomplete:</strong> "
|
||||||
|
f"{_esc_text(detail or 'unspecified')}. The coordinator fails "
|
||||||
|
"closed on an incomplete inventory.</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions = impact.get("affected_sessions") or []
|
||||||
|
session_rows = "".join(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><code>{_esc(s.get('session_id'))}</code></td>"
|
||||||
|
f"<td>{_esc(s.get('role'))}</td>"
|
||||||
|
f"<td>{_esc(s.get('pid'))}</td>"
|
||||||
|
f"<td>{_bool_badge(bool(s.get('live')), true_label='live', false_label='idle')}</td>"
|
||||||
|
f"<td>{_bool_badge(not s.get('heartbeat_stale'), true_label='fresh', false_label='stale')}</td>"
|
||||||
|
"</tr>"
|
||||||
|
for s in sessions[:50]
|
||||||
|
)
|
||||||
|
session_table = (
|
||||||
|
"<h4>Sessions a restart would terminate</h4>"
|
||||||
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
||||||
|
"<th>Session</th><th>Role</th><th>PID</th><th>State</th>"
|
||||||
|
"<th>Heartbeat</th></tr></thead><tbody>"
|
||||||
|
f"{session_rows}</tbody></table></div>"
|
||||||
|
if session_rows
|
||||||
|
else "<p class='muted'>No affected sessions reported.</p>"
|
||||||
|
)
|
||||||
|
truncated = (
|
||||||
|
f"<p class='muted'>Showing the first 50 of {_esc(len(sessions))} "
|
||||||
|
"affected sessions.</p>"
|
||||||
|
if len(sessions) > 50
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
head
|
||||||
|
+ "<p class='health-headline'>Verdict "
|
||||||
|
f"<span class='badge {verdict_css}'>{_esc(verdict)}</span> · "
|
||||||
|
f"blast radius <code>{_esc(impact.get('blast_radius'))}</code> · "
|
||||||
|
f"class <code>{_esc(impact.get('restart_class'))}</code></p>"
|
||||||
|
+ incomplete
|
||||||
|
+ (f"<ul class='reasons'>{reasons}</ul>" if reasons else "")
|
||||||
|
+ (f"<table class='registry'><tbody>{rows}</tbody></table>" if rows else "")
|
||||||
|
+ session_table
|
||||||
|
+ truncated
|
||||||
|
+ "</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drain_section(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
head = (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
f"<h3>Drain proof {_source_badge(snapshot.drain_source)}</h3>"
|
||||||
|
)
|
||||||
|
drain = snapshot.drain
|
||||||
|
if drain is None:
|
||||||
|
return (
|
||||||
|
head
|
||||||
|
+ "<p class='muted'>No drain proof has been presented to this view. "
|
||||||
|
"The #661 gate authorizes a restart only against a valid, unexpired, "
|
||||||
|
"clean proof, so the absence of one is a denial, not a pass.</p>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
reasons = "".join(
|
||||||
|
f"<li>{_esc_text(reason)}</li>" for reason in (drain.get("reasons") or [])
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
head
|
||||||
|
+ "<table class='registry'><tbody>"
|
||||||
|
f"<tr><th>Valid</th><td>{_bool_badge(bool(drain.get('valid')))}</td></tr>"
|
||||||
|
f"<tr><th>Clean</th><td>{_bool_badge(bool(drain.get('clean')))}</td></tr>"
|
||||||
|
f"<tr><th>Expired</th><td>{_bool_badge(not drain.get('expired'), true_label='no', false_label='yes')}</td></tr>"
|
||||||
|
f"<tr><th>Tampered</th><td>{_bool_badge(not drain.get('tampered'), true_label='no', false_label='yes')}</td></tr>"
|
||||||
|
f"<tr><th>Proof id</th><td><code>{_esc(drain.get('proof_id'))}</code></td></tr>"
|
||||||
|
"</tbody></table>"
|
||||||
|
+ (f"<ul class='reasons'>{reasons}</ul>" if reasons else "")
|
||||||
|
+ "</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_section(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
head = (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
f"<h3>Post-restart reconcile {_source_badge(snapshot.reconcile_source)}</h3>"
|
||||||
|
)
|
||||||
|
proof = snapshot.reconcile
|
||||||
|
if proof is None:
|
||||||
|
return (
|
||||||
|
head
|
||||||
|
+ "<p class='muted'>No post-restart completion proof is recorded. "
|
||||||
|
"Until one is, the last restart's recovery state is unproven.</p>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
items = "".join(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{_esc(item.get('dimension'))}</td>"
|
||||||
|
f"<td>{_esc(item.get('status'))}</td>"
|
||||||
|
f"<td>{_esc_text(item.get('summary'))}</td>"
|
||||||
|
f"<td>{_bool_badge(not item.get('follow_up_required'), true_label='no', false_label='yes')}</td>"
|
||||||
|
"</tr>"
|
||||||
|
for item in (proof.get("items") or [])
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
head
|
||||||
|
+ "<p class='health-headline'>Status "
|
||||||
|
f"<code>{_esc(proof.get('overall_status'))}</code> · mode "
|
||||||
|
f"<code>{_esc(proof.get('mode'))}</code> · resolved "
|
||||||
|
f"{_esc(proof.get('resolved_count'))} · unresolved "
|
||||||
|
f"{_esc(proof.get('unresolved_count'))}</p>"
|
||||||
|
+ (
|
||||||
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
||||||
|
"<th>Dimension</th><th>Status</th><th>Summary</th>"
|
||||||
|
"<th>Follow-up required</th></tr></thead><tbody>"
|
||||||
|
f"{items}</tbody></table></div>"
|
||||||
|
if items
|
||||||
|
else "<p class='muted'>No reconcile dimensions reported.</p>"
|
||||||
|
)
|
||||||
|
+ "</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _class_matrix_section(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
rows = "".join(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><code>{_esc(view.restart_class)}</code></td>"
|
||||||
|
f"<td><code>{_esc(view.required_permission)}</code></td>"
|
||||||
|
f"<td>{_esc(view.expected_blast_radius)}</td>"
|
||||||
|
f"<td>{_esc(view.drain_requirement)}</td>"
|
||||||
|
f"<td>{_esc(view.approval_requirement)}</td>"
|
||||||
|
f"<td>{_bool_badge(view.viewer_may_request)}</td>"
|
||||||
|
f"<td>{_bool_badge(view.viewer_may_execute)}</td>"
|
||||||
|
"</tr>"
|
||||||
|
for view in snapshot.restart_classes
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
"<h3>Restart classes</h3>"
|
||||||
|
"<p class='muted'>The least-privilege matrix each restart request is "
|
||||||
|
"resolved against. “You may request” and “you may "
|
||||||
|
"execute” are computed for the current viewer role, not for a "
|
||||||
|
"generic operator.</p>"
|
||||||
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
||||||
|
"<th>Class</th><th>Permission</th><th>Blast radius</th>"
|
||||||
|
"<th>Drain</th><th>Approval</th><th>You may request</th>"
|
||||||
|
"<th>You may execute</th></tr></thead><tbody>"
|
||||||
|
f"{rows}</tbody></table></div>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _approval_section(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
rows = "".join(
|
||||||
|
"<tr>"
|
||||||
|
f"<td><code>{_esc(a.action_id)}</code></td>"
|
||||||
|
f"<td>{_esc(a.required_role)}</td>"
|
||||||
|
f"<td>{_bool_badge(a.allowed)}</td>"
|
||||||
|
f"<td>{_bool_badge(a.execution_enabled)}</td>"
|
||||||
|
f"<td><code>{_esc(a.reason_code)}</code></td>"
|
||||||
|
f"<td>{_esc_text(a.detail)}</td>"
|
||||||
|
"</tr>"
|
||||||
|
for a in snapshot.authorizations
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
"<h3>Approval controls</h3>"
|
||||||
|
"<p class='muted'>Authorization is probed the way execution would probe "
|
||||||
|
"it, so “execution enabled” answers whether the action would "
|
||||||
|
"actually run — not merely whether this role outranks the requirement. "
|
||||||
|
"No control on this page performs the action.</p>"
|
||||||
|
"<div class='table-scroll'><table class='registry'><thead><tr>"
|
||||||
|
"<th>Action</th><th>Required role</th><th>Authorized</th>"
|
||||||
|
"<th>Execution enabled</th><th>Reason</th><th>Detail</th>"
|
||||||
|
"</tr></thead><tbody>"
|
||||||
|
f"{rows}</tbody></table></div>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _break_glass_section(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
bg = snapshot.break_glass
|
||||||
|
if not bg.viewer_is_privileged:
|
||||||
|
return (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
"<h3>Break-glass</h3>"
|
||||||
|
"<p class='muted'>Break-glass status is visible to operator-class "
|
||||||
|
"roles only. Your role does not carry that authority, so no "
|
||||||
|
"emergency surface is shown.</p>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"<section class='health-card'>"
|
||||||
|
"<h3>Break-glass "
|
||||||
|
f"{_bool_badge(bg.available, true_label='available', false_label='unavailable')}"
|
||||||
|
"</h3>"
|
||||||
|
f"<p class='muted'>{_esc_text(bg.reason)}</p>"
|
||||||
|
f"<p class='meta'>Tracked by issue #{_esc(bg.issue)}.</p>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_restart_console_page(snapshot: RestartConsoleSnapshot) -> str:
|
||||||
|
"""Render the whole read-only restart console body."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
"<h2>Restart status and impact</h2>"
|
||||||
|
f"<p class='meta'>Generated <code>{_esc(snapshot.generated_at)}</code> · "
|
||||||
|
f"viewer role <code>{_esc(snapshot.viewer_role)}</code> · "
|
||||||
|
f"authenticated {_bool_badge(snapshot.viewer_authenticated)} · "
|
||||||
|
f"read-only {_bool_badge(snapshot.read_only)}</p>"
|
||||||
|
+ _notes_block(snapshot)
|
||||||
|
+ _impact_section(snapshot)
|
||||||
|
+ _drain_section(snapshot)
|
||||||
|
+ _reconcile_section(snapshot)
|
||||||
|
+ _class_matrix_section(snapshot)
|
||||||
|
+ _approval_section(snapshot)
|
||||||
|
+ _break_glass_section(snapshot)
|
||||||
|
)
|
||||||
@@ -270,24 +270,51 @@ def _probe_error_card(snapshot: SystemHealthSnapshot) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _recovery_card() -> str:
|
def _recovery_card() -> str:
|
||||||
"""Sanctioned recovery pointers only — never a manual process kill (#630)."""
|
"""Sanctioned recovery controls & playbooks (#644, Phase 2)."""
|
||||||
|
try:
|
||||||
|
from webui import console_recovery
|
||||||
|
diag = console_recovery.diagnose_recovery()
|
||||||
|
# Every other card in this file escapes at the interpolation boundary.
|
||||||
|
# This one did not, and it is where a #630 marker's operator-supplied
|
||||||
|
# command_summary lands once the contamination gate is wired.
|
||||||
|
status_badge = (
|
||||||
|
f"<span class='status-pill {_esc(diag.status)}'>{_esc(diag.status)}</span>"
|
||||||
|
)
|
||||||
|
playbook_lis = ""
|
||||||
|
for pb in diag.playbooks:
|
||||||
|
elig = "eligible" if pb.eligible else "disabled"
|
||||||
|
playbook_lis += (
|
||||||
|
f"<li><strong>{_esc(pb.label)}</strong> "
|
||||||
|
f"(<code>{_esc(pb.playbook_id)}</code>) — "
|
||||||
|
f"<span class='badge {elig}'>{elig}</span>: {_esc(pb.description)} "
|
||||||
|
f"<em class='muted'>({_esc(pb.reason)})</em></li>"
|
||||||
|
)
|
||||||
|
reasons_html = ""
|
||||||
|
if diag.reasons:
|
||||||
|
items = "".join(f"<li>{_esc(r)}</li>" for r in diag.reasons)
|
||||||
|
reasons_html = f"<ul class='reasons'>{items}</ul>"
|
||||||
|
else:
|
||||||
|
reasons_html = "<p class='clean-note'>No recovery actions currently required. Control plane is healthy.</p>"
|
||||||
|
|
||||||
|
return (
|
||||||
|
"<section class='health-card recovery-card'>"
|
||||||
|
f"<h3>Sanctioned Recovery Controls (Phase 2 #644) {status_badge}</h3>"
|
||||||
|
"<p class='muted'>Guided recovery wizard: Diagnose → Preview → Confirm → Verify. "
|
||||||
|
"Reconnect the MCP client from the IDE, then re-run the blocked cycle. "
|
||||||
|
"Never kill the daemon process manually: unmanaged kills are recorded as runtime contamination (#630).</p>"
|
||||||
|
f"{reasons_html}"
|
||||||
|
"<h4>Available Recovery Playbooks</h4>"
|
||||||
|
f"<ul class='playbooks-list'>{playbook_lis}</ul>"
|
||||||
|
"<p class='meta'>APIs: <code>/api/v1/system/recovery/diagnose</code>, "
|
||||||
|
"<code>/api/v1/system/recovery/preview</code>, <code>/api/v1/system/recovery/apply</code>, "
|
||||||
|
"<code>/api/v1/system/recovery/verify</code>.</p>"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
return (
|
return (
|
||||||
"<section class='health-card'>"
|
"<section class='health-card'>"
|
||||||
"<h3>Recovery</h3>"
|
"<h3>Sanctioned Recovery Controls (Phase 2 #644)</h3>"
|
||||||
"<p class='muted'>This dashboard is read-only. Restart and reload "
|
f"<p class='error'>Recovery diagnostics unavailable: {_esc(exc)}</p>"
|
||||||
"controls arrive in Phase 2 (#642); until then recovery runs through "
|
|
||||||
"the sanctioned client reconnect / operator restart path.</p>"
|
|
||||||
"<ul class='reasons'>"
|
|
||||||
"<li><a href='/runtime'>Runtime health</a> — active profile, workflow "
|
|
||||||
"hashes, and shell health.</li>"
|
|
||||||
"<li><a href='/sessions'>Runtime and sessions</a> — namespaces, session "
|
|
||||||
"rows, worktree bindings, and contamination markers (#641).</li>"
|
|
||||||
"<li>Reconnect the MCP client from the IDE, then re-run the blocked "
|
|
||||||
"cycle. Never kill the daemon process manually: unmanaged kills are "
|
|
||||||
"recorded as runtime contamination (#630).</li>"
|
|
||||||
"<li>See <code>docs/webui-local-dev.md</code> for the documented "
|
|
||||||
"recovery sequence.</li>"
|
|
||||||
"</ul>"
|
|
||||||
"</section>"
|
"</section>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,16 @@ def _candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate
|
|||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def candidates_from_queue_snapshot(q_snap: QueueSnapshot) -> list[WorkCandidate]:
|
||||||
|
"""Public alias for :func:`_candidates_from_queue_snapshot` (#643).
|
||||||
|
|
||||||
|
The request-initiation service ranks the same candidate set this view
|
||||||
|
renders, so both must agree on how a queue row becomes a candidate. One
|
||||||
|
construction, two callers — not two that can drift apart.
|
||||||
|
"""
|
||||||
|
return _candidates_from_queue_snapshot(q_snap)
|
||||||
|
|
||||||
|
|
||||||
def _claim_lease_records(inventory: dict[str, Any] | None) -> list[dict[str, Any]]:
|
def _claim_lease_records(inventory: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
"""Normalize ``build_claim_inventory`` entries into lease records.
|
"""Normalize ``build_claim_inventory`` entries into lease records.
|
||||||
|
|
||||||
|
|||||||
+30
-1
@@ -279,6 +279,7 @@ def assess_root_source_mutation(
|
|||||||
locked_issue_number: int | None = None,
|
locked_issue_number: int | None = None,
|
||||||
role_kind: str | None = None,
|
role_kind: str | None = None,
|
||||||
mutation_task: str | None = None,
|
mutation_task: str | None = None,
|
||||||
|
bootstrap_assessment: Any | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
"""Fail closed for diagnostic/source edits on the control/root checkout.
|
||||||
|
|
||||||
@@ -286,6 +287,13 @@ def assess_root_source_mutation(
|
|||||||
tracked source/test files on the control checkout always block, including
|
tracked source/test files on the control checkout always block, including
|
||||||
temporary/diagnostic/test-only intent.
|
temporary/diagnostic/test-only intent.
|
||||||
|
|
||||||
|
#941: ``bootstrap_author_issue_worktree`` is judged by the canonical
|
||||||
|
``create_issue_bootstrap.bootstrap_permits_control_checkout`` decision over
|
||||||
|
*bootstrap_assessment* — the same server-derived evidence the #274 and
|
||||||
|
#604 guards consume — instead of a task-name allowlist local to this
|
||||||
|
module. Evidence that is absent, malformed, wrongly scoped, or bound to
|
||||||
|
another workspace leaves the ordinary block in force.
|
||||||
|
|
||||||
#749: ``create_issue`` is a pure remote mutation with no local tree write.
|
#749: ``create_issue`` is a pure remote mutation with no local tree write.
|
||||||
When *mutation_task* is create_issue and the control checkout has no dirty
|
When *mutation_task* is create_issue and the control checkout has no dirty
|
||||||
source/test files, the missing-worktree signal is suppressed so the
|
source/test files, the missing-worktree signal is suppressed so the
|
||||||
@@ -336,6 +344,19 @@ def assess_root_source_mutation(
|
|||||||
if _cib is not None and _cib.is_create_issue_task(mutation_task):
|
if _cib is not None and _cib.is_create_issue_task(mutation_task):
|
||||||
# #749: clean-root create_issue is the sanctioned bootstrap path.
|
# #749: clean-root create_issue is the sanctioned bootstrap path.
|
||||||
create_issue_bootstrap = True
|
create_issue_bootstrap = True
|
||||||
|
elif _cib is not None and _cib.bootstrap_permits_control_checkout(
|
||||||
|
bootstrap_assessment,
|
||||||
|
task=mutation_task,
|
||||||
|
workspace_path=workspace,
|
||||||
|
canonical_repo_root=root,
|
||||||
|
):
|
||||||
|
# #941: the author issue-worktree bootstrap is authorized by the
|
||||||
|
# canonical shared decision over server-derived task-scope
|
||||||
|
# evidence, never by a task-name allowlist kept in this module.
|
||||||
|
# The predicate fails closed on missing, malformed, cross-scope,
|
||||||
|
# dirty, drifted, or wrongly bound evidence, so this arm cannot
|
||||||
|
# widen the waiver beyond the one sanctioned bootstrap task.
|
||||||
|
create_issue_bootstrap = True
|
||||||
else:
|
else:
|
||||||
# Explicit missing-worktree signal for force-on author entrypoints.
|
# Explicit missing-worktree signal for force-on author entrypoints.
|
||||||
reasons.append(
|
reasons.append(
|
||||||
@@ -393,8 +414,15 @@ def assess_production_mutation_guards(
|
|||||||
require_author_lock: bool = False,
|
require_author_lock: bool = False,
|
||||||
in_test_mode: bool = False,
|
in_test_mode: bool = False,
|
||||||
mutation_task: str | None = None,
|
mutation_task: str | None = None,
|
||||||
|
bootstrap_assessment: Any | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Compose root + scope production guards when they must be active (#683)."""
|
"""Compose root + scope production guards when they must be active (#683).
|
||||||
|
|
||||||
|
#941: *bootstrap_assessment* is the server-derived author-bootstrap
|
||||||
|
evidence, forwarded unchanged to :func:`assess_root_source_mutation` so
|
||||||
|
this guard reaches the same canonical decision as the #274 and #604
|
||||||
|
guards. Omitting it preserves the pre-existing behaviour.
|
||||||
|
"""
|
||||||
if not production_guards_active(in_test_mode=in_test_mode):
|
if not production_guards_active(in_test_mode=in_test_mode):
|
||||||
return {
|
return {
|
||||||
"proven": True,
|
"proven": True,
|
||||||
@@ -414,6 +442,7 @@ def assess_production_mutation_guards(
|
|||||||
locked_issue_number=locked_issue_number,
|
locked_issue_number=locked_issue_number,
|
||||||
role_kind=role_kind,
|
role_kind=role_kind,
|
||||||
mutation_task=mutation_task,
|
mutation_task=mutation_task,
|
||||||
|
bootstrap_assessment=bootstrap_assessment,
|
||||||
)
|
)
|
||||||
if root_assess["block"]:
|
if root_assess["block"]:
|
||||||
return {**root_assess, "skipped": False}
|
return {**root_assess, "skipped": False}
|
||||||
|
|||||||
Reference in New Issue
Block a user