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:
@@ -16,10 +16,13 @@ from temporalio.client import (
|
||||
Client,
|
||||
Schedule,
|
||||
ScheduleActionStartWorkflow,
|
||||
ScheduleAlreadyRunningError,
|
||||
ScheduleBackfill,
|
||||
ScheduleCalendarSpec,
|
||||
ScheduleHandle,
|
||||
ScheduleOverlapPolicy,
|
||||
SchedulePolicy,
|
||||
ScheduleRange,
|
||||
ScheduleSpec,
|
||||
ScheduleState,
|
||||
ScheduleUpdate,
|
||||
@@ -27,7 +30,7 @@ from temporalio.client import (
|
||||
)
|
||||
from temporalio.service import RPCError
|
||||
|
||||
from activity_core.models import ActivityDefinition, CronTriggerConfig
|
||||
from activity_core.models import ActivityDefinition, CronTriggerConfig, ScheduledTriggerConfig
|
||||
|
||||
_ORCHESTRATOR_TASK_QUEUE = "orchestrator-tq"
|
||||
|
||||
@@ -68,7 +71,7 @@ def _build_schedule(defn: ActivityDefinition) -> Schedule:
|
||||
|
||||
spec = ScheduleSpec(
|
||||
cron_expressions=[cfg.cron_expression],
|
||||
timezone_name=cfg.timezone,
|
||||
time_zone_name=cfg.timezone,
|
||||
jitter=timedelta(seconds=cfg.jitter_seconds) if cfg.jitter_seconds else None,
|
||||
)
|
||||
|
||||
@@ -78,19 +81,90 @@ def _build_schedule(defn: ActivityDefinition) -> Schedule:
|
||||
return Schedule(action=action, spec=spec, policy=policy, state=state)
|
||||
|
||||
|
||||
async def upsert_schedule(client: Client, defn: ActivityDefinition) -> ScheduleHandle:
|
||||
"""Create or update a Temporal Schedule for a cron ActivityDefinition.
|
||||
def _onetime_schedule_id(activity_id: str | UUID) -> str:
|
||||
return f"activity-schedule-{activity_id}-once"
|
||||
|
||||
- Only operates on definitions with trigger_type='cron'.
|
||||
|
||||
def _build_onetime_schedule(defn: ActivityDefinition) -> tuple[str, Schedule]:
|
||||
"""Build a one-off Temporal Schedule that fires once at defn.trigger_config.at.
|
||||
|
||||
Returns (schedule_id, Schedule).
|
||||
Uses ScheduleState(remaining_actions=1) so the schedule self-disarms after firing.
|
||||
"""
|
||||
assert isinstance(defn.trigger_config, ScheduledTriggerConfig)
|
||||
cfg: ScheduledTriggerConfig = defn.trigger_config
|
||||
at = cfg.at
|
||||
|
||||
action = ScheduleActionStartWorkflow(
|
||||
"RunActivityWorkflow",
|
||||
args=[str(defn.id), SCHEDULED_TRIGGER_KEY, at.isoformat(), None],
|
||||
id=f"activity-{defn.id}:once",
|
||||
task_queue=_ORCHESTRATOR_TASK_QUEUE,
|
||||
)
|
||||
|
||||
# Calendar spec pinned to the exact minute — combined with remaining_actions=1
|
||||
# this fires exactly once at the specified time.
|
||||
spec = ScheduleSpec(
|
||||
calendars=[
|
||||
ScheduleCalendarSpec(
|
||||
second=[ScheduleRange(0)],
|
||||
minute=[ScheduleRange(at.minute)],
|
||||
hour=[ScheduleRange(at.hour)],
|
||||
day_of_month=[ScheduleRange(at.day)],
|
||||
month=[ScheduleRange(at.month)],
|
||||
year=[ScheduleRange(at.year)],
|
||||
)
|
||||
],
|
||||
time_zone_name=cfg.timezone,
|
||||
)
|
||||
|
||||
state = ScheduleState(
|
||||
limited_actions=True,
|
||||
remaining_actions=1,
|
||||
paused=not defn.enabled,
|
||||
)
|
||||
|
||||
sid = _onetime_schedule_id(defn.id)
|
||||
return sid, Schedule(action=action, spec=spec, state=state)
|
||||
|
||||
|
||||
async def cancel_scheduled(client: Client, activity_id: str | UUID) -> None:
|
||||
"""Delete the one-off Temporal Schedule for a ScheduledTriggerConfig definition.
|
||||
|
||||
No-op if the schedule does not exist.
|
||||
"""
|
||||
handle = client.get_schedule_handle(_onetime_schedule_id(activity_id))
|
||||
try:
|
||||
await handle.delete()
|
||||
except RPCError:
|
||||
pass
|
||||
|
||||
|
||||
async def upsert_schedule(client: Client, defn: ActivityDefinition) -> ScheduleHandle:
|
||||
"""Create or update a Temporal Schedule for a cron or scheduled ActivityDefinition.
|
||||
|
||||
- For cron: creates/updates the recurring schedule.
|
||||
- For scheduled: creates a one-off schedule (remaining_actions=1).
|
||||
- If enabled=False the schedule is created paused.
|
||||
- For misfire_policy='catchup', triggers a backfill covering the last hour
|
||||
after each upsert to replay any recently missed fires.
|
||||
- For cron with misfire_policy='catchup', triggers a backfill covering the
|
||||
last hour after each upsert to replay any recently missed fires.
|
||||
|
||||
Returns the ScheduleHandle for the created/updated schedule.
|
||||
"""
|
||||
if isinstance(defn.trigger_config, ScheduledTriggerConfig):
|
||||
sid, sched = _build_onetime_schedule(defn)
|
||||
try:
|
||||
handle = await client.create_schedule(sid, sched)
|
||||
except RPCError:
|
||||
handle = client.get_schedule_handle(sid)
|
||||
async def _updater_once(inp: ScheduleUpdateInput) -> ScheduleUpdate: # noqa: ARG001
|
||||
return ScheduleUpdate(schedule=sched)
|
||||
await handle.update(_updater_once)
|
||||
return handle
|
||||
|
||||
if not isinstance(defn.trigger_config, CronTriggerConfig):
|
||||
raise ValueError(
|
||||
f"upsert_schedule requires trigger_type='cron', "
|
||||
f"upsert_schedule requires trigger_type='cron' or 'scheduled', "
|
||||
f"got {defn.trigger_config.trigger_type!r}"
|
||||
)
|
||||
|
||||
@@ -99,7 +173,7 @@ async def upsert_schedule(client: Client, defn: ActivityDefinition) -> ScheduleH
|
||||
|
||||
try:
|
||||
handle = await client.create_schedule(sid, sched)
|
||||
except RPCError:
|
||||
except (RPCError, ScheduleAlreadyRunningError):
|
||||
# Schedule already exists — update it in place.
|
||||
handle = client.get_schedule_handle(sid)
|
||||
|
||||
@@ -121,13 +195,11 @@ async def upsert_schedule(client: Client, defn: ActivityDefinition) -> ScheduleH
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
backfill_start = now - timedelta(hours=1)
|
||||
await handle.backfill(
|
||||
[
|
||||
ScheduleBackfill(
|
||||
start_at=backfill_start,
|
||||
end_at=now,
|
||||
overlap=ScheduleOverlapPolicy.BUFFER_ALL,
|
||||
)
|
||||
]
|
||||
ScheduleBackfill(
|
||||
start_at=backfill_start,
|
||||
end_at=now,
|
||||
overlap=ScheduleOverlapPolicy.BUFFER_ALL,
|
||||
)
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
Reference in New Issue
Block a user