Files
Gitea-Tools/review_workflow_load.py
T
sysadmin 2a3d30fb3a feat: persist MCP workflow proof and decision lock across daemons (Closes #559)
Share review-workflow-load and review-decision-lock state across MCP
daemon process pools via a user-private durable store keyed by profile
identity (not host-global /tmp), with TTL and fail-closed profile checks.
2026-07-08 22:22:34 -04:00

322 lines
12 KiB
Python

"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403, #559)."""
from __future__ import annotations
import hashlib
import os
import re
from pathlib import Path
import mcp_session_state
import review_workflow_boundary as boundary
WORKFLOW_REL_PATH = (
"skills/llm-project-workflow/workflows/review-merge-pr.md"
)
SCHEMA_REL_PATH = (
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
)
TASK_MODE = "review-merge-pr"
LOAD_TOOL_NAME = "gitea_load_review_workflow"
_REVIEW_WORKFLOW_LOAD: dict | None = None
def compute_content_hash(text: str) -> str:
"""Short deterministic hash for workflow/schema version proof."""
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
root = Path(project_root)
workflow = root / WORKFLOW_REL_PATH
schema = root / SCHEMA_REL_PATH
if not workflow.is_file():
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
if not schema.is_file():
raise FileNotFoundError(f"final report schema missing: {schema}")
return workflow, schema
def build_canonical_workflow_metadata(
project_root: str,
*,
prompt_text: str | None = None,
) -> dict:
"""Load workflow + schema from disk and compute proof metadata."""
workflow_path, schema_path = _canonical_paths(project_root)
workflow_text = _read_text(workflow_path)
schema_text = _read_text(schema_path)
workflow_hash = compute_content_hash(workflow_text)
schema_hash = compute_content_hash(schema_text)
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
return {
"workflow_source": WORKFLOW_REL_PATH,
"workflow_path": str(workflow_path),
"task_mode": TASK_MODE,
"workflow_hash": workflow_hash,
"workflow_version": workflow_hash,
"final_report_schema_path": SCHEMA_REL_PATH,
"final_report_schema_hash": schema_hash,
"prompt_conflicts_with_workflow": conflict,
"prompt_conflict_reasons": conflict_reasons,
"load_tool": LOAD_TOOL_NAME,
}
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
"""Detect obvious task-mode conflicts between prompt and review workflow."""
if not (prompt_text or "").strip():
return False, []
text = prompt_text.lower()
reasons: list[str] = []
conflicting = (
(r"\bwork[- ]issue\b", "work-issue author mode"),
(r"\bcreate[- ]issue\b", "create-issue mode"),
(r"\bauthor/coder\b", "author/coder mode"),
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
)
for pattern, label in conflicting:
if re.search(pattern, text):
reasons.append(
f"active prompt appears to request {label} while loading "
f"{TASK_MODE} workflow"
)
return bool(reasons), reasons
def _session_binding_fields() -> dict:
"""Capture profile identity used to share state across daemon processes."""
env_lock = (os.environ.get(mcp_session_state.SESSION_PROFILE_LOCK_ENV) or "").strip()
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
remote = (os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None
identity = mcp_session_state.current_profile_identity(
profile_name=profile_name,
session_profile_lock=env_lock,
)
return {
"session_profile": profile_name or identity,
"session_profile_lock": env_lock or identity,
"profile_identity": identity,
"remote": remote,
}
def _persist_workflow_load(record: dict | None) -> dict | None:
"""Write durable workflow-load proof (or clear it)."""
binding = _session_binding_fields()
if record is None:
mcp_session_state.clear_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
remote=binding.get("remote"),
profile_identity=binding.get("profile_identity"),
)
return None
payload = dict(record)
payload.update({
k: v for k, v in binding.items() if v is not None
})
return mcp_session_state.save_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
payload=payload,
remote=payload.get("remote"),
org=payload.get("org"),
repo=payload.get("repo"),
profile_identity=payload.get("profile_identity"),
)
def _load_durable_workflow_load() -> dict | None:
binding = _session_binding_fields()
return mcp_session_state.load_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
remote=binding.get("remote"),
profile_identity=binding.get("profile_identity"),
)
def _active_workflow_load() -> dict | None:
"""Prefer in-process cache; fall back to durable shared state (#559)."""
global _REVIEW_WORKFLOW_LOAD
if _REVIEW_WORKFLOW_LOAD is not None:
return _REVIEW_WORKFLOW_LOAD
durable = _load_durable_workflow_load()
if durable is not None:
_REVIEW_WORKFLOW_LOAD = dict(durable)
return _REVIEW_WORKFLOW_LOAD
def record_review_workflow_load(
project_root: str,
*,
prompt_text: str | None = None,
) -> dict:
"""Record workflow load proof for the current MCP session (durable + memory)."""
global _REVIEW_WORKFLOW_LOAD
meta = build_canonical_workflow_metadata(
project_root, prompt_text=prompt_text)
boundary_state = boundary.assess_boundary_status(project_root)
binding = _session_binding_fields()
record = {
**meta,
"session_pid": os.getpid(),
"loaded": True,
"boundary_status": boundary_state.get("boundary_status"),
"boundary_clean": boundary_state.get("boundary_clean"),
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
"boundary_reasons": list(boundary_state.get("reasons") or []),
**{k: v for k, v in binding.items() if v is not None},
}
persisted = _persist_workflow_load(record)
_REVIEW_WORKFLOW_LOAD = dict(persisted or record)
return dict(_REVIEW_WORKFLOW_LOAD)
def clear_review_workflow_load() -> None:
"""Test helper and review_pr session reset."""
global _REVIEW_WORKFLOW_LOAD
_REVIEW_WORKFLOW_LOAD = None
_persist_workflow_load(None)
boundary.clear_pre_review_commands()
def workflow_load_status(project_root: str | None = None) -> dict:
"""Non-throwing status for capability/runtime reports."""
load = _active_workflow_load()
if load is None:
return {
"workflow_load_proof_present": False,
"workflow_load_valid": False,
"workflow_source": None,
"workflow_hash": None,
"final_report_schema_path": SCHEMA_REL_PATH,
"reasons": [
f"{LOAD_TOOL_NAME} has not been called in this session "
"(fail closed for reviewer mutations)"
],
}
reasons = _session_validation_reasons(load, project_root)
boundary_reasons = boundary.boundary_blockers(project_root)
if boundary_reasons:
reasons = list(reasons) + boundary_reasons
return {
"workflow_load_proof_present": True,
"workflow_load_valid": not reasons,
"workflow_source": load.get("workflow_source"),
"workflow_hash": load.get("workflow_hash"),
"task_mode": load.get("task_mode"),
"final_report_schema_path": load.get("final_report_schema_path"),
"final_report_schema_hash": load.get("final_report_schema_hash"),
"prompt_conflicts_with_workflow": load.get(
"prompt_conflicts_with_workflow"),
"session_pid": load.get("session_pid"),
"writer_pid": load.get("writer_pid"),
"profile_identity": load.get("profile_identity"),
"boundary_status": load.get("boundary_status"),
"boundary_clean": load.get("boundary_clean"),
"workflow_load_helper_result": boundary.workflow_load_helper_result(
load, project_root),
"reasons": reasons,
}
def _session_validation_reasons(
load: dict,
project_root: str | None,
) -> list[str]:
"""Validate durable/in-memory load proof for this session identity (#559)."""
reasons: list[str] = []
# Cross-process daemon pools are allowed when profile identity matches.
# Reject only when the stored profile identity conflicts with this process.
binding = _session_binding_fields()
stored_identity = (
load.get("session_profile_lock")
or load.get("profile_identity")
or ""
).strip()
active_identity = (binding.get("profile_identity") or "").strip()
if (
stored_identity
and active_identity
and stored_identity != active_identity
and active_identity != "unknown-profile"
and stored_identity != "unknown-profile"
):
reasons.append(
"workflow load proof profile identity mismatch "
f"(stored={stored_identity!r}, active={active_identity!r}; fail closed)"
)
return reasons
# Expired durable records are treated as absent.
identity_reasons = mcp_session_state.identity_match_reasons(
load,
remote=binding.get("remote") or load.get("remote"),
org=load.get("org"),
repo=load.get("repo"),
profile_identity=active_identity or stored_identity,
)
# Filter out remote-mismatch noise when remote was not bound at load time.
for reason in identity_reasons:
if "missing recorded_at" in reason or "expired" in reason or "future" in reason:
reasons.append(reason)
elif "profile identity mismatch" in reason:
reasons.append(reason)
if reasons:
return reasons
if load.get("prompt_conflicts_with_workflow"):
reasons.extend(load.get("prompt_conflict_reasons") or [
"active prompt conflicts with loaded review-merge workflow"
])
if project_root:
try:
current = build_canonical_workflow_metadata(project_root)
except OSError as exc:
reasons.append(f"cannot re-verify workflow hash: {exc}")
return reasons
if current["workflow_hash"] != load.get("workflow_hash"):
reasons.append(
"stored workflow hash is stale; reload via "
f"{LOAD_TOOL_NAME} (fail closed)"
)
if current["final_report_schema_hash"] != load.get(
"final_report_schema_hash"):
reasons.append(
"stored final-report schema hash is stale; reload via "
f"{LOAD_TOOL_NAME} (fail closed)"
)
return reasons
def review_workflow_load_blockers(
project_root: str | None = None,
) -> list[str]:
"""Reasons reviewer mutations must fail closed."""
boundary_reasons = boundary.boundary_blockers(project_root)
load = _active_workflow_load()
if boundary_reasons and load is None:
return boundary_reasons
status = workflow_load_status(project_root)
if not status.get("workflow_load_proof_present"):
return list(status.get("reasons") or []) + boundary_reasons
if not status.get("workflow_load_valid"):
return list(status.get("reasons") or [])
return []
def recovery_handoff_without_replay() -> list[str]:
"""Safe next-step lines that must not include approve/merge replay."""
return [
"Reload the canonical workflow via gitea_load_review_workflow, then "
"rerun the full review-merge workflow from inventory.",
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
"or gitea_merge_pr until workflow-load proof is present.",
"Do not include approve/merge replay commands in the recovery handoff.",
]