feat(state-hub): CUST-WP-0040 — NATS lifecycle event publishing for activity-core

Makes the state hub an event publisher so activity-core can drive
maintenance automation declaratively via ActivityDefinitions, rather
than the hub creating tasks itself.

- api/events/: lazy JetStream publisher + EventEnvelope mirroring
  activity-core's contract; no-op when NATS_URL unset, fire-and-forget
  with logged failures so publishing never breaks an API request.
- Wired publishers on the five v1.0 lifecycle events:
    org.statehub.repo.registered        (POST /repos/)
    org.statehub.workstream.completed   (PATCH /workstreams/* on transition)
    org.statehub.decision.resolved      (POST /decisions/*/resolve)
    org.statehub.domain.goal.activated  (POST /domain-goals/*/activate)
    org.statehub.task.stale             (scripts/cleanup_stale_tasks.py)
- docs/nats-event-subjects.md: subject naming convention + catalog.
- docs/cron-migration.md: design stub for replacing custodian-sync
  systemd timer and cleanup-stale cron with ActivityDefinitions
  (depends on activity-core WP-0003).
- docs/activity-core-delegation.md: protocol, invariants, cutover plan.
- SCOPE.md: declares activity-core as downstream event consumer and
  restates that the state hub stays a read model, not a task factory.

Workplan: workplans/CUST-WP-0040-state-hub-nats-activity-core-integration.md
242 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 05:49:29 +02:00
parent 2bc7fd8ce7
commit ca8a09ed04
16 changed files with 770 additions and 9 deletions

View File

@@ -11,12 +11,24 @@ Exit codes:
1 — API unreachable or unexpected error
"""
import asyncio
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
# Make the api package importable when running as `python scripts/cleanup_stale_tasks.py`
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from api.events import EventEnvelope, publish_event, shutdown_publisher
except Exception: # pragma: no cover — event publishing is optional
EventEnvelope = None # type: ignore[assignment]
publish_event = None # type: ignore[assignment]
shutdown_publisher = None # type: ignore[assignment]
API = "http://127.0.0.1:8000"
STALE_STATUSES = {"todo", "in_progress", "blocked"}
CLOSED_WS_STATUS = {"completed", "archived"}
@@ -85,6 +97,7 @@ def main() -> int:
cancelled = []
errors = []
nats_events: list[tuple[str, "EventEnvelope"]] = []
for t in stale:
ws = closed_ws[t["workstream_id"]]
@@ -100,10 +113,35 @@ def main() -> int:
)
cancelled.append(t)
print(f" cancelled [{t['priority']:8}] {t['title'][:70]}")
if EventEnvelope is not None:
subject = "org.statehub.task.stale"
nats_events.append((
subject,
EventEnvelope.new(
subject,
attributes={
"task_id": t["id"],
"workstream_id": t["workstream_id"],
"workstream_status": ws["status"],
"task_title": t["title"],
"task_status_before": t["status"],
},
),
))
except Exception as e:
errors.append((t, str(e)))
print(f" ERROR {t['title'][:60]}{e}", file=sys.stderr)
if nats_events and publish_event is not None and shutdown_publisher is not None:
async def _flush_events() -> None:
for subject, env in nats_events:
await publish_event(subject, env)
await shutdown_publisher()
try:
asyncio.run(_flush_events())
except Exception as e: # pragma: no cover — publishing is best-effort
print(f"[cleanup-stale] WARNING: NATS publish failed — {e}", file=sys.stderr)
# Emit a single progress event summarising the run
if cancelled:
by_ws: dict[str, list] = {}