Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b6eb5d54 | ||
|
|
f21f81f9b5 | ||
|
|
08061b7b8a |
+1
-103
@@ -53,8 +53,6 @@ OUTCOME_CANDIDATE_SET_DRIFT = "candidate_set_drift"
|
|||||||
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
SKIP_CLAIMED_BY_OTHER_SESSION = "claimed_by_other_session"
|
||||||
# #776: controller-supplied pre-rank exclusion.
|
# #776: controller-supplied pre-rank exclusion.
|
||||||
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
SKIP_EXCLUDED_BY_CONTROLLER = "excluded_by_controller"
|
||||||
# #844: epic / child-only implementation container (pre-rank).
|
|
||||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER = "epic_or_child_only_container"
|
|
||||||
|
|
||||||
# Ownership verdicts for a live claim on a candidate (#765).
|
# Ownership verdicts for a live claim on a candidate (#765).
|
||||||
OWNERSHIP_OWN = "own"
|
OWNERSHIP_OWN = "own"
|
||||||
@@ -132,39 +130,6 @@ ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Body phrases that prove an issue is an implementation container, not a
|
|
||||||
# unit of direct author work (#844). Matched case-insensitively against the
|
|
||||||
# issue body. Title alone is never sufficient (ordinary issues may mention
|
|
||||||
# "epic" incidentally).
|
|
||||||
_CHILD_ONLY_BODY_MARKERS: tuple[str, ...] = (
|
|
||||||
"implementation is delivered via child issues only",
|
|
||||||
"implementation is delivered through child issues only",
|
|
||||||
"implementation is delivered via child issues",
|
|
||||||
"implementation is delivered through child issues",
|
|
||||||
"do not implement product features in this epic",
|
|
||||||
"do not implement product features in this epic issue itself",
|
|
||||||
"no product feature implementation is claimed complete solely on this epic",
|
|
||||||
"implementable child issues remain independently eligible",
|
|
||||||
"owns the product roadmap and linkage",
|
|
||||||
"this epic owns the product roadmap",
|
|
||||||
"coordination container",
|
|
||||||
"child-only container",
|
|
||||||
"implementation is delegated to child",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Explicit epic / umbrella labels (structured evidence preferred over title).
|
|
||||||
_EPIC_LABELS: frozenset[str] = frozenset(
|
|
||||||
{
|
|
||||||
"type:epic",
|
|
||||||
"epic",
|
|
||||||
"kind:epic",
|
|
||||||
"scope:epic",
|
|
||||||
"type:umbrella",
|
|
||||||
"umbrella",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class WorkCandidate:
|
class WorkCandidate:
|
||||||
"""One assignable Gitea issue or PR presented to the allocator."""
|
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||||
@@ -174,7 +139,6 @@ class WorkCandidate:
|
|||||||
state: str = "open"
|
state: str = "open"
|
||||||
labels: tuple[str, ...] = ()
|
labels: tuple[str, ...] = ()
|
||||||
title: str = ""
|
title: str = ""
|
||||||
body: str = ""
|
|
||||||
priority: int = 0
|
priority: int = 0
|
||||||
head_sha: str | None = None
|
head_sha: str | None = None
|
||||||
# Routing signals (callers derive from Gitea / review feedback).
|
# Routing signals (callers derive from Gitea / review feedback).
|
||||||
@@ -194,7 +158,6 @@ class WorkCandidate:
|
|||||||
self.labels = tuple(
|
self.labels = tuple(
|
||||||
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||||
)
|
)
|
||||||
self.body = str(self.body or "")
|
|
||||||
if self.kind not in WORK_KINDS:
|
if self.kind not in WORK_KINDS:
|
||||||
raise InvalidWorkKindError(
|
raise InvalidWorkKindError(
|
||||||
f"candidate kind '{self.kind}' is not assignable; only "
|
f"candidate kind '{self.kind}' is not assignable; only "
|
||||||
@@ -208,7 +171,6 @@ class WorkCandidate:
|
|||||||
"state": self.state,
|
"state": self.state,
|
||||||
"labels": list(self.labels),
|
"labels": list(self.labels),
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"body": self.body,
|
|
||||||
"priority": self.priority,
|
"priority": self.priority,
|
||||||
"head_sha": self.head_sha,
|
"head_sha": self.head_sha,
|
||||||
"request_changes_current_head": self.request_changes_current_head,
|
"request_changes_current_head": self.request_changes_current_head,
|
||||||
@@ -222,51 +184,6 @@ class WorkCandidate:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def classify_epic_or_child_only_container(
|
|
||||||
c: WorkCandidate,
|
|
||||||
) -> tuple[bool, str | None]:
|
|
||||||
"""Return whether *c* is an epic / child-only implementation container (#844).
|
|
||||||
|
|
||||||
Exclusion uses structured evidence first (labels, body scope language).
|
|
||||||
A bare title containing the word "epic" is **not** enough — ordinary
|
|
||||||
implementable issues may mention epics incidentally. A title that is
|
|
||||||
explicitly prefixed ``Epic:`` only counts when the body also proves
|
|
||||||
child-only / no-direct-implementation scope (or an epic label is present).
|
|
||||||
|
|
||||||
PRs are never classified as containers here (they already have a head).
|
|
||||||
"""
|
|
||||||
if c.kind != "issue":
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
labels = set(c.labels)
|
|
||||||
epic_label = sorted(labels & _EPIC_LABELS)
|
|
||||||
body_l = (c.body or "").lower()
|
|
||||||
title = (c.title or "").strip()
|
|
||||||
title_l = title.lower()
|
|
||||||
|
|
||||||
body_hits = [m for m in _CHILD_ONLY_BODY_MARKERS if m in body_l]
|
|
||||||
title_epic_prefix = title_l.startswith("epic:") or title_l.startswith("epic ")
|
|
||||||
|
|
||||||
if epic_label:
|
|
||||||
detail = f"label={epic_label[0]}"
|
|
||||||
if body_hits:
|
|
||||||
detail = f"{detail}; body_marker={body_hits[0]!r}"
|
|
||||||
return True, detail
|
|
||||||
|
|
||||||
if body_hits:
|
|
||||||
# Body proves child-only / umbrella scope. Title "Epic:" is corroborating
|
|
||||||
# but not required — containers without the word still exclude.
|
|
||||||
detail = f"body_marker={body_hits[0]!r}"
|
|
||||||
if title_epic_prefix:
|
|
||||||
detail = f"title_epic_prefix; {detail}"
|
|
||||||
return True, detail
|
|
||||||
|
|
||||||
# Title-only "Epic:" without body scope evidence is insufficient (#844 AC:
|
|
||||||
# eligibility does not rely solely on the word "Epic" in a title).
|
|
||||||
# Similarly, incidental "epic" mid-title without markers stays eligible.
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SkipRecord:
|
class SkipRecord:
|
||||||
kind: str
|
kind: str
|
||||||
@@ -933,8 +850,7 @@ def allocate_next_work(
|
|||||||
ownership_defects: list[dict[str, Any]] = []
|
ownership_defects: list[dict[str, Any]] = []
|
||||||
controller_excluded: list[dict[str, Any]] = []
|
controller_excluded: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# #776 AC2 + #844: remove excluded numbers *and* epic/child-only containers
|
# #776 AC2: remove excluded numbers *before* ranking / selection / lease.
|
||||||
# *before* ranking / selection / lease so they never receive assignments.
|
|
||||||
rankable: list[WorkCandidate] = []
|
rankable: list[WorkCandidate] = []
|
||||||
for c in candidates:
|
for c in candidates:
|
||||||
if int(c.number) in exclude_set:
|
if int(c.number) in exclude_set:
|
||||||
@@ -1013,23 +929,6 @@ def allocate_next_work(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
# #844: epics / child-only containers are never direct implement targets.
|
|
||||||
is_container, container_detail = classify_epic_or_child_only_container(c)
|
|
||||||
if is_container:
|
|
||||||
detail = container_detail or "epic or child-only container"
|
|
||||||
reason = (
|
|
||||||
f"{c.kind}#{c.number} {SKIP_EPIC_OR_CHILD_ONLY_CONTAINER}: "
|
|
||||||
f"{detail}; implementation is delegated to child issues"
|
|
||||||
)
|
|
||||||
skipped.append(
|
|
||||||
SkipRecord(
|
|
||||||
c.kind,
|
|
||||||
c.number,
|
|
||||||
reason,
|
|
||||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
rankable.append(c)
|
rankable.append(c)
|
||||||
|
|
||||||
ordered = sort_candidates(rankable)
|
ordered = sort_candidates(rankable)
|
||||||
@@ -1442,7 +1341,6 @@ def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
|||||||
state=str(data.get("state") or "open"),
|
state=str(data.get("state") or "open"),
|
||||||
labels=tuple(data.get("labels") or ()),
|
labels=tuple(data.get("labels") or ()),
|
||||||
title=str(data.get("title") or ""),
|
title=str(data.get("title") or ""),
|
||||||
body=str(data.get("body") or ""),
|
|
||||||
priority=priority,
|
priority=priority,
|
||||||
head_sha=data.get("head_sha"),
|
head_sha=data.get("head_sha"),
|
||||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||||
|
|||||||
@@ -73,6 +73,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
|
||||||
@@ -153,6 +158,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**
|
||||||
|
|||||||
@@ -19912,7 +19912,6 @@ def _allocator_candidates_from_gitea(
|
|||||||
state="open",
|
state="open",
|
||||||
labels=tuple(labels),
|
labels=tuple(labels),
|
||||||
title=title,
|
title=title,
|
||||||
body=body,
|
|
||||||
priority=20 if "status:ready" in labels else 1,
|
priority=20 if "status:ready" in labels else 1,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
dependency_unmet=dep_unmet,
|
dependency_unmet=dep_unmet,
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
"""Allocator epic / child-only container pre-rank exclusion (#844).
|
|
||||||
|
|
||||||
Covers:
|
|
||||||
* Issue #631-shaped child-only epic is excluded before ranking.
|
|
||||||
* Implementable child issues remain eligible and can be selected.
|
|
||||||
* Ordinary issues that merely mention "epic" in title/body are not excluded.
|
|
||||||
* Excluded containers never receive assignments or workflow leases.
|
|
||||||
* Structured skip reason ``epic_or_child_only_container`` is reported.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from allocator_service import (
|
|
||||||
OUTCOME_ASSIGNED,
|
|
||||||
OUTCOME_PREVIEW,
|
|
||||||
SKIP_EPIC_OR_CHILD_ONLY_CONTAINER,
|
|
||||||
WorkCandidate,
|
|
||||||
allocate_next_work,
|
|
||||||
classify_epic_or_child_only_container,
|
|
||||||
)
|
|
||||||
from control_plane_db import ControlPlaneDB
|
|
||||||
|
|
||||||
REMOTE = "prgs"
|
|
||||||
ORG = "Scaled-Tech-Consulting"
|
|
||||||
REPO = "Gitea-Tools"
|
|
||||||
|
|
||||||
# Minimal body mirroring issue #631 authoritative scope language.
|
|
||||||
_EPIC_631_BODY = """
|
|
||||||
## Scope (umbrella)
|
|
||||||
|
|
||||||
This epic owns the **product roadmap and linkage** for the Web Console.
|
|
||||||
Implementation is delivered via child issues only.
|
|
||||||
|
|
||||||
## Explicit non-goals
|
|
||||||
|
|
||||||
* Do not implement product features in this epic issue itself.
|
|
||||||
* No product feature implementation is claimed complete solely on this epic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_CHILD_BODY = """
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Operators need a workflow-event timeline model for Phase 1.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [ ] Timeline model API exists
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _issue(
|
|
||||||
number: int,
|
|
||||||
*,
|
|
||||||
title: str = "",
|
|
||||||
body: str = "",
|
|
||||||
labels: tuple[str, ...] = ("status:ready", "type:feature"),
|
|
||||||
priority: int = 20,
|
|
||||||
) -> WorkCandidate:
|
|
||||||
return WorkCandidate(
|
|
||||||
kind="issue",
|
|
||||||
number=number,
|
|
||||||
state="open",
|
|
||||||
labels=labels,
|
|
||||||
title=title or f"issue {number}",
|
|
||||||
body=body,
|
|
||||||
priority=priority,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClassifyEpicContainerTest(unittest.TestCase):
|
|
||||||
def test_631_shaped_body_and_title_is_container(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
is_c, detail = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertTrue(is_c)
|
|
||||||
self.assertIsNotNone(detail)
|
|
||||||
self.assertIn("body_marker", detail or "")
|
|
||||||
|
|
||||||
def test_body_markers_without_epic_title(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
900,
|
|
||||||
title="Control plane roadmap tracker",
|
|
||||||
body="Implementation is delivered via child issues only.",
|
|
||||||
)
|
|
||||||
is_c, _ = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertTrue(is_c)
|
|
||||||
|
|
||||||
def test_epic_label_alone_is_container(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
901,
|
|
||||||
title="Roadmap linkage",
|
|
||||||
body="Track children.",
|
|
||||||
labels=("status:ready", "type:epic"),
|
|
||||||
)
|
|
||||||
is_c, detail = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertTrue(is_c)
|
|
||||||
self.assertIn("type:epic", detail or "")
|
|
||||||
|
|
||||||
def test_title_epic_prefix_alone_not_container(self) -> None:
|
|
||||||
"""Title-only 'Epic:' without body scope evidence stays eligible (#844)."""
|
|
||||||
c = _issue(
|
|
||||||
902,
|
|
||||||
title="Epic: something mentioned only in title",
|
|
||||||
body="Implement a concrete fix for the allocator skip list.",
|
|
||||||
)
|
|
||||||
is_c, detail = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertFalse(is_c)
|
|
||||||
self.assertIsNone(detail)
|
|
||||||
|
|
||||||
def test_incidental_epic_word_not_container(self) -> None:
|
|
||||||
c = _issue(
|
|
||||||
903,
|
|
||||||
title="Document epic handoff conventions",
|
|
||||||
body=(
|
|
||||||
"Update the docs so implementable issues that mention an epic "
|
|
||||||
"remain independently executable."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is_c, _ = classify_epic_or_child_only_container(c)
|
|
||||||
self.assertFalse(is_c)
|
|
||||||
|
|
||||||
def test_prs_never_classified(self) -> None:
|
|
||||||
pr = WorkCandidate(
|
|
||||||
kind="pr",
|
|
||||||
number=10,
|
|
||||||
state="open",
|
|
||||||
title="Epic: fake",
|
|
||||||
body="Implementation is delivered via child issues only.",
|
|
||||||
head_sha="a" * 40,
|
|
||||||
priority=5,
|
|
||||||
)
|
|
||||||
is_c, _ = classify_epic_or_child_only_container(pr)
|
|
||||||
self.assertFalse(is_c)
|
|
||||||
|
|
||||||
|
|
||||||
class AllocateEpicContainerExclusionTest(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.addCleanup(self._tmp.cleanup)
|
|
||||||
self.db = ControlPlaneDB(os.path.join(self._tmp.name, "cp.sqlite3"))
|
|
||||||
|
|
||||||
def _alloc(self, candidates, **kwargs):
|
|
||||||
defaults = dict(
|
|
||||||
session_id="sess-844",
|
|
||||||
role="author",
|
|
||||||
remote=REMOTE,
|
|
||||||
org=ORG,
|
|
||||||
repo=REPO,
|
|
||||||
profile_name="prgs-author",
|
|
||||||
username="jcwalker3",
|
|
||||||
claims={},
|
|
||||||
apply=False,
|
|
||||||
)
|
|
||||||
defaults.update(kwargs)
|
|
||||||
return allocate_next_work(self.db, candidates=candidates, **defaults)
|
|
||||||
|
|
||||||
def test_631_shaped_epic_excluded_child_selected(self) -> None:
|
|
||||||
epic = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
child = _issue(
|
|
||||||
637,
|
|
||||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
|
||||||
body=_CHILD_BODY,
|
|
||||||
)
|
|
||||||
res = self._alloc([epic, child], apply=False)
|
|
||||||
self.assertTrue(res["success"], res)
|
|
||||||
self.assertEqual(res["outcome"], OUTCOME_PREVIEW)
|
|
||||||
self.assertEqual(res["selected"]["number"], 637)
|
|
||||||
skipped = {s["number"]: s for s in res["skipped"]}
|
|
||||||
self.assertIn(631, skipped)
|
|
||||||
self.assertEqual(
|
|
||||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
|
||||||
)
|
|
||||||
self.assertIn(SKIP_EPIC_OR_CHILD_ONLY_CONTAINER, skipped[631]["reason"])
|
|
||||||
|
|
||||||
def test_container_cannot_receive_assignment_or_lease(self) -> None:
|
|
||||||
epic = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
res = self._alloc([epic], apply=True)
|
|
||||||
self.assertTrue(res["success"], res)
|
|
||||||
# Only container present → no safe work; never assigned_work.
|
|
||||||
self.assertNotEqual(res["outcome"], OUTCOME_ASSIGNED)
|
|
||||||
self.assertIsNone(res.get("assignment"))
|
|
||||||
self.assertIsNone(res.get("selected"))
|
|
||||||
skipped = {s["number"]: s for s in res["skipped"]}
|
|
||||||
self.assertEqual(
|
|
||||||
skipped[631]["reason_code"], SKIP_EPIC_OR_CHILD_ONLY_CONTAINER
|
|
||||||
)
|
|
||||||
# No lease row for the epic.
|
|
||||||
leases = self.db.list_active_leases(
|
|
||||||
remote=REMOTE, org=ORG, repo=REPO
|
|
||||||
) if hasattr(self.db, "list_active_leases") else []
|
|
||||||
# Prefer generic inventory if available.
|
|
||||||
if not leases and hasattr(self.db, "list_leases"):
|
|
||||||
leases = self.db.list_leases(remote=REMOTE, org=ORG, repo=REPO)
|
|
||||||
for lease in leases or []:
|
|
||||||
work_number = lease.get("work_number") if isinstance(lease, dict) else None
|
|
||||||
self.assertNotEqual(work_number, 631)
|
|
||||||
|
|
||||||
def test_incidental_epic_title_remains_eligible(self) -> None:
|
|
||||||
ordinary = _issue(
|
|
||||||
700,
|
|
||||||
title="Document epic handoff conventions",
|
|
||||||
body="Write runbook text about epic vs child issues.",
|
|
||||||
)
|
|
||||||
res = self._alloc([ordinary], apply=False)
|
|
||||||
self.assertTrue(res["success"], res)
|
|
||||||
self.assertEqual(res["selected"]["number"], 700)
|
|
||||||
self.assertEqual(res["skipped"], [])
|
|
||||||
|
|
||||||
def test_apply_selects_child_not_epic(self) -> None:
|
|
||||||
epic = _issue(
|
|
||||||
631,
|
|
||||||
title="Epic: MCP Control Plane Web Console",
|
|
||||||
body=_EPIC_631_BODY,
|
|
||||||
)
|
|
||||||
child = _issue(
|
|
||||||
637,
|
|
||||||
title="Web Console: Workflow-event timeline model (Phase 1)",
|
|
||||||
body=_CHILD_BODY,
|
|
||||||
)
|
|
||||||
res = self._alloc([epic, child], apply=True)
|
|
||||||
self.assertTrue(res["success"], res)
|
|
||||||
self.assertEqual(res["outcome"], OUTCOME_ASSIGNED)
|
|
||||||
self.assertEqual(res["selected"]["number"], 637)
|
|
||||||
self.assertEqual(res["assignment"]["work_number"], 637)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -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,
|
||||||
@@ -59,24 +60,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:
|
||||||
bind_host = _request.app.state.webui_bind_host
|
bind_host = _request.app.state.webui_bind_host
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -438,6 +477,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