Pārlūkot izejas kodu

feat: add event service

Jax Docker 1 mēnesi atpakaļ
vecāks
revīzija
e16e81ed1d
30 mainītis faili ar 782 papildinājumiem un 4 dzēšanām
  1. 31 0
      README.md
  2. 1 0
      deployments/docker/.env.example
  3. 30 0
      deployments/docker/docker-compose.yml
  4. 12 3
      libs/core-events/src/core_events/__init__.py
  5. 34 1
      libs/core-events/src/core_events/envelope.py
  6. 27 0
      services/api-gateway/app/api/routes.py
  7. 1 0
      services/api-gateway/app/bootstrap/settings.py
  8. 1 0
      services/api-gateway/app/infrastructure/proxy.py
  9. 37 0
      services/event-service/alembic.ini
  10. 38 0
      services/event-service/alembic/env.py
  11. 1 0
      services/event-service/alembic/versions/.gitkeep
  12. 73 0
      services/event-service/alembic/versions/20260425_0001_init_event_models.py
  13. 1 0
      services/event-service/app/__init__.py
  14. 1 0
      services/event-service/app/api/__init__.py
  15. 115 0
      services/event-service/app/api/routes.py
  16. 1 0
      services/event-service/app/application/__init__.py
  17. 97 0
      services/event-service/app/application/services.py
  18. 1 0
      services/event-service/app/bootstrap/__init__.py
  19. 17 0
      services/event-service/app/bootstrap/app.py
  20. 8 0
      services/event-service/app/bootstrap/settings.py
  21. 1 0
      services/event-service/app/db/__init__.py
  22. 5 0
      services/event-service/app/db/models/__init__.py
  23. 27 0
      services/event-service/app/db/models/event_record.py
  24. 28 0
      services/event-service/app/db/session.py
  25. 1 0
      services/event-service/app/domain/__init__.py
  26. 119 0
      services/event-service/app/domain/repositories.py
  27. 3 0
      services/event-service/app/main.py
  28. 1 0
      services/event-service/app/schemas/__init__.py
  29. 44 0
      services/event-service/app/schemas/event.py
  30. 26 0
      services/event-service/pyproject.toml

+ 31 - 0
README.md

@@ -22,6 +22,7 @@
 - `skill-service`
 - `human-service`
 - `knowledge-service`
+- `event-service`
 - `tool-service`
 
 每个服务都提供了最小 `FastAPI` 启动入口和健康检查接口,数据库相关服务也已经带上了 `SQLAlchemy` 模型骨架与 Alembic 目录。
@@ -57,6 +58,7 @@ pip install -e .\services\team-service
 pip install -e .\services\skill-service
 pip install -e .\services\human-service
 pip install -e .\services\knowledge-service
+pip install -e .\services\event-service
 pip install -e .\services\tool-service
 ```
 
@@ -203,6 +205,7 @@ services/
   skill-service/
   human-service/
   knowledge-service/
+  event-service/
   tool-service/
 libs/
   core-domain/
@@ -486,6 +489,32 @@ Invoke-RestMethod -Method Post `
 
 Through `api-gateway`, use `/gateway/knowledge/**`.
 
