81 lines
4.3 KiB
Python
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)
|