feat(WP-0003b): parser, workflow wiring, triggers, webhooks

T44: ActivityDefinition markdown file parser (definition_parser.py)
  - Scans activity-definitions/*.md and ACTIVITY_DEFINITION_DIRS paths
  - Parses YAML frontmatter + fenced rule/instruction blocks
  - Raises ParseError on any malformed file — never silently skips

T45: ActivityDefinition sync command
  - Migration 0006: adds rules_json/instructions_json JSONB columns
  - sync_activity_definitions.py + make sync-activity-definitions
  - Called at worker startup before schedule sync

T46: Rule/instruction pipeline wired into RunActivityWorkflow
  - New evaluate_rules and emit_tasks Temporal activities
  - Workflow passes event_envelope_json to enable rule evaluation
  - EventRouter now passes full envelope JSON as 4th workflow arg
  - IssueSink.emit() writes task_spawn_log rows per task

T47: ScheduledTriggerConfig model (one-off future datetime trigger)

T48: One-off Temporal Schedule support
  - Fixed timezone_name → time_zone_name (was causing all schedule tests to fail)
  - Added ScheduleCalendarSpec-based one-off schedule with remaining_actions=1
  - cancel_scheduled() for admin cancellation
  - Fixed backfill() call to use *args unpacking (not list wrapper)
  - Fixed ScheduleAlreadyRunningError catch in upsert_schedule
  - sync_schedules now handles ScheduledTriggerConfig definitions

T49: Webhook receiver
  - POST /webhooks/gitea  — HMAC-SHA256 via X-Gitea-Signature-256
  - POST /webhooks/github — HMAC-SHA256 via X-Hub-Signature-256
  - Normalisers: repo.created, push, issue.closed → EventEnvelope
  - Publishes to NATS activity.{type} subject after registry validation
  - Mounted in api.py at /webhooks prefix

T50: Gitea event type definitions
  - gitea.repo.created.md, gitea.push.md, gitea.issue.closed.md
  - Each includes normaliser field mapping in Consumer Notes

Tests: 18 passed, 1 skipped (integration). Fixed embedded Temporal
server visibility latency in test_upsert_schedule_creates_schedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:02:33 +02:00
parent dc20c44a44
commit 176867cbe3
18 changed files with 1106 additions and 55 deletions

View File

@@ -21,8 +21,10 @@ from temporalio import activity
from temporalio.exceptions import ApplicationError
from activity_core.db import make_engine
from activity_core.issue_sink import get_issue_sink
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow
from activity_core.orm import ActivityRun, TaskInstance
from activity_core.orm import ActivityRun, TaskInstance, TaskSpawnLog
from activity_core.rules import evaluate_condition
_session_factory: async_sessionmaker[AsyncSession] | None = None
@@ -79,6 +81,8 @@ async def load_activity_definition(activity_id: str) -> dict:
"trigger_config": row.trigger_config,
"context_sources": row.context_sources,
"task_templates": row.task_templates,
"rules": row.rules_json,
"instructions": row.instructions_json,
"dedupe_key_strategy": row.dedupe_key_strategy,
"version": row.version,
}
@@ -200,3 +204,103 @@ async def persist_task_instance(task_payload: dict) -> str:
await session.execute(stmt)
return str(task_id)
@activity.defn
async def evaluate_rules(payload: dict) -> list[dict]:
"""Evaluate each rule condition against the event and context.
Returns the list of matching rule dicts (those whose condition is True).
Rules that raise UnsafeExpression or any other error are skipped and logged.
Expected keys in payload:
rules list[dict] — RuleDef serialised dicts
event dict — EventEnvelope attributes (or empty for cron)
context dict — context snapshot from resolve_context
"""
from activity_core.rules.evaluator import UnsafeExpression
rules = payload.get("rules", [])
event_attrs = payload.get("event", {})
context = payload.get("context", {})
# Build a simple object whose attributes mirror event fields for the evaluator.
class _Env:
def __init__(self, attrs: dict) -> None:
self.attributes = _DictObj(attrs)
class _DictObj:
def __init__(self, d: dict) -> None:
self.__dict__.update(d)
event_obj = _Env(event_attrs)
matched: list[dict] = []
for rule in rules:
condition = rule.get("condition", "")
try:
if evaluate_condition(condition, event_obj, context):
matched.append(rule)
except UnsafeExpression as exc:
activity.logger.warning("rule %r unsafe expression — skipping: %s", rule.get("id"), exc)
except Exception as exc:
activity.logger.warning("rule %r eval error — skipping: %s", rule.get("id"), exc)
return matched
@activity.defn
async def emit_tasks(payload: dict) -> list[str]:
"""Emit TaskSpecs to IssueSink and write task_spawn_log rows.
Returns list of external task ref IDs.
Expected keys in payload:
task_specs list[dict] — from evaluate_rules matched actions
activity_id str — UUID of the ActivityDefinition
triggering_event_id str — event ID or workflow ID for cron
run_id str — UUID of the ActivityRun
"""
from activity_core.rules.models import TaskSpec
task_specs_raw = payload.get("task_specs", [])
activity_id = payload.get("activity_id", "")
triggering_event_id = payload.get("triggering_event_id", "")
sink = get_issue_sink()
Session = _get_session_factory()
refs: list[str] = []
async with Session() as session:
async with session.begin():
for spec_dict in task_specs_raw:
spec = TaskSpec(
title=spec_dict.get("title", ""),
description=spec_dict.get("description", ""),
target_repo=spec_dict.get("target_repo"),
priority=spec_dict.get("priority", "medium"),
labels=spec_dict.get("labels", []),
due_in_days=spec_dict.get("due_in_days"),
source_type=spec_dict.get("source_type", "rule"),
source_id=spec_dict.get("source_id", ""),
triggering_event_id=triggering_event_id,
activity_definition_id=activity_id,
)
try:
ref = sink.emit(spec)
refs.append(ref.external_id)
log_row = TaskSpawnLog(
activity_def_id=uuid.UUID(activity_id),
source_type=spec.source_type,
source_id=spec.source_id,
source_version="1",
triggering_event_id=triggering_event_id,
task_ref=ref.external_id,
condition_matched=spec_dict.get("condition"),
)
session.add(log_row)
except Exception as exc:
activity.logger.warning("emit_tasks: sink.emit failed — %s", exc)
return refs