Fail closed on invalid Glas profiles
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a028de-e2c8-7732-8521-46a7fc5db82f
This commit is contained in:
parent
561538c95e
commit
b933cf52c8
12 changed files with 455 additions and 9 deletions
|
|
@ -503,6 +503,9 @@ async def evaluate_instructions(payload: dict) -> dict:
|
|||
"model": result.model,
|
||||
"output_validated": result.output_validated,
|
||||
"review_required": result.review_required,
|
||||
"approach_hint": instruction.approach_hint,
|
||||
"harness_profile_ref": instruction.harness_profile_ref,
|
||||
"execution_refs": instruction.execution_refs,
|
||||
})
|
||||
|
||||
return {"task_specs": task_specs, "reports": reports}
|
||||
|
|
@ -538,6 +541,41 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
activity_id = payload.get("activity_id", "")
|
||||
triggering_event_id = payload.get("triggering_event_id", "")
|
||||
|
||||
# Profile errors are policy failures, not best-effort queue failures. Check
|
||||
# the whole batch before opening a DB transaction or touching IssueSink so a
|
||||
# later malformed item cannot leave an earlier item partially emitted.
|
||||
from activity_core.glas_profile import (
|
||||
ProfileRefError,
|
||||
normalise_execution_refs,
|
||||
resolve_execution_selector,
|
||||
)
|
||||
|
||||
validated_specs: list[dict] = []
|
||||
for index, raw_spec in enumerate(task_specs_raw):
|
||||
if not isinstance(raw_spec, dict):
|
||||
raise ApplicationError(
|
||||
f"task spec {index} is not a mapping",
|
||||
non_retryable=True,
|
||||
)
|
||||
spec_dict = dict(raw_spec)
|
||||
try:
|
||||
profile_ref, approach_hint = resolve_execution_selector(
|
||||
spec_dict.get("harness_profile_ref"),
|
||||
spec_dict.get("approach_hint"),
|
||||
)
|
||||
except ProfileRefError as exc:
|
||||
source = f"{spec_dict.get('source_type', 'rule')}:{spec_dict.get('source_id', '')}"
|
||||
raise ApplicationError(
|
||||
f"execution profile refused for {source}: {exc}",
|
||||
non_retryable=True,
|
||||
) from exc
|
||||
spec_dict["harness_profile_ref"] = profile_ref
|
||||
spec_dict["approach_hint"] = approach_hint
|
||||
spec_dict["execution_refs"] = normalise_execution_refs(
|
||||
spec_dict.get("execution_refs")
|
||||
)
|
||||
validated_specs.append(spec_dict)
|
||||
|
||||
sink = get_issue_sink()
|
||||
Session = _get_session_factory()
|
||||
|
||||
|
|
@ -545,7 +583,7 @@ async def emit_tasks(payload: dict) -> list[str]:
|
|||
errors: list[str] = []
|
||||
async with Session() as session:
|
||||
async with session.begin():
|
||||
for spec_dict in task_specs_raw:
|
||||
for spec_dict in validated_specs:
|
||||
spec = TaskSpec(
|
||||
title=spec_dict.get("title", ""),
|
||||
description=spec_dict.get("description", ""),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ from typing import Any
|
|||
|
||||
import yaml
|
||||
|
||||
from activity_core.glas_profile import (
|
||||
ProfileRefError,
|
||||
require_harness_profile,
|
||||
validate_profile_ref,
|
||||
)
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""Raised when a definition file cannot be parsed."""
|
||||
|
|
@ -50,6 +56,72 @@ _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 _scan_dirs() -> list[Path]:
|
||||
dirs: list[Path] = []
|
||||
default_dir = Path("activity-definitions")
|
||||
|
|
@ -164,6 +236,8 @@ def parse_file(path: Path) -> ActivityDefinitionDef:
|
|||
raise ParseError(path, None, "instruction block missing required field 'id'")
|
||||
instructions.append(block_data)
|
||||
|
||||
_validate_execution_declarations(rules, instructions, path)
|
||||
|
||||
return ActivityDefinitionDef(
|
||||
id=str(fm["id"]),
|
||||
name=str(fm["name"]),
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ class ActionDef(BaseModel):
|
|||
priority: str = Field(default="medium")
|
||||
labels: list[str] = Field(default_factory=list)
|
||||
due_in_days: int | None = Field(default=None)
|
||||
approach_hint: str | None = Field(default=None)
|
||||
harness_profile_ref: str | None = Field(default=None)
|
||||
execution_refs: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RuleDef(BaseModel):
|
||||
|
|
@ -136,6 +139,9 @@ class InstructionDef(BaseModel):
|
|||
output_schema: str = Field(description="Path to JSON Schema file for output validation.")
|
||||
review_required: bool = Field(default=False)
|
||||
report_sinks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
approach_hint: str | None = Field(default=None)
|
||||
harness_profile_ref: str | None = Field(default=None)
|
||||
execution_refs: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Context sources ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -80,10 +80,31 @@ def _task_spec_for_rule(rule: dict, event: Any, context: dict) -> dict:
|
|||
source_id=rule.get("id", ""),
|
||||
)
|
||||
result = asdict(spec)
|
||||
for key in ("approach_hint", "harness_profile_ref"):
|
||||
if key in action:
|
||||
result[key] = _string_or_none(
|
||||
_render_value(action.get(key), event, context)
|
||||
)
|
||||
if "execution_refs" in action:
|
||||
result["execution_refs"] = _render_execution_refs(
|
||||
action.get("execution_refs"), event, context
|
||||
)
|
||||
result["condition"] = rule.get("condition", "")
|
||||
return result
|
||||
|
||||
|
||||
def _render_execution_refs(value: Any, event: Any, context: dict) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
rendered: dict[str, Any] = {}
|
||||
for key, raw in value.items():
|
||||
if isinstance(raw, list):
|
||||
rendered[key] = [_render_value(item, event, context) for item in raw]
|
||||
else:
|
||||
rendered[key] = _render_value(raw, event, context)
|
||||
return rendered
|
||||
|
||||
|
||||
def _render_labels(value: Any, event: Any, context: dict) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue