"""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.", ]