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:
@@ -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] = {}
|
||||
|
||||
Reference in New Issue
Block a user