File indexing completed on 2026-08-12 09:36:17
0001 """Capcom endpoints: display-ready SWF state and buffered SWF events.
0002
0003 Capcom polls the open read endpoints every few minutes (through the
0004 swf-remote proxy for external reach). Each entry under 'states' is shaped
0005 exactly as tjai's capcom.set_state(source, value, color, url) expects,
0006 following the pax-eden Ahbazon producer, so the capcom-side collector can
0007 apply each entry as delivered.
0008
0009 Discrete SWF events (the campaign-delivery and task-operation feeds) are
0010 buffered here in CapcomNotice rows and served by the open notices
0011 endpoint; the consumer drains them with a since-cursor on its own poll.
0012 SWF never posts into an external feed and holds no external credential —
0013 the producing agent writes notices to this monitor with its ordinary
0014 monitor token, and the feed system's credentials stay entirely on the
0015 feed system's side.
0016 """
0017
0018 import logging
0019 import re
0020 from datetime import datetime, timedelta
0021 from datetime import timezone as datetime_timezone
0022 from urllib.parse import urlencode
0023
0024 from django.http import JsonResponse
0025 from django.utils import timezone
0026 from rest_framework.authentication import TokenAuthentication
0027 from rest_framework.decorators import (api_view, authentication_classes,
0028 permission_classes)
0029 from rest_framework.permissions import IsAuthenticated
0030 from rest_framework.response import Response
0031
0032 logger = logging.getLogger(__name__)
0033
0034
0035
0036 NOTICE_RETENTION_DAYS = 30
0037
0038 NOTICE_PAGE_MAX = 500
0039
0040 REMOTE_FACE = 'https://epic-devcloud.org/prod'
0041 CAPCOM_WORKLOAD_CHECKS = frozenset({'stale-state'})
0042 USERNAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$')
0043 CAPCOM_VALUE_MAX_CHARS = 50
0044
0045
0046 def _compact_value(value):
0047 """Keep state-tile summaries within Capcom's usual 50 characters."""
0048 value = str(value)
0049 if len(value) <= CAPCOM_VALUE_MAX_CHARS:
0050 return value
0051 return value[:CAPCOM_VALUE_MAX_CHARS - 1].rstrip() + '…'
0052
0053
0054 def _running_panda_task_count():
0055 """Return the count of PanDA tasks currently in status running."""
0056 from django.db import connections
0057 from ..panda.queries import PANDA_SCHEMA
0058
0059 connection = connections['panda']
0060 with connection.cursor() as cursor:
0061 cursor.execute(
0062 f'SELECT COUNT(*) FROM "{PANDA_SCHEMA}"."jedi_tasks" '
0063 'WHERE "status" = %s',
0064 ['running'],
0065 )
0066 return int(cursor.fetchone()[0] or 0)
0067
0068
0069 def _paused_panda_tasks(limit=20):
0070 """Return the complete paused count plus recent task detail."""
0071 from django.db import connections
0072 from ..panda.queries import PANDA_SCHEMA
0073
0074 connection = connections['panda']
0075 with connection.cursor() as cursor:
0076 cursor.execute(
0077 f'SELECT COUNT(*) FROM "{PANDA_SCHEMA}"."jedi_tasks" '
0078 'WHERE "status" = %s',
0079 ['paused'],
0080 )
0081 count = int(cursor.fetchone()[0] or 0)
0082 cursor.execute(
0083 f'SELECT "jeditaskid", "taskname", "username", '
0084 f'"modificationtime" FROM "{PANDA_SCHEMA}"."jedi_tasks" '
0085 'WHERE "status" = %s ORDER BY "modificationtime" DESC '
0086 'LIMIT %s',
0087 ['paused', limit],
0088 )
0089 tasks = [
0090 {
0091 'jedi_task_id': row[0],
0092 'task_name': row[1] or '',
0093 'username': row[2] or '',
0094 'modified_at': row[3].isoformat() if row[3] else None,
0095 }
0096 for row in cursor.fetchall()
0097 ]
0098 return count, tasks
0099
0100
0101 def _user_testbed_summary(username, now):
0102 """Return display and detail state for one user's SWF testbed."""
0103 from ..models import SysConfig, SystemAgent
0104 from ..workflow_models import Namespace, WorkflowExecution
0105
0106 healthy_after = now - timedelta(minutes=5)
0107 stale_hours = float(SysConfig.get_setting('state_stale_hours', 12))
0108 stale_before = now - timedelta(hours=stale_hours)
0109
0110 manager = SystemAgent.objects.filter(
0111 instance_name=f'agent-manager-{username}').first()
0112 executions = WorkflowExecution.objects.filter(executed_by=username)
0113 latest_execution = executions.order_by('-start_time').first()
0114
0115 manager_is_fresh = bool(
0116 manager and manager.last_heartbeat
0117 and manager.last_heartbeat >= healthy_after
0118 and manager.operational_state != 'EXITED')
0119 owned_namespace = (Namespace.objects.filter(owner=username)
0120 .order_by('-updated_at')
0121 .values_list('name', flat=True).first())
0122 namespace = None
0123 if manager_is_fresh and manager.namespace:
0124 namespace = manager.namespace
0125 elif latest_execution and latest_execution.namespace:
0126 namespace = latest_execution.namespace
0127 elif manager and manager.namespace:
0128 namespace = manager.namespace
0129 else:
0130 namespace = owned_namespace
0131
0132 agents = []
0133 if namespace:
0134 agents = list(SystemAgent.objects.filter(namespace=namespace)
0135 .exclude(instance_name=f'agent-manager-{username}')
0136 .exclude(operational_state='EXITED')
0137 .order_by('-last_heartbeat'))
0138 fresh_agents = [
0139 agent for agent in agents
0140 if agent.last_heartbeat and agent.last_heartbeat >= healthy_after
0141 ]
0142 stale_agents = [agent for agent in agents if agent not in fresh_agents]
0143 error_agents = [agent for agent in fresh_agents if agent.status == 'ERROR']
0144 running_workflows = executions.filter(status='running').count()
0145 stale_workflows = executions.filter(
0146 status='running', end_time__isnull=True,
0147 start_time__lt=stale_before).count()
0148
0149 if stale_workflows:
0150 color = 'yellow'
0151 elif error_agents or (manager_is_fresh and manager.status == 'ERROR'):
0152 color = 'red'
0153 elif running_workflows:
0154 color = 'green'
0155 elif fresh_agents:
0156 color = 'green'
0157 else:
0158 color = None
0159 display_count = len(fresh_agents)
0160 label = f'testbed {display_count}'
0161
0162 return {
0163 'label': label,
0164 'color': color,
0165 'namespace': namespace,
0166 'agent_manager': {
0167 'alive': manager_is_fresh,
0168 'status': manager.status if manager else None,
0169 'last_heartbeat': (
0170 manager.last_heartbeat.isoformat()
0171 if manager and manager.last_heartbeat else None),
0172 },
0173 'agents': {
0174 'display_count': display_count,
0175 'fresh': len(fresh_agents),
0176 'stale': len(stale_agents),
0177 'error': len(error_agents),
0178 },
0179 'workflows': {
0180 'running': running_workflows,
0181 'stale': stale_workflows,
0182 'stale_after_hours': stale_hours,
0183 },
0184 'last_execution': ({
0185 'execution_id': latest_execution.execution_id,
0186 'status': latest_execution.status,
0187 'start_time': (latest_execution.start_time.isoformat()
0188 if latest_execution.start_time else None),
0189 'end_time': (latest_execution.end_time.isoformat()
0190 if latest_execution.end_time else None),
0191 } if latest_execution else None),
0192 }
0193
0194
0195 def _user_panda_summary(username):
0196 """Return a compact trailing-day PanDA summary for one effective user."""
0197 from ..panda.queries import get_activity
0198
0199 activity = get_activity(days=1, username=username)
0200 if activity.get('error'):
0201 raise RuntimeError(activity['error'])
0202 jobs = activity.get('jobs') or {}
0203 tasks = activity.get('tasks') or {}
0204 job_status = jobs.get('by_status') or {}
0205 task_status = tasks.get('by_status') or {}
0206
0207 running_jobs = int(job_status.get('running', 0) or 0)
0208 terminal_tasks = {'done', 'failed', 'aborted', 'broken', 'finished'}
0209 active_tasks = sum(
0210 int(count or 0) for status, count in task_status.items()
0211 if status not in terminal_tasks)
0212 finished_jobs = int(job_status.get('finished', 0) or 0)
0213 failed_jobs = sum(int(job_status.get(status, 0) or 0)
0214 for status in ('failed', 'cancelled', 'closed'))
0215
0216 if running_jobs:
0217 display_count = running_jobs
0218 elif active_tasks:
0219 display_count = active_tasks
0220 else:
0221 display_count = finished_jobs + failed_jobs
0222 label = f'PanDA {display_count}'
0223
0224 return {
0225 'label': label,
0226 'window_hours': 24,
0227 'display_count': display_count,
0228 'running_jobs': running_jobs,
0229 'active_tasks': active_tasks,
0230 'finished_jobs': finished_jobs,
0231 'failed_jobs': failed_jobs,
0232 'jobs_by_status': job_status,
0233 'tasks_by_status': task_status,
0234 }
0235
0236
0237 def capcom_state(request):
0238 """SWF state tiles: the System page verdict and current PanDA activity.
0239
0240 states: tile-exact entries (source, value, color, url).
0241 detail: the numbers behind them, for any richer capcom-side use.
0242 Each section degrades independently: a failing source contributes an
0243 error tile rather than hiding or failing the whole response.
0244 """
0245 from ..snapper_panda import _in_flight_activity, _terminal_outcome_rows
0246 from ..system_status import status_summary
0247
0248 now = timezone.now()
0249 states = []
0250 detail = {}
0251
0252 try:
0253 summary = status_summary(exclude_names=CAPCOM_WORKLOAD_CHECKS)
0254 status = summary.get('overall_status', 'unknown')
0255 bad = int(summary.get('warning', 0)) + int(summary.get('error', 0))
0256 value = status.upper() + (f' ({bad})' if bad else '')
0257 color = {'ok': 'green', 'warning': 'yellow',
0258 'error': 'red'}.get(status)
0259 entry = {'source': 'swf-system', 'value': value,
0260 'url': f'{REMOTE_FACE}/system/'}
0261 if color:
0262 entry['color'] = color
0263 states.append(entry)
0264 latest = summary.get('latest_checked_at')
0265 detail['system'] = {
0266 'scope': 'infrastructure-operations',
0267 'excluded_checks': sorted(CAPCOM_WORKLOAD_CHECKS),
0268 'status': status,
0269 'reason': summary.get('overall_reason', ''),
0270 'ok': summary.get('ok', 0),
0271 'warning': summary.get('warning', 0),
0272 'error': summary.get('error', 0),
0273 'checked_at': latest.isoformat() if latest else None,
0274 }
0275 except Exception as exc:
0276 logger.error('capcom state: system summary failed: %s', exc)
0277 states.append({'source': 'swf-system', 'value': 'UNAVAILABLE',
0278 'url': f'{REMOTE_FACE}/system/'})
0279 detail['system'] = {'error_text': str(exc)}
0280
0281 try:
0282 running_jobs = sum(
0283 row['jobs'] for row in _in_flight_activity()
0284 if row['status'] == 'running')
0285 finished = 0
0286 failed = 0
0287 for _site, status, _cls, count in _terminal_outcome_rows(
0288 now - timedelta(hours=12), now):
0289 if status == 'finished':
0290 finished += int(count or 0)
0291 elif status == 'failed':
0292 failed += int(count or 0)
0293 decided = finished + failed
0294 pct = round(100.0 * finished / decided, 1) if decided else None
0295 running_tasks = _running_panda_task_count()
0296 paused_count, paused_tasks = _paused_panda_tasks()
0297 value_parts = [f'{running_jobs} jobs', f'{running_tasks} tasks',
0298 f'{paused_count} paused']
0299 if pct is not None:
0300 value_parts.append(f'{pct:.0f}%')
0301 entry = {
0302 'source': 'swf-panda',
0303 'value': _compact_value(' · '.join(value_parts)),
0304 'url': f'{REMOTE_FACE}/panda/jobs/',
0305 }
0306 if paused_count:
0307 entry['color'] = 'yellow'
0308 states.append(entry)
0309 detail['panda'] = {
0310 'running_jobs': running_jobs,
0311 'running_tasks': running_tasks,
0312 'finished_12h': finished,
0313 'failed_12h': failed,
0314 'success_pct_12h': pct,
0315 'paused_tasks': paused_count,
0316 'paused_task_detail': paused_tasks,
0317 }
0318 except Exception as exc:
0319 logger.error('capcom state: panda queries failed: %s', exc)
0320 states.append({'source': 'swf-panda', 'value': 'UNAVAILABLE',
0321 'url': f'{REMOTE_FACE}/panda/jobs/'})
0322 detail['panda'] = {'error_text': str(exc)}
0323
0324 try:
0325 from ..alarms_data import active_event_count, alarm_configs
0326
0327 counts = {}
0328 for cfg in alarm_configs():
0329 entry_id = cfg.get('entry_id') or ''
0330 if entry_id:
0331 counts[cfg.get('name') or entry_id] = (
0332 active_event_count(entry_id))
0333 active = sum(counts.values())
0334 states.append({
0335 'source': 'swf-alarms',
0336 'value': f'{active} active' if active else 'OK',
0337 'color': 'red' if active else 'green',
0338 'url': f'{REMOTE_FACE}/alarms/'})
0339 detail['alarms'] = {
0340 'active': active,
0341 'by_alarm': {name: n for name, n in counts.items() if n}}
0342 except Exception as exc:
0343 logger.error('capcom state: alarm counts failed: %s', exc)
0344 states.append({'source': 'swf-alarms', 'value': 'UNAVAILABLE',
0345 'url': f'{REMOTE_FACE}/alarms/'})
0346 detail['alarms'] = {'error_text': str(exc)}
0347
0348 try:
0349 from ..epicprod_logging import SUBLEVEL_VALUES, live_stream_q
0350 from ..models import AIMemory, AppLog, SysConfig
0351
0352 since = now - timedelta(hours=24)
0353
0354
0355 min_sublevel = str(SysConfig.get_setting(
0356 'epicprod_live_min_sublevel', 'normal') or '')
0357 if min_sublevel not in SUBLEVEL_VALUES:
0358 min_sublevel = 'normal'
0359 posts = AppLog.objects.filter(
0360 live_stream_q(min_sublevel), timestamp__gte=since).count()
0361
0362
0363 queries = AIMemory.objects.filter(
0364 username='pandabot', session_id='mattermost', role='user',
0365 created_at__gte=since).count()
0366 states.append({
0367 'source': 'swf-bot',
0368 'value': (f'{posts} post{"s" if posts != 1 else ""} · '
0369 f'{queries} quer{"ies" if queries != 1 else "y"}/24h'),
0370 'url': 'https://chat.epic-eic.org/main/channels/dispatcher'})
0371 detail['dispatcher'] = {'posts_24h': posts, 'queries_24h': queries,
0372 'min_sublevel': min_sublevel}
0373 except Exception as exc:
0374 logger.error('capcom state: dispatcher counts failed: %s', exc)
0375 states.append({
0376 'source': 'swf-bot', 'value': 'UNAVAILABLE',
0377 'url': 'https://chat.epic-eic.org/main/channels/dispatcher'})
0378 detail['dispatcher'] = {'error_text': str(exc)}
0379
0380 return JsonResponse({'built_at': now.isoformat(),
0381 'states': states, 'detail': detail})
0382
0383
0384 def capcom_notices(request):
0385 """Open read: buffered discrete SWF events after a consumer's cursor.
0386
0387 Query params:
0388 since (ISO-8601 timestamp, optional) — return notices created
0389 strictly after this instant; a naive value is read as UTC.
0390 Default: the trailing 24 hours.
0391
0392 Rows come back oldest-first so the consumer's next cursor is the last
0393 row's created_at; 'more' is true when the page cap truncated the
0394 response and another read should follow immediately.
0395 """
0396 from ..models import CapcomNotice
0397
0398 now = timezone.now()
0399 since_raw = (request.GET.get('since') or '').strip()
0400 if since_raw:
0401 try:
0402 since = datetime.fromisoformat(since_raw)
0403 except ValueError:
0404 return JsonResponse(
0405 {'error': 'since must be an ISO-8601 timestamp'}, status=400)
0406 if timezone.is_naive(since):
0407 since = since.replace(tzinfo=datetime_timezone.utc)
0408 else:
0409 since = now - timedelta(hours=24)
0410
0411 rows = list(CapcomNotice.objects.filter(created_at__gt=since)
0412 .order_by('created_at')[:NOTICE_PAGE_MAX + 1])
0413 more = len(rows) > NOTICE_PAGE_MAX
0414 rows = rows[:NOTICE_PAGE_MAX]
0415 return JsonResponse({
0416 'built_at': now.isoformat(),
0417 'since': since.isoformat(),
0418 'more': more,
0419 'notices': [{
0420 'created_at': row.created_at.isoformat(),
0421 'source': row.source,
0422 'severity': row.severity,
0423 'title': row.title,
0424 'detail': row.detail,
0425 'url': row.url,
0426 'dedup_key': row.dedup_key,
0427 } for row in rows],
0428 })
0429
0430
0431 @api_view(['POST'])
0432 @authentication_classes([TokenAuthentication])
0433 @permission_classes([IsAuthenticated])
0434 def capcom_notice_ingest(request):
0435 """Token-authenticated notice write from the prod-ops agent."""
0436 from ..models import CapcomNotice
0437
0438 source = str(request.data.get('source') or '').strip()
0439 title = str(request.data.get('title') or '').strip()
0440 if not source or not title:
0441 return Response({'error': 'source and title are required'},
0442 status=400)
0443 notice = CapcomNotice.objects.create(
0444 source=source[:100],
0445 severity=str(request.data.get('severity') or 'info')[:20],
0446 title=title[:300],
0447 detail=str(request.data.get('detail') or ''),
0448 url=str(request.data.get('url') or '')[:500],
0449 dedup_key=str(request.data.get('dedup_key') or '')[:200],
0450 )
0451 purged, _ = CapcomNotice.objects.filter(
0452 created_at__lt=timezone.now()
0453 - timedelta(days=NOTICE_RETENTION_DAYS)).delete()
0454 if purged:
0455 logger.info('capcom notices: purged %d expired rows', purged)
0456 return Response({'status': 'ok', 'id': notice.id})
0457
0458
0459 def capcom_user_state(request):
0460 """One display-ready SWF tile for a requested user's own activity/state."""
0461 username = (request.GET.get('username') or '').strip()
0462 if not USERNAME_RE.fullmatch(username):
0463 return JsonResponse({
0464 'error': ('username is required and may contain only letters, '
0465 'numbers, dot, underscore, and hyphen'),
0466 }, status=400)
0467
0468 now = timezone.now()
0469 detail = {'username': username}
0470 color = None
0471 try:
0472 testbed = _user_testbed_summary(username, now)
0473 testbed_label = testbed.pop('label')
0474 color = testbed.pop('color')
0475 detail['testbed'] = testbed
0476 except Exception as exc:
0477 logger.error('capcom user state: testbed query for %s failed: %s',
0478 username, exc)
0479 testbed_label = 'testbed unavailable'
0480 color = 'red'
0481 detail['testbed'] = {'error_text': str(exc)}
0482
0483 try:
0484 panda = _user_panda_summary(username)
0485 panda_label = panda.pop('label')
0486 detail['panda'] = panda
0487 except Exception as exc:
0488 logger.error('capcom user state: PanDA query for %s failed: %s',
0489 username, exc)
0490 panda_label = 'PanDA unavailable'
0491 color = 'red'
0492 detail['panda'] = {'error_text': str(exc)}
0493
0494 entry = {
0495 'source': 'swf-user',
0496 'value': f'{testbed_label} · {panda_label}',
0497 'url': f'{REMOTE_FACE}/panda/jobs/?{urlencode({"days": 1, "username": username})}',
0498 }
0499 if color:
0500 entry['color'] = color
0501 return JsonResponse({
0502 'built_at': now.isoformat(),
0503 'username': username,
0504 'states': [entry],
0505 'detail': detail,
0506 })