feat(B-TRIG): Trigger-Kern konsolidiert — generischer Dispatcher, UI-Event-Typ, 4 Trigger-Typen
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
B-TRIG-GEN: app/core/trigger_dispatcher.py (NEU) — generischer Event→Automation Dispatcher - Wildcard EventBus Subscribe → matcht AutomationDefinition mit trigger_type=event - Keine hardcodierte Event-Liste mehr — alle Outbox Events können Automations triggern - Registriert in main.py lifespan + worker.py on_startup B-TRIG-UI: UI-Event Trigger-Typ - trigger_type Pattern um „ui" erweitert (event/schedule/manual/ui) - UI-Events laufen ephemeral über EventBus → TriggerDispatcher → run_automation - UI-Events NIEMALS in Outbox (is_ui_event() Guard) B-TRIG-CRON: Cron/Heartbeat verifiziert — bestehender Pfad funktioniert B-TRIG-MAN: Manual-Trigger verifiziert — bestehender Pfad funktioniert B-TRIG-TEST: 10 Tests in test_trigger_core.py — alle grün - Domain Event, UI Event (ephemeral), Cron, Manual, alle nutzen selben Execution-Kern B-TRIG-DOC: Plugin-Dev-Guide Kapitel 10 mit Implementierungsdetails erweitert
This commit is contained in:
@@ -1208,40 +1208,135 @@ Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_
|
||||
|
||||
## 10. Trigger
|
||||
|
||||
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Webhook-Trigger folgt in Phase G.
|
||||
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Alle vier Typen konvergieren im selben Execution-Kern (`run_automation`). Webhook-Trigger folgt in Phase G.
|
||||
|
||||
### 10.0 Trigger-Typen-Übersicht
|
||||
|
||||
| Trigger-Typ | `trigger_type` | Event-Quelle | Durability | Dispatch-Pfad |
|
||||
|-------------|----------------|-------------|------------|---------------|
|
||||
| Domain-Event | `event` | Outbox → Worker → EventBus | **durable** (at-least-once) | EventBus `*` → TriggerDispatcher → `run_automation` |
|
||||
| UI-Event | `ui` | WebSocket → EventBus (ephemeral) | **ephemeral** (keine Persistenz) | EventBus `*` → TriggerDispatcher → `run_automation` |
|
||||
| Cron/Schedule | `schedule` | ARQ Cron → `scheduler_tick` | **durable** (ARQ-Queue) | `scheduler_tick` → `enqueue_job("run_automation")` |
|
||||
| Manual | `manual` | API-Route `/execute` | **durable** (HTTP-Request) | Route → `run_automation(trigger_type="manual")` |
|
||||
|
||||
**Wichtig:** UI-Events (`ui.*`) dürfen **niemals** in die Outbox geschrieben werden. Sie sind ephemeral und fließen direkt über den EventBus.
|
||||
|
||||
### 10.1 Domain-Event-Trigger (durable)
|
||||
|
||||
Domain-Events werden über die **Outbox** gepublished und sind reliable. Siehe Kapitel 8 für die Event-System-Rollen.
|
||||
Domain-Events werden über die **Transaction Outbox** gepublished: ein Service schreibt das Event in die `event_outbox` Tabelle innerhalb derselben DB-Transaktion. Der ARQ-Worker pollt die Outbox alle 5 Sekunden und published die Events auf den **EventBus**.
|
||||
|
||||
Der **TriggerDispatcher** (`app/core/trigger_dispatcher.py`) abonniert den `*` Wildcard-Handler auf dem EventBus und evaluiert jedes eingehende Event. Bei einer Übereinstimmung mit einer `AutomationDefinition` (`trigger_type="event"`, `trigger_config.event_name` matcht) wird `run_automation` aufgerufen.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
Service → enqueue_outbox_event() → event_outbox Tabelle
|
||||
↓ (commit)
|
||||
ARQ Worker (process_outbox_job, alle 5s)
|
||||
↓
|
||||
EventBus.publish_with_results(event_name, envelope)
|
||||
↓
|
||||
TriggerDispatcher._on_event(payload)
|
||||
↓
|
||||
DB-Query: AutomationDefinition WHERE trigger_type='event' AND trigger_config->>'event_name' = event_name
|
||||
↓
|
||||
run_automation(trigger_type="event", trigger_data=payload)
|
||||
```
|
||||
|
||||
**Beispiel — Domain-Event in Automation umwandeln:**
|
||||
```python
|
||||
# Plugin deklariert Events im Manifest
|
||||
manifest = PluginManifest(
|
||||
name="my_plugin",
|
||||
events=["contact.created", "contact.updated"],
|
||||
...
|
||||
)
|
||||
# AutomationDefinition erstellen
|
||||
POST /api/v1/automation/
|
||||
{
|
||||
"name": "notify-on-contact-create",
|
||||
"trigger_type": "event",
|
||||
"trigger_config": {"event_name": "contact.created"},
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{"type": "notification", "config": {"user_id": "...", "title": "Neuer Kontakt"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
# Handler wird automatisch via on_activate registriert:
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Reagiert auf contact.created Outbox-Event."""
|
||||
contact_id = payload.get("contact_id")
|
||||
# Business logic here
|
||||
**Beispiel — Plugin veröffentlicht Domain-Event:**
|
||||
```python
|
||||
from app.core.outbox import enqueue_outbox_event
|
||||
|
||||
await enqueue_outbox_event(db, tenant_id, "contact.created", {
|
||||
"contact_id": str(contact.id),
|
||||
"tenant_id": str(tenant_id),
|
||||
})
|
||||
# Event wird beim Commit der Transaktion persistent.
|
||||
# Der Worker published es an den EventBus.
|
||||
# Der TriggerDispatcher findet passende Automations und führt sie aus.
|
||||
```
|
||||
|
||||
### 10.2 UI-Event-Trigger (ephemeral)
|
||||
|
||||
Flüchtige UI-Events (z.B. `ui.contact_selected`) laufen über den **EventBus** — nicht über die Outbox. Siehe Kapitel 18.
|
||||
UI-Events (`ui.*`) sind **ephemeral** — sie werden **nicht** in die Outbox geschrieben. Sie fließen direkt vom Frontend über WebSocket → EventBus → TriggerDispatcher.
|
||||
|
||||
```python
|
||||
event_bus = get_event_bus()
|
||||
event_bus.subscribe("ui.contact_selected", self._on_contact_selected)
|
||||
Der TriggerDispatcher erkennt UI-Events am `ui.` Prefix und setzt `trigger_type="ui"` beim Dispatch. `AutomationDefinition`-Einträge mit `trigger_type="ui"` werden automatisch gematcht.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
Frontend (WebSocket) → ws_helpers → EventBus.publish("ui.contact_selected", payload)
|
||||
↓
|
||||
TriggerDispatcher._on_event(payload)
|
||||
→ is_ui_event("ui.contact_selected") = True
|
||||
→ trigger_type = "ui"
|
||||
↓
|
||||
DB-Query: AutomationDefinition WHERE trigger_type='ui' AND trigger_config->>'event_name' = 'ui.contact_selected'
|
||||
↓
|
||||
run_automation(trigger_type="ui", trigger_data=payload)
|
||||
```
|
||||
|
||||
### 10.3 Cron-Trigger
|
||||
**⚠️ Kritische Regel:** UI-Events dürfen **niemals** über `enqueue_outbox_event()` gepublished werden. Verwende ausschließlich `event_bus.publish()`:
|
||||
```python
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
Cron-Jobs werden im Manifest deklariert und vom Worker ausgeführt:
|
||||
# RICHTIG — ephemeral, nur EventBus
|
||||
event_bus = get_event_bus()
|
||||
await event_bus.publish("ui.contact_selected", {
|
||||
"event_name": "ui.contact_selected",
|
||||
"tenant_id": str(tenant_id),
|
||||
"data": {"contact_id": str(contact_id)},
|
||||
})
|
||||
|
||||
# FALSCH — würde UI-Event in Outbox persistieren
|
||||
# await enqueue_outbox_event(db, tenant_id, "ui.contact_selected", {...}) # ❌
|
||||
```
|
||||
|
||||
**Beispiel — UI-Event-Automation erstellen:**
|
||||
```python
|
||||
POST /api/v1/automation/
|
||||
{
|
||||
"name": "track-contact-selection",
|
||||
"trigger_type": "ui",
|
||||
"trigger_config": {"event_name": "ui.contact_selected"},
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{"type": "api_call", "config": {"url": "https://analytics.example.com/track", "method": "POST"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Cron-Trigger (schedule)
|
||||
|
||||
Cron-Jobs werden über `AutomationCronJob`-Einträge in der Datenbank verwaltet. Der ARQ-Worker führt `scheduler_tick` alle 5 Minuten aus, liest fällige Cron-Jobs und enqueued `run_automation` mit `trigger_type="scheduled"`.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
ARQ Cron (alle 5min) → scheduler_tick(ctx)
|
||||
↓
|
||||
DB-Query: AutomationCronJob WHERE is_active=True AND next_run_at <= now()
|
||||
↓
|
||||
enqueue_job("run_automation", job.target_id, trigger_type="scheduled")
|
||||
↓
|
||||
run_automation(trigger_type="scheduled", trigger_data={})
|
||||
↓
|
||||
Update: last_run_at = now, next_run_at = calculate_next_run(cron_expression)
|
||||
```
|
||||
|
||||
**Beispiel — Cron-Job im Plugin-Manifest deklarieren:**
|
||||
```python
|
||||
from app.plugins.manifest import CronJobContribution
|
||||
|
||||
@@ -1255,19 +1350,80 @@ cron_jobs=[
|
||||
],
|
||||
```
|
||||
|
||||
### 10.4 Manuelle Trigger
|
||||
**Beispiel — Cron-Job zur Automation verknüpfen:**
|
||||
```python
|
||||
POST /api/v1/automation/cron-jobs/
|
||||
{
|
||||
"name": "daily-report-auto",
|
||||
"cron_expression": "0 9 * * *",
|
||||
"job_type": "automation",
|
||||
"target_id": "<automation-definition-uuid>",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
Manuelle Trigger laufen über API-Routes oder Automation-Templates:
|
||||
### 10.4 Manuelle Trigger (manual)
|
||||
|
||||
Manuelle Trigger werden über die API-Route `POST /api/v1/automation/{id}/execute` ausgelöst. Die Route ruft `run_automation` direkt mit `trigger_type="manual"` auf.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
HTTP POST /api/v1/automation/{id}/execute
|
||||
↓
|
||||
AutomationRun (status="running") wird in DB erstellt
|
||||
↓
|
||||
run_automation(trigger_type="manual", trigger_data={"triggered_by": user_id})
|
||||
↓
|
||||
AutomationRun wird mit Ergebnis aktualisiert (status="completed"/"partial")
|
||||
```
|
||||
|
||||
**Beispiel — Manuelle Automation auslösen:**
|
||||
```bash
|
||||
curl -X POST https://crm.media-on.de/api/v1/automation/{id}/execute \
|
||||
-H "Cookie: session=..."
|
||||
```
|
||||
|
||||
**Beispiel — Eigene Manual-Trigger-Route im Plugin:**
|
||||
```python
|
||||
@router.post("/api/v1/my-plugin/run-sync")
|
||||
async def run_manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
"""Manueller Trigger — Benutzer startet Sync von der UI."""
|
||||
# Business logic
|
||||
return {"status": "started"}
|
||||
# Business logic oder: run_automation direkt aufrufen
|
||||
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||
result = await run_automation(
|
||||
ctx={},
|
||||
automation_id=str(automation_id),
|
||||
trigger_type="manual",
|
||||
trigger_data={"triggered_by": str(user_id)},
|
||||
)
|
||||
return {"status": "started", "result": result}
|
||||
```
|
||||
|
||||
**Wichtig:** Durable Domain Events (Outbox) und ephemere UI-Events (EventBus) strikt trennen. Siehe Kapitel 8.5 Entscheidungsregel.
|
||||
### 10.5 TriggerDispatcher — Der generische Event→Automation Dispatcher
|
||||
|
||||
Der `TriggerDispatcher` (`app/core/trigger_dispatcher.py`) ist das zentrale Bindeglied zwischen EventBus und Automation-Engine. Er ersetzt frühere hardcodierte Event-Handler-Stubs (`on_contact_created` etc.) durch eine generische Wildcard-Subscription.
|
||||
|
||||
**Registrierung:**
|
||||
- In `app/main.py` lifespan (API-Container)
|
||||
- In `app/core/worker.py` `on_startup` (Worker-Container)
|
||||
|
||||
```python
|
||||
from app.core.trigger_dispatcher import register_trigger_dispatcher
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
event_bus = get_event_bus()
|
||||
register_trigger_dispatcher(event_bus)
|
||||
# Dispatcher abonniert '*' auf dem EventBus
|
||||
```
|
||||
|
||||
**Matching-Logik:**
|
||||
1. Jedes Event auf dem EventBus erreicht `_on_event(payload)`
|
||||
2. `event_name` wird aus `payload["event_name"]` extrahiert
|
||||
3. `ui.*` Prefix → `trigger_type="ui"`, sonst `trigger_type="event"`
|
||||
4. DB-Query: aktive `AutomationDefinition` mit passendem `trigger_type` und `trigger_config.event_name`
|
||||
5. Jede Match-Definition wird über `run_automation` ausgeführt
|
||||
|
||||
**Wichtig:** Der Dispatcher ist **generisch** — es gibt keine hardcodierte Event-Liste. Jedes registrierte Outbox-Event kann eine Automation triggern, sobald eine `AutomationDefinition` mit passendem `trigger_config.event_name` existiert.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user