347 lines
14 KiB
Python
347 lines
14 KiB
Python
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"]},
|
|
},
|
|
}
|