+## Event Service APIs
+
+`event-service` stores platform events with delivery status so services can use
+a durable outbox pattern now, and later swap delivery to Kafka/RabbitMQ behind
+the same API.
+
+Publish an event:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8013/events `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","event_type":"run.created","source_service":"runtime-service","aggregate_type":"workflow_run","aggregate_id":"run-id","payload_json":{"run_id":"run-id"}}'
+```
+
+Claim pending events for a delivery worker:
+
+```powershell
+Invoke-RestMethod -Method Post `
+  -Uri http://127.0.0.1:8013/events/claim-pending `
+  -ContentType "application/json" `
+  -Body '{"tenant_id":"t1","limit":50}'
+```
+
+Through `api-gateway`, use `/gateway/events/**`.
+
 Execute an agent run without calling an external model:
 
 ```powershell
@@ -860,6 +889,7 @@ $env:AGENT_PLATFORM_SMOKE_RUNTIME_URL="http://127.0.0.1:8000/gateway/runtime"
 - `/gateway/skills/**` -> `skill-service /skills/**`
 - `/gateway/human/**` -> `human-service /human/**`
 - `/gateway/knowledge/**` -> `knowledge-service /knowledge/**`
+- `/gateway/events/**` -> `event-service /events/**`
 - `/gateway/tools/**` -> `tool-service /tools/**`
 - `/gateway/models/**` -> `model-gateway-service /models/**`
 - `/gateway/code/**` -> `code-runner-service /code/**`
@@ -1104,6 +1134,7 @@ Important notes:
 - `skill-service` stores skill definitions, versions, marketplace-style installations, and skill execution runs under `/data`
 - `human-service` stores human approval, input, pause/resume, and takeover task records under `/data`
 - `knowledge-service` stores knowledge bases, documents, chunks, and local retrieval metadata under `/data`
+- `event-service` stores platform events and delivery status under `/data`
 - `agent-worker` has no exposed port and can be scaled independently; set `AGENT_PLATFORM_AGENT_WORKER_DRY_RUN=true` for no-key local smoke runs
 - `runtime-worker` has no exposed port and can be scaled independently; prefer PostgreSQL for real multi-worker write concurrency
 - `runtime-service` automatically resolves internal URLs to `workflow-service`, `tool-service`, `model-gateway-service`, and `code-runner-service`

+ 1 - 0
deployments/docker/.env.example

@@ -6,3 +6,4 @@ AGENT_PLATFORM_AUTH_REQUIRED=false
 AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS=1
 AGENT_PLATFORM_WORKER_LEASE_SECONDS=300
 AGENT_PLATFORM_AGENT_WORKER_DRY_RUN=false
+AGENT_PLATFORM_TEAM_WORKER_DRY_RUN=true

+ 30 - 0
deployments/docker/docker-compose.yml

@@ -282,6 +282,26 @@ services:
       timeout: 5s
       retries: 5
 
+  event-service:
+    build:
+      context: ../..
+      dockerfile: deployments/docker/python-service.Dockerfile
+      args:
+        SERVICE_PATH: services/event-service
+    container_name: agent-platform-event-service
+    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8013"]
+    environment:
+      AGENT_PLATFORM_DATABASE_URL: sqlite:////data/event_service.db
+    ports:
+      - "8013:8013"
+    volumes:
+      - event_service_data:/data
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8013/events/health').read()"]
+      interval: 15s
+      timeout: 5s
+      retries: 5
+
   runtime-service:
     build:
       context: ../..
@@ -302,6 +322,7 @@ services:
       AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
       AGENT_PLATFORM_HUMAN_SERVICE_URL: http://human-service:8011
       AGENT_PLATFORM_KNOWLEDGE_SERVICE_URL: http://knowledge-service:8012
+      AGENT_PLATFORM_EVENT_SERVICE_URL: http://event-service:8013
     ports:
       - "8003:8003"
     volumes:
@@ -327,6 +348,8 @@ services:
         condition: service_started
       knowledge-service:
         condition: service_started
+      event-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8003/runtime/health').read()"]
       interval: 15s
@@ -347,6 +370,7 @@ services:
       AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
       AGENT_PLATFORM_CODE_RUNNER_SERVICE_URL: http://code-runner-service:8006
       AGENT_PLATFORM_KNOWLEDGE_SERVICE_URL: http://knowledge-service:8012
+      AGENT_PLATFORM_EVENT_SERVICE_URL: http://event-service:8013
       AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS: ${AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS:-1}
       AGENT_PLATFORM_WORKER_LEASE_SECONDS: ${AGENT_PLATFORM_WORKER_LEASE_SECONDS:-300}
     volumes:
@@ -362,6 +386,8 @@ services:
         condition: service_started
       knowledge-service:
         condition: service_started
+      event-service:
+        condition: service_started
 
   api-gateway:
     build:
@@ -385,6 +411,7 @@ services:
       AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
       AGENT_PLATFORM_HUMAN_SERVICE_URL: http://human-service:8011
       AGENT_PLATFORM_KNOWLEDGE_SERVICE_URL: http://knowledge-service:8012
+      AGENT_PLATFORM_EVENT_SERVICE_URL: http://event-service:8013
       AGENT_PLATFORM_AUTH_REQUIRED: ${AGENT_PLATFORM_AUTH_REQUIRED:-false}
     ports:
       - "8000:8000"
@@ -415,6 +442,8 @@ services:
         condition: service_started
       knowledge-service:
         condition: service_started
+      event-service:
+        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
       interval: 15s
@@ -429,6 +458,7 @@ volumes:
   skill_service_data:
   human_service_data:
   knowledge_service_data:
+  event_service_data:
   workflow_service_data:
   session_service_data:
   runtime_service_data:

+ 12 - 3
libs/core-events/src/core_events/__init__.py

@@ -1,4 +1,13 @@
-from .envelope import EventEnvelope
-
-__all__ = ["EventEnvelope"]
+from .envelope import (
+    EventDeliveryStatus,
+    EventEnvelope,
+    EventPublishContract,
+    EventRecordContract,
+)
 
+__all__ = [
+    "EventDeliveryStatus",
+    "EventEnvelope",
+    "EventPublishContract",
+    "EventRecordContract",
+]

+ 34 - 1
libs/core-events/src/core_events/envelope.py

@@ -1,13 +1,46 @@
 from datetime import datetime
+from typing import Literal
 from uuid import uuid4
 
 from pydantic import BaseModel, Field
 
+from core_shared import JSONValue
+
+
+EventDeliveryStatus = Literal["pending", "published", "failed", "cancelled"]
+
 
 class EventEnvelope(BaseModel):
     event_id: str = Field(default_factory=lambda: str(uuid4()))
     event_type: str
     source_service: str
-    payload: dict = Field(default_factory=dict)
+    tenant_id: str | None = None
+    aggregate_type: str | None = None
+    aggregate_id: str | None = None
+    correlation_id: str | None = None
+    causation_id: str | None = None
+    payload_json: dict[str, JSONValue] = Field(default_factory=dict)
+    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
     event_time: datetime = Field(default_factory=datetime.utcnow)
 
+
+class EventRecordContract(EventEnvelope):
+    id: str
+    status: EventDeliveryStatus
+    publish_attempt_count: int = 0
+    published_time: datetime | None = None
+    last_error_message: str | None = None
+    created_time: datetime
+
+
+class EventPublishContract(BaseModel):
+    event_type: str
+    source_service: str
+    tenant_id: str | None = None
+    aggregate_type: str | None = None
+    aggregate_id: str | None = None
+    correlation_id: str | None = None
+    causation_id: str | None = None
+    payload_json: dict[str, JSONValue] = Field(default_factory=dict)
+    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
+    event_time: datetime | None = None

+ 27 - 0
services/api-gateway/app/api/routes.py

@@ -187,6 +187,12 @@ def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName,
             path_prefix="/knowledge",
             health_path="/knowledge/health",
         ),
+        "event-service": ProxyTarget(
+            service_name="event-service",
+            base_url=settings.event_service_url,
+            path_prefix="/events",
+            health_path="/events/health",
+        ),
     }
 
 
@@ -395,6 +401,27 @@ async def proxy_knowledge_service(
     )
 
 
+@router.api_route(
+    "/gateway/events",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+@router.api_route(
+    "/gateway/events/{path:path}",
+    methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+)
+async def proxy_event_service(
+    request: Request,
+    path: str = "",
+    settings: ApiGatewaySettings = Depends(get_gateway_settings),
+    proxy: ServiceProxy = Depends(get_service_proxy),
+) -> Response:
+    return await proxy.forward(
+        request=request,
+        target=build_proxy_targets(settings)["event-service"],
+        path=path,
+    )
+
+
 @router.api_route(
     "/gateway/tools",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"],

+ 1 - 0
services/api-gateway/app/bootstrap/settings.py

@@ -17,6 +17,7 @@ class ApiGatewaySettings(ServiceSettings):
     skill_service_url: str = "http://127.0.0.1:8010"
     human_service_url: str = "http://127.0.0.1:8011"
     knowledge_service_url: str = "http://127.0.0.1:8012"
+    event_service_url: str = "http://127.0.0.1:8013"
     proxy_timeout_seconds: float = 30.0
     downstream_health_timeout_seconds: float = 2.0
     auth_required: bool = False

+ 1 - 0
services/api-gateway/app/infrastructure/proxy.py

@@ -25,6 +25,7 @@ ProxyServiceName = Literal[
     "skill-service",
     "human-service",
     "knowledge-service",
+    "event-service",
 ]
 
 

+ 37 - 0
services/event-service/alembic.ini

@@ -0,0 +1,37 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = sqlite:///./event_service.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 38 - 0
services/event-service/alembic/env.py

@@ -0,0 +1,38 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app.db.models import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+    with connectable.connect() as connection:
+        context.configure(connection=connection, target_metadata=target_metadata)
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 1 - 0
services/event-service/alembic/versions/.gitkeep

@@ -0,0 +1 @@
+

+ 73 - 0
services/event-service/alembic/versions/20260425_0001_init_event_models.py

@@ -0,0 +1,73 @@
+"""init event models
+
+Revision ID: 20260425_0001
+Revises:
+Create Date: 2026-04-25 23:40:00
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "20260425_0001"
+down_revision: str | None = None
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "event_record",
+        sa.Column("event_id", sa.String(length=36), nullable=False),
+        sa.Column("event_type", sa.String(length=128), nullable=False),
+        sa.Column("source_service", sa.String(length=64), nullable=False),
+        sa.Column("aggregate_type", sa.String(length=64), nullable=True),
+        sa.Column("aggregate_id", sa.String(length=64), nullable=True),
+        sa.Column("correlation_id", sa.String(length=64), nullable=True),
+        sa.Column("causation_id", sa.String(length=64), nullable=True),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("payload_json", sa.JSON(), nullable=False),
+        sa.Column("metadata_json", sa.JSON(), nullable=False),
+        sa.Column("event_time", sa.DateTime(), nullable=False),
+        sa.Column("published_time", sa.DateTime(), nullable=True),
+        sa.Column("publish_attempt_count", sa.Integer(), nullable=False),
+        sa.Column("last_error_message", sa.Text(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("tenant_id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("event_id"),
+    )
+    op.create_index("ix_event_record_event_id", "event_record", ["event_id"])
+    op.create_index("ix_event_record_event_type", "event_record", ["event_type"])
+    op.create_index("ix_event_record_source_service", "event_record", ["source_service"])
+    op.create_index("ix_event_record_aggregate_type", "event_record", ["aggregate_type"])
+    op.create_index("ix_event_record_aggregate_id", "event_record", ["aggregate_id"])
+    op.create_index("ix_event_record_correlation_id", "event_record", ["correlation_id"])
+    op.create_index("ix_event_record_causation_id", "event_record", ["causation_id"])
+    op.create_index("ix_event_record_status", "event_record", ["status"])
+    op.create_index("ix_event_record_event_time", "event_record", ["event_time"])
+    op.create_index("ix_event_record_published_time", "event_record", ["published_time"])
+    op.create_index("ix_event_record_tenant_id", "event_record", ["tenant_id"])
+
+
+def downgrade() -> None:
+    op.drop_index("ix_event_record_tenant_id", table_name="event_record")
+    op.drop_index("ix_event_record_published_time", table_name="event_record")
+    op.drop_index("ix_event_record_event_time", table_name="event_record")
+    op.drop_index("ix_event_record_status", table_name="event_record")
+    op.drop_index("ix_event_record_causation_id", table_name="event_record")
+    op.drop_index("ix_event_record_correlation_id", table_name="event_record")
+    op.drop_index("ix_event_record_aggregate_id", table_name="event_record")
+    op.drop_index("ix_event_record_aggregate_type", table_name="event_record")
+    op.drop_index("ix_event_record_source_service", table_name="event_record")
+    op.drop_index("ix_event_record_event_type", table_name="event_record")
+    op.drop_index("ix_event_record_event_id", table_name="event_record")
+    op.drop_table("event_record")

+ 1 - 0
services/event-service/app/__init__.py

@@ -0,0 +1 @@
+

+ 1 - 0
services/event-service/app/api/__init__.py

@@ -0,0 +1 @@
+

+ 115 - 0
services/event-service/app/api/routes.py

@@ -0,0 +1,115 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from core_domain import ServiceHealth
+from core_events import EventDeliveryStatus
+
+from app.application.services import EventApplicationService
+from app.db.session import get_db
+from app.domain.repositories import EventRecordRepository
+from app.schemas.event import (
+    EventBatchPublishRequest,
+    EventBatchPublishResponse,
+    EventDeliveryStatusUpdateRequest,
+    EventPublishRequest,
+    EventRecordResponse,
+    EventStatsResponse,
+    PendingEventClaimRequest,
+)
+
+router = APIRouter()
+
+
+def get_event_application_service(db: Session = Depends(get_db)) -> EventApplicationService:
+    return EventApplicationService(event_repository=EventRecordRepository(db))
+
+
+@router.get("/health", response_model=ServiceHealth)
+def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
+    db.execute(text("SELECT 1"))
+    return ServiceHealth(service="event-service", status="ok", database="ok")
+
+
+@router.post("", response_model=EventRecordResponse)
+def publish_event(
+    payload: EventPublishRequest,
+    service: EventApplicationService = Depends(get_event_application_service),
+) -> EventRecordResponse:
+    return EventRecordResponse.from_entity(service.publish_event(payload))
+
+
+@router.post("/batch", response_model=EventBatchPublishResponse)
+def publish_batch(
+    payload: EventBatchPublishRequest,
+    service: EventApplicationService = Depends(get_event_application_service),
+) -> EventBatchPublishResponse:
+    events = service.publish_batch(payload)
+    return EventBatchPublishResponse(
+        events=[EventRecordResponse.from_entity(item) for item in events],
+        count=len(events),
+    )
+
+
+@router.get("", response_model=list[EventRecordResponse])
+def list_events(
+    tenant_id: str = Query(...),
+    event_type: str | None = Query(default=None),
+    source_service: str | None = Query(default=None),
+    aggregate_type: str | None = Query(default=None),
+    aggregate_id: str | None = Query(default=None),
+    correlation_id: str | None = Query(default=None),
+    status: EventDeliveryStatus | None = Query(default=None),
+    limit: int = Query(default=100, ge=1, le=500),
+    service: EventApplicationService = Depends(get_event_application_service),
+) -> list[EventRecordResponse]:
+    return [
+        EventRecordResponse.from_entity(item)
+        for item in service.list_events(
+            tenant_id=tenant_id,
+            event_type=event_type,
+            source_service=source_service,
+            aggregate_type=aggregate_type,
+            aggregate_id=aggregate_id,
+            correlation_id=correlation_id,
+            status=status,
+            limit=limit,
+        )
+    ]
+
+
+@router.post("/claim-pending", response_model=list[EventRecordResponse])
+def claim_pending_events(
+    payload: PendingEventClaimRequest,
+    service: EventApplicationService = Depends(get_event_application_service),
+) -> list[EventRecordResponse]:
+    return [
+        EventRecordResponse.from_entity(item)
+        for item in service.claim_pending_events(payload)
+    ]
+
+
+@router.post("/{event_record_id}/delivery-status", response_model=EventRecordResponse)
+def update_delivery_status(
+    event_record_id: str,
+    payload: EventDeliveryStatusUpdateRequest,
+    service: EventApplicationService = Depends(get_event_application_service),
+) -> EventRecordResponse:
+    entity = service.update_delivery_status(
+        event_record_id=event_record_id,
+        payload=payload,
+    )
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"event not found: {event_record_id}")
+    return EventRecordResponse.from_entity(entity)
+
+
+@router.get("/stats", response_model=EventStatsResponse)
+def event_stats(
+    tenant_id: str = Query(...),
+    service: EventApplicationService = Depends(get_event_application_service),
+) -> EventStatsResponse:
+    return EventStatsResponse(
+        tenant_id=tenant_id,
+        counts_json=service.build_stats(tenant_id=tenant_id),
+    )

+ 1 - 0
services/event-service/app/application/__init__.py

@@ -0,0 +1 @@
+

+ 97 - 0
services/event-service/app/application/services.py

@@ -0,0 +1,97 @@
+from datetime import datetime
+from uuid import uuid4
+
+from core_events import EventDeliveryStatus
+from core_shared import JSONValue
+
+from app.db.models import EventRecord
+from app.domain.repositories import EventRecordRepository
+from app.schemas.event import (
+    EventBatchPublishRequest,
+    EventDeliveryStatusUpdateRequest,
+    EventPublishRequest,
+    PendingEventClaimRequest,
+)
+
+
+class EventApplicationService:
+    def __init__(self, *, event_repository: EventRecordRepository) -> None:
+        self.event_repository = event_repository
+
+    def publish_event(self, payload: EventPublishRequest) -> EventRecord:
+        return self.event_repository.create(
+            tenant_id=payload.tenant_id or "public",
+            event_id=str(uuid4()),
+            event_type=payload.event_type,
+            source_service=payload.source_service,
+            aggregate_type=payload.aggregate_type,
+            aggregate_id=payload.aggregate_id,
+            correlation_id=payload.correlation_id,
+            causation_id=payload.causation_id,
+            payload_json=payload.payload_json,
+            metadata_json=payload.metadata_json,
+            event_time=payload.event_time or datetime.utcnow(),
+        )
+
+    def publish_batch(self, payload: EventBatchPublishRequest) -> list[EventRecord]:
+        return [self.publish_event(item) for item in payload.events]
+
+    def list_events(
+        self,
+        *,
+        tenant_id: str,
+        event_type: str | None = None,
+        source_service: str | None = None,
+        aggregate_type: str | None = None,
+        aggregate_id: str | None = None,
+        correlation_id: str | None = None,
+        status: EventDeliveryStatus | None = None,
+        limit: int = 100,
+    ) -> list[EventRecord]:
+        return self.event_repository.list_by_scope(
+            tenant_id=tenant_id,
+            event_type=event_type,
+            source_service=source_service,
+            aggregate_type=aggregate_type,
+            aggregate_id=aggregate_id,
+            correlation_id=correlation_id,
+            status=status,
+            limit=limit,
+        )
+
+    def claim_pending_events(self, payload: PendingEventClaimRequest) -> list[EventRecord]:
+        return self.event_repository.claim_pending(
+            tenant_id=payload.tenant_id,
+            limit=payload.limit,
+        )
+
+    def update_delivery_status(
+        self,
+        *,
+        event_record_id: str,
+        payload: EventDeliveryStatusUpdateRequest,
+    ) -> EventRecord | None:
+        entity = self.event_repository.get_by_id(
+            tenant_id=payload.tenant_id,
+            event_record_id=event_record_id,
+        )
+        if entity is None:
+            return None
+        return self.event_repository.update_delivery_status(
+            event_record_id=event_record_id,
+            status=payload.status,
+            last_error_message=payload.last_error_message,
+        )
+
+    def build_stats(self, *, tenant_id: str) -> dict[str, JSONValue]:
+        events = self.event_repository.list_by_scope(tenant_id=tenant_id, limit=500)
+        by_status: dict[str, int] = {}
+        by_type: dict[str, int] = {}
+        for event in events:
+            by_status[event.status] = by_status.get(event.status, 0) + 1
+            by_type[event.event_type] = by_type.get(event.event_type, 0) + 1
+        return {
+            "sample_size": len(events),
+            "by_status": by_status,
+            "by_type": by_type,
+        }

+ 1 - 0
services/event-service/app/bootstrap/__init__.py

@@ -0,0 +1 @@
+

+ 17 - 0
services/event-service/app/bootstrap/app.py

@@ -0,0 +1,17 @@
+from fastapi import FastAPI
+
+from app.api.routes import router
+from app.bootstrap.settings import EventServiceSettings
+from app.db.session import build_session_factory
+
+
+def create_app() -> FastAPI:
+    settings = EventServiceSettings()
+    app = FastAPI(
+        title="agent-platform event-service",
+        version="0.1.0",
+    )
+    app.state.settings = settings
+    app.state.session_factory = build_session_factory(settings)
+    app.include_router(router, prefix="/events", tags=["events"])
+    return app

+ 8 - 0
services/event-service/app/bootstrap/settings.py

@@ -0,0 +1,8 @@
+from core_shared import ServiceSettings
+
+
+class EventServiceSettings(ServiceSettings):
+    service_name: str = "event-service"
+    service_port: int = 8013
+    database_url: str = "sqlite:///./event_service.db"
+    default_claim_limit: int = 100

+ 1 - 0
services/event-service/app/db/__init__.py

@@ -0,0 +1 @@
+

+ 5 - 0
services/event-service/app/db/models/__init__.py

@@ -0,0 +1,5 @@
+from core_db import Base
+
+from .event_record import EventRecord
+
+__all__ = ["Base", "EventRecord"]

+ 27 - 0
services/event-service/app/db/models/event_record.py

@@ -0,0 +1,27 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, Text
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+from core_db import AuditMixin, Base, TenantMixin, VersionMixin
+from core_shared import JSONValue
+
+
+class EventRecord(TenantMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "event_record"
+
+    event_id: Mapped[str] = mapped_column(String(36), unique=True, index=True)
+    event_type: Mapped[str] = mapped_column(String(128), index=True)
+    source_service: Mapped[str] = mapped_column(String(64), index=True)
+    aggregate_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    aggregate_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    correlation_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    causation_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+    status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+    payload_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    metadata_json: Mapped[dict[str, JSONValue]] = mapped_column(JSON, default=dict)
+    event_time: Mapped[datetime] = mapped_column(DateTime, index=True)
+    published_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
+    publish_attempt_count: Mapped[int] = mapped_column(Integer, default=0)
+    last_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 28 - 0
services/event-service/app/db/session.py

@@ -0,0 +1,28 @@
+from collections.abc import Generator
+
+from fastapi import Request
+from sqlalchemy.orm import Session, sessionmaker
+
+from core_db import DatabaseSettings, create_engine_from_settings, create_session_factory
+
+from app.bootstrap.settings import EventServiceSettings
+
+
+def build_session_factory(settings: EventServiceSettings | None = None) -> sessionmaker[Session]:
+    resolved_settings = settings or EventServiceSettings()
+    engine = create_engine_from_settings(
+        DatabaseSettings(
+            database_url=resolved_settings.database_url,
+            echo_sql=resolved_settings.echo_sql,
+        )
+    )
+    return create_session_factory(engine)
+
+
+def get_db(request: Request) -> Generator[Session, None, None]:
+    session_factory: sessionmaker[Session] = request.app.state.session_factory
+    session = session_factory()
+    try:
+        yield session
+    finally:
+        session.close()

+ 1 - 0
services/event-service/app/domain/__init__.py

@@ -0,0 +1 @@
+

+ 119 - 0
services/event-service/app/domain/repositories.py

@@ -0,0 +1,119 @@
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from core_events import EventDeliveryStatus
+from core_shared import JSONValue
+
+from app.db.models import EventRecord
+
+
+class EventRecordRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(
+        self,
+        *,
+        tenant_id: str,
+        event_id: str,
+        event_type: str,
+        source_service: str,
+        aggregate_type: str | None,
+        aggregate_id: str | None,
+        correlation_id: str | None,
+        causation_id: str | None,
+        payload_json: dict[str, JSONValue],
+        metadata_json: dict[str, JSONValue],
+        event_time: datetime,
+    ) -> EventRecord:
+        entity = EventRecord(
+            tenant_id=tenant_id,
+            event_id=event_id,
+            event_type=event_type,
+            source_service=source_service,
+            aggregate_type=aggregate_type,
+            aggregate_id=aggregate_id,
+            correlation_id=correlation_id,
+            causation_id=causation_id,
+            payload_json=payload_json,
+            metadata_json=metadata_json,
+            event_time=event_time,
+            status="pending",
+        )
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_by_scope(
+        self,
+        *,
+        tenant_id: str,
+        event_type: str | None = None,
+        source_service: str | None = None,
+        aggregate_type: str | None = None,
+        aggregate_id: str | None = None,
+        correlation_id: str | None = None,
+        status: EventDeliveryStatus | None = None,
+        limit: int = 100,
+    ) -> list[EventRecord]:
+        stmt = select(EventRecord).where(EventRecord.tenant_id == tenant_id)
+        if event_type is not None:
+            stmt = stmt.where(EventRecord.event_type == event_type)
+        if source_service is not None:
+            stmt = stmt.where(EventRecord.source_service == source_service)
+        if aggregate_type is not None:
+            stmt = stmt.where(EventRecord.aggregate_type == aggregate_type)
+        if aggregate_id is not None:
+            stmt = stmt.where(EventRecord.aggregate_id == aggregate_id)
+        if correlation_id is not None:
+            stmt = stmt.where(EventRecord.correlation_id == correlation_id)
+        if status is not None:
+            stmt = stmt.where(EventRecord.status == status)
+        stmt = stmt.order_by(EventRecord.event_time.desc()).limit(limit)
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, tenant_id: str, event_record_id: str) -> EventRecord | None:
+        stmt = (
+            select(EventRecord)
+            .where(EventRecord.tenant_id == tenant_id)
+            .where(EventRecord.id == event_record_id)
+        )
+        return self.db.scalar(stmt)
+
+    def claim_pending(self, *, tenant_id: str, limit: int) -> list[EventRecord]:
+        stmt = (
+            select(EventRecord)
+            .where(EventRecord.tenant_id == tenant_id)
+            .where(EventRecord.status == "pending")
+            .order_by(EventRecord.event_time.asc())
+            .limit(limit)
+        )
+        entities = list(self.db.scalars(stmt))
+        for entity in entities:
+            entity.publish_attempt_count += 1
+        if entities:
+            self.db.commit()
+            for entity in entities:
+                self.db.refresh(entity)
+        return entities
+
+    def update_delivery_status(
+        self,
+        *,
+        event_record_id: str,
+        status: EventDeliveryStatus,
+        last_error_message: str | None = None,
+    ) -> EventRecord | None:
+        entity = self.db.get(EventRecord, event_record_id)
+        if entity is None:
+            return None
+        entity.status = status
+        entity.last_error_message = last_error_message
+        if status == "published":
+            entity.published_time = datetime.utcnow()
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 3 - 0
services/event-service/app/main.py

@@ -0,0 +1,3 @@
+from app.bootstrap.app import create_app
+
+app = create_app()

+ 1 - 0
services/event-service/app/schemas/__init__.py

@@ -0,0 +1 @@
+

+ 44 - 0
services/event-service/app/schemas/event.py

@@ -0,0 +1,44 @@
+from typing import TYPE_CHECKING
+
+from pydantic import BaseModel, Field
+
+from core_events import EventDeliveryStatus, EventPublishContract, EventRecordContract
+from core_shared import JSONValue
+
+if TYPE_CHECKING:
+    from app.db.models import EventRecord
+
+
+class EventPublishRequest(EventPublishContract):
+    pass
+
+
+class EventRecordResponse(EventRecordContract):
+    @classmethod
+    def from_entity(cls, entity: "EventRecord") -> "EventRecordResponse":
+        return cls.model_validate(entity, from_attributes=True)
+
+
+class EventDeliveryStatusUpdateRequest(BaseModel):
+    tenant_id: str
+    status: EventDeliveryStatus
+    last_error_message: str | None = None
+
+
+class PendingEventClaimRequest(BaseModel):
+    tenant_id: str
+    limit: int = Field(default=100, ge=1, le=500)
+
+
+class EventBatchPublishRequest(BaseModel):
+    events: list[EventPublishRequest] = Field(default_factory=list, min_length=1, max_length=500)
+
+
+class EventBatchPublishResponse(BaseModel):
+    events: list[EventRecordResponse]
+    count: int
+
+
+class EventStatsResponse(BaseModel):
+    tenant_id: str
+    counts_json: dict[str, JSONValue]

+ 26 - 0
services/event-service/pyproject.toml

@@ -0,0 +1,26 @@
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "event-service"
+version = "0.1.0"
+description = "Event store and delivery service for agent platform."
+requires-python = ">=3.11"
+dependencies = [
+  "alembic>=1.13,<2.0",
+  "fastapi>=0.111,<1.0",
+  "uvicorn[standard]>=0.30,<1.0",
+  "pydantic>=2.7,<3.0",
+  "sqlalchemy>=2.0,<3.0",
+  "core-db",
+  "core-domain",
+  "core-events",
+  "core-shared",
+]
+
+[tool.setuptools]
+package-dir = {"" = "."}
+
+[tool.setuptools.packages.find]
+where = ["."]