generated from coulomb/repo-seed
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user