feat(schema): protobuf event registry + buf CI gate (#54)

- Add proto schemas in packages/shared-types/events/ (oo.events.v1):
  envelope.proto, signals.proto, integration.proto
- buf.yaml with STANDARD lint + FILE breaking-change rules
- .gitea/workflows/buf-check.yaml: lint + breaking check on every PR
  touching events/ (needs a Gitea Actions runner to execute)
- scripts/buf-check.sh: local equivalent of the CI check
- NormalizedEvent TS envelope gains eventId, schemaVersion, producer
  to align with the proto Envelope message
- ml/serving/schemas.py: pydantic models mirroring the v1 proto types
- nats_consumer.py: validate payloads via pydantic instead of raw .get()

A field-rename PR will now fail buf breaking with exit code 100 and
show the offending messages. To make a breaking change: keep the old
field reserved, add the new one, bump schema_version to v2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:48:24 +00:00
parent f48b5a7646
commit d539fde0c1
10 changed files with 213 additions and 13 deletions

View File

@@ -23,6 +23,8 @@ import time
from pathlib import Path
from typing import Optional
from schemas import TaskSyncedPayload, TipFeedbackPayload
logger = logging.getLogger(__name__)
NATS_URL = os.getenv("NATS_URL", "")
@@ -48,19 +50,18 @@ def _sync_meta_path(state_dir: Path, user_id: str) -> Path:
async def _handle(subject: str, payload: dict, state_dir: Path) -> None:
if subject == "signals.task.synced":
user_id = payload.get("userId", "")
if user_id:
p = _sync_meta_path(state_dir, user_id)
p.write_text(json.dumps({
"last_sync_ts": payload.get("syncedAt") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"task_count": payload.get("count", 0),
}))
logger.info("[nats] task_synced user=%s count=%s", user_id, payload.get("count"))
msg = TaskSyncedPayload.model_validate(payload)
p = _sync_meta_path(state_dir, msg.userId)
p.write_text(json.dumps({
"last_sync_ts": msg.syncedAt,
"task_count": msg.count,
}))
logger.info("[nats] task_synced user=%s count=%s", msg.userId, msg.count)
elif subject == "signals.tip.feedback":
msg = TipFeedbackPayload.model_validate(payload)
logger.info(
"[nats] tip_feedback user=%s tip=%s action=%s reward=%s",
payload.get("userId"), payload.get("tipId"),
payload.get("action"), payload.get("reward"),
msg.userId, msg.tipId, msg.action, msg.reward,
)
else:
logger.debug("[nats] unhandled subject=%s", subject)