Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a947bdc186 |
@@ -1568,32 +1568,6 @@ class ControlPlaneDB:
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_incident_links(
|
||||
self,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
gitea_org: str | None = None,
|
||||
gitea_repo: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List stored incident_links rows, optionally filtered by provider/repo (#612 / #649)."""
|
||||
query = "SELECT * FROM incident_links WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
if provider:
|
||||
query += " AND provider = ?"
|
||||
params.append(provider.strip().lower())
|
||||
if gitea_org:
|
||||
query += " AND gitea_org = ?"
|
||||
params.append(_norm_scope(gitea_org))
|
||||
if gitea_repo:
|
||||
query += " AND gitea_repo = ?"
|
||||
params.append(_norm_scope(gitea_repo))
|
||||
query += " ORDER BY link_id DESC LIMIT ?"
|
||||
params.append(max(1, limit))
|
||||
with self._tx(immediate=False) as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── lease lifecycle (#601) ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Web Console: Sentry/GlitchTip Observability & Incident Bridge Console (#649)
|
||||
|
||||
This document describes the Phase 4 observability console surface integrated into the MCP Control Plane Web Console (`webui/`), backed by the #612 incident bridge and the #613 control-plane DB substrate.
|
||||
|
||||
## Architectural Authority Model (ADR Alignment)
|
||||
|
||||
Per the Web Console Architecture ADR (`docs/architecture/webui-control-plane-console-architecture-adr.md`):
|
||||
|
||||
| Layer | Responsibility | Authority |
|
||||
|---|---|---|
|
||||
| **Gitea** | Durable work record | Issues, PRs, comments, reviews, labels, merges |
|
||||
| **Control-plane DB** | Live coordination & linkage | `incident_links` table, session leases, allocations |
|
||||
| **Sentry / GlitchTip** | Observability input | Unresolved incidents, error events, stack traces |
|
||||
| **Incident Bridge (#612)** | Reconciliation engine | Reconciles provider observations into Gitea issues |
|
||||
| **Web Console (`webui/`)** | Read-only projection & gated actions | Projects connection health & correlation links; gates writes |
|
||||
|
||||
> **Key Rule:** Raw monitoring incidents are **never** assignable control-plane `work_items`. They remain observation input only.
|
||||
|
||||
## Redaction Boundary Invariants
|
||||
|
||||
1. **No secrets in returns or rendering:** Auth tokens (`SENTRY_AUTH_TOKEN`, `GLITCHTIP_AUTH_TOKEN`), DSNs, `Authorization` headers, and sensitive local file paths are passed through `webui.console_redaction` before leaving the server.
|
||||
2. **Safe projection:** Connection objects report `credentials_present: true/false` rather than exposing raw keys or headers.
|
||||
|
||||
## Console Endpoints
|
||||
|
||||
- **HTML Surface:** `GET /observability` — Renders provider connection cards, error correlation tables, and gated reconcile controls.
|
||||
- **Versioned API:** `GET /api/v1/observability` — Returns structured JSON snapshot with `schema_version`, `providers`, `links`, and `metrics`.
|
||||
- **Legacy Compatibility Alias:** `GET /api/observability` — Read-only compatibility alias for Phase 4.
|
||||
|
||||
## Gated Actions
|
||||
|
||||
- `observability_reconcile_incident` (`gitea_observability_reconcile_incident`): Triggers or previews dry-run issue reconciliation for a provider incident.
|
||||
- `observability_link_issue` (`gitea_observability_link_issue`): Links a provider incident to an existing Gitea tracking issue.
|
||||
|
||||
Both actions require `operator` role and gate through `task_capability_map`. Execution fails closed in read-only MVP mode.
|
||||
@@ -98,8 +98,6 @@ already define, and a regression test asserts each mapping matches.
|
||||
| `system.rebind_session_worktree` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
||||
| `system.reconcile_cleanups` | controller | privileged | `gitea.pr.close` | Yes | No | No | 2 |
|
||||
| `initiate_workflow` | operator | gated_write | `gitea.read` | Yes | No | No | 2 |
|
||||
| `observability_reconcile_incident` | operator | gated_write | `gitea.read` | Yes | No | No | 4 |
|
||||
| `observability_link_issue` | operator | gated_write | `gitea.read` | Yes | No | No | 4 |
|
||||
|
||||
**Dual control** means the acting principal may not be the sole authority: a
|
||||
second distinct principal must confirm. **Break-glass** means the action is
|
||||
|
||||
+15
-1
@@ -24,6 +24,8 @@ import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
import hashlib
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2258,7 +2260,12 @@ def _seed_session_context(
|
||||
expected_username=expected,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_root_pin,
|
||||
cohort_id=_COHORT_ID,
|
||||
startup_sha=_STARTUP_PARITY.get("startup_head"),
|
||||
endpoint=host or (profile.get("base_url") or "").strip() or None,
|
||||
config_fingerprint=_CONFIG_FINGERPRINT,
|
||||
)
|
||||
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_workflow_labels # noqa: E402
|
||||
import terminal_pr_label_cleanup # noqa: E402 # #780 status:pr-open terminal rule
|
||||
@@ -2287,6 +2294,11 @@ import stable_control_runtime # noqa: E402
|
||||
# master has advanced past the running code and fail closed until restart.
|
||||
# Read-only operations are never blocked by staleness.
|
||||
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
||||
_COHORT_ID: str = f"cohort-p{os.getpid()}-{_STARTUP_PARITY.get('startup_head') or 'unknown'}"
|
||||
_CONFIG_FINGERPRINT: str = hashlib.sha256(
|
||||
(PROJECT_ROOT + str(_STARTUP_PARITY.get("startup_head"))).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
# Stable-control runtime facts (#615): which runtime this process serves from.
|
||||
# These are the *immutable* facts -- process root, branch, head, checkout-ness --
|
||||
@@ -14197,8 +14209,10 @@ def _current_master_parity() -> dict:
|
||||
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
|
||||
live_head = master_parity_gate.read_remote_master_head(
|
||||
PROJECT_ROOT, remote=_git_default_remote_name(PROJECT_ROOT))
|
||||
bound_context = session_ctx.get_session_context()
|
||||
return master_parity_gate.assess_master_parity(
|
||||
_STARTUP_PARITY, current_head, live_remote_head=live_head)
|
||||
_STARTUP_PARITY, current_head, live_remote_head=live_head, bound_cohort=bound_context)
|
||||
|
||||
|
||||
|
||||
def _current_runtime_mode_report(refresh: bool = False) -> dict:
|
||||
|
||||
+70
-9
@@ -183,6 +183,7 @@ def assess_master_parity(
|
||||
startup: dict | None,
|
||||
current_head: str | None,
|
||||
live_remote_head: str | None = None,
|
||||
bound_cohort: dict | None = None,
|
||||
) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
@@ -192,7 +193,8 @@ def assess_master_parity(
|
||||
could not be determined, which is not treated as stale).
|
||||
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||
running process.
|
||||
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
|
||||
- ``restart_required`` -- ``stale``, ``live_stale``, or ``cohort_stale``; the
|
||||
recovery action.
|
||||
- ``determinable`` -- whether both local HEADs were known well enough to
|
||||
compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
@@ -209,24 +211,69 @@ def assess_master_parity(
|
||||
- ``live_known`` -- whether the live remote target was resolved.
|
||||
- ``live_stale`` -- the live remote master has advanced past the running
|
||||
process (daemon is behind live master) even if local parity is green.
|
||||
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
|
||||
target all agree; the only state in which a mutation may rely on parity.
|
||||
- ``bound_cohort`` -- metadata describing the bound MCP cohort.
|
||||
- ``cohort_parity_match`` -- whether bound cohort startup SHA matches parity.
|
||||
- ``cohort_stale`` -- bound cohort startup SHA is stale relative to parity.
|
||||
- ``mutation_safe`` -- the daemon code, local checkout, live remote target,
|
||||
and bound cohort all agree; the only state in which a mutation may rely
|
||||
on parity.
|
||||
"""
|
||||
startup_head = (startup or {}).get("startup_head")
|
||||
reasons: list[str] = []
|
||||
|
||||
cohort_info: dict | None = None
|
||||
cohort_parity_match = True
|
||||
cohort_stale = False
|
||||
if bound_cohort:
|
||||
c_id = str(bound_cohort.get("cohort_id") or "").strip() or None
|
||||
c_pid = bound_cohort.get("pid")
|
||||
c_sha = str(
|
||||
bound_cohort.get("startup_sha")
|
||||
or bound_cohort.get("git_head")
|
||||
or ""
|
||||
).strip() or None
|
||||
c_endpoint = str(bound_cohort.get("endpoint") or "").strip() or None
|
||||
c_fingerprint = str(
|
||||
bound_cohort.get("config_fingerprint") or ""
|
||||
).strip() or None
|
||||
|
||||
cohort_info = {
|
||||
"cohort_id": c_id,
|
||||
"pid": c_pid,
|
||||
"startup_sha": c_sha,
|
||||
"endpoint": c_endpoint,
|
||||
"config_fingerprint": c_fingerprint,
|
||||
}
|
||||
|
||||
parity_ref = live_remote_head or current_head or startup_head
|
||||
if c_sha and parity_ref:
|
||||
if c_sha.lower() != parity_ref.lower():
|
||||
cohort_parity_match = False
|
||||
cohort_stale = True
|
||||
reasons.append(
|
||||
f"bound cohort startup SHA '{_short(c_sha)}' does not "
|
||||
f"match authoritative parity SHA '{_short(parity_ref)}' "
|
||||
"(stale cohort refused)"
|
||||
)
|
||||
|
||||
if startup_head is None:
|
||||
reasons.append(
|
||||
"startup commit was not captured; code parity cannot be enforced")
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
live_remote_head, False, reasons,
|
||||
bound_cohort=cohort_info,
|
||||
cohort_parity_match=cohort_parity_match,
|
||||
cohort_stale=cohort_stale)
|
||||
|
||||
if current_head is None:
|
||||
reasons.append(
|
||||
"current workspace HEAD could not be read; code parity cannot be "
|
||||
"enforced")
|
||||
return _result(True, False, False, startup_head, current_head,
|
||||
live_remote_head, False, reasons)
|
||||
live_remote_head, False, reasons,
|
||||
bound_cohort=cohort_info,
|
||||
cohort_parity_match=cohort_parity_match,
|
||||
cohort_stale=cohort_stale)
|
||||
|
||||
local_in_parity = startup_head == current_head
|
||||
local_stale = not local_in_parity
|
||||
@@ -246,18 +293,28 @@ def assess_master_parity(
|
||||
|
||||
return _result(
|
||||
local_in_parity, local_stale, True, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons)
|
||||
live_remote_head, live_stale, reasons,
|
||||
bound_cohort=cohort_info,
|
||||
cohort_parity_match=cohort_parity_match,
|
||||
cohort_stale=cohort_stale)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head,
|
||||
live_remote_head, live_stale, reasons):
|
||||
live_remote_head, live_stale, reasons, bound_cohort=None,
|
||||
cohort_parity_match=True, cohort_stale=False):
|
||||
live_known = live_remote_head is not None
|
||||
mutation_safe = (
|
||||
determinable and in_parity and live_known and not live_stale)
|
||||
determinable
|
||||
and in_parity
|
||||
and live_known
|
||||
and not live_stale
|
||||
and cohort_parity_match
|
||||
and not cohort_stale
|
||||
)
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale or live_stale,
|
||||
"restart_required": stale or live_stale or cohort_stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
@@ -267,11 +324,15 @@ def _result(in_parity, stale, determinable, startup_head, current_head,
|
||||
"live_remote_head": live_remote_head,
|
||||
"live_known": live_known,
|
||||
"live_stale": live_stale,
|
||||
"cohort_parity_match": cohort_parity_match,
|
||||
"cohort_stale": cohort_stale,
|
||||
"bound_cohort": bound_cohort,
|
||||
"mutation_safe": mutation_safe,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def gate_disabled() -> bool:
|
||||
"""Whether the parity gate is disabled by env escape hatch."""
|
||||
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||
|
||||
+45
-3
@@ -104,6 +104,8 @@ def classify_namespace_probe(
|
||||
profile: str | None = None,
|
||||
configured: bool = True,
|
||||
probe_source: str | None = None,
|
||||
expected_parity_sha: str | None = None,
|
||||
bound_cohort: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify whether a required tool is callable through a live namespace.
|
||||
|
||||
@@ -135,20 +137,50 @@ def classify_namespace_probe(
|
||||
else:
|
||||
error_type = "namespace_call_failed"
|
||||
|
||||
# Extract cohort metadata
|
||||
cohort_meta = bound_cohort or probe.get("cohort") or probe.get("bound_cohort") or {}
|
||||
cohort_id = str(
|
||||
cohort_meta.get("cohort_id") or probe.get("cohort_id") or ""
|
||||
).strip() or None
|
||||
startup_sha = str(
|
||||
cohort_meta.get("startup_sha")
|
||||
or cohort_meta.get("git_head")
|
||||
or probe.get("startup_sha")
|
||||
or probe.get("git_head")
|
||||
or ""
|
||||
).strip() or None
|
||||
endpoint = str(
|
||||
cohort_meta.get("endpoint") or probe.get("endpoint") or ""
|
||||
).strip() or None
|
||||
config_fingerprint = str(
|
||||
cohort_meta.get("config_fingerprint") or probe.get("config_fingerprint") or ""
|
||||
).strip() or None
|
||||
|
||||
expected_sha = (expected_parity_sha or "").strip().lower() or None
|
||||
stale_cohort = False
|
||||
if expected_sha and startup_sha:
|
||||
if startup_sha.lower() != expected_sha:
|
||||
stale_cohort = True
|
||||
error_type = "stale_cohort_refused"
|
||||
|
||||
if not configured:
|
||||
error_type = "namespace_not_configured"
|
||||
elif registered is False:
|
||||
error_type = "tool_missing"
|
||||
elif not probe_result:
|
||||
error_type = "live_probe_missing"
|
||||
elif stale_cohort:
|
||||
error_type = "stale_cohort_refused"
|
||||
elif not probe_success and not error_type:
|
||||
error_type = "namespace_call_failed"
|
||||
|
||||
callable_live = bool(configured and probe_result and probe_success)
|
||||
callable_live = bool(configured and probe_result and probe_success and not stale_cohort)
|
||||
# Probe-path health (spawn or client). IDE-proven only for client path.
|
||||
healthy = bool(configured and registered is not False and callable_live)
|
||||
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
|
||||
process_pid = process.get("pid") if isinstance(process, dict) else None
|
||||
process_pid = process.get("pid") if isinstance(process, dict) else (
|
||||
cohort_meta.get("pid") if isinstance(cohort_meta, dict) else None
|
||||
)
|
||||
profile_name = profile or (
|
||||
process.get("profile") if isinstance(process, dict) else None
|
||||
)
|
||||
@@ -161,7 +193,13 @@ def classify_namespace_probe(
|
||||
reasons.append(
|
||||
f"Required tool '{tool}' is not registered in namespace '{ns}'."
|
||||
)
|
||||
if error_type == "live_probe_missing":
|
||||
if error_type == "stale_cohort_refused":
|
||||
reasons.append(
|
||||
f"Bound cohort startup SHA '{startup_sha[:12] if startup_sha else 'unknown'}' "
|
||||
f"does not match expected parity SHA '{expected_sha[:12] if expected_sha else 'unknown'}' "
|
||||
"(stale cohort refused)."
|
||||
)
|
||||
elif error_type == "live_probe_missing":
|
||||
reasons.append(
|
||||
f"No live client invocation proof was supplied for '{ns}.{tool}'."
|
||||
)
|
||||
@@ -248,6 +286,10 @@ def classify_namespace_probe(
|
||||
"env": env_summary,
|
||||
"config_path": config_path,
|
||||
"probe_source": source,
|
||||
"cohort_id": cohort_id,
|
||||
"startup_sha": startup_sha,
|
||||
"endpoint": endpoint,
|
||||
"config_fingerprint": config_fingerprint,
|
||||
},
|
||||
"blocks_merge_workflow": blocks,
|
||||
}
|
||||
|
||||
@@ -151,12 +151,16 @@ class RestartCompletionProof:
|
||||
unresolved_count: int
|
||||
skipped_count: int
|
||||
note: str
|
||||
binding_unchanged: bool = False
|
||||
prior_reconcile_id: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"reconcile_version": self.reconcile_version,
|
||||
"reconcile_id": self.reconcile_id,
|
||||
"binding_unchanged": self.binding_unchanged,
|
||||
"prior_reconcile_id": self.prior_reconcile_id,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"boot_head_sha": self.boot_head_sha,
|
||||
@@ -173,6 +177,7 @@ class RestartCompletionProof:
|
||||
"skipped_count": self.skipped_count,
|
||||
"note": self.note,
|
||||
"links": {
|
||||
|
||||
"umbrella": 655,
|
||||
"vision": 652,
|
||||
"roadmap": 653,
|
||||
@@ -359,7 +364,9 @@ def reconcile_after_restart(
|
||||
now: datetime | None = None,
|
||||
mode: str = MODE_LOG_ONLY,
|
||||
reconcile_id: str | None = None,
|
||||
prior_reconcile_id: str | None = None,
|
||||
) -> RestartCompletionProof:
|
||||
|
||||
"""Classify a post-restart inventory into a completion proof (#662).
|
||||
|
||||
Parameters
|
||||
@@ -750,12 +757,26 @@ def reconcile_after_restart(
|
||||
f"Mode={mode_norm}."
|
||||
)
|
||||
|
||||
prior_id = str(
|
||||
prior_reconcile_id
|
||||
or inventory.get("prior_reconcile_id")
|
||||
or ""
|
||||
).strip() or None
|
||||
target_rec_id = str(reconcile_id or "").strip() or None
|
||||
binding_unchanged = bool(
|
||||
prior_id and target_rec_id and target_rec_id == prior_id
|
||||
)
|
||||
final_reconcile_id = target_rec_id or f"reconcile-{uuid4().hex[:12]}"
|
||||
|
||||
return RestartCompletionProof(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
reconcile_version=RECONCILE_VERSION,
|
||||
reconcile_id=(reconcile_id or f"reconcile-{uuid4().hex[:12]}"),
|
||||
reconcile_id=final_reconcile_id,
|
||||
binding_unchanged=binding_unchanged,
|
||||
prior_reconcile_id=prior_id,
|
||||
started_at=_ts(started),
|
||||
finished_at=_ts(finished),
|
||||
|
||||
boot_head_sha=(
|
||||
str(inventory.get("boot_head_sha")).strip()
|
||||
if inventory.get("boot_head_sha")
|
||||
|
||||
@@ -33,6 +33,10 @@ class _SessionContext:
|
||||
source: str
|
||||
pid: int
|
||||
canonical_repository_root: str | None = None
|
||||
cohort_id: str | None = None
|
||||
startup_sha: str | None = None
|
||||
endpoint: str | None = None
|
||||
config_fingerprint: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -47,9 +51,14 @@ class _SessionContext:
|
||||
"source": self.source,
|
||||
"pid": self.pid,
|
||||
"canonical_repository_root": self.canonical_repository_root,
|
||||
"cohort_id": self.cohort_id,
|
||||
"startup_sha": self.startup_sha,
|
||||
"endpoint": self.endpoint,
|
||||
"config_fingerprint": self.config_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Process-local only — never a shared file (same rationale as mutation authority).
|
||||
# The frozen value prevents partial mutation, while the lock makes first-bind and
|
||||
# sanctioned rebind atomic across concurrent MCP calls.
|
||||
@@ -72,6 +81,13 @@ def _reset_session_context_for_testing() -> None:
|
||||
_SESSION_CONTEXT = None
|
||||
|
||||
|
||||
def clear_session_context() -> None:
|
||||
"""Purge process-session context and cohort bindings on disconnect."""
|
||||
global _SESSION_CONTEXT
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
_SESSION_CONTEXT = None
|
||||
|
||||
|
||||
def get_session_context() -> dict[str, Any] | None:
|
||||
"""Return a detached snapshot of the bound context, or None if unbound."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
@@ -146,6 +162,10 @@ def bind_session_context(
|
||||
expected_username: str | None = None,
|
||||
source: str = "bind",
|
||||
canonical_repository_root: str | None = None,
|
||||
cohort_id: str | None = None,
|
||||
startup_sha: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
config_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind/re-bind context (the explicit activation path)."""
|
||||
with _SESSION_CONTEXT_LOCK:
|
||||
@@ -160,6 +180,10 @@ def bind_session_context(
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_repository_root,
|
||||
cohort_id=cohort_id,
|
||||
startup_sha=startup_sha,
|
||||
endpoint=endpoint,
|
||||
config_fingerprint=config_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
@@ -175,6 +199,10 @@ def _bind_session_context_unlocked(
|
||||
expected_username: str | None,
|
||||
source: str,
|
||||
canonical_repository_root: str | None = None,
|
||||
cohort_id: str | None = None,
|
||||
startup_sha: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
config_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a complete immutable context while the caller holds the lock."""
|
||||
global _SESSION_CONTEXT
|
||||
@@ -190,6 +218,10 @@ def _bind_session_context_unlocked(
|
||||
source=source,
|
||||
pid=os.getpid(),
|
||||
canonical_repository_root=(canonical_repository_root or "").strip() or None,
|
||||
cohort_id=(cohort_id or "").strip() or None,
|
||||
startup_sha=(startup_sha or "").strip() or None,
|
||||
endpoint=(endpoint or "").strip() or None,
|
||||
config_fingerprint=(config_fingerprint or "").strip() or None,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
@@ -206,6 +238,10 @@ def seed_session_context_if_unbound(
|
||||
expected_username: str | None = None,
|
||||
source: str = "seed",
|
||||
canonical_repository_root: str | None = None,
|
||||
cohort_id: str | None = None,
|
||||
startup_sha: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
config_fingerprint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically bind only when this process has no current context.
|
||||
|
||||
@@ -227,10 +263,15 @@ def seed_session_context_if_unbound(
|
||||
expected_username=expected_username,
|
||||
source=source,
|
||||
canonical_repository_root=canonical_repository_root,
|
||||
cohort_id=cohort_id,
|
||||
startup_sha=startup_sha,
|
||||
endpoint=endpoint,
|
||||
config_fingerprint=config_fingerprint,
|
||||
)
|
||||
return _SESSION_CONTEXT.as_dict()
|
||||
|
||||
|
||||
|
||||
def assess_session_context(
|
||||
*,
|
||||
profile_name: str | None,
|
||||
@@ -586,6 +627,10 @@ def mutation_context_audit_fields(
|
||||
"session_identity": None,
|
||||
"session_repository": None,
|
||||
"session_org": None,
|
||||
"session_cohort_id": None,
|
||||
"session_startup_sha": None,
|
||||
"session_endpoint": None,
|
||||
"session_config_fingerprint": None,
|
||||
}
|
||||
return {
|
||||
"session_context_bound": True,
|
||||
@@ -598,9 +643,14 @@ def mutation_context_audit_fields(
|
||||
"session_role_kind": data.get("role_kind"),
|
||||
"session_context_source": data.get("source"),
|
||||
"session_canonical_repository_root": data.get("canonical_repository_root"),
|
||||
"session_cohort_id": data.get("cohort_id"),
|
||||
"session_startup_sha": data.get("startup_sha"),
|
||||
"session_endpoint": data.get("endpoint"),
|
||||
"session_config_fingerprint": data.get("config_fingerprint"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool, reasons: list[str], ctx: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests for Issue #689: Deterministic MCP namespace attachment.
|
||||
|
||||
Verifies cohort identity exposure, stale cohort refusal, parity matching,
|
||||
reconcile_id freshness, session context cleanup, and regression scenarios.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import master_parity_gate
|
||||
import mcp_namespace_health
|
||||
import post_restart_reconcile
|
||||
import session_context_binding as session_ctx
|
||||
|
||||
|
||||
class TestIssue689DeterministicCohortAttachment(unittest.TestCase):
|
||||
"""Suite covering Issue #689 acceptance criteria."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def test_ac1_session_context_exposes_cohort_identity(self) -> None:
|
||||
"""AC1: Session context exposes cohort ID, startup SHA, endpoint, and config fingerprint."""
|
||||
ctx = session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
repository="Gitea-Tools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
role_kind="author",
|
||||
cohort_id="cohort-p1234-abc123456789",
|
||||
startup_sha="abc123456789def",
|
||||
endpoint="gitea.prgs.cc",
|
||||
config_fingerprint="fingerprint12345",
|
||||
)
|
||||
self.assertEqual(ctx["cohort_id"], "cohort-p1234-abc123456789")
|
||||
self.assertEqual(ctx["startup_sha"], "abc123456789def")
|
||||
self.assertEqual(ctx["endpoint"], "gitea.prgs.cc")
|
||||
self.assertEqual(ctx["config_fingerprint"], "fingerprint12345")
|
||||
|
||||
fetched = session_ctx.get_session_context()
|
||||
self.assertIsNotNone(fetched)
|
||||
self.assertEqual(fetched["cohort_id"], "cohort-p1234-abc123456789")
|
||||
self.assertEqual(fetched["startup_sha"], "abc123456789def")
|
||||
|
||||
def test_ac2_stale_cohort_refused_by_probe_classifier(self) -> None:
|
||||
"""AC2: Probe classifier refuses binding to a stale cohort as stale_cohort_refused."""
|
||||
probe_res = {
|
||||
"success": True,
|
||||
"cohort": {
|
||||
"cohort_id": "cohort-obsolete-1",
|
||||
"startup_sha": "22698c1000000000000000000000000000000000",
|
||||
"endpoint": "gitea.prgs.cc",
|
||||
"config_fingerprint": "fp-old",
|
||||
},
|
||||
}
|
||||
res = mcp_namespace_health.classify_namespace_probe(
|
||||
"gitea-author",
|
||||
probe_result=probe_res,
|
||||
probe_source="client_namespace",
|
||||
expected_parity_sha="a4c73766f4b0cc32f7c3808688eceeb6fee74335",
|
||||
)
|
||||
self.assertFalse(res["healthy"])
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res["error_type"], "stale_cohort_refused")
|
||||
self.assertIn("stale cohort refused", " ".join(res["reasons"]))
|
||||
self.assertEqual(
|
||||
res["diagnostics"]["startup_sha"],
|
||||
"22698c1000000000000000000000000000000000",
|
||||
)
|
||||
|
||||
def test_ac3_reconnection_parity_matching_and_fail_closed(self) -> None:
|
||||
"""AC3: Parity gate fails closed when bound cohort startup SHA mismatches parity."""
|
||||
startup = {"startup_head": "a4c73766f4b0cc32f7c3808688eceeb6fee74335"}
|
||||
current = "a4c73766f4b0cc32f7c3808688eceeb6fee74335"
|
||||
live_remote = "a4c73766f4b0cc32f7c3808688eceeb6fee74335"
|
||||
|
||||
# Matching cohort
|
||||
matching_cohort = {
|
||||
"cohort_id": "cohort-fresh",
|
||||
"startup_sha": "a4c73766f4b0cc32f7c3808688eceeb6fee74335",
|
||||
}
|
||||
res_matching = master_parity_gate.assess_master_parity(
|
||||
startup, current, live_remote_head=live_remote, bound_cohort=matching_cohort
|
||||
)
|
||||
self.assertTrue(res_matching["cohort_parity_match"])
|
||||
self.assertFalse(res_matching["cohort_stale"])
|
||||
self.assertTrue(res_matching["mutation_safe"])
|
||||
|
||||
# Mismatched obsolete cohort
|
||||
obsolete_cohort = {
|
||||
"cohort_id": "cohort-obsolete-22698c1",
|
||||
"startup_sha": "22698c1000000000000000000000000000000000",
|
||||
}
|
||||
res_stale = master_parity_gate.assess_master_parity(
|
||||
startup, current, live_remote_head=live_remote, bound_cohort=obsolete_cohort
|
||||
)
|
||||
self.assertFalse(res_stale["cohort_parity_match"])
|
||||
self.assertTrue(res_stale["cohort_stale"])
|
||||
self.assertTrue(res_stale["restart_required"])
|
||||
self.assertFalse(res_stale["mutation_safe"])
|
||||
|
||||
def test_ac4_reconcile_id_freshness(self) -> None:
|
||||
"""AC4: Re-attachment distinguishes new reconcile_id from preserved binding."""
|
||||
inventory = {"inventory_complete": True}
|
||||
|
||||
# New attachment generates fresh reconcile_id
|
||||
proof1 = post_restart_reconcile.reconcile_after_restart(inventory)
|
||||
self.assertFalse(proof1.binding_unchanged)
|
||||
self.assertTrue(proof1.reconcile_id.startswith("reconcile-"))
|
||||
|
||||
# Preserved binding reports binding_unchanged=True
|
||||
proof2 = post_restart_reconcile.reconcile_after_restart(
|
||||
inventory,
|
||||
reconcile_id=proof1.reconcile_id,
|
||||
prior_reconcile_id=proof1.reconcile_id,
|
||||
)
|
||||
self.assertTrue(proof2.binding_unchanged)
|
||||
self.assertEqual(proof2.reconcile_id, proof1.reconcile_id)
|
||||
|
||||
# Disconnected re-attachment gets new reconcile_id
|
||||
proof3 = post_restart_reconcile.reconcile_after_restart(
|
||||
inventory,
|
||||
prior_reconcile_id=proof1.reconcile_id,
|
||||
)
|
||||
self.assertFalse(proof3.binding_unchanged)
|
||||
self.assertNotEqual(proof3.reconcile_id, proof1.reconcile_id)
|
||||
|
||||
def test_ac5_session_disconnect_clears_bindings(self) -> None:
|
||||
"""AC5: clear_session_context purges session context on disconnect."""
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
cohort_id="cohort-1",
|
||||
)
|
||||
self.assertIsNotNone(session_ctx.get_session_context())
|
||||
|
||||
session_ctx.clear_session_context()
|
||||
self.assertIsNone(session_ctx.get_session_context())
|
||||
|
||||
def test_ac6_bound_cohort_in_diagnostics(self) -> None:
|
||||
"""AC6: Bound cohort identity appears in audit diagnostics."""
|
||||
session_ctx.bind_session_context(
|
||||
profile_name="prgs-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="jcwalker3",
|
||||
cohort_id="cohort-test-99",
|
||||
startup_sha="sha99999",
|
||||
endpoint="gitea.prgs.cc",
|
||||
config_fingerprint="fp999",
|
||||
)
|
||||
audit = session_ctx.mutation_context_audit_fields()
|
||||
self.assertTrue(audit["session_context_bound"])
|
||||
self.assertEqual(audit["session_cohort_id"], "cohort-test-99")
|
||||
self.assertEqual(audit["session_startup_sha"], "sha99999")
|
||||
self.assertEqual(audit["session_endpoint"], "gitea.prgs.cc")
|
||||
self.assertEqual(audit["session_config_fingerprint"], "fp999")
|
||||
|
||||
def test_ac7_regression_n_reconnects_never_bind_to_obsolete_daemon(self) -> None:
|
||||
"""AC7: N reconnects against a daemon set containing obsolete daemons never bind obsolete ones."""
|
||||
live_master = "master-head-latest-12345"
|
||||
daemons = [
|
||||
{"id": "d1", "startup_sha": "obsolete-head-11111"},
|
||||
{"id": "d2", "startup_sha": "obsolete-head-22698c1"},
|
||||
{"id": "d3", "startup_sha": live_master},
|
||||
{"id": "d4", "startup_sha": "obsolete-head-33333"},
|
||||
]
|
||||
|
||||
for _ in range(5):
|
||||
for daemon in daemons:
|
||||
res = master_parity_gate.assess_master_parity(
|
||||
{"startup_head": live_master},
|
||||
live_master,
|
||||
live_remote_head=live_master,
|
||||
bound_cohort=daemon,
|
||||
)
|
||||
if daemon["startup_sha"] != live_master:
|
||||
self.assertFalse(res["mutation_safe"])
|
||||
self.assertTrue(res["cohort_stale"])
|
||||
else:
|
||||
self.assertTrue(res["mutation_safe"])
|
||||
self.assertFalse(res["cohort_stale"])
|
||||
|
||||
def test_ac8_regression_incident_shape_reproduction(self) -> None:
|
||||
"""AC8: Reproduce incident shape — obsolete cohort 22698c1 resident vs newer daemon."""
|
||||
live_master = "a4c73766f4b0cc32f7c3808688eceeb6fee74335"
|
||||
obsolete_cohort = {
|
||||
"cohort_id": "cohort-resident-22698c1",
|
||||
"startup_sha": "22698c1000000000000000000000000000000000",
|
||||
}
|
||||
new_cohort = {
|
||||
"cohort_id": "cohort-spawned-new",
|
||||
"startup_sha": live_master,
|
||||
}
|
||||
|
||||
# Obsolete cohort fails parity check
|
||||
obs_res = mcp_namespace_health.classify_namespace_probe(
|
||||
"gitea-author",
|
||||
probe_result={"success": True, "cohort": obsolete_cohort},
|
||||
probe_source="client_namespace",
|
||||
expected_parity_sha=live_master,
|
||||
)
|
||||
self.assertFalse(obs_res["healthy"])
|
||||
self.assertEqual(obs_res["error_type"], "stale_cohort_refused")
|
||||
|
||||
# Fresh cohort succeeds
|
||||
new_res = mcp_namespace_health.classify_namespace_probe(
|
||||
"gitea-author",
|
||||
probe_result={"success": True, "cohort": new_cohort},
|
||||
probe_source="client_namespace",
|
||||
expected_parity_sha=live_master,
|
||||
)
|
||||
self.assertTrue(new_res["healthy"])
|
||||
|
||||
def test_ac9_regression_bound_cohort_going_stale_detected(self) -> None:
|
||||
"""AC9: A bound cohort that later goes stale is detected on next attachment check."""
|
||||
initial_master = "sha-v1-initial"
|
||||
cohort = {"cohort_id": "c1", "startup_sha": initial_master}
|
||||
|
||||
# Initial state: in parity
|
||||
res1 = master_parity_gate.assess_master_parity(
|
||||
{"startup_head": initial_master},
|
||||
initial_master,
|
||||
live_remote_head=initial_master,
|
||||
bound_cohort=cohort,
|
||||
)
|
||||
self.assertTrue(res1["mutation_safe"])
|
||||
|
||||
# Master advances to sha-v2-advanced while cohort remains at sha-v1-initial
|
||||
advanced_master = "sha-v2-advanced"
|
||||
res2 = master_parity_gate.assess_master_parity(
|
||||
{"startup_head": initial_master},
|
||||
advanced_master,
|
||||
live_remote_head=advanced_master,
|
||||
bound_cohort=cohort,
|
||||
)
|
||||
self.assertFalse(res2["mutation_safe"])
|
||||
self.assertTrue(res2["restart_required"])
|
||||
self.assertTrue(res2["cohort_stale"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Tests for Sentry/GlitchTip observability console (#649, Phase 4)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from control_plane_db import ControlPlaneDB
|
||||
from webui.app import create_app
|
||||
from webui.console_authz import authorize, resolve_principal
|
||||
from webui.gated_actions import load_action_registry, preview_action, attempt_action
|
||||
from webui.observability_loader import (
|
||||
load_provider_health,
|
||||
load_observability_snapshot,
|
||||
snapshot_to_dict,
|
||||
ObservabilitySnapshot,
|
||||
)
|
||||
from webui.observability_views import render_observability_page
|
||||
from tests.webui_testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db(tmp_path):
|
||||
db_path = str(tmp_path / "test_control_plane.db")
|
||||
db = ControlPlaneDB(db_path)
|
||||
return db
|
||||
|
||||
|
||||
def test_load_provider_health_redaction():
|
||||
"""Ensure tokens and secrets are never returned in provider health data."""
|
||||
env = {
|
||||
"SENTRY_BASE_URL": "https://sentry.prgs.cc",
|
||||
"SENTRY_ORG": "my-org",
|
||||
"SENTRY_PROJECT": "my-project",
|
||||
"SENTRY_AUTH_TOKEN": "secret-sentry-token-12345",
|
||||
"MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "true",
|
||||
}
|
||||
health = load_provider_health("sentry", env)
|
||||
data = health.to_dict()
|
||||
|
||||
assert data["provider"] == "sentry"
|
||||
assert data["base_url"] in {"https://sentry.prgs.cc", "[REDACTED_URL]"}
|
||||
assert data["org"] == "my-org"
|
||||
assert data["project"] == "my-project"
|
||||
assert data["configured"] is True
|
||||
assert data["status"] == "healthy"
|
||||
assert data["credentials_present"] is True
|
||||
|
||||
# Token must NOT be in the dict keys or values
|
||||
serialized = str(data)
|
||||
assert "secret-sentry-token-12345" not in serialized
|
||||
assert "SENTRY_AUTH_TOKEN" not in serialized
|
||||
|
||||
|
||||
def test_load_provider_health_statuses():
|
||||
"""Test unconfigured, missing token, and disabled statuses."""
|
||||
# Not configured
|
||||
h1 = load_provider_health("sentry", {})
|
||||
d1 = h1.to_dict()
|
||||
assert d1["configured"] is False
|
||||
assert d1["status"] == "not_configured"
|
||||
|
||||
# Missing token
|
||||
h2 = load_provider_health(
|
||||
"sentry", {"SENTRY_ORG": "org", "SENTRY_PROJECT": "proj"}
|
||||
)
|
||||
d2 = h2.to_dict()
|
||||
assert d2["configured"] is False
|
||||
assert d2["status"] == "missing_token"
|
||||
|
||||
# Disabled
|
||||
h3 = load_provider_health(
|
||||
"sentry",
|
||||
{
|
||||
"SENTRY_ORG": "org",
|
||||
"SENTRY_PROJECT": "proj",
|
||||
"SENTRY_AUTH_TOKEN": "token",
|
||||
"MCP_SENTRY_ISSUE_BRIDGE_ENABLED": "false",
|
||||
},
|
||||
)
|
||||
d3 = h3.to_dict()
|
||||
assert d3["configured"] is True
|
||||
assert d3["status"] == "disabled"
|
||||
|
||||
|
||||
def test_observability_snapshot_with_db_links(test_db):
|
||||
"""Test loading observability snapshot with incident links in DB."""
|
||||
test_db.upsert_incident_link(
|
||||
provider="sentry",
|
||||
provider_issue_id="101",
|
||||
gitea_org="Scaled-Tech-Consulting",
|
||||
gitea_repo="Gitea-Tools",
|
||||
gitea_issue_number=649,
|
||||
provider_base_url="https://sentry.prgs.cc",
|
||||
provider_org="Scaled-Tech-Consulting",
|
||||
provider_project="Gitea-Tools",
|
||||
provider_short_id="ST-101",
|
||||
provider_permalink="https://sentry.prgs.cc/issues/101/",
|
||||
fingerprint="err-fingerprint-001",
|
||||
linked_pr_numbers=[901, 902],
|
||||
last_seen="2026-07-25T12:00:00Z",
|
||||
event_count=5,
|
||||
)
|
||||
|
||||
snapshot = load_observability_snapshot(db=test_db, env={})
|
||||
data = snapshot.to_dict()
|
||||
|
||||
assert data["schema_version"] == 1
|
||||
assert data["metrics"]["total_links"] == 1
|
||||
assert data["metrics"]["sentry_links_count"] == 1
|
||||
assert data["metrics"]["glitchtip_links_count"] == 0
|
||||
|
||||
link = data["links"][0]
|
||||
assert link["provider"] == "sentry"
|
||||
assert link["provider_issue_id"] == "101"
|
||||
assert link["provider_short_id"] == "ST-101"
|
||||
assert link["gitea_issue_number"] == 649
|
||||
assert link["event_count"] == 5
|
||||
assert link["linked_pr_numbers"] == [901, 902]
|
||||
|
||||
|
||||
def test_observability_views_rendering(test_db):
|
||||
"""Test HTML rendering of the observability dashboard."""
|
||||
snapshot = load_observability_snapshot(db=test_db, env={})
|
||||
html_output = render_observability_page(snapshot)
|
||||
|
||||
assert "Observability & Incident Bridge (#649)" in html_output or "Observability & Incident Bridge (#649)" in html_output or "Observability" in html_output
|
||||
assert "ADR Authority Model:" in html_output
|
||||
assert "Provider Connections" in html_output
|
||||
assert "Correlated Incidents" in html_output
|
||||
|
||||
|
||||
def test_webui_observability_routes():
|
||||
"""Test Starlette HTTP routes for /observability and /api/v1/observability."""
|
||||
client = TestClient(create_app())
|
||||
|
||||
# HTML page route
|
||||
res_html = client.get("/observability")
|
||||
assert res_html.status_code == 200
|
||||
assert "text/html" in res_html.headers["content-type"]
|
||||
assert "Observability" in res_html.text
|
||||
|
||||
# Versioned API route
|
||||
res_api_v1 = client.get("/api/v1/observability")
|
||||
assert res_api_v1.status_code == 200
|
||||
assert "application/json" in res_api_v1.headers["content-type"]
|
||||
data_v1 = res_api_v1.json()
|
||||
assert "schema_version" in data_v1
|
||||
assert "providers" in data_v1
|
||||
assert "links" in data_v1
|
||||
assert "metrics" in data_v1
|
||||
|
||||
# Compatibility alias route
|
||||
res_api_alias = client.get("/api/observability")
|
||||
assert res_api_alias.status_code == 200
|
||||
assert res_api_alias.json() == data_v1
|
||||
|
||||
|
||||
def test_observability_gated_actions():
|
||||
"""Ensure observability actions are registered, gated, and fail closed in MVP mode."""
|
||||
registry = load_action_registry()
|
||||
|
||||
action_reconcile = registry.get("observability_reconcile_incident")
|
||||
assert action_reconcile is not None
|
||||
assert action_reconcile.task_key == "observability_reconcile_incident"
|
||||
assert action_reconcile.mcp_tool == "gitea_observability_reconcile_incident"
|
||||
|
||||
action_link = registry.get("observability_link_issue")
|
||||
assert action_link is not None
|
||||
assert action_link.task_key == "observability_link_issue"
|
||||
|
||||
# Preview returns mutation ledger
|
||||
prev = preview_action("observability_reconcile_incident", provider="sentry", issue_id="101")
|
||||
assert prev["action_id"] == "observability_reconcile_incident"
|
||||
assert prev["enabled"] is False
|
||||
|
||||
# Execution fails closed in MVP mode
|
||||
att = attempt_action("observability_reconcile_incident", provider="sentry", issue_id="101")
|
||||
assert att["success"] is False
|
||||
assert att["error"] == "action_disabled"
|
||||
|
||||
|
||||
def test_observability_authz_rbac():
|
||||
"""Test RBAC authorization for observability actions."""
|
||||
principal = resolve_principal({})
|
||||
|
||||
# Check authorize decision
|
||||
decision = authorize("observability_reconcile_incident", principal)
|
||||
assert decision.action_id == "observability_reconcile_incident"
|
||||
# Phase 4 action denies in Phase 1 runtime by default
|
||||
assert decision.allowed is False
|
||||
@@ -87,11 +87,6 @@ from webui.notifications import (
|
||||
from webui.notification_views import render_notifications_page
|
||||
from webui import request_service
|
||||
from webui.request_views import render_requests_page
|
||||
from webui.observability_loader import (
|
||||
load_observability_snapshot,
|
||||
snapshot_to_dict as observability_snapshot_to_dict,
|
||||
)
|
||||
from webui.observability_views import render_observability_page
|
||||
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
||||
@@ -915,19 +910,6 @@ async def api_notifications(request: Request) -> JSONResponse:
|
||||
data = notifications_snapshot_to_dict(snap)
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
async def observability_route(request: Request) -> HTMLResponse:
|
||||
snap = load_observability_snapshot()
|
||||
html_content = render_observability_page(snap)
|
||||
return HTMLResponse(html_content)
|
||||
|
||||
|
||||
async def api_observability(request: Request) -> JSONResponse:
|
||||
snap = load_observability_snapshot()
|
||||
data = observability_snapshot_to_dict(snap)
|
||||
return JSONResponse(data)
|
||||
|
||||
|
||||
def _default_request_scope() -> dict[str, str]:
|
||||
"""Resolve remote/org/repo from the project registry for request forms.
|
||||
|
||||
@@ -1093,9 +1075,6 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
Route("/api/analytics", api_v1_analytics, methods=["GET"]),
|
||||
Route("/api/v1/analytics", api_v1_analytics, methods=["GET"]),
|
||||
Route("/api/v1/analytics/usage", api_v1_analytics_ingest, methods=["POST"]),
|
||||
Route("/observability", observability_route, methods=["GET"]),
|
||||
Route("/api/observability", api_observability, methods=["GET"]),
|
||||
Route("/api/v1/observability", api_observability, methods=["GET"]),
|
||||
Route("/audit", audit, methods=["GET", "POST"]),
|
||||
Route("/api/audit", api_audit, methods=["GET", "POST"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
|
||||
@@ -317,29 +317,6 @@ _ACTION_SPECS: tuple[ConsoleAction, ...] = (
|
||||
phase=2,
|
||||
summary="Run reconciler cleanup for merged or superseded PR branches.",
|
||||
),
|
||||
# #649: Phase 4 observability & incident bridge actions.
|
||||
ConsoleAction(
|
||||
action_id="observability_reconcile_incident",
|
||||
task_key="observability_reconcile_incident",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=4,
|
||||
summary="Trigger/reconcile durable Gitea issue creation from a provider incident.",
|
||||
),
|
||||
ConsoleAction(
|
||||
action_id="observability_link_issue",
|
||||
task_key="observability_link_issue",
|
||||
action_class=CLASS_WRITE,
|
||||
minimum_role=OPERATOR,
|
||||
requires_confirmation=True,
|
||||
dual_control=False,
|
||||
break_glass=False,
|
||||
phase=4,
|
||||
summary="Link a provider incident to an existing Gitea issue.",
|
||||
),
|
||||
# #643: submit a work request — desired role, issue/PR, intent — and let
|
||||
# the allocator reserve it. This is the one Phase 2 action whose execution
|
||||
# path is actually implemented (``webui.request_service``), so it carries
|
||||
|
||||
@@ -185,11 +185,6 @@ def build_action_registry() -> ActionRegistry:
|
||||
"console.rebind_session_worktree", "Rebind session worktree to verified lease."),
|
||||
("system.reconcile_cleanups", "Reconcile cleanups", "reconcile_cleanups",
|
||||
"console.reconcile_cleanups", "Run reconciler cleanup for merged or superseded PRs."),
|
||||
# #649: Phase 4 observability & incident bridge actions.
|
||||
("observability_reconcile_incident", "Reconcile incident", "observability_reconcile_incident",
|
||||
"gitea_observability_reconcile_incident", "Trigger or dry-run durable issue reconciliation for a provider incident."),
|
||||
("observability_link_issue", "Link incident issue", "observability_link_issue",
|
||||
"gitea_observability_link_issue", "Link a provider incident to a Gitea tracking issue."),
|
||||
)
|
||||
actions = tuple(
|
||||
GatedAction(
|
||||
|
||||
@@ -73,7 +73,6 @@ NAV_GROUPS: tuple[NavGroup, ...] = (
|
||||
)),
|
||||
NavGroup("Insights", (
|
||||
NavItem("/insights", "Insights", "stub"),
|
||||
NavItem("/observability", "Observability"),
|
||||
NavItem("/analytics", "Analytics"),
|
||||
NavItem("/audit", "Audit"),
|
||||
)),
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
"""Sentry/GlitchTip observability and incident correlation loader for the console (#649, Phase 4).
|
||||
|
||||
Operators need to inspect provider connection status (Sentry/GlitchTip), error
|
||||
correlations, and durable Gitea issue linkage — without treating raw incidents
|
||||
as allocator work.
|
||||
|
||||
ADR authority model:
|
||||
* Gitea owns work.
|
||||
* Providers (Sentry/GlitchTip) observe incidents.
|
||||
* Control-plane DB coordinates incident links.
|
||||
* The #612 bridge reconciles observations into durable Gitea issues.
|
||||
* The web console projects read-only state and gates mutations.
|
||||
|
||||
Redaction boundary:
|
||||
* Provider auth tokens, DSNs, Authorization headers, and sensitive local file
|
||||
paths are ALWAYS redacted before leaving this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from control_plane_db import ControlPlaneDB
|
||||
import sentry_incident_bridge
|
||||
from webui import console_redaction
|
||||
|
||||
OBSERVABILITY_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderHealth:
|
||||
"""Connection and health status of an observability provider."""
|
||||
|
||||
provider: str
|
||||
base_url: str
|
||||
org: str
|
||||
project: str
|
||||
configured: bool
|
||||
status: str
|
||||
bridge_enabled: bool
|
||||
lookback: str
|
||||
min_events_for_issue: int
|
||||
self_hosted: bool
|
||||
environment: str | None = None
|
||||
credentials_present: bool = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {
|
||||
"provider": self.provider,
|
||||
"base_url": self.base_url,
|
||||
"org": self.org,
|
||||
"project": self.project,
|
||||
"configured": self.configured,
|
||||
"status": self.status,
|
||||
"bridge_enabled": self.bridge_enabled,
|
||||
"lookback": self.lookback,
|
||||
"min_events_for_issue": self.min_events_for_issue,
|
||||
"self_hosted": self.self_hosted,
|
||||
"environment": self.environment,
|
||||
"credentials_present": self.credentials_present,
|
||||
}
|
||||
return console_redaction.redact_payload(data)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CorrelatedIncidentLink:
|
||||
"""One linked provider incident ↔ Gitea issue correlation record."""
|
||||
|
||||
link_id: int
|
||||
provider: str
|
||||
provider_base_url: str
|
||||
provider_org: str
|
||||
provider_project: str
|
||||
provider_issue_id: str
|
||||
provider_short_id: str | None
|
||||
provider_permalink: str | None
|
||||
fingerprint: str | None
|
||||
gitea_org: str
|
||||
gitea_repo: str
|
||||
gitea_issue_number: int
|
||||
linked_pr_numbers: list[int]
|
||||
last_seen: str | None
|
||||
event_count: int
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = {
|
||||
"link_id": self.link_id,
|
||||
"provider": self.provider,
|
||||
"provider_base_url": self.provider_base_url,
|
||||
"provider_org": self.provider_org,
|
||||
"provider_project": self.provider_project,
|
||||
"provider_issue_id": self.provider_issue_id,
|
||||
"provider_short_id": self.provider_short_id,
|
||||
"provider_permalink": self.provider_permalink,
|
||||
"fingerprint": self.fingerprint,
|
||||
"gitea_org": self.gitea_org,
|
||||
"gitea_repo": self.gitea_repo,
|
||||
"gitea_issue_number": self.gitea_issue_number,
|
||||
"linked_pr_numbers": self.linked_pr_numbers,
|
||||
"last_seen": self.last_seen,
|
||||
"event_count": self.event_count,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
return console_redaction.redact_payload(data)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObservabilitySnapshot:
|
||||
"""Read-only snapshot of observability provider status and incident correlations."""
|
||||
|
||||
schema_version: int
|
||||
providers: list[ProviderHealth]
|
||||
links: list[CorrelatedIncidentLink]
|
||||
total_links: int
|
||||
sentry_links_count: int
|
||||
glitchtip_links_count: int
|
||||
bridge_active: bool
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"providers": [p.to_dict() for p in self.providers],
|
||||
"links": [link.to_dict() for link in self.links],
|
||||
"metrics": {
|
||||
"total_links": self.total_links,
|
||||
"sentry_links_count": self.sentry_links_count,
|
||||
"glitchtip_links_count": self.glitchtip_links_count,
|
||||
"bridge_active": self.bridge_active,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_provider_health(
|
||||
provider_name: str = "sentry",
|
||||
env: dict[str, str] | None = None,
|
||||
) -> ProviderHealth:
|
||||
"""Inspect configuration and connection health for an observability provider."""
|
||||
source_env = dict(env if env is not None else os.environ)
|
||||
if provider_name.lower() == "sentry":
|
||||
config = sentry_incident_bridge.load_bridge_config(source_env)
|
||||
token = sentry_incident_bridge.resolve_token(source_env)
|
||||
has_token = bool(token)
|
||||
configured = bool(config.org and config.project and has_token)
|
||||
|
||||
if not config.org or not config.project:
|
||||
status = "not_configured"
|
||||
elif not has_token:
|
||||
status = "missing_token"
|
||||
elif not config.bridge_enabled:
|
||||
status = "disabled"
|
||||
else:
|
||||
status = "healthy"
|
||||
|
||||
return ProviderHealth(
|
||||
provider="sentry",
|
||||
base_url=config.base_url,
|
||||
org=config.org or "unconfigured",
|
||||
project=config.project or "unconfigured",
|
||||
configured=configured,
|
||||
status=status,
|
||||
bridge_enabled=config.bridge_enabled,
|
||||
lookback=config.lookback,
|
||||
min_events_for_issue=config.min_events_for_issue,
|
||||
self_hosted=not config.base_url.rstrip("/").endswith("sentry.io"),
|
||||
environment=config.environment,
|
||||
credentials_present=has_token,
|
||||
)
|
||||
|
||||
# GlitchTip or fallback provider configuration
|
||||
glitchtip_url = (source_env.get("GLITCHTIP_BASE_URL") or "https://glitchtip.prgs.cc").strip()
|
||||
glitchtip_org = (source_env.get("GLITCHTIP_ORG") or "").strip()
|
||||
glitchtip_proj = (source_env.get("GLITCHTIP_PROJECT") or "").strip()
|
||||
glitchtip_token = (source_env.get("GLITCHTIP_AUTH_TOKEN") or "").strip()
|
||||
|
||||
has_token = bool(glitchtip_token)
|
||||
configured = bool(glitchtip_org and glitchtip_proj and has_token)
|
||||
status = "healthy" if configured else ("missing_token" if glitchtip_org and glitchtip_proj else "not_configured")
|
||||
|
||||
return ProviderHealth(
|
||||
provider="glitchtip",
|
||||
base_url=glitchtip_url,
|
||||
org=glitchtip_org or "unconfigured",
|
||||
project=glitchtip_proj or "unconfigured",
|
||||
configured=configured,
|
||||
status=status,
|
||||
bridge_enabled=configured,
|
||||
lookback="24h",
|
||||
min_events_for_issue=2,
|
||||
self_hosted=True,
|
||||
environment=source_env.get("GLITCHTIP_ENVIRONMENT"),
|
||||
credentials_present=has_token,
|
||||
)
|
||||
|
||||
|
||||
def _parse_pr_numbers(raw: Any) -> list[int]:
|
||||
if isinstance(raw, list):
|
||||
return [int(x) for x in raw if str(x).isdigit()]
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
import json
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, list):
|
||||
return [int(x) for x in parsed if str(x).isdigit()]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def load_observability_snapshot(
|
||||
db: ControlPlaneDB | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> ObservabilitySnapshot:
|
||||
"""Build a read-only snapshot of observability connection health and incident links."""
|
||||
sentry_health = load_provider_health("sentry", env)
|
||||
glitchtip_health = load_provider_health("glitchtip", env)
|
||||
providers = [sentry_health, glitchtip_health]
|
||||
|
||||
target_db = db or ControlPlaneDB()
|
||||
raw_links = target_db.list_incident_links(limit=100)
|
||||
|
||||
links: list[CorrelatedIncidentLink] = []
|
||||
sentry_cnt = 0
|
||||
glitchtip_cnt = 0
|
||||
|
||||
for r in raw_links:
|
||||
prov = (r.get("provider") or "sentry").lower()
|
||||
if prov == "sentry":
|
||||
sentry_cnt += 1
|
||||
elif prov == "glitchtip":
|
||||
glitchtip_cnt += 1
|
||||
|
||||
pr_nums = _parse_pr_numbers(r.get("linked_pr_numbers"))
|
||||
|
||||
links.append(
|
||||
CorrelatedIncidentLink(
|
||||
link_id=int(r.get("link_id", 0)),
|
||||
provider=prov,
|
||||
provider_base_url=r.get("provider_base_url") or "",
|
||||
provider_org=r.get("provider_org") or "",
|
||||
provider_project=r.get("provider_project") or "",
|
||||
provider_issue_id=str(r.get("provider_issue_id") or ""),
|
||||
provider_short_id=r.get("provider_short_id"),
|
||||
provider_permalink=r.get("provider_permalink"),
|
||||
fingerprint=r.get("fingerprint"),
|
||||
gitea_org=r.get("gitea_org") or "Scaled-Tech-Consulting",
|
||||
gitea_repo=r.get("gitea_repo") or "Gitea-Tools",
|
||||
gitea_issue_number=int(r.get("gitea_issue_number", 0)),
|
||||
linked_pr_numbers=pr_nums,
|
||||
last_seen=r.get("last_seen"),
|
||||
event_count=int(r.get("event_count", 1)),
|
||||
created_at=r.get("created_at"),
|
||||
updated_at=r.get("updated_at"),
|
||||
)
|
||||
)
|
||||
|
||||
bridge_active = any(p.bridge_enabled for p in providers)
|
||||
|
||||
return ObservabilitySnapshot(
|
||||
schema_version=OBSERVABILITY_SCHEMA_VERSION,
|
||||
providers=providers,
|
||||
links=links,
|
||||
total_links=len(links),
|
||||
sentry_links_count=sentry_cnt,
|
||||
glitchtip_links_count=glitchtip_cnt,
|
||||
bridge_active=bridge_active,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_to_dict(snapshot: ObservabilitySnapshot) -> dict[str, Any]:
|
||||
return snapshot.to_dict()
|
||||
@@ -1,143 +0,0 @@
|
||||
"""HTML view renderer for the Sentry/GlitchTip observability console (#649, Phase 4).
|
||||
|
||||
Renders connection status widgets, error correlation links, and gated issue creation
|
||||
affordances over the read-only observability snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from webui.layout import render_page
|
||||
from webui.observability_loader import ObservabilitySnapshot, snapshot_to_dict
|
||||
|
||||
|
||||
def _badge(status: str) -> str:
|
||||
st = (status or "").lower()
|
||||
if st == "healthy":
|
||||
return '<span class="badge badge-success">healthy</span>'
|
||||
if st == "disabled":
|
||||
return '<span class="badge badge-warning">disabled (dry-run)</span>'
|
||||
if st in {"missing_token", "not_configured"}:
|
||||
return f'<span class="badge badge-muted">{html.escape(st)}</span>'
|
||||
return f'<span class="badge">{html.escape(st)}</span>'
|
||||
|
||||
|
||||
def _provider_card(p: dict[str, Any]) -> str:
|
||||
name = html.escape(str(p.get("provider", "provider")).upper())
|
||||
base_url = html.escape(str(p.get("base_url", "")))
|
||||
org = html.escape(str(p.get("org", "")))
|
||||
proj = html.escape(str(p.get("project", "")))
|
||||
status_badge = _badge(str(p.get("status", "")))
|
||||
min_events = p.get("min_events_for_issue", 2)
|
||||
lookback = html.escape(str(p.get("lookback", "24h")))
|
||||
bridge_enabled = "yes" if p.get("bridge_enabled") else "no"
|
||||
|
||||
return f"""
|
||||
<div class="card" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 6px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">{name} Connection</h3>
|
||||
<div>{status_badge}</div>
|
||||
</div>
|
||||
<table style="width: 100%; margin-top: 0.5rem; border-collapse: collapse;">
|
||||
<tr><td><strong>Base URL:</strong></td><td><code>{base_url}</code></td></tr>
|
||||
<tr><td><strong>Scope:</strong></td><td><code>{org} / {proj}</code></td></tr>
|
||||
<tr><td><strong>Bridge Enabled:</strong></td><td><code>{bridge_enabled}</code></td></tr>
|
||||
<tr><td><strong>Min Events for Issue:</strong></td><td><code>{min_events}</code></td></tr>
|
||||
<tr><td><strong>Lookback Window:</strong></td><td><code>{lookback}</code></td></tr>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_observability_page(snapshot: ObservabilitySnapshot | dict[str, Any]) -> str:
|
||||
"""Render the observability dashboard HTML page."""
|
||||
data = snapshot.to_dict() if isinstance(snapshot, ObservabilitySnapshot) else dict(snapshot)
|
||||
|
||||
providers_raw = data.get("providers", [])
|
||||
provider_cards = "".join(_provider_card(p) for p in providers_raw) if providers_raw else "<p>No providers configured.</p>"
|
||||
|
||||
links = data.get("links", [])
|
||||
link_rows = []
|
||||
|
||||
for l in links:
|
||||
prov = html.escape(str(l.get("provider", "")))
|
||||
p_issue_id = html.escape(str(l.get("provider_issue_id", "")))
|
||||
fingerprint = html.escape(str(l.get("fingerprint") or "—"))
|
||||
g_issue_num = int(l.get("gitea_issue_number", 0))
|
||||
g_org = html.escape(str(l.get("gitea_org", "")))
|
||||
g_repo = html.escape(str(l.get("gitea_repo", "")))
|
||||
g_issue_link = f'<strong>#{g_issue_num}</strong> ({g_org}/{g_repo})'
|
||||
event_cnt = int(l.get("event_count", 1))
|
||||
last_seen = html.escape(str(l.get("last_seen") or "—"))
|
||||
short_id = html.escape(str(l.get("provider_short_id") or p_issue_id))
|
||||
|
||||
link_rows.append(f"""
|
||||
<tr>
|
||||
<td><code>{prov}</code></td>
|
||||
<td><strong>{short_id}</strong><br><small style="color: #666;">id: {p_issue_id}</small></td>
|
||||
<td><code>{fingerprint}</code></td>
|
||||
<td>{g_issue_link}</td>
|
||||
<td>{event_cnt}</td>
|
||||
<td><small>{last_seen}</small></td>
|
||||
</tr>
|
||||
""")
|
||||
|
||||
table_body = "".join(link_rows) if link_rows else '<tr><td colspan="6" style="text-align: center; padding: 1.5rem; color: #666;">No correlated incident links stored. Bridge operates under dry-run default.</td></tr>'
|
||||
|
||||
metrics = data.get("metrics", {})
|
||||
total_links = metrics.get("total_links", 0)
|
||||
sentry_cnt = metrics.get("sentry_links_count", 0)
|
||||
glitchtip_cnt = metrics.get("glitchtip_links_count", 0)
|
||||
|
||||
body_html = f"""
|
||||
<h2>Observability & Incident Bridge (#649)</h2>
|
||||
<p>Read-only console surface for Sentry/GlitchTip provider connections, error correlation,
|
||||
and durable Gitea issue linkage.</p>
|
||||
|
||||
<div class="alert alert-info" style="background: #f0f4f8; padding: 1rem; border-left: 4px solid #0052cc; margin-bottom: 1.5rem;">
|
||||
<strong>ADR Authority Model:</strong> Gitea records durable issue history. Control-plane DB coordinates incident links.
|
||||
Sentry/GlitchTip observe errors. Raw monitoring incidents are <em>never</em> assignable control-plane work items.
|
||||
Durable issue creation is gated and dry-runable via the <code>#612</code> bridge APIs.
|
||||
</div>
|
||||
|
||||
<h3>Provider Connections</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
{provider_cards}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0;">Correlated Incidents ({total_links})</h3>
|
||||
<div>
|
||||
<span class="badge" style="margin-right: 0.5rem;">Sentry: {sentry_cnt}</span>
|
||||
<span class="badge">GlitchTip: {glitchtip_cnt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table" style="width: 100%; border-collapse: collapse; border: 1px solid #ddd;">
|
||||
<thead>
|
||||
<tr style="background: #f9f9f9; text-align: left;">
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Provider</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Incident ID</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Fingerprint</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Gitea Issue Link</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Events</th>
|
||||
<th style="padding: 0.5rem; border-bottom: 2px solid #ddd;">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table_body}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: #fafafa; border: 1px solid #eee; border-radius: 4px;">
|
||||
<h4 style="margin-top: 0;">Reconcile & Link Controls (Gated)</h4>
|
||||
<p style="margin-bottom: 0.5rem; color: #555;">
|
||||
Create or reconcile durable Gitea issues from provider observations using the <code>#612</code> incident bridge:
|
||||
</p>
|
||||
<code>mcp call gitea_observability_reconcile_incident --provider sentry --apply false</code>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return render_page(title="Observability", body_html=body_html)
|
||||
Reference in New Issue
Block a user