Merge branch 'master' into feat/issue-637-timeline-model
This commit is contained in:
@@ -74,6 +74,11 @@ status, onboarding checklist state, and the fail-closed error payloads (#635).
|
|||||||
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||||
| `/leases` | Lease and collision visibility (#433) |
|
| `/leases` | Lease and collision visibility (#433) |
|
||||||
| `/api/leases` | JSON lease/collision export |
|
| `/api/leases` | JSON lease/collision export |
|
||||||
|
| `/sessions` | Phase 1 shell stub — session inventory (backed by #636) |
|
||||||
|
| `/inventory` | Phase 1 shell stub — unified inventory (backed by #636) |
|
||||||
|
| `/timeline` | Phase 1 shell stub — workflow event timeline |
|
||||||
|
| `/policy` | Phase 1 shell stub — capability/role policy placeholder |
|
||||||
|
| `/insights` | Phase 1 shell stub — operational insights placeholder |
|
||||||
|
|
||||||
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||||
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||||
@@ -233,6 +238,26 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
|||||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||||
no tokens or MCP restart actions are exposed.
|
no tokens or MCP restart actions are exposed.
|
||||||
|
|
||||||
|
## Application shell — Phase 1 (#638)
|
||||||
|
|
||||||
|
The console shell (`webui/layout.py`) renders a grouped navigation driven by a
|
||||||
|
single nav-config module, `webui/nav.py`. Nav groups follow the epic #631
|
||||||
|
Phase 1 information architecture: **Health, Traffic, Runtime/Sessions,
|
||||||
|
Projects, Inventory, Timeline, Policy** (placeholder), and **Insights**
|
||||||
|
(placeholder). Live views and Phase 1 placeholders (`stub`) are declared in one
|
||||||
|
place so the layout and the route table cannot drift.
|
||||||
|
|
||||||
|
The header carries two read-only status badges — an **environment** badge
|
||||||
|
(`local` for loopback binds, `remote` otherwise, derived from `WEBUI_HOST`) and
|
||||||
|
a **mode: read-only** badge — plus a **Docs** link to this document. No
|
||||||
|
privileged action controls are present in the Phase 1 shell.
|
||||||
|
|
||||||
|
Not-yet-implemented surfaces (`/sessions`, `/inventory`, `/timeline`,
|
||||||
|
`/policy`, `/insights`) resolve to graceful read-only stub pages instead of
|
||||||
|
404s; their backing views land in later child issues of #631 (the inventory
|
||||||
|
surfaces are backed by #636). Mutating methods on stub routes still fail closed
|
||||||
|
with `read-only-mvp`.
|
||||||
|
|
||||||
## Deployment boundary (#435)
|
## Deployment boundary (#435)
|
||||||
|
|
||||||
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
||||||
|
|||||||
+62
-26
@@ -11242,25 +11242,22 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
report["dry_run"] = True
|
report["dry_run"] = True
|
||||||
report["executed"] = False
|
report["executed"] = False
|
||||||
|
# #851: surface planned lifecycle order so dry-run matches execute.
|
||||||
|
report["planned_execution_orders"] = {
|
||||||
|
str(entry.get("pr_number")): entry.get("planned_execution_order") or []
|
||||||
|
for entry in (report.get("entries") or [])
|
||||||
|
}
|
||||||
return {"success": True, "performed": False, **report}
|
return {"success": True, "performed": False, **report}
|
||||||
|
|
||||||
verify_preflight_purity(
|
verify_preflight_purity(
|
||||||
remote, task="reconcile_merged_cleanups", org=org, repo=repo
|
remote, task="reconcile_merged_cleanups", org=org, repo=repo
|
||||||
)
|
)
|
||||||
actions: list[dict] = []
|
actions: list[dict] = []
|
||||||
for entry in report.get("entries") or []:
|
project_root = _canonical_local_git_root()
|
||||||
head_branch = entry.get("head_branch") or ""
|
|
||||||
remote_assessment = entry.get("remote_branch") or {}
|
|
||||||
local_assessment = entry.get("local_worktree") or {}
|
|
||||||
|
|
||||||
if remote_assessment.get("safe_to_delete_remote"):
|
def _ownership_records_for_branch(
|
||||||
import urllib.parse
|
head_branch: str, pr_num_int: int | None
|
||||||
|
) -> list[dict]:
|
||||||
pr_num = entry.get("pr_number")
|
|
||||||
try:
|
|
||||||
pr_num_int = int(pr_num) if pr_num is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pr_num_int = None
|
|
||||||
ownership_bundle = _collect_branch_ownership_records(
|
ownership_bundle = _collect_branch_ownership_records(
|
||||||
remote=remote,
|
remote=remote,
|
||||||
host=h,
|
host=h,
|
||||||
@@ -11268,7 +11265,7 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
repo=r,
|
repo=r,
|
||||||
branch=head_branch,
|
branch=head_branch,
|
||||||
pr_number=pr_num_int,
|
pr_number=pr_num_int,
|
||||||
project_root=_canonical_local_git_root(),
|
project_root=project_root,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
base_api=base,
|
base_api=base,
|
||||||
)
|
)
|
||||||
@@ -11289,6 +11286,18 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
"role": "inventory",
|
"role": "inventory",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
return ownership_records
|
||||||
|
|
||||||
|
def _attempt_owned_remote_delete(
|
||||||
|
*,
|
||||||
|
head_branch: str,
|
||||||
|
pr_num_int: int | None,
|
||||||
|
after_worktree_removal: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""Fail-closed remote delete with live ownership reassessment (#851)."""
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
ownership_records = _ownership_records_for_branch(head_branch, pr_num_int)
|
||||||
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
ownership = branch_cleanup_guard.assess_active_branch_ownership(
|
||||||
remote=remote,
|
remote=remote,
|
||||||
org=o,
|
org=o,
|
||||||
@@ -11298,8 +11307,7 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
records=ownership_records,
|
records=ownership_records,
|
||||||
)
|
)
|
||||||
if ownership.get("block"):
|
if ownership.get("block"):
|
||||||
actions.append(
|
return {
|
||||||
{
|
|
||||||
"action": "delete_remote_branch",
|
"action": "delete_remote_branch",
|
||||||
"branch": head_branch,
|
"branch": head_branch,
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -11308,13 +11316,10 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
"verified_absent": False,
|
"verified_absent": False,
|
||||||
"blocker_kind": "active_branch_ownership",
|
"blocker_kind": "active_branch_ownership",
|
||||||
"reasons": ownership.get("reasons") or [],
|
"reasons": ownership.get("reasons") or [],
|
||||||
"blocking_categories": ownership.get(
|
"blocking_categories": ownership.get("blocking_categories") or [],
|
||||||
"blocking_categories"
|
"after_worktree_removal": after_worktree_removal,
|
||||||
)
|
"ownership_reassessed": after_worktree_removal,
|
||||||
or [],
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
encoded = urllib.parse.quote(head_branch, safe="")
|
encoded = urllib.parse.quote(head_branch, safe="")
|
||||||
url = f"{base}/branches/{encoded}"
|
url = f"{base}/branches/{encoded}"
|
||||||
@@ -11329,6 +11334,7 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
"branch": head_branch,
|
"branch": head_branch,
|
||||||
"source": "reconcile_merged_cleanups",
|
"source": "reconcile_merged_cleanups",
|
||||||
"ownership_checked": True,
|
"ownership_checked": True,
|
||||||
|
"after_worktree_removal": after_worktree_removal,
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
api_request("DELETE", url, auth)
|
api_request("DELETE", url, auth)
|
||||||
@@ -11337,8 +11343,7 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
readback
|
readback
|
||||||
)
|
)
|
||||||
verified = bool(readback_assessment.get("verified_absent"))
|
verified = bool(readback_assessment.get("verified_absent"))
|
||||||
actions.append(
|
return {
|
||||||
{
|
|
||||||
"action": "delete_remote_branch",
|
"action": "delete_remote_branch",
|
||||||
"branch": head_branch,
|
"branch": head_branch,
|
||||||
"success": bool(readback_assessment.get("ok")),
|
"success": bool(readback_assessment.get("ok")),
|
||||||
@@ -11347,22 +11352,53 @@ def gitea_reconcile_merged_cleanups(
|
|||||||
"verified_absent": verified,
|
"verified_absent": verified,
|
||||||
"readback": readback_assessment.get("readback"),
|
"readback": readback_assessment.get("readback"),
|
||||||
"reasons": readback_assessment.get("reasons") or [],
|
"reasons": readback_assessment.get("reasons") or [],
|
||||||
|
"after_worktree_removal": after_worktree_removal,
|
||||||
|
"ownership_reassessed": after_worktree_removal,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
for entry in report.get("entries") or []:
|
||||||
|
head_branch = entry.get("head_branch") or ""
|
||||||
|
remote_assessment = entry.get("remote_branch") or {}
|
||||||
|
local_assessment = entry.get("local_worktree") or {}
|
||||||
|
pr_num = entry.get("pr_number")
|
||||||
|
try:
|
||||||
|
pr_num_int = int(pr_num) if pr_num is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pr_num_int = None
|
||||||
|
|
||||||
|
# #851 lifecycle: when the target worktree is independently safe, remove
|
||||||
|
# it first so worktree_binding ownership does not permanently strand
|
||||||
|
# both the worktree and the remote branch. Never skip worktree removal
|
||||||
|
# merely because remote delete would be blocked by that binding.
|
||||||
|
# Ownership protection for remote delete remains fail-closed below.
|
||||||
|
worktree_removed = False
|
||||||
if local_assessment.get("safe_to_remove_worktree"):
|
if local_assessment.get("safe_to_remove_worktree"):
|
||||||
result = merged_cleanup_reconcile.remove_local_worktree(
|
result = merged_cleanup_reconcile.remove_local_worktree(
|
||||||
_canonical_local_git_root(),
|
project_root,
|
||||||
head_branch,
|
head_branch,
|
||||||
worktree_path=local_assessment.get("worktree_path"),
|
worktree_path=local_assessment.get("worktree_path"),
|
||||||
)
|
)
|
||||||
actions.append({"action": "remove_local_worktree", **result})
|
actions.append({"action": "remove_local_worktree", **result})
|
||||||
|
# Idempotent resume: absent worktree is already gone.
|
||||||
|
msg = (result.get("message") or "").lower()
|
||||||
|
worktree_removed = bool(result.get("success")) or (
|
||||||
|
"not found" in msg
|
||||||
|
)
|
||||||
|
|
||||||
|
if remote_assessment.get("safe_to_delete_remote"):
|
||||||
|
actions.append(
|
||||||
|
_attempt_owned_remote_delete(
|
||||||
|
head_branch=head_branch,
|
||||||
|
pr_num_int=pr_num_int,
|
||||||
|
after_worktree_removal=worktree_removed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for scratch in report.get("reviewer_scratch_entries") or []:
|
for scratch in report.get("reviewer_scratch_entries") or []:
|
||||||
if not scratch.get("safe_to_remove_worktree"):
|
if not scratch.get("safe_to_remove_worktree"):
|
||||||
continue
|
continue
|
||||||
result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree(
|
result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree(
|
||||||
_canonical_local_git_root(), scratch.get("worktree_path") or ""
|
project_root, scratch.get("worktree_path") or ""
|
||||||
)
|
)
|
||||||
actions.append({"action": "remove_reviewer_scratch_worktree", **result})
|
actions.append({"action": "remove_reviewer_scratch_worktree", **result})
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,10 @@ def build_pr_cleanup_entry(
|
|||||||
worktree_state=worktree_state,
|
worktree_state=worktree_state,
|
||||||
active_lock=active_lock,
|
active_lock=active_lock,
|
||||||
)
|
)
|
||||||
|
planned = plan_cleanup_execution_order(
|
||||||
|
remote_assessment=remote,
|
||||||
|
local_assessment=local,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"issue_number": issue_number,
|
"issue_number": issue_number,
|
||||||
@@ -576,9 +580,63 @@ def build_pr_cleanup_entry(
|
|||||||
"merged": merged,
|
"merged": merged,
|
||||||
"remote_branch": remote,
|
"remote_branch": remote,
|
||||||
"local_worktree": local,
|
"local_worktree": local,
|
||||||
|
# #851: dry-run and execute share the same lifecycle order description.
|
||||||
|
"planned_execution_order": planned,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def plan_cleanup_execution_order(
|
||||||
|
*,
|
||||||
|
remote_assessment: dict[str, Any] | None,
|
||||||
|
local_assessment: dict[str, Any] | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Describe independent worktree-then-reassess-then-remote cleanup order (#851).
|
||||||
|
|
||||||
|
Remote ownership protection remains fail-closed at execute time. A worktree
|
||||||
|
that is independently safe to remove is never skipped merely because remote
|
||||||
|
deletion may be blocked by that same ``worktree_binding``.
|
||||||
|
"""
|
||||||
|
remote = remote_assessment or {}
|
||||||
|
local = local_assessment or {}
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
worktree_safe = bool(local.get("safe_to_remove_worktree"))
|
||||||
|
remote_safe = bool(remote.get("safe_to_delete_remote"))
|
||||||
|
|
||||||
|
if worktree_safe:
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"action": "remove_local_worktree",
|
||||||
|
"reason": "independently_safe_to_remove",
|
||||||
|
"phase": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if remote_safe:
|
||||||
|
if worktree_safe:
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"action": "reassess_branch_ownership",
|
||||||
|
"reason": "after_worktree_removal_clear_worktree_binding",
|
||||||
|
"phase": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"action": "delete_remote_branch",
|
||||||
|
"reason": "only_if_independently_safe_after_reassessment",
|
||||||
|
"phase": 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"action": "delete_remote_branch",
|
||||||
|
"reason": "safe_to_delete_and_no_independent_worktree_removal",
|
||||||
|
"phase": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
def build_reconciliation_report(
|
def build_reconciliation_report(
|
||||||
*,
|
*,
|
||||||
project_root: str,
|
project_root: str,
|
||||||
|
|||||||
@@ -1266,6 +1266,378 @@ class TestSecondRemediationIntegration(unittest.TestCase):
|
|||||||
self.assertIn("delete_acknowledged", delete_actions[0])
|
self.assertIn("delete_acknowledged", delete_actions[0])
|
||||||
self.assertTrue(delete_actions[0].get("verified_absent"))
|
self.assertTrue(delete_actions[0].get("verified_absent"))
|
||||||
|
|
||||||
|
def test_issue_851_worktree_removed_when_remote_blocked_only_by_worktree_binding(self):
|
||||||
|
"""#851: remote blocked by worktree_binding must not skip safe worktree removal.
|
||||||
|
|
||||||
|
Lifecycle: remove clean owned worktree → reassess ownership → delete
|
||||||
|
remote only if independently safe. Unrelated entries stay untouched.
|
||||||
|
"""
|
||||||
|
from mcp_server import gitea_reconcile_merged_cleanups
|
||||||
|
|
||||||
|
target_branch = "fix/issue-844-exclude-epic-containers"
|
||||||
|
foreign_branch = "fix/issue-999-unrelated-active"
|
||||||
|
worktree_path = "/tmp/branches/fix-issue-844-exclude-epic-containers"
|
||||||
|
ownership_calls = []
|
||||||
|
remove_calls = []
|
||||||
|
delete_api_calls = []
|
||||||
|
|
||||||
|
def fake_collect(**kwargs):
|
||||||
|
ownership_calls.append(dict(kwargs))
|
||||||
|
# Ownership is reassessed *after* independent worktree removal (#851).
|
||||||
|
# Target worktree is already gone → no worktree_binding remains.
|
||||||
|
# Foreign branch keeps an active author lease → remote delete blocked.
|
||||||
|
if kwargs.get("branch") == foreign_branch:
|
||||||
|
# Match session-bound org/repo + host used by the tool resolve path.
|
||||||
|
return {
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
|
"status": "active",
|
||||||
|
"remote": kwargs.get("remote") or "prgs",
|
||||||
|
"host": kwargs.get("host") or "gitea.example.com",
|
||||||
|
"org": kwargs.get("org") or "Scaled-Tech-Consulting",
|
||||||
|
"repo": kwargs.get("repo") or "Gitea-Tools",
|
||||||
|
"branch": foreign_branch,
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inventory_error": False,
|
||||||
|
}
|
||||||
|
return {"records": [], "inventory_error": False}
|
||||||
|
|
||||||
|
def fake_remove(project_root, branch, worktree_path=None):
|
||||||
|
remove_calls.append(
|
||||||
|
{"branch": branch, "worktree_path": worktree_path}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"performed": True,
|
||||||
|
"message": f"removed worktree {worktree_path}",
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_probe(h, o, r, auth, br):
|
||||||
|
return guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_api(method, url, auth, **kwargs):
|
||||||
|
if method == "DELETE":
|
||||||
|
delete_api_calls.append(url)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"pr_number": 848,
|
||||||
|
"head_branch": target_branch,
|
||||||
|
"remote_branch": {"safe_to_delete_remote": True},
|
||||||
|
"local_worktree": {
|
||||||
|
"safe_to_remove_worktree": True,
|
||||||
|
"worktree_path": worktree_path,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pr_number": 999,
|
||||||
|
"head_branch": foreign_branch,
|
||||||
|
"remote_branch": {"safe_to_delete_remote": True},
|
||||||
|
"local_worktree": {
|
||||||
|
"safe_to_remove_worktree": False,
|
||||||
|
"worktree_path": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"reviewer_scratch_entries": [],
|
||||||
|
}
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
"gitea.pr.close",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||||
|
return_value=report,
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||||
|
return_value=[],
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||||
|
return_value=(True, []),
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server._collect_branch_ownership_records",
|
||||||
|
side_effect=fake_collect,
|
||||||
|
).start()
|
||||||
|
patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.remove_local_worktree",
|
||||||
|
side_effect=fake_remove,
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = fake_api
|
||||||
|
|
||||||
|
res = gitea_reconcile_merged_cleanups(
|
||||||
|
dry_run=False,
|
||||||
|
execute_confirmed=True,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
self.assertTrue(res.get("performed") or res.get("executed"))
|
||||||
|
actions = res.get("actions") or []
|
||||||
|
|
||||||
|
remove_actions = [
|
||||||
|
a for a in actions if a.get("action") == "remove_local_worktree"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(remove_actions), 1, actions)
|
||||||
|
self.assertTrue(remove_actions[0].get("success"))
|
||||||
|
self.assertEqual(remove_calls[0]["branch"], target_branch)
|
||||||
|
self.assertEqual(remove_calls[0]["worktree_path"], worktree_path)
|
||||||
|
|
||||||
|
# Target remote delete succeeds after worktree removal + reassessment.
|
||||||
|
target_deletes = [
|
||||||
|
a
|
||||||
|
for a in actions
|
||||||
|
if a.get("action") == "delete_remote_branch"
|
||||||
|
and a.get("branch") == target_branch
|
||||||
|
]
|
||||||
|
self.assertEqual(len(target_deletes), 1, actions)
|
||||||
|
self.assertTrue(target_deletes[0].get("success"))
|
||||||
|
self.assertTrue(target_deletes[0].get("after_worktree_removal"))
|
||||||
|
self.assertTrue(target_deletes[0].get("ownership_reassessed"))
|
||||||
|
self.assertTrue(target_deletes[0].get("verified_absent"))
|
||||||
|
|
||||||
|
# Foreign branch remains protected (author lease) and is not deleted.
|
||||||
|
foreign_deletes = [
|
||||||
|
a
|
||||||
|
for a in actions
|
||||||
|
if a.get("action") == "delete_remote_branch"
|
||||||
|
and a.get("branch") == foreign_branch
|
||||||
|
]
|
||||||
|
self.assertEqual(len(foreign_deletes), 1, actions)
|
||||||
|
self.assertFalse(foreign_deletes[0].get("success"))
|
||||||
|
self.assertEqual(
|
||||||
|
foreign_deletes[0].get("blocker_kind"), "active_branch_ownership"
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
|
||||||
|
foreign_deletes[0].get("blocking_categories") or [],
|
||||||
|
)
|
||||||
|
# Only the target branch should hit the DELETE API.
|
||||||
|
self.assertEqual(len(delete_api_calls), 1)
|
||||||
|
|
||||||
|
# Ownership collected for target (post-removal) and foreign; worktree
|
||||||
|
# removal happened before target remote delete in the action log.
|
||||||
|
target_idx = next(
|
||||||
|
i
|
||||||
|
for i, a in enumerate(actions)
|
||||||
|
if a.get("action") == "remove_local_worktree"
|
||||||
|
)
|
||||||
|
delete_idx = next(
|
||||||
|
i
|
||||||
|
for i, a in enumerate(actions)
|
||||||
|
if a.get("action") == "delete_remote_branch"
|
||||||
|
and a.get("branch") == target_branch
|
||||||
|
and a.get("success")
|
||||||
|
)
|
||||||
|
self.assertLess(target_idx, delete_idx)
|
||||||
|
|
||||||
|
def test_issue_851_dirty_worktree_not_removed_and_remote_stays_protected(self):
|
||||||
|
"""#851: dirty/foreign worktrees remain protected; no unsafe cleanup."""
|
||||||
|
from mcp_server import gitea_reconcile_merged_cleanups
|
||||||
|
|
||||||
|
branch = "fix/issue-851-dirty"
|
||||||
|
remove_calls = []
|
||||||
|
|
||||||
|
def fake_collect(**kwargs):
|
||||||
|
return {
|
||||||
|
"records": [
|
||||||
|
{
|
||||||
|
"category": guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||||
|
"status": "active",
|
||||||
|
"remote": kwargs.get("remote") or "prgs",
|
||||||
|
"host": kwargs.get("host") or "gitea.example.com",
|
||||||
|
"org": kwargs.get("org") or "Scaled-Tech-Consulting",
|
||||||
|
"repo": kwargs.get("repo") or "Gitea-Tools",
|
||||||
|
"branch": branch,
|
||||||
|
"reclaim_allowed": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inventory_error": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"pr_number": 851,
|
||||||
|
"head_branch": branch,
|
||||||
|
"remote_branch": {"safe_to_delete_remote": True},
|
||||||
|
"local_worktree": {
|
||||||
|
"safe_to_remove_worktree": False,
|
||||||
|
"worktree_path": "/tmp/dirty-wt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reviewer_scratch_entries": [],
|
||||||
|
}
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||||
|
return_value=report,
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||||
|
return_value=[],
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||||
|
return_value=(True, []),
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server._collect_branch_ownership_records",
|
||||||
|
side_effect=fake_collect,
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.remove_local_worktree",
|
||||||
|
side_effect=lambda *a, **k: remove_calls.append(k) or {
|
||||||
|
"success": True,
|
||||||
|
"performed": True,
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = lambda *a, **k: {}
|
||||||
|
|
||||||
|
res = gitea_reconcile_merged_cleanups(
|
||||||
|
dry_run=False,
|
||||||
|
execute_confirmed=True,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
actions = res.get("actions") or []
|
||||||
|
self.assertEqual(remove_calls, [])
|
||||||
|
self.assertFalse(
|
||||||
|
any(a.get("action") == "remove_local_worktree" for a in actions)
|
||||||
|
)
|
||||||
|
deletes = [
|
||||||
|
a for a in actions if a.get("action") == "delete_remote_branch"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(deletes), 1)
|
||||||
|
self.assertFalse(deletes[0].get("success"))
|
||||||
|
self.assertEqual(deletes[0].get("blocker_kind"), "active_branch_ownership")
|
||||||
|
self.assertIn(
|
||||||
|
guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
|
||||||
|
deletes[0].get("blocking_categories") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_issue_851_idempotent_resume_when_worktree_already_absent(self):
|
||||||
|
"""#851: partial failures remain resumable and idempotent."""
|
||||||
|
from mcp_server import gitea_reconcile_merged_cleanups
|
||||||
|
|
||||||
|
branch = "fix/issue-851-resume"
|
||||||
|
ownership_calls = []
|
||||||
|
|
||||||
|
def fake_collect(**kwargs):
|
||||||
|
ownership_calls.append(kwargs)
|
||||||
|
return {"records": [], "inventory_error": False}
|
||||||
|
|
||||||
|
def fake_remove(project_root, branch, worktree_path=None):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"message": f"worktree not found: {worktree_path}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def fake_probe(h, o, r, auth, br):
|
||||||
|
return guard.classify_branch_readback_http_status(
|
||||||
|
404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"pr_number": 851,
|
||||||
|
"head_branch": branch,
|
||||||
|
"remote_branch": {"safe_to_delete_remote": True},
|
||||||
|
"local_worktree": {
|
||||||
|
"safe_to_remove_worktree": True,
|
||||||
|
"worktree_path": "/tmp/already-gone",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reviewer_scratch_entries": [],
|
||||||
|
}
|
||||||
|
patch(
|
||||||
|
"mcp_server.get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"role": "reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.branch.delete",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.api_get_all", return_value=[]).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.build_reconciliation_report",
|
||||||
|
return_value=report,
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees",
|
||||||
|
return_value=[],
|
||||||
|
).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed",
|
||||||
|
return_value=(True, []),
|
||||||
|
).start()
|
||||||
|
patch("mcp_server.verify_preflight_purity", return_value=None).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server._collect_branch_ownership_records",
|
||||||
|
side_effect=fake_collect,
|
||||||
|
).start()
|
||||||
|
patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start()
|
||||||
|
patch(
|
||||||
|
"mcp_server.merged_cleanup_reconcile.remove_local_worktree",
|
||||||
|
side_effect=fake_remove,
|
||||||
|
).start()
|
||||||
|
self.mock_api.side_effect = lambda *a, **k: {}
|
||||||
|
|
||||||
|
res = gitea_reconcile_merged_cleanups(
|
||||||
|
dry_run=False,
|
||||||
|
execute_confirmed=True,
|
||||||
|
remote="prgs",
|
||||||
|
)
|
||||||
|
actions = res.get("actions") or []
|
||||||
|
removes = [a for a in actions if a.get("action") == "remove_local_worktree"]
|
||||||
|
deletes = [a for a in actions if a.get("action") == "delete_remote_branch"]
|
||||||
|
self.assertEqual(len(removes), 1)
|
||||||
|
self.assertFalse(removes[0].get("success"))
|
||||||
|
self.assertEqual(len(deletes), 1)
|
||||||
|
self.assertTrue(deletes[0].get("success"))
|
||||||
|
self.assertTrue(deletes[0].get("after_worktree_removal"))
|
||||||
|
self.assertTrue(ownership_calls)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -12,6 +12,59 @@ import merged_cleanup_reconcile as mcr # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
class TestMergedCleanupAssessment(unittest.TestCase):
|
class TestMergedCleanupAssessment(unittest.TestCase):
|
||||||
|
def test_issue_851_plan_order_worktree_then_reassess_then_remote(self):
|
||||||
|
"""#851 dry-run plan: remove worktree, reassess ownership, then remote."""
|
||||||
|
plan = mcr.plan_cleanup_execution_order(
|
||||||
|
remote_assessment={"safe_to_delete_remote": True},
|
||||||
|
local_assessment={"safe_to_remove_worktree": True},
|
||||||
|
)
|
||||||
|
actions = [s["action"] for s in plan]
|
||||||
|
self.assertEqual(
|
||||||
|
actions,
|
||||||
|
[
|
||||||
|
"remove_local_worktree",
|
||||||
|
"reassess_branch_ownership",
|
||||||
|
"delete_remote_branch",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(plan[0]["phase"], 1)
|
||||||
|
self.assertEqual(plan[-1]["phase"], 3)
|
||||||
|
self.assertIn("independently_safe", plan[0]["reason"])
|
||||||
|
self.assertIn("reassessment", plan[-1]["reason"])
|
||||||
|
|
||||||
|
def test_issue_851_plan_remote_only_when_worktree_not_safe(self):
|
||||||
|
plan = mcr.plan_cleanup_execution_order(
|
||||||
|
remote_assessment={"safe_to_delete_remote": True},
|
||||||
|
local_assessment={"safe_to_remove_worktree": False},
|
||||||
|
)
|
||||||
|
self.assertEqual([s["action"] for s in plan], ["delete_remote_branch"])
|
||||||
|
self.assertNotIn("reassess_branch_ownership", [s["action"] for s in plan])
|
||||||
|
|
||||||
|
def test_issue_851_plan_worktree_only_when_remote_not_safe(self):
|
||||||
|
plan = mcr.plan_cleanup_execution_order(
|
||||||
|
remote_assessment={"safe_to_delete_remote": False},
|
||||||
|
local_assessment={"safe_to_remove_worktree": True},
|
||||||
|
)
|
||||||
|
self.assertEqual([s["action"] for s in plan], ["remove_local_worktree"])
|
||||||
|
|
||||||
|
def test_issue_851_entry_includes_planned_execution_order(self):
|
||||||
|
entry = mcr.build_pr_cleanup_entry(
|
||||||
|
pr={
|
||||||
|
"number": 848,
|
||||||
|
"title": "Closes #844",
|
||||||
|
"body": "",
|
||||||
|
"merged_at": "2026-07-23T00:00:00Z",
|
||||||
|
"head": {"ref": "fix/issue-844-x", "sha": "a" * 40},
|
||||||
|
},
|
||||||
|
project_root="/tmp/not-a-real-root",
|
||||||
|
open_pr_heads=set(),
|
||||||
|
remote_branch_exists=True,
|
||||||
|
head_on_master=True,
|
||||||
|
delete_capability_allowed=True,
|
||||||
|
)
|
||||||
|
self.assertIn("planned_execution_order", entry)
|
||||||
|
self.assertIsInstance(entry["planned_execution_order"], list)
|
||||||
|
|
||||||
def test_extract_linked_issue_from_closes(self):
|
def test_extract_linked_issue_from_closes(self):
|
||||||
issue = mcr.extract_linked_issue(
|
issue = mcr.extract_linked_issue(
|
||||||
"feat: cleanup (Closes #269)",
|
"feat: cleanup (Closes #269)",
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Tests for the Phase 1 operator console application shell (#638)."""
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from starlette.routing import Route
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from webui import layout
|
||||||
|
from webui.app import create_app
|
||||||
|
from webui.nav import NAV_GROUPS, STUB_PAGES, nav_hrefs
|
||||||
|
|
||||||
|
|
||||||
|
class TestShellNav(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_nav_group_labels_present(self):
|
||||||
|
text = self.client.get("/").text
|
||||||
|
for group in NAV_GROUPS:
|
||||||
|
with self.subTest(group=group.label):
|
||||||
|
self.assertIn(f">{group.label}<", text)
|
||||||
|
|
||||||
|
def test_phase1_group_labels_cover_expected_ia(self):
|
||||||
|
labels = {group.label for group in NAV_GROUPS}
|
||||||
|
for expected in (
|
||||||
|
"Health",
|
||||||
|
"Traffic",
|
||||||
|
"Runtime/Sessions",
|
||||||
|
"Projects",
|
||||||
|
"Inventory",
|
||||||
|
"Timeline",
|
||||||
|
"Policy",
|
||||||
|
"Insights",
|
||||||
|
):
|
||||||
|
with self.subTest(label=expected):
|
||||||
|
self.assertIn(expected, labels)
|
||||||
|
|
||||||
|
def test_every_nav_href_resolves_to_a_get_route(self):
|
||||||
|
app = create_app()
|
||||||
|
get_paths = {
|
||||||
|
route.path
|
||||||
|
for route in app.routes
|
||||||
|
if isinstance(route, Route) and "GET" in route.methods
|
||||||
|
}
|
||||||
|
for href in nav_hrefs():
|
||||||
|
with self.subTest(href=href):
|
||||||
|
self.assertIn(href, get_paths, f"nav href {href} has no GET route")
|
||||||
|
|
||||||
|
def test_legacy_hrefs_still_navigable(self):
|
||||||
|
text = self.client.get("/").text
|
||||||
|
for href in ("/queue", "/projects", "/prompts", "/runtime",
|
||||||
|
"/audit", "/worktrees", "/leases", "/actions"):
|
||||||
|
with self.subTest(href=href):
|
||||||
|
self.assertIn(f'href="{href}"', text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShellBadges(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_mode_badge_present(self):
|
||||||
|
self.assertIn("mode: read-only", self.client.get("/").text)
|
||||||
|
|
||||||
|
def test_environment_badge_present(self):
|
||||||
|
self.assertIn("env:", self.client.get("/").text)
|
||||||
|
|
||||||
|
def test_default_environment_is_local(self):
|
||||||
|
self.assertEqual(layout.environment_label(), "local")
|
||||||
|
|
||||||
|
def test_remote_bind_reports_remote_environment(self):
|
||||||
|
import os
|
||||||
|
|
||||||
|
prior = os.environ.get("WEBUI_HOST")
|
||||||
|
os.environ["WEBUI_HOST"] = "10.0.0.5"
|
||||||
|
try:
|
||||||
|
self.assertEqual(layout.environment_label(), "remote")
|
||||||
|
finally:
|
||||||
|
if prior is None:
|
||||||
|
os.environ.pop("WEBUI_HOST", None)
|
||||||
|
else:
|
||||||
|
os.environ["WEBUI_HOST"] = prior
|
||||||
|
|
||||||
|
def test_docs_link_present(self):
|
||||||
|
text = self.client.get("/").text
|
||||||
|
self.assertIn(layout.DOCS_URL, text)
|
||||||
|
self.assertIn(">Docs<", text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShellStubs(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_stub_routes_render_200(self):
|
||||||
|
for path, (title, _desc) in STUB_PAGES.items():
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.get(path)
|
||||||
|
self.assertEqual(response.status_code, 200, path)
|
||||||
|
self.assertIn(title, response.text)
|
||||||
|
self.assertIn("placeholder", response.text)
|
||||||
|
|
||||||
|
def test_stub_routes_are_read_only(self):
|
||||||
|
for path in STUB_PAGES:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
response = self.client.post(path)
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
self.assertEqual(response.json()["error"], "read-only-mvp")
|
||||||
|
|
||||||
|
def test_stub_pages_carry_nav_and_badges(self):
|
||||||
|
response = self.client.get("/inventory")
|
||||||
|
self.assertIn("mode: read-only", response.text)
|
||||||
|
self.assertIn('href="/queue"', response.text)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShellHome(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = TestClient(create_app())
|
||||||
|
|
||||||
|
def test_home_summarizes_console(self):
|
||||||
|
text = self.client.get("/").text
|
||||||
|
self.assertIn("Operator console", text)
|
||||||
|
self.assertIn("Phase 1", text)
|
||||||
|
|
||||||
|
def test_home_links_legacy_pages(self):
|
||||||
|
text = self.client.get("/").text
|
||||||
|
self.assertIn("MVP legacy pages", text)
|
||||||
|
for href in ("/queue", "/audit", "/leases"):
|
||||||
|
with self.subTest(href=href):
|
||||||
|
self.assertIn(f'href="{href}"', text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+54
-11
@@ -12,6 +12,7 @@ from starlette.routing import Route
|
|||||||
|
|
||||||
from webui.deployment_boundary import deployment_snapshot
|
from webui.deployment_boundary import deployment_snapshot
|
||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
|
from webui.nav import NAV_GROUPS, STUB_PAGES
|
||||||
from webui.project_registry import (
|
from webui.project_registry import (
|
||||||
ProjectRegistry,
|
ProjectRegistry,
|
||||||
RegistryError,
|
RegistryError,
|
||||||
@@ -66,24 +67,62 @@ def _stub_page(title: str, description: str) -> HTMLResponse:
|
|||||||
return HTMLResponse(render_page(title=title, body_html=body))
|
return HTMLResponse(render_page(title=title, body_html=body))
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY_PAGES = (
|
||||||
|
("/queue", "Queue", "live PR and issue dashboard (#429)"),
|
||||||
|
("/projects", "Projects", "registry and onboarding (#427)"),
|
||||||
|
("/prompts", "Prompts", "canonical workflow prompt library (#428)"),
|
||||||
|
("/runtime", "Runtime", "MCP health and stale-runtime detection (#430)"),
|
||||||
|
("/audit", "Audit", "final-report paste and validator preview (#431)"),
|
||||||
|
("/worktrees", "Worktrees", "branch hygiene dashboard (#432)"),
|
||||||
|
("/leases", "Leases", "collision and lease visibility (#433)"),
|
||||||
|
("/actions", "Actions", "gated write-action framework (#434)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_home_nav_groups() -> str:
|
||||||
|
groups = []
|
||||||
|
for group in NAV_GROUPS:
|
||||||
|
items = "".join(
|
||||||
|
f'<li><a href="{item.href}">{item.label}</a>'
|
||||||
|
+ ("" if item.status == "live" else " <span class=\"muted\">(stub)</span>")
|
||||||
|
+ "</li>"
|
||||||
|
for item in group.items
|
||||||
|
)
|
||||||
|
groups.append(f"<h3>{group.label}</h3><ul>{items}</ul>")
|
||||||
|
return "".join(groups)
|
||||||
|
|
||||||
|
|
||||||
async def home(_request: Request) -> HTMLResponse:
|
async def home(_request: Request) -> HTMLResponse:
|
||||||
|
legacy = "".join(
|
||||||
|
f"<li><strong>{label}</strong> — {desc} "
|
||||||
|
f'(<a href="{href}">{href}</a>)</li>'
|
||||||
|
for href, label, desc in _LEGACY_PAGES
|
||||||
|
)
|
||||||
body = (
|
body = (
|
||||||
"<h2>Operator console</h2>"
|
"<h2>Operator console</h2>"
|
||||||
"<p>Local entry point for MCP Control Plane operational views.</p>"
|
"<p>Read-only home for the MCP Control Plane Phase 1 operator console. "
|
||||||
"<ul>"
|
"Gitea, MCP capability gates, and canonical workflows remain the source "
|
||||||
"<li><strong>Queue</strong> — live PR and issue dashboard (#429)</li>"
|
"of truth; this console never mutates them.</p>"
|
||||||
"<li><strong>Projects</strong> — registry and onboarding (#427)</li>"
|
"<h2>Phase 1 surfaces</h2>"
|
||||||
"<li><strong>Prompts</strong> — canonical workflow prompt library (#428)</li>"
|
+ _render_home_nav_groups()
|
||||||
"<li><strong>Runtime</strong> — MCP health and stale-runtime detection (#430)</li>"
|
+ "<h2>MVP legacy pages</h2>"
|
||||||
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
|
"<ul>" + legacy + "</ul>"
|
||||||
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
|
|
||||||
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
|
|
||||||
"<li><strong>Actions</strong> — gated write-action framework (#434)</li>"
|
|
||||||
"</ul>"
|
|
||||||
)
|
)
|
||||||
return HTMLResponse(render_page(title="Home", body_html=body))
|
return HTMLResponse(render_page(title="Home", body_html=body))
|
||||||
|
|
||||||
|
|
||||||
|
async def phase_stub(request: Request) -> HTMLResponse:
|
||||||
|
"""Graceful read-only placeholder for a not-yet-implemented Phase 1 surface."""
|
||||||
|
title, description = STUB_PAGES[request.url.path]
|
||||||
|
body = (
|
||||||
|
f"<h2>{title}</h2>"
|
||||||
|
f'<div class="stub"><p>{description}</p>'
|
||||||
|
"<p>Phase 1 shell placeholder — no write actions. Tracked under "
|
||||||
|
"epic #631.</p></div>"
|
||||||
|
)
|
||||||
|
return HTMLResponse(render_page(title=title, body_html=body))
|
||||||
|
|
||||||
|
|
||||||
async def health(_request: Request) -> JSONResponse:
|
async def health(_request: Request) -> JSONResponse:
|
||||||
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
|
"""Liveness only — deliberately cheap, runs no dependency probe (#634).
|
||||||
|
|
||||||
@@ -572,6 +611,10 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
api_console_security_model,
|
api_console_security_model,
|
||||||
methods=["GET"],
|
methods=["GET"],
|
||||||
),
|
),
|
||||||
|
*[
|
||||||
|
Route(path, phase_stub, methods=["GET"])
|
||||||
|
for path in STUB_PAGES
|
||||||
|
],
|
||||||
],
|
],
|
||||||
exception_handlers={405: method_not_allowed},
|
exception_handlers={405: method_not_allowed},
|
||||||
)
|
)
|
||||||
|
|||||||
+94
-16
@@ -2,28 +2,66 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
NAV_ITEMS = (
|
import os
|
||||||
("/", "Home"),
|
|
||||||
("/queue", "Queue"),
|
from webui.nav import NAV_GROUPS
|
||||||
("/projects", "Projects"),
|
|
||||||
("/prompts", "Prompts"),
|
|
||||||
("/runtime", "Runtime"),
|
|
||||||
("/audit", "Audit"),
|
|
||||||
("/worktrees", "Worktrees"),
|
|
||||||
("/leases", "Leases"),
|
|
||||||
("/actions", "Actions"),
|
|
||||||
)
|
|
||||||
|
|
||||||
MVP_NOTICE = (
|
MVP_NOTICE = (
|
||||||
"Read-only MVP — Gitea, MCP tools, and canonical workflows remain the "
|
"Read-only MVP — Gitea, MCP tools, and canonical workflows remain the "
|
||||||
"source of truth. No mutation endpoints."
|
"source of truth. No mutation endpoints."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Canonical docs entry point surfaced from the shell header (#638).
|
||||||
|
DOCS_URL = (
|
||||||
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/src/branch/"
|
||||||
|
"master/docs/webui-local-dev.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOCAL_HOSTS = frozenset({"", "127.0.0.1", "localhost", "::1"})
|
||||||
|
|
||||||
|
|
||||||
|
def environment_label() -> str:
|
||||||
|
"""Classify the serving environment as ``local`` or ``remote`` (#638).
|
||||||
|
|
||||||
|
Derived from the same ``WEBUI_HOST`` default the app binds to; loopback
|
||||||
|
hosts are ``local``, anything else is ``remote``. Read-only signal only.
|
||||||
|
"""
|
||||||
|
host = (os.environ.get("WEBUI_HOST", "127.0.0.1") or "").strip().lower()
|
||||||
|
return "local" if host in _LOCAL_HOSTS else "remote"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_nav() -> str:
|
||||||
|
groups_html = []
|
||||||
|
for group in NAV_GROUPS:
|
||||||
|
links = "".join(
|
||||||
|
f'<a href="{item.href}"'
|
||||||
|
+ (' class="nav-stub"' if item.status == "stub" else "")
|
||||||
|
+ f'>{item.label}</a>'
|
||||||
|
for item in group.items
|
||||||
|
)
|
||||||
|
groups_html.append(
|
||||||
|
'<div class="nav-group">'
|
||||||
|
f'<span class="nav-group-label">{group.label}</span>'
|
||||||
|
f'<span class="nav-group-links">{links}</span>'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
return "".join(groups_html)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_badges() -> str:
|
||||||
|
env = environment_label()
|
||||||
|
return (
|
||||||
|
'<div class="header-badges">'
|
||||||
|
f'<span class="badge env-badge env-{env}">env: {env}</span>'
|
||||||
|
'<span class="badge mode-badge">mode: read-only</span>'
|
||||||
|
f'<a class="badge docs-link" href="{DOCS_URL}">Docs</a>'
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
||||||
nav_links = "".join(
|
nav_links = _render_nav()
|
||||||
f'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
|
header_badges = _render_badges()
|
||||||
)
|
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -53,21 +91,58 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
|||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
}}
|
}}
|
||||||
header h1 {{
|
header h1 {{
|
||||||
margin: 0 0 0.5rem;
|
margin: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}}
|
}}
|
||||||
|
.header-top {{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}}
|
||||||
|
.header-badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.4rem; }}
|
||||||
|
.env-badge.env-local {{ color: #8fd19e; border-color: #3d6b4a; }}
|
||||||
|
.env-badge.env-remote {{ color: #e0c27a; border-color: #6b5730; }}
|
||||||
|
.mode-badge {{ color: #9ec8f0; border-color: #3d5f7a; }}
|
||||||
|
a.docs-link {{
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: none;
|
||||||
|
}}
|
||||||
|
a.docs-link:hover {{ filter: brightness(1.12); }}
|
||||||
nav {{
|
nav {{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem 1rem;
|
gap: 0.5rem 1.25rem;
|
||||||
}}
|
}}
|
||||||
|
.nav-group {{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}}
|
||||||
|
.nav-group-label {{
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}}
|
||||||
|
.nav-group-links {{ display: inline-flex; flex-wrap: wrap; gap: 0.6rem; }}
|
||||||
nav a {{
|
nav a {{
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}}
|
}}
|
||||||
nav a:hover {{ text-decoration: underline; }}
|
nav a:hover {{ text-decoration: underline; }}
|
||||||
|
nav a.nav-stub {{ color: var(--muted); }}
|
||||||
|
nav a.nav-stub::after {{
|
||||||
|
content: " ·stub";
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}}
|
||||||
main {{
|
main {{
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -166,7 +241,10 @@ def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
<div class="header-top">
|
||||||
<h1>MCP Control Plane</h1>
|
<h1>MCP Control Plane</h1>
|
||||||
|
{header_badges}
|
||||||
|
</div>
|
||||||
<nav>{nav_links}</nav>
|
<nav>{nav_links}</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
"""Navigation IA for the Phase 1 operator console shell (#638).
|
||||||
|
|
||||||
|
Single source of truth for the console navigation so ``webui/layout.py`` and
|
||||||
|
the ``webui/app.py`` route table stay aligned with epic #631. Read-only: every
|
||||||
|
destination is a GET view or a Phase 1 placeholder. No mutation links.
|
||||||
|
|
||||||
|
Nav groups follow the #631 Phase 1 information architecture: Health, Traffic,
|
||||||
|
Runtime/Sessions, Projects, Inventory, Timeline, Policy (placeholder), and
|
||||||
|
Insights (placeholder). Later-phase surfaces are declared as ``stub`` items and
|
||||||
|
backed by ``STUB_PAGES`` so their nav links resolve to a graceful placeholder
|
||||||
|
instead of a 404.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NavItem:
|
||||||
|
"""A single navigation destination.
|
||||||
|
|
||||||
|
``status`` is ``"live"`` for implemented views and ``"stub"`` for Phase 1
|
||||||
|
placeholders whose backing view lands in a later child issue.
|
||||||
|
"""
|
||||||
|
|
||||||
|
href: str
|
||||||
|
label: str
|
||||||
|
status: str = "live"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NavGroup:
|
||||||
|
label: str
|
||||||
|
items: tuple[NavItem, ...]
|
||||||
|
|
||||||
|
|
||||||
|
NAV_GROUPS: tuple[NavGroup, ...] = (
|
||||||
|
NavGroup("Health", (
|
||||||
|
NavItem("/health", "Liveness"),
|
||||||
|
)),
|
||||||
|
NavGroup("Traffic", (
|
||||||
|
NavItem("/queue", "Queue"),
|
||||||
|
NavItem("/leases", "Leases"),
|
||||||
|
NavItem("/actions", "Actions"),
|
||||||
|
)),
|
||||||
|
NavGroup("Runtime/Sessions", (
|
||||||
|
NavItem("/runtime", "Runtime health"),
|
||||||
|
NavItem("/sessions", "Sessions", "stub"),
|
||||||
|
)),
|
||||||
|
NavGroup("Projects", (
|
||||||
|
NavItem("/projects", "Projects"),
|
||||||
|
)),
|
||||||
|
NavGroup("Inventory", (
|
||||||
|
NavItem("/inventory", "Inventory", "stub"),
|
||||||
|
NavItem("/worktrees", "Worktrees"),
|
||||||
|
)),
|
||||||
|
NavGroup("Timeline", (
|
||||||
|
NavItem("/timeline", "Timeline", "stub"),
|
||||||
|
)),
|
||||||
|
NavGroup("Policy", (
|
||||||
|
NavItem("/policy", "Policy", "stub"),
|
||||||
|
NavItem("/prompts", "Prompts"),
|
||||||
|
)),
|
||||||
|
NavGroup("Insights", (
|
||||||
|
NavItem("/insights", "Insights", "stub"),
|
||||||
|
NavItem("/audit", "Audit"),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 1 placeholder destinations whose backing views land in later child
|
||||||
|
# issues of epic #631. Each maps a path to (title, description). Routes are
|
||||||
|
# registered so nav links resolve to a graceful, read-only stub page.
|
||||||
|
STUB_PAGES: dict[str, tuple[str, str]] = {
|
||||||
|
"/sessions": (
|
||||||
|
"Sessions",
|
||||||
|
"Active session, capability, and role inventory. Backed by the unified "
|
||||||
|
"inventory API (#636) once it lands.",
|
||||||
|
),
|
||||||
|
"/inventory": (
|
||||||
|
"Inventory",
|
||||||
|
"Unified sessions, leases, locks, namespaces, and worktree inventory. "
|
||||||
|
"Backed by the Phase 1 inventory API (#636).",
|
||||||
|
),
|
||||||
|
"/timeline": (
|
||||||
|
"Timeline",
|
||||||
|
"Workflow event timeline across issues and PRs. A later Phase 1 surface.",
|
||||||
|
),
|
||||||
|
"/policy": (
|
||||||
|
"Policy",
|
||||||
|
"Capability and role policy surface. Placeholder until a later phase.",
|
||||||
|
),
|
||||||
|
"/insights": (
|
||||||
|
"Insights",
|
||||||
|
"Aggregate operational insights and trends. Placeholder until a later "
|
||||||
|
"phase.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_nav_items():
|
||||||
|
"""Yield every ``NavItem`` across all groups in declared order."""
|
||||||
|
for group in NAV_GROUPS:
|
||||||
|
for item in group.items:
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
|
def nav_hrefs() -> tuple[str, ...]:
|
||||||
|
"""Return every navigation href in declared order."""
|
||||||
|
return tuple(item.href for item in iter_nav_items())
|
||||||
Reference in New Issue
Block a user