from __future__ import annotations from dataclasses import dataclass, field import hashlib import json import re from typing import Any from .errors import ValidationError OID_RE = re.compile(r"^[0-9a-f]{40}$") ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") AGENTS = {"openclaw", "codex", "gitea", "system", "workspace-agent"} EVENT_STATUSES = {"READY", "RUNNING", "PASS", "BLOCKED", "FAIL", "TIMEOUT", "NOT_EXECUTED"} VERDICTS = {"DISPATCH", "BLOCKED", "COMPLETE", "NOOP"} EXECUTORS = {"openclaw", "codex"} def req_str(value: dict[str, Any], key: str, pattern: re.Pattern[str] | None = None) -> str: item = value.get(key) if not isinstance(item, str) or not item: raise ValidationError(f"invalid_{key}") if pattern is not None and not pattern.fullmatch(item): raise ValidationError(f"invalid_{key}") return item def opt_oid(item: Any, key: str) -> str | None: if item is None: return None if not isinstance(item, str) or not OID_RE.fullmatch(item): raise ValidationError(f"invalid_{key}") return item def string_list(value: dict[str, Any], key: str, limit: int) -> tuple[str, ...]: raw = value.get(key) if not isinstance(raw, list) or len(raw) > limit: raise ValidationError(f"invalid_{key}") result: list[str] = [] for item in raw: if not isinstance(item, str) or not item or len(item) > 1024: raise ValidationError(f"invalid_{key}") result.append(item) return tuple(result) @dataclass(frozen=True) class Event: schema_version: int project_key: str contract_commit: str event_id: str source_agent: str event_type: str work_unit_id: str status: str state_version: int implementation_commit: str | None = None result_commit: str | None = None blocker: str | None = None evidence_refs: tuple[str, ...] = () payload: dict[str, Any] = field(default_factory=dict) @classmethod def from_dict(cls, value: dict[str, Any]) -> "Event": allowed = { "schema_version", "project_key", "contract_commit", "event_id", "source_agent", "event_type", "work_unit_id", "status", "state_version", "implementation_commit", "result_commit", "blocker", "evidence_refs", "payload", } if not isinstance(value, dict) or set(value) - allowed: raise ValidationError("invalid_event_fields") if value.get("schema_version") != 1: raise ValidationError("unsupported_event_schema") source = req_str(value, "source_agent") status = req_str(value, "status") if source not in AGENTS: raise ValidationError("invalid_source_agent") if status not in EVENT_STATUSES: raise ValidationError("invalid_event_status") version = value.get("state_version") if not isinstance(version, int) or version < 0: raise ValidationError("invalid_state_version") blocker = value.get("blocker") if blocker is not None and (not isinstance(blocker, str) or len(blocker) > 512): raise ValidationError("invalid_blocker") payload = value.get("payload", {}) if not isinstance(payload, dict): raise ValidationError("invalid_payload") if len(json.dumps(payload, sort_keys=True).encode()) > 262_144: raise ValidationError("payload_too_large") return cls( schema_version=1, project_key=req_str(value, "project_key", ID_RE), contract_commit=req_str(value, "contract_commit", OID_RE), event_id=req_str(value, "event_id", ID_RE), source_agent=source, event_type=req_str(value, "event_type", ID_RE), work_unit_id=req_str(value, "work_unit_id", ID_RE), status=status, state_version=version, implementation_commit=opt_oid(value.get("implementation_commit"), "implementation_commit"), result_commit=opt_oid(value.get("result_commit"), "result_commit"), blocker=blocker, evidence_refs=string_list(value, "evidence_refs", 64), payload=payload, ) def to_dict(self) -> dict[str, Any]: return { "schema_version": 1, "project_key": self.project_key, "contract_commit": self.contract_commit, "event_id": self.event_id, "source_agent": self.source_agent, "event_type": self.event_type, "work_unit_id": self.work_unit_id, "status": self.status, "state_version": self.state_version, "implementation_commit": self.implementation_commit, "result_commit": self.result_commit, "blocker": self.blocker, "evidence_refs": list(self.evidence_refs), "payload": self.payload, } def canonical_json(self) -> str: return json.dumps(self.to_dict(), separators=(",", ":"), sort_keys=True) def digest(self) -> str: return hashlib.sha256(self.canonical_json().encode()).hexdigest() @dataclass(frozen=True) class WorkUnit: id: str track_id: str gate_id: str primary_agent: str fallback_agent: str clean_base_commit: str allowed_paths: tuple[str, ...] required_checks: tuple[str, ...] forbidden_operations: tuple[str, ...] instruction: str @classmethod def from_dict(cls, value: dict[str, Any]) -> "WorkUnit": expected = { "id", "track_id", "gate_id", "primary_agent", "fallback_agent", "clean_base_commit", "allowed_paths", "required_checks", "forbidden_operations", "instruction", } if not isinstance(value, dict) or set(value) != expected: raise ValidationError("invalid_work_unit_fields") primary = req_str(value, "primary_agent") fallback = req_str(value, "fallback_agent") if primary not in EXECUTORS or fallback not in EXECUTORS or primary == fallback: raise ValidationError("invalid_agent_assignment") instruction = req_str(value, "instruction") if len(instruction.encode()) > 65_536: raise ValidationError("instruction_too_large") return cls( id=req_str(value, "id", ID_RE), track_id=req_str(value, "track_id", ID_RE), gate_id=req_str(value, "gate_id", ID_RE), primary_agent=primary, fallback_agent=fallback, clean_base_commit=req_str(value, "clean_base_commit", OID_RE), allowed_paths=string_list(value, "allowed_paths", 128), required_checks=string_list(value, "required_checks", 128), forbidden_operations=string_list(value, "forbidden_operations", 64), instruction=instruction, ) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "track_id": self.track_id, "gate_id": self.gate_id, "primary_agent": self.primary_agent, "fallback_agent": self.fallback_agent, "clean_base_commit": self.clean_base_commit, "allowed_paths": list(self.allowed_paths), "required_checks": list(self.required_checks), "forbidden_operations": list(self.forbidden_operations), "instruction": self.instruction, } @dataclass(frozen=True) class Decision: schema_version: int project_key: str contract_commit: str event_id: str expected_state_version: int verdict: str state_updates: dict[str, str] next_work_units: tuple[WorkUnit, ...] requires_human_input: bool stop_reason: str | None @classmethod def from_dict(cls, value: dict[str, Any]) -> "Decision": expected = { "schema_version", "project_key", "contract_commit", "event_id", "expected_state_version", "verdict", "state_updates", "next_work_units", "requires_human_input", "stop_reason", } if not isinstance(value, dict) or set(value) != expected: raise ValidationError("invalid_decision_fields") if value.get("schema_version") != 1: raise ValidationError("unsupported_decision_schema") version = value.get("expected_state_version") if not isinstance(version, int) or version < 0: raise ValidationError("invalid_expected_state_version") verdict = req_str(value, "verdict") if verdict not in VERDICTS: raise ValidationError("invalid_verdict") updates = value.get("state_updates") normalized: dict[str, str] = {} if isinstance(updates, dict): update_items = [{"key": key, "value": item} for key, item in updates.items()] elif isinstance(updates, list): update_items = updates else: raise ValidationError("invalid_state_updates") if len(update_items) > 128: raise ValidationError("invalid_state_updates") for update in update_items: if not isinstance(update, dict) or set(update) != {"key", "value"}: raise ValidationError("invalid_state_update") key = update.get("key") item = update.get("value") if not isinstance(key, str) or not ID_RE.fullmatch(key): raise ValidationError("invalid_state_update_key") if not isinstance(item, str) or not item or len(item) > 256: raise ValidationError("invalid_state_update_value") if key in normalized: raise ValidationError("duplicate_state_update_key") normalized[key] = item raw_units = value.get("next_work_units") if not isinstance(raw_units, list) or len(raw_units) > 32: raise ValidationError("invalid_next_work_units") units = tuple(WorkUnit.from_dict(item) for item in raw_units) human = value.get("requires_human_input") if not isinstance(human, bool): raise ValidationError("invalid_requires_human_input") stop = value.get("stop_reason") if stop is not None and (not isinstance(stop, str) or len(stop) > 512): raise ValidationError("invalid_stop_reason") if verdict == "DISPATCH" and not units: raise ValidationError("dispatch_without_work_units") if verdict != "DISPATCH" and units: raise ValidationError("work_units_without_dispatch") return cls( schema_version=1, project_key=req_str(value, "project_key", ID_RE), contract_commit=req_str(value, "contract_commit", OID_RE), event_id=req_str(value, "event_id", ID_RE), expected_state_version=version, verdict=verdict, state_updates=normalized, next_work_units=units, requires_human_input=human, stop_reason=stop, ) def to_dict(self) -> dict[str, Any]: return { "schema_version": 1, "project_key": self.project_key, "contract_commit": self.contract_commit, "event_id": self.event_id, "expected_state_version": self.expected_state_version, "verdict": self.verdict, "state_updates": [ {"key": key, "value": value} for key, value in sorted(self.state_updates.items()) ], "next_work_units": [u.to_dict() for u in self.next_work_units], "requires_human_input": self.requires_human_input, "stop_reason": self.stop_reason, } DECISION_SCHEMA: dict[str, Any] = { "type": "object", "additionalProperties": False, "required": [ "schema_version", "project_key", "contract_commit", "event_id", "expected_state_version", "verdict", "state_updates", "next_work_units", "requires_human_input", "stop_reason", ], "properties": { "schema_version": {"type": "integer", "const": 1}, "project_key": {"type": "string"}, "contract_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "event_id": {"type": "string"}, "expected_state_version": {"type": "integer", "minimum": 0}, "verdict": {"type": "string", "enum": sorted(VERDICTS)}, "state_updates": { "type": "array", "items": { "type": "object", "additionalProperties": False, "required": ["key", "value"], "properties": { "key": {"type": "string"}, "value": {"type": "string"} } } }, "next_work_units": { "type": "array", "maxItems": 32, "items": { "type": "object", "additionalProperties": False, "required": [ "id", "track_id", "gate_id", "primary_agent", "fallback_agent", "clean_base_commit", "allowed_paths", "required_checks", "forbidden_operations", "instruction", ], "properties": { "id": {"type": "string"}, "track_id": {"type": "string"}, "gate_id": {"type": "string"}, "primary_agent": {"type": "string", "enum": ["openclaw", "codex"]}, "fallback_agent": {"type": "string", "enum": ["openclaw", "codex"]}, "clean_base_commit": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "allowed_paths": {"type": "array", "items": {"type": "string"}}, "required_checks": {"type": "array", "items": {"type": "string"}}, "forbidden_operations": {"type": "array", "items": {"type": "string"}}, "instruction": {"type": "string"}, }, }, }, "requires_human_input": {"type": "boolean"}, "stop_reason": {"type": ["string", "null"]}, }, }