"""The gate chain that decides whether finished work may move forward. A developer's ``WORK_REPORT`` is a claim, not evidence. Four gates check it: * **diff guard** — the diff is real, is not empty for a code task, and touches nothing the platform forbids (``.git``, CI configuration, symlinks that point outside the repo, files over a megabyte). * **no-progress** — the same diff twice in a row means the agent is spinning; the task goes to the manager instead of looping forever. * **tests** — the app profile's test command runs in the sandbox. Red sends the work back to the developer with the failure tail; green lets it continue. * **green** — the tester's ``TEST_PASS`` is only believed when the tests gate is green, or when the tester explicitly *waives* it with a reason. A waiver is always stored, never inferred (PLAN-v2 §3). Every verdict is a ``gate_results`` row **and** a ``task.gate`` event, because a gate that only exists in a log is a gate nobody can audit. """ from __future__ import annotations import hashlib from dataclasses import dataclass from app.application.engine.events import RunFeed from app.application.ports.sandbox_provider import SandboxProviderPort from app.core.config import settings from app.domain.workforce.engine import GateName, GateResult from app.domain.workforce.models import Task from app.domain.workforce.pipeline import TaskKind from app.infrastructure.repositories.app_profile import AppProfileRepository from app.infrastructure.repositories.engine import GateResultRepository #: Path prefixes a diff may never touch: git internals and CI configuration. CI_PATHS: tuple[str, ...] = (".github/", ".gitlab-ci.yml", ".gitea/", ".circleci/") #: Fail a diff that carries a file larger than this (one megabyte). MAX_FILE_BYTES = 1_000_000 #: How much of the test output is stored and shown. A traceback is at the end. TEST_OUTPUT_CHARS = 8_000 def diff_hash(text: str) -> str: """A stable fingerprint of a diff, for the no-progress gate.""" return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest() def forbidden_paths(changed_files: list[str]) -> list[str]: """Changed files the diff guard refuses, in the plan's own words.""" bad: list[str] = [] for path in changed_files: normalised = path.strip() # Strip a leading "./" *prefix*, not the set {".", "/"}: ``lstrip("./")`` # would turn ".github/ci.yml" into "github/ci.yml" and hide it from the # guard entirely. if normalised.startswith("./"): normalised = normalised[2:] if normalised.startswith(".git/") or normalised == ".git": bad.append(path) continue if any(normalised == ci.rstrip("/") or normalised.startswith(ci) for ci in CI_PATHS): bad.append(path) return bad @dataclass(slots=True, kw_only=True) class GateOutcome: """One gate verdict, and whether it sends the task back to the developer.""" gate: GateName ok: bool detail: str = "" #: True when the caller must move the task back to implementation. sends_back: bool = False class GateRunner: """Runs the gates and leaves a row + an event behind for each verdict.""" def __init__( self, *, results: GateResultRepository, feed: RunFeed, profiles: AppProfileRepository | None = None, ) -> None: self.results = results self.feed = feed self.profiles = profiles # ------------------------------------------------------------------ # async def _record(self, *, task: Task, outcome: GateOutcome) -> GateOutcome: await self.results.create( GateResult( task_id=task.id, gate=outcome.gate, ok=outcome.ok, detail=outcome.detail, ) ) await self.feed.gate( task_id=task.id, organization_id=task.organization_id, gate=str(outcome.gate), ok=outcome.ok, detail=outcome.detail, ) return outcome async def record(self, *, task: Task, outcome: GateOutcome) -> GateOutcome: """Write a verdict the caller already reasoned about. The scheduler records merge conflicts through this instead of the gate-specific helpers, because a conflict is a diff-guard verdict that comes from git rather than from hashing the diff. """ return await self._record(task=task, outcome=outcome) # -- diff guard --------------------------------------------------------- # async def diff_guard(self, *, task: Task, diff, code_task: bool) -> GateOutcome: """Refuse a diff that is empty, forbidden or unreasonably large.""" problems: list[str] = [] if diff.is_empty and code_task: problems.append("تفاوت خالی است: هیچ تغییری ثبت نشده.") forbidden = forbidden_paths(list(diff.changed_files)) if forbidden: problems.append( "تغییر در مسیرهای ممنوع: " + ", ".join(sorted(forbidden)) ) if diff.escaping_symlinks: problems.append( "پیوندهای نمادین به بیرون مخزن: " + ", ".join(diff.escaping_symlinks) ) if diff.largest_file_bytes > MAX_FILE_BYTES: problems.append( f"فایلی بزرگ‌تر از حد مجاز: {diff.largest_file_bytes} بایت." ) detail = "\n".join(problems) return await self._record( task=task, outcome=GateOutcome( gate=GateName.DIFF_GUARD, ok=not problems, detail=detail or "تفاوت قابل قبول است.", sends_back=bool(problems), ), ) # -- no-progress -------------------------------------------------------- # async def no_progress( self, *, task: Task, previous_hash: str, same_count: int, diff_text: str ) -> tuple[GateOutcome, str, int]: """Hash the diff; the same hash twice in a row means no progress. Returns the verdict and the new ``(hash, same_count)`` the caller must persist, so the decision and the counter live in one place. """ current = diff_hash(diff_text) count = same_count + 1 if current and current == previous_hash else 1 stuck = count >= 2 and bool(current) outcome = await self._record( task=task, outcome=GateOutcome( gate=GateName.NO_PROGRESS, ok=not stuck, detail=( "دو تفاوت یکسان پشت سر هم: پیشرفتی حاصل نشده." if stuck else "پیشرفت تازه‌ای ثبت شد." ), sends_back=stuck, ), ) return outcome, current, count # -- tests -------------------------------------------------------------- # async def test_command_for(self, task: Task) -> str: """The app profile's test command, or ``""`` when there is none.""" if self.profiles is None or not task.project_id: return "" profile = await self.profiles.get( organization_id=task.organization_id, project_id=task.project_id ) return (profile.test_command or "").strip() if profile else "" async def tests( self, *, task: Task, sandbox: SandboxProviderPort, session_external_id: str ) -> GateOutcome: """Run the app profile's tests in the sandbox: red or green.""" command = await self.test_command_for(task) if not command: return await self._record( task=task, outcome=GateOutcome( gate=GateName.TESTS, ok=True, detail="دستور تستی برای این برنامه تعریف نشده است.", ), ) if not session_external_id: return await self._record( task=task, outcome=GateOutcome( gate=GateName.TESTS, ok=False, detail="نشست اجرا در دسترس نیست؛ تست‌ها اجرا نشدند.", sends_back=True, ), ) result = await sandbox.run_command( session_external_id=session_external_id, command=command, timeout_seconds=settings.engine_tests_timeout_seconds, max_output_bytes=TEST_OUTPUT_CHARS, ) ok = result.exit_code == 0 output = (result.stdout + ("\n" + result.stderr if result.stderr else "")).strip() tail = output[-TEST_OUTPUT_CHARS:] detail = ( f"تست‌ها سبز شدند (کد خروج {result.exit_code})." if ok else f"تست‌ها قرمز شدند (کد خروج {result.exit_code}).\n{tail}" ) return await self._record( task=task, outcome=GateOutcome( gate=GateName.TESTS, ok=ok, detail=detail, sends_back=not ok ), ) # -- green -------------------------------------------------------------- # async def green( self, *, task: Task, waived: bool, waiver_reason: str ) -> GateOutcome: """Accept a ``TEST_PASS`` only with a green tests gate, or a waiver.""" passed = await self.results.latest_for(task.id, str(GateName.TESTS)) tests_ok = passed.ok if passed is not None else False reason = waiver_reason.strip() if tests_ok: return await self._record( task=task, outcome=GateOutcome( gate=GateName.GREEN, ok=True, detail="دروازهٔ تست سبز است." ), ) if waived and reason: return await self._record( task=task, outcome=GateOutcome( gate=GateName.GREEN, ok=True, detail=f"با اعلام صرف‌نظر: {reason}", ), ) return await self._record( task=task, outcome=GateOutcome( gate=GateName.GREEN, ok=False, detail="تست‌ها سبز نیستند و صرف‌نظری با دلیل ثبت نشده است.", sends_back=True, ), ) # -- helpers the scheduler uses ----------------------------------------- # @staticmethod def code_task(kind: TaskKind | str) -> bool: """``analysis`` produces prose, not a diff: an empty diff is fine.""" return TaskKind(kind) is not TaskKind.ANALYSIS __all__ = [ "CI_PATHS", "MAX_FILE_BYTES", "TEST_OUTPUT_CHARS", "GateOutcome", "GateRunner", "diff_hash", "forbidden_paths", ]