feat(B-HOOK): Lifecycle Hooks + Outbox Events — 55 neue Hooks, 10 Domain Events
Check Cross-Plugin Imports / check (push) Has been cancelled

B-HOOK-CORE: Company Hooks (6: before/after create/update/delete)
B-HOOK-MAIL: Mail Hooks (5: after_receive, before/after_delete, before/after_move)
B-HOOK-DMS: DMS Hooks (10: after_upload, before/after_update/delete/restore, folder CRUD)
B-HOOK-CAL: Calendar Hooks (4: before/after update/delete)
B-HOOK-TASK: Task Hooks (6: before/after create/update/delete)
B-HOOK-COMM: Communication Hooks (8: conversation create, message/edit/delete)
B-HOOK-AI: Agent Hooks (2: before/after_run)
B-HOOK-WF: Workflow Hooks (4: before/after_start, after_complete/cancel)
B-HOOK-TAG: Tag Hooks (8: create/assign/unassign/delete)
B-HOOK-SEARCH: Search Filters (2: before/after_search)

B-EVT-OUTBOX: 10 Domain Events (task.completed, file.created/deleted/restored, mail.received, workflow.started/completed/cancelled, agent.run_started/completed)

B-HOOK-TEST: 79 Tests in test_lifecycle_hooks.py — alle grün
B-HOOK-DOC: Plugin-Dev-Guide Kapitel 8 aktualisiert (Hook-Liste + Outbox Event-Liste)
This commit is contained in:
Agent Zero
2026-08-13 20:54:15 +02:00
parent b231c2d0d3
commit ae228bb484
12 changed files with 1145 additions and 0 deletions
@@ -128,6 +128,21 @@ async def run_agent(
# Reactive mode: use trigger_data as context
context_data = trigger_data or {}
# ── Lifecycle: before run ──
from app.core.hooks import do_action
await do_action("agent.before_run", agent_id=str(agent.id), tenant_id=str(agent.tenant_id), trigger_type=trigger_type)
from app.core.outbox import enqueue_outbox_event
async with factory() as db:
await enqueue_outbox_event(
db,
agent.tenant_id,
'agent.run_started',
{'agent_id': str(agent.id), 'tenant_id': str(agent.tenant_id), 'trigger_type': trigger_type},
aggregate_type='agent',
aggregate_id=agent.id,
)
await db.commit()
# Call LLM via LiteLLM
result_data: dict[str, Any] = {
"agent_id": str(agent.id),
@@ -239,6 +254,21 @@ async def run_agent(
result_data["status"] = "failed"
result_data["error"] = str(e)
# ── Lifecycle: after run ──
from app.core.hooks import do_action
await do_action("agent.after_run", agent_id=str(agent.id), tenant_id=str(agent.tenant_id), status=result_data.get("status"), result=result_data)
from app.core.outbox import enqueue_outbox_event
async with factory() as db:
await enqueue_outbox_event(
db,
agent.tenant_id,
'agent.run_completed',
{'agent_id': str(agent.id), 'tenant_id': str(agent.tenant_id), 'status': result_data.get('status'), 'cost_usd': result_data.get('cost_usd', 0.0)},
aggregate_type='agent',
aggregate_id=agent.id,
)
await db.commit()
# Save result to AgentRun
try:
async with factory() as db:
+8
View File
@@ -612,6 +612,8 @@ async def update_entry(
if not has_write:
raise HTTPException(403, detail={"detail": "No write permission", "code": "forbidden"})
from app.core.hooks import do_action
await do_action("calendar.before_update", body=body, tenant_id=tenant_id, user_id=user_id, entry_id=entry_id)
# Apply updates
if body.title is not None:
entry.title = body.title
@@ -646,6 +648,8 @@ async def update_entry(
await db.flush()
await db.refresh(entry)
from app.core.hooks import do_action
await do_action("calendar.after_update", entry_id=str(entry.id), tenant_id=tenant_id, user_id=user_id)
return _entry_to_dict(entry)
@@ -664,8 +668,12 @@ async def delete_entry(
has_write = await _check_write_permission(db, entry.calendar_id, user_id, role)
if not has_write:
raise HTTPException(403, detail={"detail": "No write permission", "code": "forbidden"})
from app.core.hooks import do_action
await do_action("calendar.before_delete", tenant_id=tenant_id, user_id=user_id, entry_id=entry_id)
entry.deleted_at = datetime.now(UTC)
await db.flush()
from app.core.hooks import do_action
await do_action("calendar.after_delete", tenant_id=tenant_id, user_id=user_id, entry_id=entry_id)
return Response(status_code=204)
+55
View File
@@ -257,6 +257,10 @@ async def create_folder(
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
)
# Lifecycle hook: dms.folder.before_create
from app.core.hooks import do_action
await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
folder = Folder(
tenant_id=tenant_id,
name=body.name,
@@ -266,6 +270,9 @@ async def create_folder(
db.add(folder)
await db.flush()
# Lifecycle hook: dms.folder.after_create
await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id)
# Build path
path = body.name
if parent_id is not None:
@@ -441,6 +448,10 @@ async def delete_folder(
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.folder.before_delete
from app.core.hooks import do_action
await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
from datetime import UTC, datetime
now = datetime.now(UTC)
@@ -476,6 +487,11 @@ async def delete_folder(
)
await db.flush()
# Lifecycle hook: dms.folder.after_delete
from app.core.hooks import do_action
await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -597,6 +613,14 @@ async def upload_file(
db.add(dms_file)
await db.flush()
# Lifecycle hook: dms.after_upload
from app.core.hooks import do_action
await do_action("dms.after_upload", {'id': str(dms_file.id), 'name': dms_file.name, 'folder_id': str(dms_file.folder_id) if dms_file.folder_id else None, 'mime_type': dms_file.mime_type, 'size_bytes': dms_file.size_bytes}, db=db, tenant_id=tenant_id, user_id=user_id)
# Outbox event: file.created
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'file.created', {'file_id': str(dms_file.id), 'tenant_id': str(tenant_id), 'name': dms_file.name, 'mime_type': dms_file.mime_type, 'size_bytes': dms_file.size_bytes}, aggregate_type='dms_file', aggregate_id=dms_file.id)
return {
"id": str(dms_file.id),
"name": dms_file.name,
@@ -763,6 +787,10 @@ async def update_file(
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.before_update
from app.core.hooks import do_action
await do_action("dms.before_update", body.model_dump(exclude_unset=True), db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
@@ -785,6 +813,10 @@ async def update_file(
await db.flush()
await db.refresh(dms_file)
# Lifecycle hook: dms.after_update
from app.core.hooks import do_action
await do_action("dms.after_update", {'id': str(dms_file.id), 'name': dms_file.name}, db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
return {
"id": str(dms_file.id),
"name": dms_file.name,
@@ -824,10 +856,22 @@ async def delete_file(
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "delete", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.before_delete
from app.core.hooks import do_action
await do_action("dms.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
from datetime import UTC, datetime
dms_file.deleted_at = datetime.now(UTC)
await db.flush()
# Lifecycle hook: dms.after_delete
await do_action("dms.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
# Outbox event: file.deleted
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'file.deleted', {'file_id': str(fid), 'tenant_id': str(tenant_id)}, aggregate_type='dms_file', aggregate_id=fid)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -857,10 +901,21 @@ async def restore_file(
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.before_restore
from app.core.hooks import do_action
await do_action("dms.before_restore", db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
dms_file.deleted_at = None
await db.flush()
await db.refresh(dms_file)
# Lifecycle hook: dms.after_restore
await do_action("dms.after_restore", {'id': str(dms_file.id), 'name': dms_file.name}, db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
# Outbox event: file.restored
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'file.restored', {'file_id': str(fid), 'tenant_id': str(tenant_id)}, aggregate_type='dms_file', aggregate_id=fid)
return {
"id": str(dms_file.id),
"name": dms_file.name,
@@ -264,8 +264,11 @@ async def create_conversation(
created_by_type="user",
metadata_={},
)
from app.core.hooks import do_action
await do_action("comm.conversation.before_create", tenant_id=tenant_id, user_id=user_id)
db.add(conv)
await db.flush()
await do_action("comm.conversation.after_create", conversation_id=conv.id, tenant_id=tenant_id, user_id=user_id)
# Add creator as admin
creator = CommParticipant(
@@ -669,8 +672,11 @@ async def send_message(
except ValueError:
pass
from app.core.hooks import do_action
await do_action("comm.before_message", conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id)
db.add(msg)
await db.flush()
await do_action("comm.after_message", message_id=msg.id, conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id)
# Create blocks
if blocks:
@@ -872,10 +878,14 @@ async def edit_message(
)
db.add(edit)
from app.core.hooks import do_action
await do_action("comm.before_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id)
# Update message
msg.content = new_content
msg.edited_at = datetime.now(timezone.utc)
await db.flush()
await do_action("comm.after_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id)
return message_to_response(msg)
@@ -891,8 +901,11 @@ async def delete_message(
msg = result.scalar_one_or_none()
if msg is None:
return False
from app.core.hooks import do_action
await do_action("comm.before_delete", message_id=message_id)
msg.deleted_at = datetime.now(timezone.utc)
await db.flush()
await do_action("comm.after_delete", message_id=message_id)
return True
+24
View File
@@ -824,6 +824,14 @@ async def imap_sync_folder(
'from_address': from_addr,
})
# Lifecycle hook: mail.after_receive
from app.core.hooks import do_action
await do_action("mail.after_receive", {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'account_id': str(account.id), 'folder_id': str(folder.id), 'subject': subject, 'from_address': from_addr}, db=db, tenant_id=tenant_id)
# Outbox event: mail.received
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'mail.received', {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'account_id': str(account.id), 'folder_id': str(folder.id), 'subject': subject, 'from_address': from_addr}, aggregate_type='mail', aggregate_id=mail.id)
# Save attachments
for att_data in attachments:
try:
@@ -2343,6 +2351,10 @@ async def imap_delete_mail(
logger.warning("imap_delete_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id)
return
# Lifecycle hook: mail.before_delete
from app.core.hooks import do_action
await do_action("mail.before_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db)
# Find Trash folder on IMAP server (only needed for non-permanent delete)
trash_folder_name = None
if not permanent:
@@ -2409,6 +2421,10 @@ async def imap_delete_mail(
await client.expunge()
logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash via COPY+DELETE", mail_id, uid_str)
# Lifecycle hook: mail.after_delete
from app.core.hooks import do_action
await do_action("mail.after_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db)
except Exception as exc:
logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc)
raise
@@ -2487,6 +2503,10 @@ async def imap_move_mail(
logger.warning("imap_move_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id)
return
# Lifecycle hook: mail.before_move
from app.core.hooks import do_action
await do_action("mail.before_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db)
password = await get_account_password(account)
client = None
@@ -2532,6 +2552,10 @@ async def imap_move_mail(
logger.info("imap_move_mail: moved mail %s (UID %s) via COPY+DELETE", mail_id, uid_str)
# Lifecycle hook: mail.after_move
from app.core.hooks import do_action
await do_action("mail.after_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db)
except Exception as exc:
logger.warning("imap_move_mail: failed for mail %s: %s", mail_id, exc)
raise
+16
View File
@@ -95,9 +95,13 @@ async def create_tag(
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
from app.core.hooks import do_action
await do_action("tag.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color, owner_id=user_id)
db.add(tag)
await db.flush()
from app.core.hooks import do_action
await do_action("tag.after_create", {"id": str(tag.id), "name": tag.name, "color": tag.color}, db=db, tenant_id=tenant_id, user_id=user_id)
return {
"id": str(tag.id),
"name": tag.name,
@@ -185,6 +189,8 @@ async def assign_tag(
"already_assigned": True,
}
from app.core.hooks import do_action
await do_action("tag.before_assign", body, db=db, tenant_id=tenant_id, user_id=user_id)
assignment = TagAssignment(
tenant_id=tenant_id,
tag_id=tag_id,
@@ -193,6 +199,8 @@ async def assign_tag(
)
db.add(assignment)
await db.flush()
from app.core.hooks import do_action
await do_action("tag.after_assign", {"id": str(assignment.id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id)}, db=db, tenant_id=tenant_id, user_id=user_id)
return {
"id": str(assignment.id),
"tag_id": str(tag_id),
@@ -225,7 +233,11 @@ async def unassign_tag(
if assignment is None:
raise HTTPException(404, detail={"detail": "Assignment not found", "code": "not_found"})
from app.core.hooks import do_action
await do_action("tag.before_unassign", body, db=db, tenant_id=tenant_id, user_id=user_id)
await db.delete(assignment)
from app.core.hooks import do_action
await do_action("tag.after_unassign", {"tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id)}, db=db, tenant_id=tenant_id, user_id=user_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -244,6 +256,8 @@ async def delete_tag(
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
from app.core.hooks import do_action
await do_action("tag.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, tag_id=tag_id)
# Cascade delete assignments
await db.execute(
delete(TagAssignment).where(
@@ -251,6 +265,8 @@ async def delete_tag(
)
)
await db.delete(tag)
from app.core.hooks import do_action
await do_action("tag.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, tag_id=tag_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+16
View File
@@ -101,6 +101,8 @@ async def create_task(
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new task."""
from app.core.hooks import do_action
await do_action("task.before_create", data, db=db, tenant_id=tenant_id, user_id=user_id)
task = Task(
tenant_id=tenant_id,
title=data["title"],
@@ -116,6 +118,9 @@ async def create_task(
db.add(task)
await db.flush()
from app.core.hooks import do_action
await do_action("task.after_create", _task_to_dict(task), db=db, tenant_id=tenant_id, user_id=user_id)
# Publish task.created event
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
@@ -144,6 +149,8 @@ async def update_task(
if task is None:
return None
from app.core.hooks import do_action
await do_action("task.before_update", data, db=db, tenant_id=tenant_id, task_id=str(task_id))
if "title" in data and data["title"] is not None:
task.title = data["title"]
if "description" in data:
@@ -161,6 +168,8 @@ async def update_task(
await db.flush()
await db.refresh(task)
from app.core.hooks import do_action
await do_action("task.after_update", _task_to_dict(task), db=db, tenant_id=tenant_id, task_id=str(task_id))
return _task_to_dict(task)
@@ -172,8 +181,12 @@ async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID
task = result.scalar_one_or_none()
if task is None:
return False
from app.core.hooks import do_action
await do_action("task.before_delete", db=db, tenant_id=tenant_id, task_id=str(task_id))
task.deleted_at = datetime.now(timezone.utc)
await db.flush()
from app.core.hooks import do_action
await do_action("task.after_delete", db=db, tenant_id=tenant_id, task_id=str(task_id))
return True
@@ -210,6 +223,9 @@ async def update_task_status(
return None
task.status = new_status
await db.flush()
if new_status == "done":
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, "task.completed", {"task_id": str(task.id), "tenant_id": str(tenant_id), "title": task.title, "assigned_to": str(task.assigned_to) if task.assigned_to else None}, aggregate_type="task", aggregate_id=task.id)
return _task_to_dict(task)
@@ -101,6 +101,8 @@ async def hybrid_search(
query_text = f"{normalized_query} {' '.join(semantic_terms)}"
query_embedding = await generate_embedding(query_text, db=db, tenant_id=tenant_id)
from app.core.hooks import apply_filters
query_analysis = await apply_filters("search.before_search", query_analysis)
all_results: list[dict[str, Any]] = []
fetch_limit = limit * 2
@@ -132,6 +134,8 @@ async def hybrid_search(
all_results.append(result)
all_results.sort(key=lambda x: x.get("score", 0.0), reverse=True)
from app.core.hooks import apply_filters
all_results = await apply_filters("search.after_search", all_results)
return all_results[:limit]