Files
VianoBugAnalysis/src/viano_bug_analysis/api/operations.py
T

81 lines
4.3 KiB
Python

"""Scoped operator endpoints; mutations are durable intent, never model calls."""
import json
from datetime import datetime, timezone
from .config import APIError
from .cases import Cases
from viano_bug_analysis.worker.control import Control
from viano_bug_analysis.package import PackageReader
from viano_bug_analysis.telemetry import read_status
class Operations:
def __init__(self, service):
self.service = service
self.control = Control(service.workspace)
def view(self, client):
value = self.control.read()
path = self.service.workspace.runtime / 'worker/status.json'
try:
status = json.loads(path.read_bytes()) if path.exists() else {}
if not isinstance(status,dict):
status={}
except (OSError,ValueError):
status={}
try:
telemetry = read_status(self.service.workspace.root)
live = (telemetry.get('observer') == 'LIVE' and bool(status.get('service_id'))
and telemetry.get('service_id') == status.get('service_id') and status.get('status') != 'STOPPED')
except Exception:
live = False
# A shared worker's case/error context may belong to another project.
# Non-operators see only scoped pause state; global health is operator-only.
full_scope = set(client.projects) == {p.id for p in self.service.config.projects}
worker = ({k:status.get(k) for k in ('status','updated_at','code')} | {'live':live}
if client.operator and full_scope else None)
return {'operator':client.operator, 'worker':worker,
'projects':[{'project_id':p,'paused':value['paused'].get(p,False)} for p in client.projects],
'events':[e for e in value['events'] if e['project_id'] in client.projects][-100:] if client.operator else [],
'pending':sum(c['project_id'] in client.projects for c in value['commands']) if client.operator else 0}
def change(self, client, data):
if not client.operator:
raise APIError('API_FORBIDDEN',403)
if not isinstance(data,dict):
raise APIError('API_CONTROL_INVALID',422)
action=data.get('action')
if not isinstance(action,str):
raise APIError('API_CONTROL_INVALID',422)
if action in {'pause','resume'} and set(data)=={'action','project_id'}:
project=data['project_id']
if project not in client.projects:
raise APIError('API_FORBIDDEN',403)
self.control.pause(client.id,project,action=='pause')
return {'status':'APPLIED'}
repair=action=='repair'
if (action=='retry' and set(data)=={'action','key','package_generation'} or
repair and set(data)=={'action','key','package_generation','reason'}):
if repair and (not isinstance(data['reason'],str) or not 1<=len(data['reason'].strip())<=1000):
raise APIError('API_CONTROL_INVALID',422)
if type(data['package_generation']) is not int or not isinstance(data['key'],str):
raise APIError('API_CONTROL_INVALID',422)
path=Cases(self.service).head(data['key'],client)
with PackageReader(path) as reader:
manifest=reader.manifest
if manifest['package_generation'] != data['package_generation']:
raise APIError('API_CONTROL_STALE',409)
if repair:
if manifest['state']!='BLOCKED' or manifest['routing']['reason']!='ENTITY_RESOLUTION_INVALID_RESPONSES':
raise APIError('API_CONTROL_RETRY_STATE',409)
elif (manifest['state']=='BLOCKED' and (manifest['routing']['reason']!='RESEARCH_UNAVAILABLE'
or (manifest['routing']['retry_not_before'] and datetime.fromisoformat(
manifest['routing']['retry_not_before'].replace('Z','+00:00')) > datetime.now(timezone.utc)))):
raise APIError('API_CONTROL_RETRY_STATE',409)
if manifest['state'] in {'OUTPUT_READY','HANDED_OFF','FAILED_TO_ANALYZE'}:
raise APIError('API_CONTROL_RETRY_STATE',409)
command=self.control.enqueue(client.id,manifest['project']['id'],manifest,
repair_reason=data['reason'].strip() if repair else None)
return {'status':'QUEUED','command_id':command}
raise APIError('API_CONTROL_INVALID',422)