diff --git a/config/api.example.toml b/config/api.example.toml index 0d9c4d5..4aad101 100644 --- a/config/api.example.toml +++ b/config/api.example.toml @@ -19,3 +19,10 @@ planning_mode = "joint" id = "wow-capture-worker" token_env = "VBA_INTAKE_TOKEN" projects = ["viano-core"] + +# Separate operator identity/token. Submission clients remain non-operators. +# [[clients]] +# id = "human-operator" +# token_env = "VBA_OPERATOR_TOKEN" +# projects = ["viano-core"] +# operator = true diff --git a/docs/analysis-worker.md b/docs/analysis-worker.md index 3ab8330..fd118ac 100644 --- a/docs/analysis-worker.md +++ b/docs/analysis-worker.md @@ -148,6 +148,13 @@ löschen oder Untersuchungsbudgets zu verbrauchen. Die konfigurierbare 20-GB-Vorgabe für Entwicklungs-/Testdaten bleibt davon unabhängig. Der Worker löscht keine Paketgenerationen und keine Inhalte erhaltener Akten. +Die [Web-Betriebssteuerung](web-console.md#betriebssteuerung) kann neue Fälle je +Projekt pausieren, während begonnene Analysen abschließen. Freigabeaufträge werden +dauerhaft gespeichert und zwischen Dispatches unter derselben Fall-Lease geprüft. +Damit ist für Web-Freigaben kein Worker-Stopp nötig. CLI-Freigaben benötigen +weiterhin den gestoppten Dienst. Alle fachlichen Retry- und Budgetregeln gelten +für beide Wege; ein veralteter Webauftrag entsperrt keinen neueren Paketstand. + Ctrl+C bzw. SIGTERM fordert einen geordneten Stopp an. Der laufende Dispatch darf noch abschließen; erst danach wird die Prozess-Lease freigegeben. Ein Dispatch kann mehrere interne Schritte/Modellanfragen enthalten und entsprechend dauern. diff --git a/docs/test-results/operator-control-20261022.md b/docs/test-results/operator-control-20261022.md new file mode 100644 index 0000000..fcad362 --- /dev/null +++ b/docs/test-results/operator-control-20261022.md @@ -0,0 +1,28 @@ +# Betriebssteuerung – 2026-10-22 + +Umfang: projektbezogene Pause neuer Analysen mit Weiterlauf begonnener Fälle, +dauerhafte Fortsetzungsaufträge zwischen Worker-Dispatches, getrennte Betreiberrechte, +Aktionsprotokoll sowie Workerstatus im Web. Keine Prompt-/Budgetänderungen und keine +Änderung der maßgeblichen Designspezifikation. + +Eingaben: künstliche CaseBuilder-/Worker-/API-Fixtures und synthetischer UI-Server. +Keine kostenpflichtigen Modellanfragen und keine externen DB-/Repositoryzugriffe. + +Prüfungen: + +- Worker-, Dashboard-, vcore-Intake- und API-Tests: 50 bestanden. +- Persistente Pause nach Worker-Neustart, Weiterlauf begonnener Fälle, + aktuelle und veraltete Freigaben, Ablehnung von Modellvalidierungssperren. +- API-Berechtigungen, Projektscope, Protokollsichtbarkeit, ungültige/zu große + Steuerungsnachrichten und unbekannte Zusatzfelder. +- Echter Headless-Edge: Pause/Wiederzulassung, Aktionsprotokoll, Desktop/Mobil, + bisherige Diagramme/Falldetails/Abmeldung: PASS, keine Browserfehler/Überläufe. + Nach zwei Fehlern beim Test-Abschluss wurde geordnetes Browserbeenden ergänzt; + der abschließende Test endet erfolgreich einschließlich temporärer Bereinigung. +- Test-Workspaces vor Testbeginn: 16.939.129.428 Bytes; zusammen mit kleinen + Fixtures und temporären lokalen Tests unter 20 GB. Keine alten Akten gelöscht. + +Grenzen: HTTP-Annahme bleibt während einer Pause verfügbar; Pause stoppt neue +Analysen an Worker-Grenzen, keine laufenden Modellaufrufe. Freigaben sind asynchron +und können bei erneuter Prüfung abgelehnt werden. Ein gestoppter Worker wird durch +die Weboberfläche nicht als Betriebssystemdienst gestartet. diff --git a/docs/web-console.md b/docs/web-console.md index 11aebb4..f70656a 100644 --- a/docs/web-console.md +++ b/docs/web-console.md @@ -70,8 +70,8 @@ Die Oberfläche bietet ein einfaches Formular für neue wowdiag-/wowsrc-Aufträg für abgeschlossene ZIP-Akten mit Testerfeedback. Es verwendet dieselbe Multipart- API und denselben Idempotenzmechanismus wie externe Programme. Bei unveränderten Eingaben verwendet eine Wiederholung denselben Schlüssel. Anhänge und Fallakten -werden nicht clientseitig bearbeitet. Administrative Worker-Eingriffe bleiben ein -weiterer Ausbauschritt. +werden nicht clientseitig bearbeitet. Betreiberaktionen stehen separat berechtigten +Clients zur Verfügung. ## Fallakte und Ergebnisse @@ -88,8 +88,43 @@ Die Revisionsauswahl liest akzeptierte Artefakte aus der Checkpointhistorie der aktuellen vollständigen Akte. Ergebnisse neuerer Revisionen erscheinen nicht in älteren Ansichten. Über die Übergabe-API erfasste Patch-Rückmeldungen werden bis zur gewählten Revision dargestellt. Bei gesperrten Fällen erscheinen Sperrgrund -und passende Betreiberhinweise; die Ansicht setzt keine Budgets zurück und hebt -keine Sperren auf. +und passende Betreiberhinweise. Für berechtigte Betreiber kann eine zulässige +Fortsetzung angefordert werden; Budgets werden dabei nicht zurückgesetzt. + +## Betriebssteuerung + +Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit +eigenem Token und `operator = true` in der API-Konfiguration erhält Aktionen für +seine freigegebenen Projekte (Beispiel in `config/api.example.toml`). API nach einer +Rechteänderung neu starten. Der Gemini-Schlüssel ist dafür nicht geeignet. + +- **Neue Fälle pausieren / wieder zulassen:** gilt je Projekt und bleibt nach + Neustart erhalten. HTTP-Aufträge werden weiterhin dauerhaft angenommen. Der + Worker lässt sie im Eingang bzw. wartet bei noch nicht begonnenen Fällen. + Begonnene Analysen laufen weiter. Eine Pause unterbricht keinen Modellaufruf. +- **Fortsetzung nach Fehlerbehebung anfordern:** in der aktuellen Falldetailansicht + bei passenden Schedulerfehlern bzw. `RESEARCH_UNAVAILABLE`. Die API bestätigt + nur `QUEUED`, nicht bereits eine Freigabe. Der lebende Worker prüft den Auftrag + zwischen Dispatches erneut; ein gestoppter Worker bearbeitet ihn nach Neustart. +- Veraltete Paketgeneration, geänderte Konfigurationsbindung, noch nicht fällige + Research-Sperre und unzulässige fachliche Sperren werden abgewiesen. Keine + Zurücksetzung von Hypothesenbudgets, Versuchszählern oder LLM-Operationen. +- Das Aktionsprotokoll dokumentiert Clientkennung, UTC-Zeit, Projekt, Aktion und + Ergebnis. Fortsetzungen haben getrennte Eingangs- und Ausführungseinträge mit + derselben Auftragskennung. Die Ansicht zeigt die letzten 100 berechtigten + Einträge; das vollständige Protokoll bleibt unter `runtime/worker/control/`. + Es enthält keine Tokens oder Konfigurationsinhalte. Erfolgreiche lokale + CLI-Freigaben erscheinen als `local-cli`. +- Workerdetails zeigen Erreichbarkeit, letzten Zustand, Aktivität und Fehlercode. + Ein alter Snapshot gilt nicht als lebender Worker. Globale Workerdetails sind + nur Betreibern mit Zugriff auf alle konfigurierten Projekte zugänglich. + +`GET /v1/operations` liefert Rechte, projektbezogenen Pausenzustand und für Betreiber +Protokoll/Workerzustand. `POST /v1/operations` benötigt Betreiberrechte und JSON: +`{"action":"pause","project_id":"viano-core"}` bzw. `resume`, oder +`{"action":"retry","key":"","package_generation":42}`. +Bei gleichzeitigem Zugriff kann `API_CONTROL_BUSY` auftreten; nach Aktualisierung +erneut versuchen. Der Worker muss nach diesem Softwareupdate neu gestartet werden. `GET /v1/cases/{key}?revision=N` liefert diese projektgebundene Ansicht. `GET /v1/cases/{key}/package` lädt die vollständige aktuelle Akte einschließlich diff --git a/src/viano_bug_analysis/api/app.py b/src/viano_bug_analysis/api/app.py index 38d6504..7d556a5 100644 --- a/src/viano_bug_analysis/api/app.py +++ b/src/viano_bug_analysis/api/app.py @@ -1,6 +1,7 @@ import asyncio import functools import hmac +import json import shutil import tempfile from pathlib import Path @@ -19,6 +20,8 @@ from .contracts import schema from .service import IntakeService from .dashboard import Dashboard from .cases import Cases +from .operations import Operations +from viano_bug_analysis.lifecycle.errors import LockBusy from viano_bug_analysis.lifecycle.errors import LifecycleError from viano_bug_analysis.package import PackageReadError from importlib.resources import files @@ -28,6 +31,7 @@ def create_app(config): service = IntakeService(config) dashboard = Dashboard(service) cases = Cases(service) + operations = Operations(service) tokens = config.tokens() service.recover() active = 0 @@ -84,14 +88,45 @@ def create_app(config): response.headers['Cache-Control'] = 'no-store' return response + async def operation(request): + client = authenticate(request) + if request.method == 'POST': + if not client.operator: + raise APIError('API_FORBIDDEN',403) + if request.headers.get('content-type','').split(';')[0] != 'application/json': + raise APIError('API_CONTENT_TYPE',415) + body=bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body)>4096: + raise APIError('API_CONTROL_INVALID',413) + try: + data=json.loads(body) + except ValueError: + raise APIError('API_CONTROL_INVALID',422) from None + try: + result=await run_in_threadpool(operations.change,client,data) + except LockBusy: + raise APIError('API_CONTROL_BUSY',409) from None + except (LifecycleError,PackageReadError): + raise APIError('API_CASE_UNAVAILABLE',409) from None + except (ValueError,KeyError,TypeError): + raise APIError('API_CONTROL_UNAVAILABLE',503) from None + else: + try: + result=await run_in_threadpool(operations.view,client) + except (ValueError,KeyError,TypeError): + raise APIError('API_CONTROL_UNAVAILABLE',503) from None + return JSONResponse(result,headers={'Cache-Control':'no-store'}) + async def web(request): from starlette.responses import Response name = request.path_params.get('asset', 'index.html') - if name not in {'index.html', 'dashboard.js', 'dashboard.css', 'case-detail.js'}: + if name not in {'index.html', 'dashboard.js', 'dashboard.css', 'case-detail.js','operations.js'}: raise APIError('API_NOT_FOUND', 404) data = files('viano_bug_analysis.web').joinpath(name).read_bytes() response = Response(data, media_type={'index.html': 'text/html', 'dashboard.js': 'text/javascript', - 'dashboard.css': 'text/css', 'case-detail.js':'text/javascript'}[name]) + 'dashboard.css': 'text/css', 'case-detail.js':'text/javascript','operations.js':'text/javascript'}[name]) response.headers.update({'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store'}) return response @@ -165,6 +200,7 @@ def create_app(config): app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web), Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles), + Route('/v1/operations',operation,methods=['GET','POST']), Route('/v1/cases/{handle}', case_detail), Route('/v1/cases/{handle}/package', case_package), Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']), Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)], diff --git a/src/viano_bug_analysis/api/config.py b/src/viano_bug_analysis/api/config.py index eaf9d69..c1850ed 100644 --- a/src/viano_bug_analysis/api/config.py +++ b/src/viano_bug_analysis/api/config.py @@ -66,6 +66,7 @@ class Client: id: str token_env: str projects: tuple[str, ...] + operator: bool = False @dataclass(frozen=True) @@ -85,7 +86,7 @@ class APIConfig: or not isinstance(self.host, str) or not self.host): raise APIError('API_CONFIG', 500) for c in self.clients: - if (not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id) + if (type(c.operator) is not bool or not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id) or not isinstance(c.token_env, str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', c.token_env) or not c.projects or not set(c.projects) <= ids): diff --git a/src/viano_bug_analysis/api/operations.py b/src/viano_bug_analysis/api/operations.py new file mode 100644 index 0000000..0060cf1 --- /dev/null +++ b/src/viano_bug_analysis/api/operations.py @@ -0,0 +1,72 @@ +"""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'} + if action=='retry' and set(data)=={'action','key','package_generation'}: + 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 (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) + return {'status':'QUEUED','command_id':command} + raise APIError('API_CONTROL_INVALID',422) diff --git a/src/viano_bug_analysis/web/case-detail.js b/src/viano_bug_analysis/web/case-detail.js index fb3b998..516b98b 100644 --- a/src/viano_bug_analysis/web/case-detail.js +++ b/src/viano_bug_analysis/web/case-detail.js @@ -62,7 +62,9 @@ function renderDetail(data){ const actions=text('div','','next-actions'); if(data.reason)actions.append(text('p',`Sperr-/Reviewgrund: ${data.reason}`)); if(data.worker.code)actions.append(text('p',`Workerhinweis: ${data.worker.code}`)); - data.actions.forEach(a=>actions.append(text('p',a.text)));root.append(panel('Zustand und nächste Schritte',actions)); + data.actions.forEach(a=>actions.append(text('p',a.text))); + if(typeof addRetryControl==='function')addRetryControl(data,actions); + root.append(panel('Zustand und nächste Schritte',actions)); root.append(panel('Testerbeobachtungen',semantic({observations:data.observations,tester_assumptions:data.tester_assumptions,expected_behavior:data.expected_behavior}))); root.append(panel('Caseinterpretation',Object.keys(data.interpretation).length?semantic(data.interpretation):text('p','Für diese Revision liegt noch keine akzeptierte Interpretation vor.','hint'),false)); const hypotheses=text('div','','hypothesis-list'); diff --git a/src/viano_bug_analysis/web/dashboard.js b/src/viano_bug_analysis/web/dashboard.js index 3d5a736..299f0c7 100644 --- a/src/viano_bug_analysis/web/dashboard.js +++ b/src/viano_bug_analysis/web/dashboard.js @@ -26,6 +26,7 @@ function logout() { $('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true; ['workers','case-rows','bars','legend','project','project-filter','result-project','result-rows','case-detail'].forEach(id=>$(id).replaceChildren()); if(typeof clearDetails==='function')clearDetails(); + if(typeof clearOperations==='function')clearOperations(); ['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—'); $('pie').getContext('2d').clearRect(0,0,360,360); } @@ -106,6 +107,7 @@ async function refresh() { clearTimeout(timer); if(!token)return;const generation=session; try {const response=await api('/v1/overview');if(generation!==session)return;overview=response;render();$('updated').textContent=`Aktualisiert ${new Date().toLocaleTimeString('de-DE')}`; error(overview.read_errors?'Einige Akten konnten nicht gelesen werden. Die Verbrauchsdaten können unvollständig sein.':''); + if(typeof refreshOperations==='function')await refreshOperations(); } catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);} if(token && generation===session)timer=setTimeout(refresh,4000); } diff --git a/src/viano_bug_analysis/web/index.html b/src/viano_bug_analysis/web/index.html index 4a2c1d5..ea87b61 100644 --- a/src/viano_bug_analysis/web/index.html +++ b/src/viano_bug_analysis/web/index.html @@ -3,7 +3,7 @@ BugAnalysis · Übersicht - +