Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""ActivityDefinition markdown file parser (T44).
|
|
|
|
Scans activity-definitions/*.md in the local repo and any directories
|
|
listed in ACTIVITY_DEFINITION_DIRS (colon-separated). Returns parsed
|
|
ActivityDefinitionDef objects; raises ParseError on malformed input.
|
|
|
|
Never silently ignores a broken definition file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
from activity_core.bounded_operations import validate_bounded_operations
|
|
from activity_core.glas_profile import (
|
|
ProfileRefError,
|
|
require_harness_profile,
|
|
validate_profile_ref,
|
|
)
|
|
|
|
|
|
class ParseError(Exception):
|
|
"""Raised when a definition file cannot be parsed."""
|
|
|
|
def __init__(self, file: Path, line: int | None, message: str) -> None:
|
|
self.file = file
|
|
self.line = line
|
|
self.message = message
|
|
loc = f"{file}:{line}" if line else str(file)
|
|
super().__init__(f"{loc}: {message}")
|
|
|
|
|
|
@dataclass
|
|
class ActivityDefinitionDef:
|
|
"""Parsed in-memory representation of an activity-definitions/*.md file."""
|
|
|
|
id: str
|
|
name: str
|
|
enabled: bool
|
|
trigger_config: dict[str, Any]
|
|
context_sources: list[dict[str, Any]]
|
|
rules: list[dict[str, Any]]
|
|
instructions: list[dict[str, Any]]
|
|
governance: str
|
|
owner: str
|
|
status: str
|
|
source_file: Path
|
|
|
|
|
|
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
_FENCED_BLOCK_RE = re.compile(r"^```(\w+)\s*\n(.*?)\n```", re.DOTALL | re.MULTILINE)
|
|
|
|
|
|
def _instruction_can_emit_tasks(instruction: dict[str, Any]) -> bool:
|
|
"""Return false only for the explicit deterministic report-only shape."""
|
|
model = str(instruction.get("model") or "").strip().lower()
|
|
return not (
|
|
model in {"none", "deterministic", "unused"}
|
|
and bool(instruction.get("report_sinks"))
|
|
)
|
|
|
|
|
|
def _validate_execution_declarations(
|
|
rules: list[dict[str, Any]],
|
|
instructions: list[dict[str, Any]],
|
|
file: Path,
|
|
) -> None:
|
|
"""Validate declared profile structure without mirroring the Glas catalog."""
|
|
profile_required = require_harness_profile()
|
|
declarations: list[tuple[str, dict[str, Any], bool]] = []
|
|
|
|
for rule in rules:
|
|
action = rule.get("action")
|
|
if not isinstance(action, dict):
|
|
raise ParseError(
|
|
file,
|
|
None,
|
|
f"rule {rule.get('id')!r} action must be a YAML mapping",
|
|
)
|
|
declarations.append((f"rule {rule.get('id')!r}", action, True))
|
|
|
|
for instruction in instructions:
|
|
declarations.append(
|
|
(
|
|
f"instruction {instruction.get('id')!r}",
|
|
instruction,
|
|
_instruction_can_emit_tasks(instruction),
|
|
)
|
|
)
|
|
|
|
for location, declaration, can_emit_tasks in declarations:
|
|
raw_profile = declaration.get("harness_profile_ref")
|
|
if raw_profile is None:
|
|
if profile_required and can_emit_tasks:
|
|
raise ParseError(
|
|
file,
|
|
None,
|
|
f"{location} must declare harness_profile_ref while "
|
|
"ACTIVITY_CORE_REQUIRE_HARNESS_PROFILE is enabled",
|
|
)
|
|
else:
|
|
try:
|
|
declaration["harness_profile_ref"] = validate_profile_ref(raw_profile)
|
|
except ProfileRefError as exc:
|
|
raise ParseError(
|
|
file,
|
|
None,
|
|
f"{location} has invalid harness_profile_ref: {exc}",
|
|
) from exc
|
|
|
|
execution_refs = declaration.get("execution_refs")
|
|
if execution_refs is not None and not isinstance(execution_refs, dict):
|
|
raise ParseError(
|
|
file,
|
|
None,
|
|
f"{location} execution_refs must be a YAML mapping",
|
|
)
|
|
|
|
|
|
def _normalise_review_advisory(
|
|
instructions: list[dict[str, Any]], file: Path
|
|
) -> None:
|
|
"""Migrate the legacy gate-sounding field to advisory-only semantics."""
|
|
for instruction in instructions:
|
|
legacy_present = "review_required" in instruction
|
|
advisory_present = "review_advisory" in instruction
|
|
if legacy_present and advisory_present and (
|
|
bool(instruction["review_required"])
|
|
!= bool(instruction["review_advisory"])
|
|
):
|
|
raise ParseError(
|
|
file,
|
|
None,
|
|
f"instruction {instruction.get('id')!r} declares conflicting "
|
|
"review_required and review_advisory values",
|
|
)
|
|
if legacy_present:
|
|
instruction.setdefault("review_advisory", bool(instruction["review_required"]))
|
|
instruction.pop("review_required", None)
|
|
|
|
|
|
def _scan_dirs() -> list[Path]:
|
|
dirs: list[Path] = []
|
|
default_dir = Path("activity-definitions")
|
|
if default_dir.is_dir():
|
|
dirs.append(default_dir)
|
|
extra = os.environ.get("ACTIVITY_DEFINITION_DIRS", "")
|
|
for part in extra.split(":"):
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
p = Path(part) / "activity-definitions"
|
|
if p.is_dir():
|
|
dirs.append(p)
|
|
return dirs
|
|
|
|
|
|
def _parse_trigger(trigger_dict: dict, file: Path) -> dict[str, Any]:
|
|
"""Normalise the trigger section into a trigger_config dict."""
|
|
trigger_type = trigger_dict.get("type")
|
|
if trigger_type == "cron":
|
|
return {
|
|
"trigger_type": "cron",
|
|
"cron_expression": trigger_dict.get("cron_expression", ""),
|
|
"timezone": trigger_dict.get("timezone", "UTC"),
|
|
"jitter_seconds": trigger_dict.get("jitter_seconds", 0),
|
|
"misfire_policy": trigger_dict.get("misfire_policy", "skip"),
|
|
}
|
|
elif trigger_type == "event":
|
|
return {
|
|
"trigger_type": "event",
|
|
"event_type": trigger_dict.get("event_type", ""),
|
|
"filters": trigger_dict.get("filters", {}),
|
|
}
|
|
elif trigger_type == "scheduled":
|
|
at = trigger_dict.get("at")
|
|
if at is None:
|
|
raise ParseError(file, None, "trigger.at is required for type=scheduled")
|
|
at_str = at.isoformat() if hasattr(at, "isoformat") else str(at)
|
|
return {
|
|
"trigger_type": "scheduled",
|
|
"at": at_str,
|
|
"timezone": trigger_dict.get("timezone", "UTC"),
|
|
}
|
|
else:
|
|
raise ParseError(file, None, f"unknown trigger type {trigger_type!r}")
|
|
|
|
|
|
def parse_file(path: Path) -> ActivityDefinitionDef:
|
|
"""Parse one ActivityDefinition markdown file.
|
|
|
|
Raises ParseError on malformed or missing required fields.
|
|
"""
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
raise ParseError(path, None, f"cannot read file: {exc}") from exc
|
|
|
|
fm_match = _FRONTMATTER_RE.match(text)
|
|
if not fm_match:
|
|
raise ParseError(path, 1, "missing or malformed YAML frontmatter")
|
|
|
|
try:
|
|
fm = yaml.safe_load(fm_match.group(1))
|
|
except yaml.YAMLError as exc:
|
|
raise ParseError(path, None, f"YAML parse error in frontmatter: {exc}") from exc
|
|
|
|
if not isinstance(fm, dict):
|
|
raise ParseError(path, 1, "frontmatter must be a YAML mapping")
|
|
|
|
for req in ("id", "name", "trigger"):
|
|
if req not in fm:
|
|
raise ParseError(path, None, f"missing required frontmatter field: {req!r}")
|
|
|
|
trigger_section = fm["trigger"]
|
|
if not isinstance(trigger_section, dict):
|
|
raise ParseError(path, None, "trigger must be a YAML mapping")
|
|
|
|
trigger_config = _parse_trigger(trigger_section, path)
|
|
|
|
raw_sources = fm.get("context_sources", []) or []
|
|
context_sources: list[dict[str, Any]] = []
|
|
for cs in raw_sources:
|
|
if not isinstance(cs, dict):
|
|
raise ParseError(path, None, "each context_source must be a mapping")
|
|
context_sources.append(cs)
|
|
|
|
# Parse fenced rule/instruction blocks from the body
|
|
body = text[fm_match.end():]
|
|
rules: list[dict[str, Any]] = []
|
|
instructions: list[dict[str, Any]] = []
|
|
|
|
for block_match in _FENCED_BLOCK_RE.finditer(body):
|
|
lang = block_match.group(1).strip()
|
|
block_body = block_match.group(2)
|
|
try:
|
|
block_data = yaml.safe_load(block_body)
|
|
except yaml.YAMLError as exc:
|
|
raise ParseError(path, None, f"YAML parse error in {lang!r} block: {exc}") from exc
|
|
|
|
if lang == "rule":
|
|
if not isinstance(block_data, dict):
|
|
raise ParseError(path, None, "rule block must be a YAML mapping")
|
|
if "id" not in block_data:
|
|
raise ParseError(path, None, "rule block missing required field 'id'")
|
|
if "action" not in block_data:
|
|
raise ParseError(path, None, f"rule {block_data['id']!r} missing 'action'")
|
|
rules.append(block_data)
|
|
elif lang == "instruction":
|
|
if not isinstance(block_data, dict):
|
|
raise ParseError(path, None, "instruction block must be a YAML mapping")
|
|
if "id" not in block_data:
|
|
raise ParseError(path, None, "instruction block missing required field 'id'")
|
|
instructions.append(block_data)
|
|
|
|
_normalise_review_advisory(instructions, path)
|
|
_validate_execution_declarations(rules, instructions, path)
|
|
try:
|
|
validate_bounded_operations(context_sources, instructions)
|
|
except ValueError as exc:
|
|
raise ParseError(path, None, str(exc)) from exc
|
|
|
|
return ActivityDefinitionDef(
|
|
id=str(fm["id"]),
|
|
name=str(fm["name"]),
|
|
enabled=bool(fm.get("enabled", True)),
|
|
trigger_config=trigger_config,
|
|
context_sources=context_sources,
|
|
rules=rules,
|
|
instructions=instructions,
|
|
governance=str(fm.get("governance", "publisher-declared")),
|
|
owner=str(fm.get("owner", "")),
|
|
status=str(fm.get("status", "active")),
|
|
source_file=path,
|
|
)
|
|
|
|
|
|
def scan_and_parse() -> list[ActivityDefinitionDef]:
|
|
"""Scan all definition directories and parse all .md files.
|
|
|
|
Raises ParseError on any broken file — never silently skips.
|
|
"""
|
|
dirs = _scan_dirs()
|
|
defs: list[ActivityDefinitionDef] = []
|
|
for d in dirs:
|
|
for path in sorted(d.glob("*.md")):
|
|
defs.append(parse_file(path))
|
|
return defs
|