Compare commits
3
Commits
830dabe870
...
58fc00601e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58fc00601e | ||
|
|
012a8d8e91 | ||
|
|
27feda6d7f |
No files matched your search
@@ -10,6 +10,13 @@ Generic case construction and its input contracts are documented in
|
||||
[docs/case-builder-v1.md](docs/case-builder-v1.md).
|
||||
Vcore imports for wowdiag/wowsrc are documented in
|
||||
[docs/vcore-adapters-v1.md](docs/vcore-adapters-v1.md).
|
||||
Authenticated submission of new cases and completed-case feedback is documented in
|
||||
[docs/intake-api.md](docs/intake-api.md). The HTTP API creates and queues packages;
|
||||
external callers do not need to implement the internal package format.
|
||||
The [continuous analysis worker](docs/analysis-worker.md) consumes that inbox,
|
||||
routes cases to configured projects and resumes checkpointed analysis after restart.
|
||||
The [web console](docs/web-console.md) shows active workers, case progress and token
|
||||
charts, with corrections accounted for separately, and accepts new/return submissions.
|
||||
|
||||
Requires Python 3.11 or newer.
|
||||
|
||||
@@ -26,8 +33,7 @@ per-hypothesis budget accounting. An LLM-driven investigation controller connect
|
||||
read-only queries, durable evidence and asynchronous test handoffs. Final evaluation
|
||||
produces traceable diagnoses or human review. Confirmed diagnoses can produce
|
||||
policy-bound Change Requests, with final output validation and atomic local
|
||||
outbox/review publication. Opt-in operational telemetry observes the existing APIs. A continuous worker
|
||||
remains a future milestone.
|
||||
outbox/review publication. Operational telemetry observes the APIs and continuous worker.
|
||||
|
||||
A bounded single-case command-line trial now connects import, analysis and local
|
||||
delivery with telemetry: [first real LLM trial](docs/first-llm-trial.md).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[api]
|
||||
workspace = "../service-data/service"
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
|
||||
[[projects]]
|
||||
id = "viano-core"
|
||||
type = "wow"
|
||||
profile = "vcore"
|
||||
adapter = "vcore"
|
||||
planning_mode = "joint"
|
||||
# Trusted server-side analysis settings; never accepted from upload metadata.
|
||||
# [projects.settings]
|
||||
# project_config = "project.local.toml"
|
||||
# llm_config = "llm.early-review.local.toml"
|
||||
# investigation_config = "investigation.bounded.toml"
|
||||
|
||||
[[clients]]
|
||||
id = "wow-capture-worker"
|
||||
token_env = "VBA_INTAKE_TOKEN"
|
||||
projects = ["viano-core"]
|
||||
@@ -0,0 +1,8 @@
|
||||
[worker]
|
||||
# Same intake configuration/workspace/project catalogue as the HTTP API.
|
||||
intake_config = "api.local.toml"
|
||||
poll_seconds = 5
|
||||
error_retry_seconds = 60
|
||||
max_inbox_per_pass = 20
|
||||
min_free_bytes = 536870912
|
||||
# telemetry_config = "telemetry.local.toml"
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Viano BugAnalysis analysis worker
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=buganalysis
|
||||
Group=buganalysis
|
||||
WorkingDirectory=/opt/viano-bug-analysis
|
||||
EnvironmentFile=/etc/viano-bug-analysis/worker.env
|
||||
ExecStart=/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.worker run --config /opt/viano-bug-analysis/config/worker.local.toml
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
KillSignal=SIGTERM
|
||||
# Finish the current checkpointed dispatch; operator can still force-stop.
|
||||
TimeoutStopSec=infinity
|
||||
UMask=0077
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,169 @@
|
||||
# Dauerhafter Analyseworker
|
||||
|
||||
Der Worker verbindet die Übergabe-API mit dem vorhandenen Analyseablauf. Er nimmt
|
||||
Pakete aus derselben Inbox auf, wählt die serverseitige Projektkonfiguration und
|
||||
bearbeitet aktive Fälle bis zu Outbox oder Review. Er erstellt keine Patches,
|
||||
führt keine Builds aus und verändert keine Projekt-Datenbank.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
`config/worker.example.toml` verweist auf die Konfiguration der Übergabe-API.
|
||||
API und Worker müssen denselben Workspace und Projektkatalog verwenden. Der
|
||||
Worker benötigt die API-Clienttokens nicht, wohl aber die in seiner LLM-Konfiguration
|
||||
genannten Provider-Zugangsdaten. Beide Prozesse dürfen getrennt laufen.
|
||||
|
||||
Für jedes freigegebene Projekt werden die Analysekonfigurationen in der
|
||||
API-Konfiguration hinterlegt:
|
||||
|
||||
```toml
|
||||
[[projects]]
|
||||
id = "viano-core"
|
||||
type = "wow"
|
||||
profile = "vcore"
|
||||
adapter = "vcore"
|
||||
planning_mode = "joint"
|
||||
[projects.settings]
|
||||
project_config = "project.local.toml"
|
||||
llm_config = "llm.early-review.local.toml"
|
||||
investigation_config = "investigation.bounded.toml"
|
||||
```
|
||||
|
||||
Die Pfade sind relativ zur API-Konfigurationsdatei. `llm_config` ist erforderlich.
|
||||
Weitere optionale Einstellungen: `analysis_config`, `budget_config`,
|
||||
`diagnosis_config`, `change_request_config`, `correction_config`. Ohne explizite
|
||||
Datei gelten deren bestehende Standardwerte. Für den aktuellen Vcore-Betrieb die
|
||||
erprobte `investigation.bounded.toml` verwenden; ältere ausführliche Promptmodi
|
||||
können an der unveränderten 20.000-Zeichen-Grenze scheitern.
|
||||
|
||||
`planning_mode` ist je Projekt `joint` (Standard) oder `separate`. Die Profilbindung
|
||||
einer Akte muss vollständig zum Katalog passen: `project.id`, `type`, `profile`.
|
||||
Alte manuelle Pakete ohne `project.id` werden nicht automatisch zugeordnet.
|
||||
Andere Projektadapter bleiben ein eigener Ausbauschritt.
|
||||
|
||||
## Start und Prüfung
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.worker check --config config/worker.local.toml
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.worker run --config config/worker.local.toml
|
||||
```
|
||||
|
||||
`check` lädt Konfiguration und prüft die Anwesenheit der LLM-Zugangsdaten. Es sendet
|
||||
keine Modellanfrage und ist kein Erreichbarkeitstest für API, Datenbank oder
|
||||
Repository. `run` verarbeitet angenommene Aufträge und kann deshalb kostenpflichtige
|
||||
LLM-Aufrufe auslösen. Der reine Start ohne anstehende Fälle führt keine Modellaufrufe
|
||||
aus. `run --once` führt genau einen Scheduler-Durchgang aus, nicht einen vollständigen
|
||||
Buglauf. Auch dieser Modus kann LLM-Aufrufe auslösen.
|
||||
|
||||
API zusätzlich in einem zweiten Prozess starten:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml
|
||||
```
|
||||
|
||||
Für diese Entwicklung wurden die zuvor nicht vorhandenen lokalen Dateien
|
||||
`config/api.local.toml` und `config/worker.local.toml` mit dem Vcore-Profil vorbereitet.
|
||||
Sie sind nicht versioniert und enthalten keine Zugangsschlüssel. Vorhandene lokale
|
||||
Konfigurationen werden bei späteren Änderungen nicht automatisch überschrieben.
|
||||
|
||||
## Scheduling und Wiederanlauf
|
||||
|
||||
- Pro Durchgang erhält jeder berechtigte aktive Fall einen Dispatch. Danach
|
||||
darf der nächste Fall arbeiten. Gibt es Fortschritt, folgt der nächste Durchgang
|
||||
unmittelbar; nur im Leerlauf oder bei Wartefällen greift das Pollintervall.
|
||||
- Der Dienst läuft als einzelner Analyseprozess. Eine OS-Dateisperre verhindert
|
||||
einen zweiten Worker im selben Workspace. Die Fall-Lease ist mit dem manuellen
|
||||
Trialbefehl geteilt. API-Veröffentlichungen verwenden weiterhin die kurzen
|
||||
Checkpoint-/Inbox-Sperren; während einer Modellanfrage bleibt die API benutzbar.
|
||||
- Paketmanifest und Checkpoints bleiben maßgeblich. Es wird nur die neueste
|
||||
Generation pro Fall geprüft, nicht jede historische Generation erneut geöffnet.
|
||||
- Bei Neustart werden verwaltete aktive Fälle und noch nicht übernommene Uploads
|
||||
wiedergefunden. PREPARED-Veröffentlichungen der API werden vor der Verarbeitung
|
||||
abgeschlossen. Rückläufer starten die bereits eröffnete neue Analyse-Revision;
|
||||
alte Interpretation und Hypothesen werden nicht als neue Ergebnisse behandelt.
|
||||
- Bestehende LLM-Operationskennungen bleiben stabil. Der Dienst ändert keine
|
||||
Versuchskennungen, Budgets, Iterationsgrenzen oder Korrekturregeln, um einen
|
||||
blockierten Fall weiterzutreiben.
|
||||
- `RETRY_PENDING`/`DEFERRED` beachten die gespeicherte Wartezeit. Ein unerwarteter
|
||||
Konfigurations-/Vorbereitungsfehler erhält einen lokalen Cooldown; andere Fälle
|
||||
werden weiterbearbeitet. Eine ausdrücklich zurückgegebene `ERROR`-Antwort oder
|
||||
unerwartete Exception während eines Dispatch erzeugt `ATTENTION`, statt dieselbe
|
||||
Fehlantwort endlos abzuspielen oder möglicherweise erneut Modellkosten auszulösen.
|
||||
- BLOCKED bleibt BLOCKED, wenn eine menschliche Testerantwort benötigt wird,
|
||||
automatische Wiederholung untersagt ist, Modellvalidierung gescheitert ist oder
|
||||
die LLM-Wiederholungen ausgeschöpft sind. Explizit freigegebene, fällige technische
|
||||
Wiederholungen dürfen über den vorhandenen Resume-Checkpoint fortgesetzt werden.
|
||||
- Outbox-/Review-Veröffentlichung wird nach Erfolg nicht ständig wiederholt. Nach
|
||||
einem Absturz zwischen Paketabschluss und Veröffentlichung kann der bestehende
|
||||
Outputvalidator die fehlende Veröffentlichung idempotent nachholen.
|
||||
|
||||
Ein Prozessabbruch während einer externen Modellanfrage kann deren bereits
|
||||
berechnete Kosten nicht zurücknehmen. Ist die Antwort vor dem Checkpoint verloren
|
||||
gegangen, lässt sich eine erneute Anfrage nicht grundsätzlich ausschließen. Der
|
||||
Worker verspricht keine Exactly-once-Ausführung beim externen Anbieter.
|
||||
|
||||
## Betrieb und Fehlerbehandlung
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.worker status --config config/worker.local.toml
|
||||
```
|
||||
|
||||
`runtime/worker/status.json` enthält den letzten Dienstzustand und gegebenenfalls
|
||||
den bearbeiteten Fall. Es ist ein Snapshot, kein Beweis für einen noch lebenden
|
||||
Prozess. Die bestehende Telemetrie liefert ergänzend Heartbeat, Phasen, Laufzeiten
|
||||
und Modellverbrauch. `runtime/worker/cases/` enthält kleine lokale
|
||||
Schedulingquittungen mit Status, Fehlercode, Wartezeit und Konfigurationshashes.
|
||||
Keine Prompts, Antworten oder Geheimnisse werden diesen Quittungen hinzugefügt.
|
||||
Die Statusantwort der Übergabe-API enthält für einen zugeordneten Fall zusätzlich
|
||||
den letzten Workerstatus.
|
||||
|
||||
Die Akte bleibt vollständig transportabel. Die lokalen Quittungen sind keine
|
||||
fachliche Beweisquelle und keine verteilte Jobdatenbank. Bei ihrer vollständigen
|
||||
Entfernung müssen reine Scheduler-Cooldowns/Attention-Markierungen gegebenenfalls
|
||||
neu erkannt werden; fachliche Sperren und LLM-Operationshistorie bleiben im Paket.
|
||||
|
||||
Konfigurationsdateien werden über ihre Hashes vor dem ersten Dispatch gebunden.
|
||||
Eine geänderte Konfiguration wird nicht unbemerkt auf dieselbe aktive Revision
|
||||
angewendet. Neue Profileinstellungen benötigen einen Worker-Neustart; laufende
|
||||
Revisionen mit abweichender Bindung gehen auf `ATTENTION/WORKER_CONFIG_CHANGED`.
|
||||
Auch abhängige Profil-/Relationsdateien nicht während einer Untersuchung ändern.
|
||||
Eine allgemeine Migration laufender Modelloperationen auf neue Policies ist noch
|
||||
kein Bestandteil dieses Dienstes.
|
||||
|
||||
Nach Behebung eines lokalen Schedulingfehlers kann der Betreiber den **gestoppten**
|
||||
Worker freigeben:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.worker retry --config config/worker.local.toml --case-id BUG-000020 --package-id api-PAKETIDENTITAET
|
||||
```
|
||||
|
||||
Dies entfernt die lokale Warte-/Attention-Markierung, aber setzt keine LLM-Operation
|
||||
oder fachliche Budgets zurück. Für einen fälligen `RESEARCH_UNAVAILABLE`-Fall
|
||||
bestätigt der Befehl zusätzlich die Reparatur des Research-Dienstes und setzt dessen
|
||||
Resume-Checkpoint fort. Andere BLOCKED-Gründe, ausstehende menschliche Rückmeldungen
|
||||
und abweichende Konfigurationsbindungen werden damit nicht umgangen.
|
||||
|
||||
Bei zu wenig freiem Speicher pausiert der Worker mit `STORAGE_LOW`, ohne Akten zu
|
||||
löschen oder Untersuchungsbudgets zu verbrauchen. Die konfigurierbare
|
||||
`min_free_bytes`-Reserve ersetzt keine Speicherüberwachung oder Retention. Die
|
||||
20-GB-Vorgabe für Entwicklungs-/Testdaten bleibt davon unabhängig. Der Worker
|
||||
löscht keine Paketgenerationen und keine Inhalte erhaltener Akten.
|
||||
|
||||
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.
|
||||
Bei hartem Beenden greift beim nächsten Start der Checkpoint-Wiederanlauf.
|
||||
|
||||
Für einen Server ist die Vorlage `deploy/viano-bug-analysis-worker.service`
|
||||
vorbereitet. Sie wird nicht automatisch installiert oder gestartet. Dienstbenutzer,
|
||||
Pfade, Zugangsdaten und gemeinsame Workspace-Berechtigungen müssen zum Zielserver
|
||||
passen. Der Worker startet weder die lokale Testdatenbank noch einen Repository-
|
||||
Synchronisationsdienst. Diese Abhängigkeiten werden separat betrieben.
|
||||
|
||||
## Lokale Tests
|
||||
|
||||
Worker-, Übergabe- und Wiederanlauftests verwenden künstliche Daten und lokale
|
||||
Provideradapter; es werden keine kostenpflichtigen Modellanfragen ausgeführt:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests/test_worker.py tests/profiles/vcore/test_worker_intake.py tests/profiles/vcore/test_intake_api.py -q
|
||||
```
|
||||
@@ -0,0 +1,169 @@
|
||||
# Übergabe-API v1
|
||||
|
||||
Die API erstellt interne Case-Pakete und veröffentlicht sie atomar in der Inbox.
|
||||
Externe Programme liefern Metadaten und Dateien; sie erzeugen und bearbeiten keine
|
||||
internen Manifeste. Die API führt keine Analyse und keine LLM-Aufrufe aus. Ein
|
||||
dauerhaft laufender [Analyseworker](analysis-worker.md) übernimmt anschließend die
|
||||
Aufnahme und Verarbeitung. Die allgemeine Weboberfläche bleibt ein weiterer Ausbauschritt.
|
||||
|
||||
## Lokaler Start
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pip install -e '.[api]'
|
||||
Copy-Item config/api.example.toml config/api.local.toml
|
||||
$env:VBA_INTAKE_TOKEN = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api check --config config/api.local.toml
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml
|
||||
```
|
||||
|
||||
Der API-Token ist ein eigener Zugangsschlüssel, kein Gemini-Key. Programme erhalten
|
||||
je einen Client und einen Token aus einer serverseitigen Umgebungsvariable. Der
|
||||
Token benötigt 32–256 Zeichen aus `A-Z a-z 0-9 _ -`. Auf älterem Windows PowerShell
|
||||
kann ein solcher Token über einen Passwortmanager erzeugt und als Variable gesetzt
|
||||
werden. Der Beispielstart bindet nur an `127.0.0.1:8080`. Für entfernte Nutzung sind
|
||||
HTTPS und ein passend konfigurierter Reverseproxy erforderlich. Tokens nicht in
|
||||
URLs oder Git-Konfiguration ablegen. Zugriff auf das Workspace-Dateisystem bleibt
|
||||
auf den Dienst und vertrauenswürdige Betreiber beschränkt.
|
||||
|
||||
`projects` ist die serverseitige Freigabeliste. Jeder Auftrag benennt `project_id`
|
||||
und `profile_id`. Er kann keine Repository-URLs, DB-Zugänge, Budgets oder
|
||||
Konfigurationspfade überschreiben. Optionale `projects.settings` halten die spätere
|
||||
Worker-Konfiguration bereit; die Eingangsschicht verwendet diese Dateien nicht für
|
||||
Analysen. Aktuell ist der Importadapter `vcore` für `wow/vcore` implementiert.
|
||||
Weitere Projekte benötigen passende Adapter/Profile, keine separat gepflegten Forks.
|
||||
|
||||
## Endpunkte
|
||||
|
||||
| Methode/Pfad | Bedeutung |
|
||||
|---|---|
|
||||
| `GET /healthz` | Minimale öffentliche Erreichbarkeitsprüfung |
|
||||
| `GET /v1/profiles` | Für diesen Client freigegebene Projekte/Profile |
|
||||
| `GET /v1/schema` | Öffentliches JSON-Schema der Übergabemetadaten |
|
||||
| `GET /v1/overview` | Projektbezogene Worker-/Fallübersicht und Tokenstatistik |
|
||||
| `POST /v1/submissions` | Multipart-Upload, `201` bei neuer Einreihung, `200` bei Wiederholung |
|
||||
| `GET /v1/submissions/{id}` | Quittung und aktueller verwalteter Zustand |
|
||||
| `GET /v1/submissions/{id}/package` | Abgeschlossene Akte herunterladen; sonst `409` |
|
||||
|
||||
Alle Endpunkte außer Health benötigen `Authorization: Bearer TOKEN`.
|
||||
POST benötigt zusätzlich `Idempotency-Key: frei-gewaehlte-eindeutige-id` (1–128
|
||||
Zeichen, Buchstaben/Ziffern sowie Punkt, Unterstrich, Bindestrich; erstes Zeichen
|
||||
alphanumerisch). Derselbe Client/Schlüssel mit denselben Metadaten und Dateiinhalten
|
||||
liefert dieselbe Quittung, auch nach einem Neustart. Abweichender Inhalt ergibt
|
||||
`409 API_IDEMPOTENCY_CONFLICT`. Dateinamen und Multipart-Grenzen zählen nicht als
|
||||
Inhaltsänderung; Rollen und Reihenfolge der Dateien zählen. Ein zweiter neuer
|
||||
Auftrag für dasselbe Projekt und dieselbe Case-ID wird zurückgewiesen.
|
||||
|
||||
## Neuer Auftrag
|
||||
|
||||
`metadata` ist ein UTF-8-JSON-Textfeld, kein Datei-Upload. Dateien: genau einmal
|
||||
`wowdiag`, optional einmal `wowsrc`, optional mehrfach `attachments`. Für Vcore muss
|
||||
die Case-ID dem Bug des Erfassungsarchivs entsprechen (`BUG-000020`). Beide
|
||||
Erfassungsarchive müssen zueinander passen. Beispiel `submission.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "new_case",
|
||||
"project_id": "viano-core",
|
||||
"profile_id": "vcore",
|
||||
"case_id": "BUG-000020",
|
||||
"report": {
|
||||
"title": "Wolpertinger fehlen",
|
||||
"observations": ["Nach Questannahme waren im untersuchten Gebiet keine Tiere sichtbar."],
|
||||
"expected_behavior": ["Fangbare Tiere sind im Gebiet vorhanden."]
|
||||
},
|
||||
"versions": {
|
||||
"reported_server": "commit-des-gemeldeten-serverstands",
|
||||
"capture_server": "commit-des-erfassungsstands"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```powershell
|
||||
curl.exe --fail-with-body -H "Authorization: Bearer $env:VBA_INTAKE_TOKEN" -H "Idempotency-Key: bug20-capture-001" -F "metadata=<submission.json" -F "wowdiag=@wowdiag.tar.gz" -F "wowsrc=@wowsrc.tar.gz" http://127.0.0.1:8080/v1/submissions
|
||||
```
|
||||
|
||||
Versionsangaben werden als unbestätigte Angaben des Einlieferers gespeichert, nicht
|
||||
als verifizierte technische Fakten. Sie ersetzen keine Prüfung des Deployments.
|
||||
Zusätzliche Anhänge werden unter generierten internen Namen unverändert erhalten;
|
||||
sie werden nicht ausgeführt. Originalarchive bleiben ebenfalls erhalten.
|
||||
|
||||
## Rückläufer nach Abschluss
|
||||
|
||||
Dateien: genau einmal `case_package` (unveränderte abgeschlossene ZIP-Akte), optional
|
||||
mehrfach `attachments`. Kein erneutes Erzeugen interner JSON-Dateien nötig:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "case_return",
|
||||
"project_id": "viano-core",
|
||||
"profile_id": "vcore",
|
||||
"case_id": "BUG-000020",
|
||||
"feedback": {
|
||||
"outcome": "patch_failed",
|
||||
"summary": "Der Patch wurde getestet, das Verhalten besteht weiter.",
|
||||
"observations": ["Die Tiere bleiben auch nach Neustart unsichtbar."],
|
||||
"patch_id": "pr-123",
|
||||
"tested_build": "commit-des-getesteten-builds"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```powershell
|
||||
curl.exe --fail-with-body -H "Authorization: Bearer $env:VBA_INTAKE_TOKEN" -H "Idempotency-Key: bug20-patch123-feedback" -F "metadata=<return.json" -F "case_package=@case.zip" http://127.0.0.1:8080/v1/submissions
|
||||
```
|
||||
|
||||
Zulässig sind `OUTPUT_READY`, `HANDED_OFF`, `FAILED_TO_ANALYZE`. Die API übernimmt
|
||||
bei `OUTPUT_READY` die interne Übergabemarkierung, ergänzt Feedback, Bericht und
|
||||
Anhänge und wechselt anschließend nach `REOPENED`. Die Paketidentität bleibt,
|
||||
`analysis_revision` steigt genau einmal. Bisherige Artefakte bleiben erhalten und
|
||||
der Casebuilder sieht die neuen Beobachtungen. Das neue Paket wird in die Inbox
|
||||
gelegt; `QUEUED` bedeutet ausschließlich angenommen, nicht erfolgreich analysiert.
|
||||
|
||||
Ein verwalteter Fall darf nur mit seiner exakten aktuellen Akte zurückgegeben
|
||||
werden. Veraltete/divergierende Akten und parallele Rückgaben werden abgewiesen.
|
||||
Ohne lokale Fallhistorie ist eine gültige abgeschlossene, passend zugeordnete Akte
|
||||
importierbar. Alte manuelle Testpakete ohne `project.id` benötigen vorerst eine
|
||||
betreiberseitige Migration; sie werden nicht stillschweigend einem Projekt zugeordnet.
|
||||
BLOCKED-Fälle, laufende Fälle und asynchrone Testerantworten innerhalb einer
|
||||
Untersuchung sind keine Abschlussrückläufer und werden hier nicht wiedereröffnet.
|
||||
|
||||
## Grenzen und Wiederanlauf
|
||||
|
||||
Standard: 768 MiB gesamter HTTP-Body, 512 MiB je Datei, 64 KiB Metadaten, 12 Dateien,
|
||||
120 Sekunden Uploadzeit und zwei gleichzeitige Uploads pro Prozess. Konfigurierbar
|
||||
über `[limits]` mit den Feldnamen aus `IntakeLimits`. Die existierenden Import- und
|
||||
ZIP-Validatoren setzen zusätzlich ihre eigenen Grenzen durch (Vcore-Import ist
|
||||
enger begrenzt). Dies sind Betriebsschutzwerte, keine fachlichen Analysebudgets.
|
||||
|
||||
Uploads werden vor dem Parsen authentifiziert. Auch Requests ohne Content-Length
|
||||
werden anhand tatsächlich empfangener Bytes begrenzt. Multipart-Spools werden bei
|
||||
Fehler/Timeout geschlossen, eigene temporäre Arbeitskopien wieder entfernt. Der
|
||||
Dienst prüft freien Speicher vor Aufnahme und Paketbau. Für den ersten Betrieb
|
||||
einen einzelnen Uvicorn-Prozess verwenden; die Upload-Parallelitätsgrenze gilt pro
|
||||
Prozess. Der Workspace verwendet zusätzlich OS-Dateisperren für Veröffentlichung.
|
||||
|
||||
Unter `runtime/intake/requests` bleiben kleine Quittungen und Hashes. Vor der
|
||||
atomaren Inbox-Veröffentlichung wird eine PREPARED-Quittung mit fertigem Paket
|
||||
gesichert. Beim Neustart werden solche Veröffentlichungen fortgesetzt. Nach
|
||||
Einreihung wird die zusätzliche Paketkopie entfernt. Bereits vom Worker übernommene
|
||||
Pakete werden über ihre Checkpoint-Kette erkannt und nicht erneut eingereiht.
|
||||
Nach hartem Prozessabbruch können verwaiste Arbeitsverzeichnisse unter
|
||||
`runtime/intake/staging` und Betriebssystem-Temporärdateien verbleiben; deren
|
||||
betriebliche Bereinigung/Retention gehört zum folgenden Worker-/Dienst-Ausbau.
|
||||
|
||||
Fehlerantworten enthalten stabile Codes, keine Dateipfade oder Secrets. Wesentliche
|
||||
Fälle: `401` Zugriff, `403` Projektfreigabe, `409` Konflikt/Zustand, `413` Größe,
|
||||
`422` ungültige Metadaten/Archive, `503` beschäftigt/Speicherfehler, `507` freier
|
||||
Speicher zu gering. Bei `503` mit demselben Idempotenzschlüssel wiederholen.
|
||||
|
||||
## Lokale Prüfung
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pip install -e '.[api,api-test]'
|
||||
.\.venv\Scripts\python.exe -m pytest tests/profiles/vcore/test_intake_api.py -q
|
||||
```
|
||||
|
||||
Die Tests verwenden künstliche Erfassungsarchive und HTTP-Testtransport. Sie prüfen
|
||||
auch Feedbackübernahme durch den echten Casebuilder, Idempotenz, veraltete
|
||||
Rückläufer, Uploadgrenzen sowie Abstürze vor und nach Queue-Veröffentlichung. Es
|
||||
finden keine kostenpflichtigen LLM-Aufrufe statt.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Lokale Prüfung des dauerhaften Analyseworkers – 2026-10-21
|
||||
|
||||
Ausgangspunkt: `27feda6` (Übergabe-API). Keine echten Provideranfragen, keine
|
||||
kostenpflichtigen LLM-Tests und kein Start gegen produktive Aufträge.
|
||||
|
||||
## Umfang und Ergebnis
|
||||
|
||||
252 Tests bestanden, fünf bestehende Trialfälle und ein bestehender
|
||||
Telemetrie-Integrationstest separat ausgeklammert.
|
||||
Geprüft wurden Worker, API-Intake, Vcore-Import, Checkpoints, Queue, Casebuilder,
|
||||
Paketfabrik und Paketwriter sowie die übrigen 15 Trialtests und 27 Telemetrietests.
|
||||
|
||||
Die gezielten Diensttests umfassen:
|
||||
|
||||
- Übernahme einer API-Einlieferung und tatsächlichen Casebau ohne manuellen Trial;
|
||||
- Interpretation mit lokalem Fixture-Provider und Wiederanlauf aus einem neuen Workerobjekt;
|
||||
- Wiedereröffnung einer Akte: alte Interpretation bleibt erhalten, neue Revision wird neu interpretiert;
|
||||
- Projekttrennung, Fortschritt anderer Fälle trotz fehlerhaftem Fall;
|
||||
- persistente Wartezeiten, unveränderte LLM-Versuchskennung;
|
||||
- Bewahrung menschlicher/technischer BLOCKED-Sperren und ausgeschöpfter Modellversuche;
|
||||
- explizit zugelassene technische Wiederaufnahme;
|
||||
- Dienst- und Fall-Leases, geordneten Stopp, Konfigurationswechsel und Speicherreserve;
|
||||
- Attention-Markierung der tatsächlich neuesten Generation bei Exception nach einem Checkpoint;
|
||||
- keine Wiederveröffentlichung bei unverändertem abgeschlossenem Scheduler-Ergebnis;
|
||||
- CLI-Prüfung/Status und leeren Einmalbetrieb ohne API-Clienttoken.
|
||||
|
||||
Die vorhandenen fünf Trialfehler sind `INVESTIGATION_PROMPT_LIMIT` in alten
|
||||
synthetischen Untersuchungsfixtures. Gegenprobe: Der vollständige unveränderte
|
||||
`trial.py`-Dispatcher aus `27feda6` wurde ausschließlich im Arbeitsspeicher geladen;
|
||||
dieselben fünf Tests scheiterten, 15 bestanden. Keine Quelldatei wurde dafür
|
||||
zurückgesetzt. Die 20.000-Zeichen-Grenze und die Produktionsprompts wurden nicht
|
||||
verändert, um diese alten Tests passend zu machen.
|
||||
|
||||
Betroffene Tests:
|
||||
|
||||
- `test_trial_full_investigation_delivery_and_resume` (beide Parameterfälle)
|
||||
- `test_cli_run_connects_telemetry_and_publishes`
|
||||
- `test_human_test_returns_blocked_with_pending_budget`
|
||||
- `test_dispatch_yield_resumes_without_repeating_first_model_call`
|
||||
|
||||
Der zusätzliche bestehende Test
|
||||
`test_full_pipeline_observed_without_telemetry_artifacts_or_double_accounting`
|
||||
scheitert in seiner alten synthetischen Pipeline vor dem Codingauftrag mit
|
||||
`CHANGE_REQUEST_DIAGNOSIS_NOT_READY`. Eine Gegenprobe mit der unveränderten
|
||||
Telemetrie-Allowlist aus `27feda6` scheiterte ebenfalls. Dieser Test verwendet
|
||||
keinen Worker; seine Pipeline-/Prompt-Fixtures wurden hier nicht umgebaut.
|
||||
|
||||
Ein Deployment als Betriebssystemdienst und echte Provider-/DB-Erreichbarkeit
|
||||
wurden nicht getestet. Eine einzelne externe Modellanfrage kann bei hartem
|
||||
Prozessabbruch vor dem Antwort-Checkpoint erneut erforderlich werden; die lokalen
|
||||
Tests belegen keine Exactly-once-Garantie des Modellanbieters.
|
||||
|
||||
Vor den Tests geprüft: Test-Workspaces 16.939.129.428 Bytes, kuratiertes Fehlerkorpus
|
||||
2.471.041 Bytes und vorhandene pytest-Temporärkopien 6.143.826 Bytes. Damit bestand
|
||||
Reserve unter dem Entwicklungsbudget von 20.000.000.000 Bytes. Die neuen Tests
|
||||
verwenden kleine künstliche Archive.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Webübersicht – lokale Prüfung vom 2026-10-21
|
||||
|
||||
Ausgangspunkt `012a8d8`. Keine kostenpflichtigen Modellanfragen und kein echter
|
||||
Analyseworker gegen Nutzeraufträge gestartet.
|
||||
|
||||
224 automatisierte Tests bestanden: Dashboard/Zuordnung, Worker/Intake,
|
||||
Antwortreparatur, Response-Recovery, Gateway, Fehlerdiagnostik und Cache.
|
||||
Die Verbrauchszuordnung ergänzt ein optionales Feld im gespeicherten LLM-Aufruf;
|
||||
vorhandene Aufrufe bleiben unverändert und bestehende Schemas kompatibel.
|
||||
|
||||
Geprüft wurden insbesondere:
|
||||
|
||||
- Zugriffsschutz und Projektfreigaben des Overview-Endpunkts;
|
||||
- Fallname, Fortschritt nach abgeschlossenen Stufen und Ausblenden abgeschlossener Fälle;
|
||||
- keine Anzeige alter Worker-Snapshots als lebender Worker;
|
||||
- regulärer erster Aufruf und zusätzliche Korrektur als disjunkte Kategorien;
|
||||
- abgeleitete Dokumente und doppelte Checkpoint-Verweise nicht erneut als Aufrufe gezählt;
|
||||
- Denktokens nicht erneut auf die Ausgabe addiert;
|
||||
- unbekannte Messwerte/Zuordnungen kenntlich gemacht;
|
||||
- Auslieferung lokaler Assets mit Content Security Policy.
|
||||
|
||||
Zusätzlich realer Browser-Smoke-Test mit unsichtbarem Edge und synthetischer
|
||||
temporärer API: Desktop 1440×1200 und Mobil 390×1200, keine horizontale Überbreite,
|
||||
keine JavaScript-/CSP-Konsolenfehler. Projektfilter liefert dieselbe Summe,
|
||||
Rückgabeformular schaltet korrekt um, Abmeldung entfernt angezeigte Daten. Kein
|
||||
Token wurde im Local Storage abgelegt. Die erste Wiederholung des Browsertests
|
||||
traf eine zu frühe Testinteraktion vor Ausführung des defer-Scripts; die
|
||||
Wartebedingung wurde auf vollständige Dokument-/Scriptbereitschaft korrigiert.
|
||||
|
||||
Die Vorschau zeigte genau einen aktiven Worker/Fall bei 57 %, einen bereits
|
||||
abgeschlossenen Fall ausschließlich in der Verbrauchsbilanz und 37.800 synthetische
|
||||
Tokens mit 20,6 % Korrekturanteil. Diese Werte sind **UI-Fixtures, keine gemessenen
|
||||
Analyse- oder Abrechnungskosten**. Screenshots liegen als lokale Prüfergebnisse
|
||||
unter `service-data/ui-preview/desktop.png` und `mobile.png`. Vollständige
|
||||
Testpakete und Browserprofile werden nicht als dauerhaftes Ergebnis archiviert.
|
||||
|
||||
Speicheraudit vor den Tests: 16.939.129.428 Bytes Trial-Workspaces,
|
||||
2.471.041 Bytes Fehlerkorpus und 11.156.187 Bytes vorhandene pytest-Temporärdaten;
|
||||
die neuen synthetischen Tests/Screenshots sind klein und bleiben innerhalb der
|
||||
20-GB-Grenze. Temporäre Vorschauakten wurden entfernt.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Webübersicht
|
||||
|
||||
Die Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml
|
||||
```
|
||||
|
||||
Anschließend `http://127.0.0.1:8080/` öffnen und mit einem freigegebenen **API-Token**
|
||||
anmelden. Dies ist nicht der Gemini-Schlüssel. Der Token bleibt ausschließlich im
|
||||
Arbeitsspeicher des Tabs, nicht in Cookies, Local Storage oder URLs. Für entfernte
|
||||
Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter
|
||||
[Analyseworker](analysis-worker.md) beschrieben. Das Öffnen der Übersicht löst
|
||||
keine Modellanfragen aus.
|
||||
|
||||
## Worker und Fortschritt
|
||||
|
||||
Die Startseite listet lebende Worker und darunter den jeweils bearbeiteten Fall
|
||||
mit seinem Namen und einem Ladebalken. Der bestehende Dienst erlaubt einen Worker
|
||||
je Workspace; die Darstellung ist bereits als Liste aufgebaut. Die Einführung
|
||||
eines parallelen Worker-Pools ist damit nicht verbunden.
|
||||
|
||||
Der Balken folgt abgeschlossenen Arbeitsschritten, nicht vergangener Zeit. Seine
|
||||
sieben Anteile entsprechen intern Aufnahme, Fallaufbau, Interpretation,
|
||||
Hypothesenbildung, Untersuchung, Diagnose und Codingauftrag. Die Oberfläche führt
|
||||
diese Stufen nicht einzeln auf. Der Codingauftrag ist die letzte Stufe, auch wenn
|
||||
sein Dokument fachlich erklärt, dass kein Codingauftrag benötigt wird. Bei
|
||||
gemeinsamer Interpretation/Hypothesengenerierung können zwei Anteile gleichzeitig
|
||||
fertig werden. Eine lang laufende Untersuchung füllt den Balken nicht künstlich.
|
||||
|
||||
Der aktive Fall verschwindet bei `OUTPUT_READY`, `HANDED_OFF` oder
|
||||
`FAILED_TO_ANALYZE` aus der Workeransicht und der Tabelle laufender Fälle. Er bleibt
|
||||
in der Verbrauchsstatistik enthalten. Wartende Fälle bleiben in der Fallübersicht,
|
||||
belegen aber keinen arbeitenden Worker. Ein alter Dienstsnapshot ohne lebenden
|
||||
Telemetrie-Heartbeat wird nicht als aktiver Worker dargestellt.
|
||||
|
||||
## Verbrauch
|
||||
|
||||
- Kreisdiagramm: bekannte Eingabe- und Ausgabetokens je Phase; zusätzliche
|
||||
Korrektur-/Reparatur-/Wiederholungsaufrufe haben ein eigenes orangefarbenes Segment.
|
||||
- Balkendiagramm: Eingabe und Ausgabe einschließlich Denken je Kategorie.
|
||||
- Der erste reguläre Aufruf bleibt bei seiner Phase, auch wenn sein Ergebnis später
|
||||
zurückgewiesen wurde. Seine Korrektur wird ausschließlich dem Korrektursegment
|
||||
zugeordnet. Abgeleitete, deterministisch reparierte Antwortdokumente sind keine
|
||||
zusätzlichen Anbieteraufrufe und werden nicht erneut gezählt.
|
||||
- Denktokens sind bereits Bestandteil der Ausgabetokens. Sie werden weder zum
|
||||
Kreisdiagramm noch zum Gesamtverbrauch noch einmal hinzuaddiert.
|
||||
- Neue LLM-Runden speichern ihre Verbrauchszuordnung deterministisch aus der
|
||||
Operationskennung. Das verändert keine Prompts, Modellparameter oder Budgets.
|
||||
Bereits gespeicherte Aufrufe bleiben unverändert. Bei älteren Akten wird nur
|
||||
eindeutig erkennbare Korrekturinformation übernommen; sonst erscheint der Anteil
|
||||
als „Nicht zugeordnet“.
|
||||
- Fehlende Messwerte bleiben unbekannt. Die Diagramme summieren nur bekannte Werte
|
||||
und weisen unvollständige Aufrufe aus. Bei vollständig fehlender Tokennutzung
|
||||
lässt sich deren Größe nicht als Kreisanteil darstellen.
|
||||
|
||||
Der Messumfang umfasst **alle erhaltenen, für den Client freigegebenen Akten und
|
||||
deren Revisionen** im konfigurierten Workspace. Er ist keine Google-Abrechnung und
|
||||
keine lebenslange Statistik bereits gelöschter Akten. Dauerhafte Testbilanzen unter
|
||||
`docs/test-results/token-usage/` bleiben davon unabhängig. Ein Projektfilter wendet
|
||||
sich auf Fallliste, Worker und Diagramme gleichzeitig an. Gemeinsame Planungsaufrufe
|
||||
werden einmal als Fallplanung gezählt, nicht zusätzlich als Hypothesengenerierung.
|
||||
|
||||
## Übergabe
|
||||
|
||||
Die Oberfläche bietet ein einfaches Formular für neue wowdiag-/wowsrc-Aufträge und
|
||||
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. Vollständige Falldetailansichten und eine
|
||||
Bedienoberfläche für administrative Worker-Eingriffe bleiben weitere Ausbauschritte.
|
||||
|
||||
## Datenschutz und Betrieb
|
||||
|
||||
`GET /v1/overview` benötigt einen Bearer-Token und liefert nur freigegebene Projekte.
|
||||
Prompts, vollständige Modellantworten, lokale Pfade und Zugangsdaten werden nicht
|
||||
an den Browser übergeben. Fallnamen werden als Text dargestellt. Die Seite lädt
|
||||
keine externen Fonts, Chartbibliotheken oder Analysedienste; Diagramme werden lokal
|
||||
mit Canvas/CSS gezeichnet. Eine Content Security Policy begrenzt Ressourcen auf
|
||||
denselben Ursprung. Abmeldung entfernt dargestellte Daten und Formularinhalte;
|
||||
verzögert eintreffende Antworten aus einer vorherigen Anmeldung werden ignoriert.
|
||||
|
||||
Die Ansicht aktualisiert sich alle vier Sekunden. Validierte Darstellungen werden
|
||||
für unveränderte Paketgenerationen im API-Prozess zwischengespeichert; dies ist
|
||||
eine Anzeigeoptimierung, kein fachlicher Zustand und kein Analysecache.
|
||||
|
||||
## Lokale Prüfung
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python.exe -m pytest tests/test_dashboard.py -q
|
||||
node tests/dashboard-smoke.mjs
|
||||
```
|
||||
|
||||
Der Browser-Smoke-Test verwendet einen lokalen temporären API-Server, einen
|
||||
unsichtbaren Edge-Browser und ausdrücklich künstliche Fall-/Verbrauchsdaten. Er
|
||||
prüft Desktop, Mobilansicht, Projektfilter, Formularumschaltung, Abmeldung,
|
||||
Tokenanzeige, Fortschritt und Browserfehler. Unter Windows wird der installierte
|
||||
Edge erwartet; über `BROWSER_PATH` lässt sich ein anderes Chromium angeben.
|
||||
Die Testserver/-browser werden beendet, ihre erzeugten temporären Verzeichnisse
|
||||
nach Pfadprüfung entfernt. Screenshots als Prüfergebnis liegen unter
|
||||
`service-data/ui-preview/`. Es werden keine LLM- oder Datenbankanbieter aufgerufen.
|
||||
@@ -12,6 +12,8 @@ dependencies = ["jsonschema>=4.18,<5"]
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8,<10"]
|
||||
research = ["PyMySQL>=1.1,<2", "zstandard>=0.23,<1"]
|
||||
api = ["starlette>=0.52.1,<0.53", "uvicorn>=0.35,<1", "python-multipart>=0.0.32,<1"]
|
||||
api-test = ["httpx>=0.28,<1", "pytest>=8,<10"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -19,6 +21,7 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
"viano_bug_analysis.schemas" = ["*.json"]
|
||||
"viano_bug_analysis.llm.prompts" = ["*.txt"]
|
||||
"viano_bug_analysis.web" = ["*.html", "*.css", "*.js"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Authenticated intake boundary; no model calls or analysis worker."""
|
||||
|
||||
from .config import APIConfig, Client, Project, IntakeLimits, APIError
|
||||
from .service import IntakeService
|
||||
|
||||
__all__ = ['APIConfig', 'Client', 'Project', 'IntakeLimits', 'APIError', 'IntakeService']
|
||||
@@ -0,0 +1,27 @@
|
||||
import argparse
|
||||
import json
|
||||
from .config import APIConfig, APIError
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Authenticated BugAnalysis intake; no analysis worker')
|
||||
parser.add_argument('command', choices=['check', 'serve'])
|
||||
parser.add_argument('--config', required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
config = APIConfig.load(args.config)
|
||||
config.tokens()
|
||||
if args.command == 'check':
|
||||
print(json.dumps({'status': 'OK', 'projects': [p.id for p in config.projects]}))
|
||||
return 0
|
||||
import uvicorn
|
||||
from .app import create_app
|
||||
uvicorn.run(create_app(config), host=config.host, port=config.port, access_log=False)
|
||||
return 0
|
||||
except APIError as error:
|
||||
print(json.dumps({'status': 'ERROR', 'code': error.code}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,190 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import hmac
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.datastructures import UploadFile
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import FileResponse, JSONResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.formparsers import MultiPartParser, MultiPartException
|
||||
|
||||
from .config import APIError
|
||||
from .contracts import schema
|
||||
from .service import IntakeService
|
||||
from .dashboard import Dashboard
|
||||
from importlib.resources import files
|
||||
|
||||
|
||||
def create_app(config):
|
||||
service = IntakeService(config)
|
||||
dashboard = Dashboard(service)
|
||||
tokens = config.tokens()
|
||||
service.recover()
|
||||
active = 0
|
||||
|
||||
def authenticate(request):
|
||||
header = request.headers.get('authorization', '')
|
||||
if not header.startswith('Bearer ') or not header.isascii():
|
||||
raise APIError('API_UNAUTHORIZED', 401)
|
||||
for client, token in tokens:
|
||||
if hmac.compare_digest(header[7:], token):
|
||||
return client
|
||||
raise APIError('API_UNAUTHORIZED', 401)
|
||||
|
||||
async def health(request):
|
||||
return JSONResponse({'status': 'OK'})
|
||||
|
||||
async def profiles(request):
|
||||
client = authenticate(request)
|
||||
return JSONResponse({'projects': [{'project_id': p.id, 'profile_id': p.profile,
|
||||
'adapter': p.adapter} for p in config.projects if p.id in client.projects]})
|
||||
|
||||
async def contract(request):
|
||||
authenticate(request)
|
||||
return JSONResponse(schema())
|
||||
|
||||
async def overview(request):
|
||||
client = authenticate(request)
|
||||
response = JSONResponse(await run_in_threadpool(dashboard.overview, client))
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
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'}:
|
||||
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'}[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
|
||||
|
||||
async def status(request):
|
||||
client = authenticate(request)
|
||||
return JSONResponse(await run_in_threadpool(service.status, request.path_params['submission'], client))
|
||||
|
||||
async def package(request):
|
||||
client = authenticate(request)
|
||||
path = await run_in_threadpool(service.package, request.path_params['submission'], client)
|
||||
return FileResponse(path, media_type='application/zip', filename='case.zip')
|
||||
|
||||
async def submit(request):
|
||||
nonlocal active
|
||||
client = authenticate(request) # never parse an unauthenticated upload
|
||||
if active >= config.limits.max_inflight_uploads:
|
||||
raise APIError('API_BUSY', 503)
|
||||
if request.headers.get('content-type', '').split(';')[0] != 'multipart/form-data':
|
||||
raise APIError('API_CONTENT_TYPE', 415)
|
||||
active += 1
|
||||
form = None
|
||||
try:
|
||||
if shutil.disk_usage(service.root).free < config.limits.min_free_bytes:
|
||||
raise APIError('API_STORAGE_LOW', 507)
|
||||
async with asyncio.timeout(config.limits.upload_timeout_seconds):
|
||||
parser = UploadParser(request.headers, request.stream(),
|
||||
max_files=config.limits.max_files, max_fields=1,
|
||||
max_part_size=config.limits.max_metadata_bytes,
|
||||
file_limit=config.limits.max_file_bytes)
|
||||
form = await parser.parse()
|
||||
raw = form.get('metadata')
|
||||
if not isinstance(raw, str):
|
||||
raise APIError('API_METADATA_INVALID', 422)
|
||||
with tempfile.TemporaryDirectory(dir=service.staging) as temporary:
|
||||
uploads = []
|
||||
for i, (role, value) in enumerate(form.multi_items()):
|
||||
if role == 'metadata':
|
||||
continue
|
||||
if not isinstance(value, UploadFile):
|
||||
raise APIError('API_UPLOAD_ROLES', 422)
|
||||
path = Path(temporary) / str(i)
|
||||
size = 0
|
||||
with path.open('wb') as output:
|
||||
while chunk := await value.read(65536):
|
||||
size += len(chunk)
|
||||
if size > config.limits.max_file_bytes:
|
||||
raise APIError('API_FILE_LIMIT', 413)
|
||||
output.write(chunk)
|
||||
uploads.append((role, path))
|
||||
result, replay = await run_in_threadpool(functools.partial(service.submit, client,
|
||||
request.headers.get('idempotency-key'), raw.encode('utf-8'), uploads))
|
||||
return JSONResponse(result, status_code=200 if replay else 201)
|
||||
except TimeoutError:
|
||||
raise APIError('API_UPLOAD_TIMEOUT', 408) from None
|
||||
except MultiPartException:
|
||||
raise APIError('API_MULTIPART_INVALID', 400) from None
|
||||
finally:
|
||||
if form is not None:
|
||||
await form.close()
|
||||
active -= 1
|
||||
|
||||
async def error(request, exc):
|
||||
return JSONResponse({'status': 'ERROR', 'code': exc.code}, status_code=exc.status)
|
||||
|
||||
async def http_error(request, exc):
|
||||
return JSONResponse({'status': 'ERROR', 'code': 'API_BAD_REQUEST'}, status_code=exc.status_code)
|
||||
|
||||
async def storage_error(request, exc):
|
||||
return JSONResponse({'status': 'ERROR', 'code': 'API_STORAGE_ERROR'}, status_code=503)
|
||||
|
||||
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
|
||||
Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles),
|
||||
Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']),
|
||||
Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)],
|
||||
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error})
|
||||
app.state.intake = service
|
||||
return BodyLimit(app, config.limits.max_body_bytes)
|
||||
|
||||
|
||||
class UploadParser(MultiPartParser):
|
||||
"""Bound file parts while parsing and close partial spools on cancellation."""
|
||||
def __init__(self, *args, file_limit, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.file_limit = file_limit
|
||||
self.file_bytes = 0
|
||||
|
||||
def on_part_begin(self):
|
||||
super().on_part_begin()
|
||||
self.file_bytes = 0
|
||||
|
||||
def on_part_data(self, data, start, end):
|
||||
if self._current_part.file is not None:
|
||||
self.file_bytes += end - start
|
||||
if self.file_bytes > self.file_limit:
|
||||
raise APIError('API_FILE_LIMIT', 413)
|
||||
super().on_part_data(data, start, end)
|
||||
|
||||
async def parse(self):
|
||||
try:
|
||||
return await super().parse()
|
||||
except BaseException:
|
||||
for file in self._files_to_close_on_error:
|
||||
file.close()
|
||||
raise
|
||||
|
||||
|
||||
class BodyLimit:
|
||||
"""Count actual ASGI bytes, also for chunked requests without Content-Length."""
|
||||
def __init__(self, app, maximum):
|
||||
self.app, self.maximum = app, maximum
|
||||
self.state = app.state
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope['type'] != 'http':
|
||||
return await self.app(scope, receive, send)
|
||||
total = 0
|
||||
async def bounded():
|
||||
nonlocal total
|
||||
message = await receive()
|
||||
total += len(message.get('body', b''))
|
||||
if total > self.maximum:
|
||||
raise APIError('API_BODY_LIMIT', 413)
|
||||
return message
|
||||
await self.app(scope, bounded, send)
|
||||
@@ -0,0 +1,133 @@
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
IDENTIFIER = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}')
|
||||
SETTINGS = {'project_config', 'llm_config', 'investigation_config', 'budget_config',
|
||||
'analysis_config', 'diagnosis_config', 'change_request_config', 'correction_config'}
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
def __init__(self, code, status=400):
|
||||
self.code, self.status = code, status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def directory(path):
|
||||
"""Service-owned paths must not pass through links/junctions."""
|
||||
path = Path(path).absolute()
|
||||
for part in [*reversed(path.parents), path]:
|
||||
if part.exists() or part.is_symlink():
|
||||
info = part.lstat()
|
||||
if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
|
||||
raise APIError('API_UNSAFE_STORAGE', 500)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path.resolve()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntakeLimits:
|
||||
# Operational safety defaults, not permanent package-format constraints.
|
||||
max_body_bytes: int = 768 * 1024**2
|
||||
max_file_bytes: int = 512 * 1024**2
|
||||
max_metadata_bytes: int = 64 * 1024
|
||||
max_files: int = 12
|
||||
upload_timeout_seconds: int = 120
|
||||
max_inflight_uploads: int = 2
|
||||
min_free_bytes: int = 512 * 1024**2
|
||||
|
||||
def __post_init__(self):
|
||||
if any(type(v) is not int or v <= 0 for v in vars(self).values()):
|
||||
raise APIError('API_CONFIG', 500)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Project:
|
||||
id: str
|
||||
type: str
|
||||
profile: str
|
||||
adapter: str = 'vcore'
|
||||
settings: dict = field(default_factory=dict)
|
||||
planning_mode: str = 'joint'
|
||||
|
||||
def __post_init__(self):
|
||||
if (any(not isinstance(v, str) or not IDENTIFIER.fullmatch(v)
|
||||
for v in (self.id, self.type, self.profile))
|
||||
or self.adapter != 'vcore' or (self.type, self.profile) != ('wow', 'vcore')
|
||||
or set(self.settings) - SETTINGS or self.planning_mode not in {'joint', 'separate'}):
|
||||
raise APIError('API_PROFILE_CONFIG', 500)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Client:
|
||||
id: str
|
||||
token_env: str
|
||||
projects: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class APIConfig:
|
||||
workspace: Path
|
||||
projects: tuple[Project, ...]
|
||||
clients: tuple[Client, ...]
|
||||
host: str = '127.0.0.1'
|
||||
port: int = 8080
|
||||
limits: IntakeLimits = field(default_factory=IntakeLimits)
|
||||
|
||||
def __post_init__(self):
|
||||
ids = {p.id for p in self.projects}
|
||||
if (not ids or len(ids) != len(self.projects) or not self.clients
|
||||
or len({c.id for c in self.clients}) != len(self.clients)
|
||||
or type(self.port) is not int or not 0 < self.port < 65536
|
||||
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)
|
||||
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):
|
||||
raise APIError('API_CONFIG', 500)
|
||||
|
||||
def tokens(self):
|
||||
result = []
|
||||
for client in self.clients:
|
||||
token = os.environ.get(client.token_env, '')
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]{32,256}', token):
|
||||
raise APIError('API_CREDENTIALS', 500)
|
||||
if any(token == existing for _, existing in result):
|
||||
raise APIError('API_CREDENTIALS', 500)
|
||||
result.append((client, token))
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def load(cls, path):
|
||||
try:
|
||||
path = Path(path).resolve()
|
||||
data = tomllib.loads(path.read_text(encoding='utf-8'))
|
||||
if set(data) - {'api', 'projects', 'clients', 'limits'}:
|
||||
raise ValueError()
|
||||
api = data['api']
|
||||
if set(api) - {'workspace', 'host', 'port'}:
|
||||
raise ValueError()
|
||||
projects = []
|
||||
for row in data['projects']:
|
||||
row = dict(row)
|
||||
settings = row.pop('settings', {})
|
||||
if set(settings) - SETTINGS:
|
||||
raise ValueError()
|
||||
resolved = {}
|
||||
for key, value in settings.items():
|
||||
target = (path.parent / value).resolve()
|
||||
if not target.is_file():
|
||||
raise ValueError()
|
||||
resolved[key] = target
|
||||
projects.append(Project(**row, settings=resolved))
|
||||
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data['clients']]
|
||||
return cls(workspace=(path.parent / api['workspace']).resolve(),
|
||||
projects=tuple(projects), clients=tuple(clients), host=api.get('host', '127.0.0.1'),
|
||||
port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})))
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
raise APIError('API_CONFIG', 500) from None
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Public metadata contract; clients never describe internal ZIP members."""
|
||||
from importlib.resources import files
|
||||
import json
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from viano_bug_analysis.package.manifest import parse_manifest
|
||||
from .config import APIError
|
||||
|
||||
|
||||
def schema():
|
||||
return json.loads(files('viano_bug_analysis.schemas').joinpath('api-submission-v1.schema.json').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def metadata(raw, limit):
|
||||
try:
|
||||
if len(raw) > limit:
|
||||
raise APIError('API_METADATA_LIMIT', 413)
|
||||
result = parse_manifest(raw)
|
||||
if not Draft202012Validator(schema()).is_valid(result):
|
||||
raise APIError('API_METADATA_INVALID', 422)
|
||||
return result
|
||||
except (ValueError, TypeError, UnicodeError, RecursionError):
|
||||
raise APIError('API_METADATA_INVALID', 422) from None
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Scoped read models. No prompts, raw responses, credentials or host paths leave here."""
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
|
||||
from viano_bug_analysis.lifecycle.storage import key
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
from viano_bug_analysis.telemetry.inspection import read_status
|
||||
from viano_bug_analysis.telemetry.config import TelemetryError
|
||||
|
||||
FINISHED = {'OUTPUT_READY', 'HANDED_OFF', 'FAILED_TO_ANALYZE'}
|
||||
LABELS = {'building': 'Fallaufbau', 'planning': 'Fallplanung', 'hypotheses': 'Hypothesen',
|
||||
'investigation': 'Untersuchung', 'diagnosis': 'Diagnose', 'coding': 'Codingauftrag',
|
||||
'correction': 'Korrekturen / Wiederholungen', 'unknown': 'Nicht zugeordnet'}
|
||||
|
||||
|
||||
def read_json(reader, ref, limit=8*1024**2):
|
||||
if not ref or ref not in reader.files or reader._archive.getinfo(ref).file_size > limit:
|
||||
return {}
|
||||
value = json.loads(reader.read_bytes(ref))
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def phase(call, prompt):
|
||||
if prompt.startswith('change_request'):
|
||||
return 'coding'
|
||||
return {'BUILDING_CASE': 'building', 'INTERPRETING_CASE': 'planning',
|
||||
'GENERATING_HYPOTHESES': 'hypotheses', 'INVESTIGATING_HYPOTHESES': 'investigation',
|
||||
'BUILDING_DIAGNOSIS': 'diagnosis'}.get(call['phase'], 'unknown')
|
||||
|
||||
|
||||
def historical_classification(call, request):
|
||||
prompt = request.get('prompt_version', '')
|
||||
if call['rounds'] > 1 or 'repair' in prompt:
|
||||
return 'correction'
|
||||
for message in request.get('messages', []):
|
||||
try:
|
||||
data = json.loads(message.get('content', ''))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get('task') == 'correct_rejected_output' or data.get('validation_feedback'):
|
||||
return 'correction'
|
||||
context = data.get('input', data)
|
||||
if isinstance(context, dict):
|
||||
if any(context.get(k) for k in ('response_repair', 'validation_feedback', 'correction_feedback')):
|
||||
return 'correction'
|
||||
if call['phase'] == 'INVESTIGATING_HYPOTHESES' and type(context.get('attempt')) is int:
|
||||
return 'correction' if context['attempt'] > 0 else 'regular'
|
||||
# Older records are not silently counted as regular when attribution is unclear.
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def case_view(reader):
|
||||
manifest = reader.manifest
|
||||
revision = manifest['analysis_revision']
|
||||
artifacts = manifest['artifacts']
|
||||
docs = {p: read_json(reader, artifacts.get(p)) for p in (
|
||||
'case', 'case_interpretation', 'hypotheses', 'investigation_state', 'diagnosis', 'change_request')}
|
||||
def current(pointer):
|
||||
doc = docs[pointer]
|
||||
return doc.get('case', doc).get('analysis_revision') == revision
|
||||
chain = checkpoint_chain(reader)
|
||||
complete = [manifest['state'] != 'NEW', current('case'), current('case_interpretation'),
|
||||
current('hypotheses'), (current('investigation_state') and docs['investigation_state'].get('completed') is True)
|
||||
or manifest['state'] == 'BUILDING_DIAGNOSIS' or manifest['state'] in FINISHED,
|
||||
current('diagnosis'), current('change_request')]
|
||||
title = manifest['case_id']
|
||||
reports = docs['case'].get('bug', {}).get('reports', [])
|
||||
if not reports:
|
||||
index = read_json(reader, artifacts.get('case_inputs'))
|
||||
reports = [read_json(reader, ref) for ref in index.get('reports', [])[:20]]
|
||||
for report in reports:
|
||||
if isinstance(report.get('title'), str) and report['title'].strip():
|
||||
title = report['title'][:240]
|
||||
break
|
||||
calls = []
|
||||
seen = set()
|
||||
for _, cp in reversed(chain):
|
||||
detail = cp['details']
|
||||
ref = detail.get('result_ref')
|
||||
if detail.get('operation') != 'llm-round' or not ref or ref in seen:
|
||||
continue
|
||||
seen.add(ref)
|
||||
call = read_json(reader, ref, 16*1024**2)
|
||||
if not call or call.get('derived'):
|
||||
continue # derived repaired documents are not additional provider calls
|
||||
outcome = call.get('outcome', {})
|
||||
meta = outcome.get('metadata', outcome.get('diagnostics', {}))
|
||||
request = read_json(reader, call.get('request_ref'), 8*1024**2)
|
||||
prompt = meta.get('prompt_version', request.get('prompt_version', ''))
|
||||
category = outcome.get('usage_classification') or historical_classification(call, request)
|
||||
bucket = phase(call, prompt) if category == 'regular' else category
|
||||
usage = {}
|
||||
for field in ('input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_input_tokens'):
|
||||
value = meta.get(field)
|
||||
usage[field] = value if type(value) is int and value >= 0 else None
|
||||
calls.append({'bucket': bucket if bucket in LABELS else 'unknown', 'phase': phase(call, prompt),
|
||||
'category': category, 'day': cp['created_at'][:10], **usage})
|
||||
return {k: manifest[k] for k in ('case_id', 'package_id', 'analysis_revision', 'package_generation', 'state')} | {
|
||||
'project_id': manifest['project'].get('id'), 'title': title,
|
||||
'progress': round(sum(complete) / len(complete) * 100), 'calls': calls,
|
||||
'updated_at': manifest['updated_at']}
|
||||
|
||||
|
||||
def aggregate(cases):
|
||||
buckets = {name: {'id': name, 'label': label, 'input_tokens': 0, 'output_tokens': 0,
|
||||
'reasoning_tokens': 0, 'cached_input_tokens': 0, 'calls': 0, 'unknown_usage': 0}
|
||||
for name, label in LABELS.items()}
|
||||
days = {}
|
||||
for case in cases:
|
||||
for call in case['calls']:
|
||||
bucket = buckets[call['bucket']]
|
||||
bucket['calls'] += 1
|
||||
bucket['unknown_usage'] += int(call['input_tokens'] is None or call['output_tokens'] is None)
|
||||
day = days.setdefault(call['day'], {'day': call['day'], 'regular': 0, 'correction': 0, 'unknown': 0})
|
||||
total = (call['input_tokens'] or 0) + (call['output_tokens'] or 0)
|
||||
day[call['category'] if call['category'] in {'regular', 'correction'} else 'unknown'] += total
|
||||
for field in ('input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_input_tokens'):
|
||||
bucket[field] += call[field] or 0
|
||||
return {'buckets': list(buckets.values()), 'days': sorted(days.values(), key=lambda d: d['day']),
|
||||
'scope': 'retained_cases_all_revisions', 'reasoning_included_in_output': True,
|
||||
'unknown_usage_calls': sum(b['unknown_usage'] for b in buckets.values())}
|
||||
|
||||
|
||||
class Dashboard:
|
||||
def __init__(self, service):
|
||||
self.service = service
|
||||
self.lock = threading.Lock()
|
||||
self.cache = {}
|
||||
|
||||
def overview(self, client):
|
||||
with self.lock:
|
||||
views, errors, present = [], 0, set()
|
||||
for folder in self.service.workspace.cases.glob('*/*'):
|
||||
try:
|
||||
if folder.is_symlink() or folder.parent.is_symlink():
|
||||
continue
|
||||
paths = sorted(folder.glob('g*.zip'), key=lambda p: int(p.stem[1:]))
|
||||
if not paths:
|
||||
continue
|
||||
path = paths[-1]
|
||||
if path.is_symlink():
|
||||
continue
|
||||
stat = path.stat()
|
||||
signature = (str(path), stat.st_size, stat.st_mtime_ns)
|
||||
present.add(str(folder))
|
||||
cached = self.cache.get(str(folder))
|
||||
if not cached or cached[0] != signature:
|
||||
with PackageReader(path) as reader:
|
||||
m = reader.manifest
|
||||
if folder.name != key(m['package_id']) or folder.parent.name != key(m['case_id']):
|
||||
continue
|
||||
view = case_view(reader)
|
||||
self.cache[str(folder)] = signature, view
|
||||
else:
|
||||
view = cached[1]
|
||||
if view['project_id'] in client.projects:
|
||||
views.append(view)
|
||||
except Exception:
|
||||
errors += 1
|
||||
self.cache = {k: v for k, v in self.cache.items() if k in present}
|
||||
worker = self._worker(views)
|
||||
cases = [{k: v for k, v in view.items() if k != 'calls'} | {'usage': aggregate([view])['buckets']}
|
||||
for view in views]
|
||||
return {'workers': worker, 'cases': cases, 'usage': aggregate(views),
|
||||
'read_errors': errors, 'observed_at': datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
def _worker(self, cases):
|
||||
try:
|
||||
path = self.service.workspace.runtime / 'worker/status.json'
|
||||
status = json.loads(path.read_bytes())
|
||||
if status.get('status') == 'STOPPED':
|
||||
return []
|
||||
telemetry = read_status(self.service.workspace.root)
|
||||
if (telemetry.get('observer') != 'LIVE' or not status.get('service_id')
|
||||
or status['service_id'] != telemetry.get('service_id')):
|
||||
return [] # never present an old snapshot as a live worker
|
||||
identity = status.get('case')
|
||||
case = next((c for c in cases if identity and c['case_id'] == identity.get('case_id')
|
||||
and c['package_id'] == identity.get('package_id')), None)
|
||||
if identity and (not case or case['state'] in FINISHED):
|
||||
return []
|
||||
return [{'id': telemetry.get('service_id', 'analysis-worker'), 'name': 'Analyseworker 1',
|
||||
'busy': bool(case), 'case': {k: v for k, v in case.items() if k != 'calls'} if case else None}]
|
||||
except (OSError, ValueError, TypeError, AttributeError, TelemetryError):
|
||||
return []
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Trusted conversion of public submissions into append-only case artifacts."""
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
from viano_bug_analysis.lifecycle.checkpoints import CheckpointStore
|
||||
from viano_bug_analysis.lifecycle.job import timestamp
|
||||
from viano_bug_analysis.lifecycle.storage import Workspace, json_bytes
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
from viano_bug_analysis.package.factory import PackageFactory
|
||||
from viano_bug_analysis.profiles.vcore.importer import VcoreImporter
|
||||
from viano_bug_analysis.case.contracts import validate
|
||||
from .config import APIError
|
||||
|
||||
TERMINAL = {'OUTPUT_READY', 'HANDED_OFF', 'FAILED_TO_ANALYZE'}
|
||||
|
||||
|
||||
def documents(data, client, submission, uploads, index):
|
||||
prefix = f'intake/submissions/{submission}'
|
||||
request_ref = prefix + '/request.json'
|
||||
additions = {request_ref: json_bytes({'format': 'viano-api-submission', 'format_version': 1,
|
||||
'received_at': timestamp(), 'actor': client.id, 'metadata': data})}
|
||||
report = {'format': 'viano-bug-report', 'format_version': 1}
|
||||
supplied = data.get('report', {})
|
||||
if 'title' in supplied:
|
||||
report['title'] = supplied['title']
|
||||
if 'description' in supplied:
|
||||
report['summary'] = supplied['description']
|
||||
for source, target in [('observations', 'observations'), ('expected_behavior', 'expected_behavior'),
|
||||
('tester_assumptions', 'tester_hypotheses'), ('reproduction_steps', 'comments')]:
|
||||
report[target] = [{'text': text, 'author': client.id,
|
||||
'origin': {'source_ref': request_ref, 'json_pointer': f'/metadata/report/{source}/{i}'}}
|
||||
for i, text in enumerate(supplied.get(source, []))]
|
||||
if feedback := data.get('feedback'):
|
||||
report['comments'].append({'text': f"{feedback['outcome']}: {feedback['summary']}", 'author': client.id})
|
||||
for field in ('patch_id', 'tested_build'):
|
||||
if field in feedback:
|
||||
report['comments'].append({'text': f"Client-supplied {field}: {feedback[field]}", 'author': client.id})
|
||||
report['observations'].extend({'text': text, 'author': client.id,
|
||||
'origin': {'source_ref': request_ref, 'json_pointer': f'/metadata/feedback/observations/{i}'}}
|
||||
for i, text in enumerate(feedback['observations']))
|
||||
if data.get('versions'):
|
||||
report['comments'].append({'text': 'Client-supplied, unverified versions: ' +
|
||||
json_bytes(data['versions']).decode(), 'author': client.id})
|
||||
report_ref = prefix + '/report.json'
|
||||
validate(report, 'report')
|
||||
additions[report_ref] = json_bytes(report)
|
||||
index = copy.deepcopy(index)
|
||||
index.setdefault('reports', []).append(report_ref)
|
||||
index.setdefault('attachments', []).append(request_ref)
|
||||
for i, (role, path) in enumerate(uploads):
|
||||
if role == 'attachments':
|
||||
ref = prefix + f'/attachments/{i:04d}.bin'
|
||||
additions[ref] = Path(path).read_bytes()
|
||||
index['attachments'].append(ref)
|
||||
index_ref = prefix + '/case-inputs.json'
|
||||
validate(index, 'case-inputs')
|
||||
additions[index_ref] = json_bytes(index)
|
||||
return additions, {'case_inputs': index_ref, 'intake_request': request_ref}
|
||||
|
||||
|
||||
def build_new(destination, data, client, project, submission, package_id, uploads):
|
||||
roles = dict(uploads)
|
||||
plan = VcoreImporter().prepare(roles['wowdiag'], roles.get('wowsrc'))
|
||||
# Do not associate a collector archive with a different externally named bug.
|
||||
if data['case_id'] != f'BUG-{plan.bug_id:06d}':
|
||||
raise APIError('API_CASE_ID_MISMATCH', 422)
|
||||
import json
|
||||
additions, pointers = documents(data, client, submission, uploads,
|
||||
json.loads(plan.additions[plan.index_ref]))
|
||||
now = timestamp()
|
||||
manifest = {'format': 'viano-bug-case-package', 'format_version': 1, 'package_id': package_id,
|
||||
'package_generation': 0, 'case_id': data['case_id'],
|
||||
'project': {'id': project.id, 'type': project.type, 'profile': project.profile},
|
||||
'created_at': now, 'updated_at': now, 'state': 'NEW', 'analysis_revision': 0,
|
||||
'routing': {'destination': 'inbox', 'reason': None, 'retry_count': 0, 'retry_not_before': None},
|
||||
'artifacts': {'case': None, 'diagnosis': None, 'change_request': None,
|
||||
'vcore_import': plan.receipt_ref, **pointers},
|
||||
'history': {'analysis_revisions': 0, 'reproductions': plan.reproductions_count,
|
||||
'evidence': 0, 'handoffs': 0, 'feedback': 0, 'events_ref': None}, 'integrity': {}}
|
||||
PackageFactory().create(destination, manifest, plan.additions | additions)
|
||||
|
||||
|
||||
def build_return(destination, scratch, data, client, project, submission, uploads):
|
||||
source = dict(uploads)['case_package']
|
||||
with PackageReader(source) as reader:
|
||||
original = reader.manifest
|
||||
if (original['case_id'] != data['case_id'] or original['project'] !=
|
||||
{'id': project.id, 'type': project.type, 'profile': project.profile}):
|
||||
raise APIError('API_CASE_IDENTITY', 422)
|
||||
if original['state'] not in TERMINAL:
|
||||
raise APIError('API_CASE_NOT_COMPLETED', 409)
|
||||
import json
|
||||
index = json.loads(reader.read_bytes(original['artifacts']['case_inputs']))
|
||||
validate(index, 'case-inputs')
|
||||
additions, pointers = documents(data, client, submission, uploads, index)
|
||||
store = CheckpointStore(Workspace(scratch))
|
||||
store.intake(source)
|
||||
case_id, package_id = original['case_id'], original['package_id']
|
||||
if original['state'] == 'OUTPUT_READY':
|
||||
store.advance(case_id, package_id, 'HANDED_OFF', step_id=f'api:{submission}:handoff')
|
||||
result = store.advance(case_id, package_id, 'REOPENED', step_id=f'api:{submission}:reopen',
|
||||
details={'operation': 'api-case-return', 'submission_id': submission}, additions=additions,
|
||||
artifacts=pointers, feedback_increment=1)
|
||||
import shutil
|
||||
shutil.copyfile(result, destination)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Durable, idempotent inbox publication. This boundary never invokes an LLM."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from viano_bug_analysis.lifecycle.checkpoints import CheckpointStore, checkpoint_chain, fingerprint
|
||||
from viano_bug_analysis.lifecycle.errors import LifecycleError, LockBusy
|
||||
from viano_bug_analysis.lifecycle.locking import FileLock
|
||||
from viano_bug_analysis.lifecycle.storage import Workspace, atomic_json, json_bytes, key
|
||||
from viano_bug_analysis.package import PackageReader, PackageReadError, PackageWriteError
|
||||
from viano_bug_analysis.profiles.vcore.errors import AdapterError
|
||||
from viano_bug_analysis.case.errors import CaseBuildError
|
||||
from .config import APIError, IDENTIFIER, directory
|
||||
from .contracts import metadata
|
||||
from .packages import TERMINAL, build_new, build_return
|
||||
|
||||
|
||||
def digest(path):
|
||||
with open(path, 'rb') as stream:
|
||||
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
|
||||
|
||||
class IntakeService:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
directory(config.workspace)
|
||||
self.workspace = Workspace(config.workspace)
|
||||
for name in ('inbox', 'cases', 'runtime', 'runtime/locks', 'runtime/intake'):
|
||||
directory(self.workspace.root / name)
|
||||
self.root = self.workspace.runtime / 'intake'
|
||||
self.requests = directory(self.root / 'requests')
|
||||
self.staging = directory(self.root / 'staging')
|
||||
self.store = CheckpointStore(self.workspace)
|
||||
|
||||
def project(self, client, data):
|
||||
if data['project_id'] not in client.projects:
|
||||
raise APIError('API_PROJECT_FORBIDDEN', 403)
|
||||
project = next(p for p in self.config.projects if p.id == data['project_id'])
|
||||
if project.profile != data['profile_id']:
|
||||
raise APIError('API_PROFILE_MISMATCH', 422)
|
||||
return project
|
||||
|
||||
def _receipt(self, submission, client):
|
||||
if not IDENTIFIER.fullmatch(submission):
|
||||
raise APIError('API_NOT_FOUND', 404)
|
||||
path = self.requests / submission / 'receipt.json'
|
||||
if not path.is_file():
|
||||
raise APIError('API_NOT_FOUND', 404)
|
||||
receipt = json.loads(path.read_bytes())
|
||||
if receipt['project_id'] not in client.projects:
|
||||
raise APIError('API_NOT_FOUND', 404)
|
||||
return receipt
|
||||
|
||||
def status(self, submission, client):
|
||||
receipt = self._receipt(submission, client)
|
||||
status = receipt['status']
|
||||
revision = receipt['analysis_revision']
|
||||
generation = None
|
||||
latest = self.store.latest(receipt['case_id'], receipt['package_id'])
|
||||
if self._known(receipt, latest):
|
||||
with PackageReader(latest) as reader:
|
||||
status = reader.manifest['state']
|
||||
revision = reader.manifest['analysis_revision']
|
||||
generation = reader.manifest['package_generation']
|
||||
result = {k: receipt[k] for k in ('submission_id', 'project_id', 'profile_id', 'case_id',
|
||||
'package_id', 'analysis_revision')} | {'status': status}
|
||||
result['analysis_revision'] = revision
|
||||
if generation is not None:
|
||||
result['package_generation'] = generation
|
||||
ledger = self.workspace.runtime / 'worker/cases' / (key(receipt['case_id'] + ':' + receipt['package_id']) + '.json')
|
||||
if ledger.is_file():
|
||||
try:
|
||||
record = json.loads(ledger.read_bytes())
|
||||
if record.get('analysis_revision') == revision:
|
||||
result['worker'] = {k: record.get(k) for k in ('status', 'code', 'retry_not_before', 'updated_at')}
|
||||
except (OSError, ValueError, TypeError):
|
||||
result['worker'] = {'status': 'UNKNOWN'}
|
||||
return result
|
||||
|
||||
def package(self, submission, client):
|
||||
receipt = self._receipt(submission, client)
|
||||
latest = self.store.latest(receipt['case_id'], receipt['package_id'])
|
||||
if self._known(receipt, latest):
|
||||
with PackageReader(latest) as reader:
|
||||
if reader.manifest['state'] in TERMINAL:
|
||||
return latest
|
||||
raise APIError('API_RESULT_NOT_READY', 409)
|
||||
|
||||
def _known(self, receipt, head):
|
||||
if not head:
|
||||
return False
|
||||
with PackageReader(head) as reader:
|
||||
return (fingerprint(reader) == receipt['output_fingerprint'] or
|
||||
any(c['source_fingerprint'] == receipt['output_fingerprint']
|
||||
for _, c in checkpoint_chain(reader)))
|
||||
|
||||
def _publish(self, receipt):
|
||||
folder = self.requests / receipt['submission_id']
|
||||
inbox = self.workspace.inbox / (receipt['submission_id'] + '.zip')
|
||||
with self.store._locks(receipt['case_id']):
|
||||
head = self.store.latest(receipt['case_id'], receipt['package_id'])
|
||||
if inbox.exists():
|
||||
if digest(inbox) != receipt['output_sha256']:
|
||||
raise APIError('API_QUEUE_CONFLICT', 409)
|
||||
elif not self._known(receipt, head):
|
||||
actual = None
|
||||
if head:
|
||||
with PackageReader(head) as reader:
|
||||
actual = fingerprint(reader)
|
||||
if actual != receipt['base_fingerprint']:
|
||||
raise APIError('API_STALE_CASE', 409)
|
||||
prepared = folder / 'package.zip'
|
||||
if not prepared.is_file() or digest(prepared) != receipt['output_sha256']:
|
||||
raise APIError('API_PREPARED_PACKAGE_MISSING', 500)
|
||||
# Hard-link publication is atomic and cannot replace a queue entry.
|
||||
os.link(prepared, inbox)
|
||||
receipt['status'] = 'QUEUED'
|
||||
atomic_json(folder / 'receipt.json', receipt)
|
||||
(folder / 'package.zip').unlink(missing_ok=True)
|
||||
|
||||
def recover(self):
|
||||
"""Resume only committed PREPARED submissions after an interrupted publish."""
|
||||
with FileLock(self.workspace.runtime / 'locks/api-intake.lock'):
|
||||
for path in self.requests.glob('*/receipt.json'):
|
||||
receipt = json.loads(path.read_bytes())
|
||||
if receipt['status'] == 'PREPARED':
|
||||
self._publish(receipt)
|
||||
|
||||
def submit(self, client, idempotency_key, raw_metadata, uploads):
|
||||
if not isinstance(idempotency_key, str) or not IDENTIFIER.fullmatch(idempotency_key):
|
||||
raise APIError('API_IDEMPOTENCY_KEY', 400)
|
||||
data = metadata(raw_metadata, self.config.limits.max_metadata_bytes)
|
||||
project = self.project(client, data)
|
||||
roles = [r for r, _ in uploads]
|
||||
required = 'wowdiag' if data['kind'] == 'new_case' else 'case_package'
|
||||
allowed = {'wowdiag', 'wowsrc', 'attachments'} if data['kind'] == 'new_case' else {'case_package', 'attachments'}
|
||||
if (required not in roles or set(roles) - allowed or len(roles) > self.config.limits.max_files
|
||||
or any(roles.count(r) > 1 for r in allowed - {'attachments'})):
|
||||
raise APIError('API_UPLOAD_ROLES', 422)
|
||||
if any(Path(p).stat().st_size > self.config.limits.max_file_bytes for _, p in uploads):
|
||||
raise APIError('API_FILE_LIMIT', 413)
|
||||
submission = key(client.id + ':' + idempotency_key)
|
||||
request_hash = key(json_bytes({'metadata': data, 'files': [
|
||||
[r, Path(p).stat().st_size, digest(p)] for r, p in uploads]}).decode())
|
||||
try:
|
||||
with FileLock(self.workspace.runtime / 'locks/api-intake.lock'):
|
||||
folder = directory(self.requests / submission)
|
||||
receipt_path = folder / 'receipt.json'
|
||||
if receipt_path.exists():
|
||||
receipt = json.loads(receipt_path.read_bytes())
|
||||
if receipt['request_hash'] != request_hash:
|
||||
raise APIError('API_IDEMPOTENCY_CONFLICT', 409)
|
||||
if receipt['status'] == 'PREPARED':
|
||||
self._publish(receipt)
|
||||
return self.status(submission, client), True
|
||||
package_id = 'api-' + key(project.id + ':' + data['case_id'])
|
||||
base = None
|
||||
if data['kind'] == 'case_return':
|
||||
with PackageReader(dict(uploads)['case_package']) as reader:
|
||||
package_id = reader.manifest['package_id']
|
||||
incoming = fingerprint(reader)
|
||||
head = self.store.latest(data['case_id'], package_id)
|
||||
if head:
|
||||
with PackageReader(head) as reader:
|
||||
base = fingerprint(reader)
|
||||
if base != incoming:
|
||||
raise APIError('API_STALE_CASE', 409)
|
||||
elif self.store.latest(data['case_id'], package_id):
|
||||
raise APIError('API_CASE_EXISTS', 409)
|
||||
# An unconsumed submission, including a crash-prepared one, owns this identity.
|
||||
for path in self.requests.glob('*/receipt.json'):
|
||||
old = json.loads(path.read_bytes())
|
||||
if old['case_id'] == data['case_id'] and old['project_id'] == project.id:
|
||||
if data['kind'] == 'new_case':
|
||||
raise APIError('API_CASE_EXISTS', 409)
|
||||
if old['status'] == 'PREPARED' or (self.workspace.inbox / (old['submission_id'] + '.zip')).exists():
|
||||
raise APIError('API_CASE_PENDING', 409)
|
||||
reserve = sum(Path(p).stat().st_size for _, p in uploads) * 5
|
||||
if shutil.disk_usage(self.root).free < self.config.limits.min_free_bytes + reserve:
|
||||
raise APIError('API_STORAGE_LOW', 507)
|
||||
with tempfile.TemporaryDirectory(dir=self.staging) as temporary:
|
||||
destination = Path(temporary) / 'package.zip'
|
||||
if data['kind'] == 'new_case':
|
||||
build_new(destination, data, client, project, submission, package_id, uploads)
|
||||
else:
|
||||
build_return(destination, Path(temporary) / 'scratch', data, client, project, submission, uploads)
|
||||
with PackageReader(destination) as reader:
|
||||
output_fingerprint = fingerprint(reader)
|
||||
revision = reader.manifest['analysis_revision']
|
||||
# Unreferenced package from an interrupted pre-receipt build can be replaced.
|
||||
os.replace(destination, folder / 'package.zip')
|
||||
with open(folder / 'package.zip', 'r+b') as stream:
|
||||
os.fsync(stream.fileno())
|
||||
receipt = {'submission_id': submission, 'status': 'PREPARED', 'client_id': client.id,
|
||||
'project_id': project.id, 'profile_id': project.profile, 'case_id': data['case_id'],
|
||||
'package_id': package_id, 'analysis_revision': revision, 'request_hash': request_hash,
|
||||
'base_fingerprint': base, 'output_fingerprint': output_fingerprint,
|
||||
'output_sha256': digest(folder / 'package.zip')}
|
||||
atomic_json(receipt_path, receipt)
|
||||
self._publish(receipt)
|
||||
return self.status(submission, client), False
|
||||
except LockBusy:
|
||||
raise APIError('API_BUSY', 503) from None
|
||||
except (PackageReadError, PackageWriteError, AdapterError, LifecycleError,
|
||||
CaseBuildError, ValueError, TypeError, KeyError, RecursionError):
|
||||
raise APIError('API_PACKAGE_INVALID', 422) from None
|
||||
@@ -283,7 +283,8 @@ class CheckpointStore:
|
||||
reason: str | None = None, retry_not_before: str | None = None,
|
||||
details: dict | None = None, additions: dict[str, bytes] | None = None,
|
||||
artifacts: dict[str, str | None] | None = None,
|
||||
expected_generation: int | None = None, manual_retry: bool = False) -> Path:
|
||||
expected_generation: int | None = None, manual_retry: bool = False,
|
||||
feedback_increment: int = 0) -> Path:
|
||||
try:
|
||||
if not isinstance(step_id, str) or not step_id.strip():
|
||||
raise LifecycleError("INVALID_STEP", "A nonblank stable step_id is required")
|
||||
@@ -295,11 +296,15 @@ class CheckpointStore:
|
||||
raise LifecycleError("INVALID_GENERATION", "expected_generation must be a nonnegative integer")
|
||||
if type(manual_retry) is not bool:
|
||||
raise LifecycleError("INVALID_RETRY", "manual_retry must be boolean")
|
||||
if type(feedback_increment) is not int or feedback_increment not in (0, 1) or (feedback_increment and target != State.REOPENED):
|
||||
raise LifecycleError("INVALID_FEEDBACK", "Feedback may only be appended when reopening")
|
||||
if manual_retry:
|
||||
details = {**(details or {}), "manual_retry": True}
|
||||
payload = {"target": target, "reason": reason, "retry_not_before": retry_not_before,
|
||||
"details": details or {}, "artifacts": artifacts or {},
|
||||
"additions": {name: hashlib.sha256(data).hexdigest() for name, data in (additions or {}).items()}}
|
||||
if feedback_increment:
|
||||
payload['feedback_increment'] = feedback_increment
|
||||
request_hash = hashlib.sha256(json_bytes(payload)).hexdigest()
|
||||
with self._locks(case_id):
|
||||
latest = self.latest(case_id, package_id)
|
||||
@@ -362,6 +367,8 @@ class CheckpointStore:
|
||||
if target == State.REOPENED or (manifest["state"] == State.VALIDATED and target == State.BUILDING_CASE and revision == 0):
|
||||
revision += 1
|
||||
history = {"analysis_revisions": max(int(manifest["history"]["analysis_revisions"]), revision)}
|
||||
if feedback_increment:
|
||||
history['feedback'] = int(manifest['history']['feedback']) + feedback_increment
|
||||
return self._commit(reader, step_id, request_hash, target, details=details, additions=additions,
|
||||
resume_state=resume, updates={"routing": routing, "analysis_revision": revision,
|
||||
"history": history, "artifacts": artifacts or {}})
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Classify additional repair calls without moving the first rejected call."""
|
||||
import re
|
||||
|
||||
|
||||
def classification(operation, rounds, prompt):
|
||||
if rounds > 1 or any(word in operation for word in (':answer-repair:', ':retry:', 'correction-')) or 'repair' in prompt:
|
||||
return 'correction'
|
||||
for pattern, first in ((r'^investigation:v1:[^:]+:[0-9]+:([0-9]+):', 0),
|
||||
(r'^investigation-startup-review:v1:([0-9]+):', 0),
|
||||
(r'^entity-resolution:v1:[^:]+:([0-9]+)$', 1),
|
||||
(r'^(?:diagnosis|change-request):v1:.*?attempt:([0-9]+)', 1)):
|
||||
if match := re.search(pattern, operation):
|
||||
return 'correction' if int(match[1]) > first else 'regular'
|
||||
if re.fullmatch(r'(?:case-interpretation|hypotheses):v1:default', operation):
|
||||
return 'regular'
|
||||
return 'unknown'
|
||||
@@ -173,6 +173,8 @@ class CaseLLMRunner:
|
||||
record = {"format": "viano-llm-call", "format_version": 1,
|
||||
"case_id": case_id, "package_id": package_id, "analysis_revision": manifest["analysis_revision"],
|
||||
**details, "outcome": outcome}
|
||||
from .accounting import classification
|
||||
record['outcome']['usage_classification'] = classification(operation_id, rounds, request['prompt_version'])
|
||||
if phase == State.INVESTIGATING_HYPOTHESES:
|
||||
from .investigation_size import characters, TARGET, LIMIT
|
||||
record['outcome']['prompt_size'] = {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["kind", "project_id", "profile_id", "case_id"],
|
||||
"properties": {
|
||||
"kind": {"enum": ["new_case", "case_return"]},
|
||||
"project_id": {"$ref": "#/$defs/id"}, "profile_id": {"$ref": "#/$defs/id"},
|
||||
"case_id": {"$ref": "#/$defs/id"},
|
||||
"report": {"type": "object", "additionalProperties": false, "properties": {
|
||||
"title": {"$ref": "#/$defs/text"}, "description": {"$ref": "#/$defs/text"},
|
||||
"observations": {"$ref": "#/$defs/texts"}, "expected_behavior": {"$ref": "#/$defs/texts"},
|
||||
"tester_assumptions": {"$ref": "#/$defs/texts"}, "reproduction_steps": {"$ref": "#/$defs/texts"}
|
||||
}},
|
||||
"versions": {"type": "object", "additionalProperties": false, "properties": {
|
||||
"reported_server": {"$ref": "#/$defs/text"}, "capture_server": {"$ref": "#/$defs/text"},
|
||||
"tested_build": {"$ref": "#/$defs/text"}
|
||||
}},
|
||||
"feedback": {"type": "object", "additionalProperties": false, "required": ["outcome", "summary", "observations"], "properties": {
|
||||
"outcome": {"enum": ["patch_failed", "validation_failed", "review_feedback", "new_evidence"]},
|
||||
"summary": {"$ref": "#/$defs/text"}, "observations": {"$ref": "#/$defs/texts"},
|
||||
"patch_id": {"$ref": "#/$defs/text"}, "tested_build": {"$ref": "#/$defs/text"}
|
||||
}}
|
||||
},
|
||||
"allOf": [{"if": {"properties": {"kind": {"const": "case_return"}}},
|
||||
"then": {"required": ["feedback"]}, "else": {"not": {"required": ["feedback"]}}}],
|
||||
"$defs": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$"},
|
||||
"text": {"type": "string", "minLength": 1, "maxLength": 12000, "pattern": "\\S"},
|
||||
"texts": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/$defs/text"}}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@
|
||||
"outcome": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"usage_classification": {"enum": ["regular", "correction", "unknown"]},
|
||||
"http_status": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
|
||||
@@ -46,3 +46,16 @@ KNOWN_MODULES = KNOWN_MODULES | frozenset({"viano_bug_analysis.analysis.investig
|
||||
|
||||
KNOWN_CODES = KNOWN_CODES | frozenset({"INVESTIGATION_ENTITY_REFERENCE"})
|
||||
KNOWN_MODULES = KNOWN_MODULES | frozenset({"viano_bug_analysis.analysis.investigation_contract"})
|
||||
|
||||
# Operational service boundary added after the original analysis allowlist.
|
||||
KNOWN_CODES |= frozenset({
|
||||
'WORKER_CONFIG', 'WORKER_LLM_CONFIG_REQUIRED', 'WORKER_PROJECT_BINDING',
|
||||
'WORKER_CONFIG_CHANGED', 'WORKER_STORAGE_IDENTITY', 'WORKER_CASE_ERROR',
|
||||
'WORKER_PROJECT_UNKNOWN', 'WORKER_STORAGE_LOW', 'WORKER_CYCLE_ERROR',
|
||||
'WORKER_BUSY', 'WORKER_RETRY_STATE', 'WORKER_NOT_FOUND',
|
||||
})
|
||||
KNOWN_MODULES |= frozenset({
|
||||
'viano_bug_analysis.workflow', 'viano_bug_analysis.worker.config',
|
||||
'viano_bug_analysis.worker.service', 'viano_bug_analysis.worker.__main__',
|
||||
})
|
||||
KNOWN_CLASSES |= frozenset({'WorkerError'})
|
||||
@@ -25,131 +25,7 @@ from .telemetry.safety import safe_code
|
||||
from .analysis.correction import CorrectionConfig
|
||||
|
||||
|
||||
class TrialError(Exception):
|
||||
def __init__(self, code):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def preflight(path):
|
||||
"""Validate transport configuration and credentials without sending requests."""
|
||||
config = GatewayConfig.load(path)
|
||||
gateway = LLMGateway(config)
|
||||
eligible = [p for p in config.providers
|
||||
if p.enabled and p.privacy in PRIVACY[config.privacy_profile]]
|
||||
if not eligible:
|
||||
raise TrialError("LLM_PRIVACY_NO_PROVIDER")
|
||||
for provider in eligible:
|
||||
if provider.api_key_env:
|
||||
value = os.environ.get(provider.api_key_env)
|
||||
if not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||||
raise TrialError("LLM_CREDENTIALS")
|
||||
return gateway
|
||||
|
||||
|
||||
def describe(path, status, *, code=None, exported=None, retry_not_before=None):
|
||||
with PackageReader(path) as reader:
|
||||
m = reader.manifest
|
||||
return {"status": status, "code": code, "package": str(path),
|
||||
"case_id": m["case_id"], "package_id": m["package_id"],
|
||||
"package_generation": m["package_generation"],
|
||||
"analysis_revision": m["analysis_revision"], "state": m["state"],
|
||||
"routing": m["routing"], "exported": str(exported) if exported else None,
|
||||
"retry_not_before": retry_not_before}
|
||||
|
||||
|
||||
async def drive(store, gateway, case_id, package_id, *, max_dispatches=100,
|
||||
attempt_id="default", policies=None, progress=None, stop_after=None):
|
||||
if type(max_dispatches) is not int or max_dispatches <= 0:
|
||||
raise TrialError("TRIAL_DISPATCH_LIMIT")
|
||||
if stop_after not in {None, "case", "interpretation"}:
|
||||
raise TrialError("TRIAL_STOP_PHASE")
|
||||
policies = policies or {}
|
||||
identity = (case_id, package_id)
|
||||
correction = policies.get("correction", CorrectionConfig())
|
||||
from .tools import InvestigationToolRouter
|
||||
project_research = policies.get("project_research")
|
||||
interpreter = CaseInterpreter(store, gateway, correction_config=correction, project_research=project_research,
|
||||
joint_config=policies.get("analysis", HypothesisConfig()) if policies.get("planning_mode") == "joint" else None)
|
||||
hypotheses = HypothesisBuilder(store, gateway, policies.get("analysis"), correction_config=correction)
|
||||
loop = Investigator(store, gateway, policies.get("investigation"),
|
||||
budget_config=policies.get("budget"),
|
||||
router=InvestigationToolRouter(project_research=project_research))
|
||||
diagnosis = DiagnosisBuilder(store, gateway, policies.get("diagnosis"))
|
||||
request = ChangeRequestBuilder(store, gateway, policies.get("change_request"))
|
||||
output = OutputValidator(store, gateway)
|
||||
for dispatch in range(max_dispatches):
|
||||
latest = store.latest(*identity)
|
||||
if latest is None:
|
||||
raise TrialError("CASE_NOT_FOUND")
|
||||
with PackageReader(latest, store.limits) as reader:
|
||||
state = reader.manifest["state"]
|
||||
artifacts = reader.manifest["artifacts"]
|
||||
routing = reader.manifest["routing"]
|
||||
if state == State.BLOCKED:
|
||||
return describe(latest, "BLOCKED", code=routing["reason"],
|
||||
retry_not_before=routing["retry_not_before"])
|
||||
if phase_complete(latest, stop_after):
|
||||
return describe(latest, "PHASE_COMPLETE")
|
||||
if progress:
|
||||
progress({"dispatch": dispatch + 1, "state": state})
|
||||
if state in {State.OUTPUT_READY, State.FAILED_TO_ANALYZE, State.HANDED_OFF}:
|
||||
result = await output.publish(*identity)
|
||||
elif state == State.BUILDING_CASE and artifacts.get("case"):
|
||||
result = await interpreter.interpret(*identity, attempt_id=attempt_id)
|
||||
elif state in {State.VALIDATED, State.REOPENED, State.BUILDING_CASE}:
|
||||
result = CaseBuilder(store, project_research=project_research).build(*identity)
|
||||
elif state == State.INTERPRETING_CASE:
|
||||
if artifacts.get("case_interpretation"):
|
||||
result = await hypotheses.build(*identity, attempt_id=attempt_id)
|
||||
else:
|
||||
result = await interpreter.interpret(*identity, attempt_id=attempt_id)
|
||||
elif state == State.GENERATING_HYPOTHESES:
|
||||
if artifacts.get("hypotheses"):
|
||||
result = await loop.step(*identity)
|
||||
else:
|
||||
result = await hypotheses.build(*identity, attempt_id=attempt_id)
|
||||
elif state == State.INVESTIGATING_HYPOTHESES:
|
||||
result = await loop.step(*identity)
|
||||
elif state == State.BUILDING_DIAGNOSIS:
|
||||
if not artifacts.get("diagnosis"):
|
||||
result = await diagnosis.build(*identity)
|
||||
elif not artifacts.get("change_request"):
|
||||
result = await request.build(*identity)
|
||||
else:
|
||||
result = await output.publish(*identity)
|
||||
else:
|
||||
raise TrialError("TRIAL_PHASE")
|
||||
status = getattr(result, "status", "COMPLETED")
|
||||
if status == "INCONCLUSIVE":
|
||||
result = await output.publish(*identity)
|
||||
status = result.status
|
||||
if status in {"BLOCKED", "RETRY_PENDING", "DEFERRED", "ERROR", "BUSY",
|
||||
"OUTPUT_READY", "REVIEW", "HANDED_OFF"}:
|
||||
return describe(result.package, status, code=getattr(result, "code", None),
|
||||
exported=getattr(result, "exported", None),
|
||||
retry_not_before=getattr(result, "retry_not_before", None))
|
||||
latest = store.latest(*identity)
|
||||
return describe(latest, "PHASE_COMPLETE" if phase_complete(latest, stop_after) else "YIELDED")
|
||||
|
||||
|
||||
def phase_complete(path, stop_after):
|
||||
if stop_after is None:
|
||||
return False
|
||||
from .case.contracts import read_json
|
||||
with PackageReader(path) as reader:
|
||||
manifest = reader.manifest
|
||||
if stop_after == "case":
|
||||
ref = manifest["artifacts"].get("case")
|
||||
if manifest["state"] != State.BUILDING_CASE or not ref:
|
||||
return False
|
||||
doc = read_json(reader, ref, "case", 8 * 1024 * 1024)["case"]
|
||||
else:
|
||||
ref = manifest["artifacts"].get("case_interpretation")
|
||||
if manifest["state"] != State.INTERPRETING_CASE or not ref:
|
||||
return False
|
||||
doc = read_json(reader, ref, "case-interpretation", 2 * 1024 * 1024)
|
||||
return all(doc[field] == manifest[field] for field in ("case_id", "package_id", "analysis_revision"))
|
||||
from .workflow import TrialError, preflight, describe, drive, phase_complete
|
||||
|
||||
|
||||
def positive(text):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Local web console assets; no external analytics, fonts or chart dependencies."""
|
||||
@@ -0,0 +1,3 @@
|
||||
:root{--bg:#f4f5f8;--ink:#202536;--muted:#727a8c;--line:#e7e9f0;--accent:#5364de;--orange:#e9954c}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 system-ui,-apple-system,Segoe UI,sans-serif}.rail{position:fixed;inset:0 auto 0 0;width:210px;background:#202637;color:#a9b1c6;padding:36px 24px;display:flex;flex-direction:column}.brand{color:white;font-size:30px;font-weight:800;letter-spacing:-2px;text-decoration:none}.brand span{color:#a6afff}.brand small{display:block;font-size:12px;letter-spacing:2px;color:#9aa4bf;font-weight:500}nav{display:grid;gap:10px;margin-top:58px}nav a{color:#aeb7cc;text-decoration:none;padding:12px;border-radius:8px}nav a:hover,nav .selected{background:#323b56;color:white}.rail-bottom{margin-top:auto;font-size:10px;letter-spacing:1px;color:#7f8aa5}.rail-bottom span{font-size:12px;color:#c2cae0;letter-spacing:0}main{margin-left:210px;padding:42px 42px 30px;max-width:1660px}header{display:flex;align-items:center;justify-content:space-between;margin-bottom:30px}h1{font-size:36px;letter-spacing:-1.4px;line-height:1.2;margin:8px 0}h2{font-size:19px;letter-spacing:-.4px;margin:4px 0}p{color:var(--muted);margin:6px 0 12px}.eyebrow{font-size:10px;letter-spacing:1.8px;font-weight:750;color:#8b93a7}.live{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px}.dot{width:7px;height:7px;background:#8e98ad;border-radius:50%}.connected .dot{background:#3db28a}.panel{background:#fff;border:1px solid var(--line);border-radius:14px;padding:25px;margin-bottom:24px;box-shadow:0 3px 14px #17243c03}.section-head{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:22px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:24px}.stats article{background:#fff;border:1px solid var(--line);padding:22px;border-radius:12px}.stats span{color:var(--muted);font-size:12px}.stats strong{display:block;font-size:30px;font-weight:650;letter-spacing:-1px;margin:8px 0}.stats small{color:#969daf;font-size:10px}.badge{background:#eef0fd;color:#6572cb;border-radius:20px;padding:5px 12px;font-size:11px}.worker{border-top:1px solid var(--line);padding:18px 0}.worker:first-child{border-top:0;padding-top:0}.worker-top{display:flex;align-items:center;gap:9px;font-weight:600}.worker-top i{width:8px;height:8px;background:#42b98e;border-radius:50%}.worker-case{display:flex;justify-content:space-between;gap:15px;margin:12px 0 8px;color:#646d82}.progress{height:8px;background:#edf0f7;border-radius:8px;overflow:hidden}.progress-fill{height:100%;background:var(--accent);border-radius:8px;transition:width .5s}.hint{font-size:11px;color:#929aab;margin:14px 0 0}.charts{display:grid;grid-template-columns:1fr 1fr;gap:24px}.charts .panel{min-width:0}.pie-layout{display:grid;grid-template-columns:minmax(170px,1fr) 1fr;align-items:center;gap:20px}canvas{display:block;width:100%;max-width:250px;height:auto;margin:auto}.legend{padding:0;margin:0;list-style:none}.legend li{display:grid;grid-template-columns:9px 1fr auto;gap:8px;font-size:11px;align-items:center;margin:10px 0}.swatch{height:8px;width:8px;border-radius:2px}.legend b{font-size:11px;font-weight:600}.keys{display:flex;gap:20px;color:var(--muted);font-size:11px;margin-bottom:18px}.keys span:before{content:'';display:inline-block;width:8px;height:8px;background:#8e9aed;border-radius:2px;margin-right:6px}.keys .output-key:before{background:#303f96}.bar-row{margin:14px 0}.bar-label{display:flex;justify-content:space-between;font-size:11px;margin-bottom:5px;color:#68728a}.bar-track{height:12px;background:#f1f3f8;border-radius:3px;display:flex;overflow:hidden}.bar-input{background:#8e9aed}.bar-output{background:#303f96}.bar-row.correction .bar-input{background:#f2c397}.bar-row.correction .bar-output{background:var(--orange)}table{width:100%;border-collapse:collapse;font-size:12px;text-align:left}th{color:#929aaa;font-size:10px;letter-spacing:.5px;font-weight:600;padding:12px}td{padding:15px 12px;border-top:1px solid var(--line)}td small{display:block;color:var(--muted)}td .progress{width:110px;height:5px}label{display:block;color:#788297;font-size:11px;margin-bottom:14px}input,select,textarea{display:block;width:100%;font:inherit;color:var(--ink);border:1px solid #dfe3ed;background:white;border-radius:7px;padding:10px 12px;margin-top:5px}input:focus,textarea:focus,select:focus{outline:2px solid #aab3fa;outline-offset:1px}input[type=file]{font-size:11px;padding:8px}.filter{display:flex;align-items:center;gap:9px;margin:0}.filter select{width:auto;max-width:240px;margin:0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 22px}button{font:inherit;background:var(--accent);color:white;border:0;border-radius:7px;padding:11px 18px;cursor:pointer}button:hover{background:#4654be}button:disabled{opacity:.5;cursor:wait}#logout{font-size:11px;padding:6px 10px;margin-top:12px;background:#353f58}.inline{display:flex;gap:10px}.inline input{margin:0}.Line truncated
|
||||
|
||||
.worker-case span:last-child{white-space:nowrap;flex-shrink:0}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
const colors = ['#6977db','#8d9ae7','#9e83c9','#50a994','#548ba9','#344779','#e9954c','#b6bdca'];
|
||||
const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',INTERPRETING_CASE:'Fallplanung',GENERATING_HYPOTHESES:'Hypothesen',INVESTIGATING_HYPOTHESES:'Untersuchung',BUILDING_DIAGNOSIS:'Abschluss',BLOCKED:'Wartet',REOPENED:'Wiedereröffnet'};
|
||||
const finished = new Set(['OUTPUT_READY','FAILED_TO_ANALYZE','HANDED_OFF']);
|
||||
const number = n => new Intl.NumberFormat('de-DE').format(n);
|
||||
let token = '', timer = null, overview = null, projects = [], sending = false, submission = null, session = 0;
|
||||
const text = (tag, value, cls) => { const e=document.createElement(tag); e.textContent=value; if(cls)e.className=cls; return e; };
|
||||
function error(message='') { $('error').hidden=!message; $('error').textContent=message; }
|
||||
async function api(path, options={}) {
|
||||
const credential=token;
|
||||
const response=await fetch(path,{...options,headers:{...options.headers,Authorization:`Bearer ${credential}`},cache:'no-store'});
|
||||
const data=await response.json();
|
||||
if(!response.ok) { if(response.status===401 && credential===token) logout(); throw new Error(data.code || `HTTP ${response.status}`); }
|
||||
return data;
|
||||
}
|
||||
function progress(value, label) {
|
||||
const outer=text('div','','progress'); outer.setAttribute('role','progressbar');
|
||||
outer.setAttribute('aria-label',label); outer.setAttribute('aria-valuemin','0'); outer.setAttribute('aria-valuemax','100'); outer.setAttribute('aria-valuenow',String(value));
|
||||
const inner=text('div','','progress-fill'); inner.style.width=`${value}%`; outer.append(inner); return outer;
|
||||
}
|
||||
function logout() {
|
||||
session++;token=''; clearTimeout(timer); $('token').value=''; $('content').hidden=true; $('login').hidden=false;
|
||||
$('logout').hidden=true; $('connection').textContent='Nicht verbunden'; document.body.classList.remove('connected');
|
||||
$('updated').textContent='Warte auf Verbindung'; overview=null;projects=[];submission=null;
|
||||
$('submit-form').reset();$('receipt').textContent='';$('new-files').hidden=false;$('return-files').hidden=true;
|
||||
['workers','case-rows','bars','legend','project','project-filter'].forEach(id=>$(id).replaceChildren());
|
||||
['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—');
|
||||
$('pie').getContext('2d').clearRect(0,0,360,360);
|
||||
}
|
||||
function renderWorkers(workers, project) {
|
||||
const root=$('workers'); root.replaceChildren();
|
||||
const visible=workers.filter(w=>!project || (w.case && w.case.project_id===project));
|
||||
$('worker-count').textContent=`${visible.length} online`;
|
||||
if(!visible.length)root.append(text('div','Kein aktiver Worker für diese Auswahl.','empty'));
|
||||
visible.forEach(worker=>{
|
||||
const row=text('article','','worker'); const head=text('div','','worker-top');
|
||||
head.append(document.createElement('i'),text('span',worker.name)); row.append(head);
|
||||
if(worker.case) {
|
||||
const c=worker.case, line=text('div','','worker-case');
|
||||
line.append(text('span',`${c.case_id} · ${c.title}`),text('span',`${c.progress} %`));
|
||||
row.append(line,progress(c.progress,`Fortschritt ${c.case_id}`));
|
||||
} else row.append(text('p','Bereit für den nächsten Auftrag.','hint'));
|
||||
root.append(row);
|
||||
});
|
||||
}
|
||||
function sumBuckets(cases) {
|
||||
// Per-project aggregation comes from server-filtered individual usage summaries.
|
||||
return cases.reduce((map,c)=>{
|
||||
(c.usage || []).forEach(b=>{const a=map.get(b.id)||{...b,input_tokens:0,output_tokens:0,calls:0,unknown_usage:0};
|
||||
['input_tokens','output_tokens','calls','unknown_usage'].forEach(k=>a[k]+=b[k]||0);map.set(b.id,a);});return map;
|
||||
},new Map());
|
||||
}
|
||||
function renderPie(buckets) {
|
||||
const canvas=$('pie'), ctx=canvas.getContext('2d'), size=360;
|
||||
ctx.clearRect(0,0,size,size); const total=buckets.reduce((n,b)=>n+b.input_tokens+b.output_tokens,0);
|
||||
const legend=$('legend'); legend.replaceChildren(); let angle=-Math.PI/2;
|
||||
if(!total) {ctx.beginPath();ctx.arc(180,180,140,0,Math.PI*2);ctx.fillStyle='#edf0f7';ctx.fill();}
|
||||
buckets.forEach((b,i)=>{
|
||||
const count=b.input_tokens+b.output_tokens; if(!count&&!b.calls)return;
|
||||
const color=colors[i%colors.length]; if(total&&count) {const end=angle+count/total*Math.PI*2;
|
||||
ctx.beginPath();ctx.moveTo(180,180);ctx.arc(180,180,140,angle,end);ctx.closePath();ctx.fillStyle=color;ctx.fill();angle=end;}
|
||||
const item=document.createElement('li'), swatch=text('span','','swatch');swatch.style.background=color;
|
||||
const name=text('span',b.label), val=text('b',total?`${(count/total*100).toFixed(1).replace('.',',')} %`:'—');
|
||||
item.title=`${b.label}: ${number(count)} Tokens, ${b.calls} Aufrufe${b.unknown_usage?' · Messwerte unvollständig':''}`;
|
||||
item.append(swatch,name,val);legend.append(item);
|
||||
});
|
||||
canvas.setAttribute('aria-label',total?'Tokenanteile: '+buckets.filter(b=>b.input_tokens+b.output_tokens).map(b=>`${b.label}: ${number(b.input_tokens+b.output_tokens)}`).join(', '):'Noch keine gemessene Tokennutzung');
|
||||
}
|
||||
function renderBars(buckets) {
|
||||
const root=$('bars');root.replaceChildren(); const max=Math.max(1,...buckets.map(b=>b.input_tokens+b.output_tokens));
|
||||
buckets.filter(b=>b.calls).forEach(b=>{
|
||||
const row=text('div','',`bar-row ${b.id}`), label=text('div','','bar-label');
|
||||
label.append(text('span',b.label),text('span',number(b.input_tokens+b.output_tokens)));
|
||||
const track=text('div','','bar-track'), input=text('div','','bar-input'), output=text('div','','bar-output');
|
||||
input.style.width=`${b.input_tokens/max*100}%`;output.style.width=`${b.output_tokens/max*100}%`;
|
||||
row.title=`Eingabe: ${number(b.input_tokens)} · Ausgabe inkl. Denken: ${number(b.output_tokens)}`;
|
||||
track.append(input,output);row.append(label,track);root.append(row);
|
||||
});
|
||||
if(!root.children.length)root.append(text('div','Noch keine gemessene Tokennutzung.','empty'));
|
||||
}
|
||||
function render() {
|
||||
if(!overview)return;
|
||||
const project=$('project-filter').value;
|
||||
const cases=overview.cases.filter(c=>!project || c.project_id===project), active=cases.filter(c=>!finished.has(c.state));
|
||||
const buckets=project?[...sumBuckets(cases).values()]:overview.usage.buckets;
|
||||
const total=buckets.reduce((n,b)=>n+b.input_tokens+b.output_tokens,0), corrections=buckets.find(b=>b.id==='correction');
|
||||
const corrected=corrections?corrections.input_tokens+corrections.output_tokens:0;
|
||||
$('active-count').textContent=number(active.length);$('finished-count').textContent=number(cases.length-active.length);
|
||||
$('token-count').textContent=number(total);$('correction-count').textContent=total?`${(corrected/total*100).toFixed(1).replace('.',',')} %`:'—';
|
||||
renderWorkers(overview.workers,project);renderPie(buckets);renderBars(buckets);
|
||||
const missing=buckets.reduce((n,b)=>n+b.unknown_usage,0), unassigned=buckets.find(b=>b.id==='unknown');
|
||||
$('coverage').textContent=`${buckets.reduce((n,b)=>n+b.calls,0)} erfasste Aufrufe. ${missing?`${missing} Aufrufe mit fehlenden Tokenmesswerten; die Diagramme zeigen nur bekannte Werte.`:'Alle erfassten Aufrufe mit vollständigen Eingabe-/Ausgabewerten.'}${unassigned?.calls?` ${unassigned.calls} ältere Aufrufe ohne sichere Korrekturzuordnung.`:''}`;
|
||||
const root=$('case-rows');root.replaceChildren();
|
||||
active.forEach(c=>{const row=document.createElement('tr'), title=document.createElement('td');
|
||||
title.append(text('strong',c.case_id),text('small',c.title));row.append(title,text('td',c.project_id),text('td',names[c.state]||c.state),text('td',String(c.analysis_revision)));
|
||||
const cell=document.createElement('td');cell.append(progress(c.progress,`Fortschritt ${c.case_id}`));row.append(cell);root.append(row);});
|
||||
if(!active.length){const row=document.createElement('tr'),cell=text('td','Keine offenen Fälle.','empty');cell.colSpan=5;row.append(cell);root.append(row);}
|
||||
}
|
||||
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.':'');
|
||||
} catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);}
|
||||
if(token && generation===session)timer=setTimeout(refresh,4000);
|
||||
}
|
||||
$('login-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();const generation=++session;token=$('token').value.trim();error();
|
||||
try {const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
|
||||
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));
|
||||
projects.forEach(p=>{$('project').add(new Option(p.project_id,p.project_id));$('project-filter').add(new Option(p.project_id,p.project_id));});
|
||||
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('token').value='';
|
||||
$('connection').textContent='Verbunden';document.body.classList.add('connected');await refresh();
|
||||
} catch(e) {if(generation===session){token='';error(`Verbindung fehlgeschlagen: ${e.message}`);}else if(!token)error('Verbindung fehlgeschlagen: API-Zugangsschlüssel prüfen.');}
|
||||
});
|
||||
$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render);
|
||||
$('kind').addEventListener('change',()=>{$('new-files').hidden=$('kind').value!=='new_case';$('return-files').hidden=$('kind').value!=='case_return';});
|
||||
$('submit-form').addEventListener('submit',async e=>{
|
||||
e.preventDefault();if(sending)return;error();
|
||||
const project=projects.find(p=>p.project_id===$('project').value), kind=$('kind').value;
|
||||
if(!project)return;
|
||||
const observations=$('observations').value.split('\n').map(v=>v.trim()).filter(Boolean);
|
||||
if(!observations.length)return error('Bitte mindestens eine Beobachtung angeben.');
|
||||
const metadata={kind,project_id:project.project_id,profile_id:project.profile_id,case_id:$('case-id').value.trim()};
|
||||
const form=new FormData(), chosen=[];
|
||||
function append(role, files){for(const file of files){form.append(role,file);chosen.push([role,file.name,file.size,file.lastModified]);}}
|
||||
if(kind==='new_case') {if(!$('wowdiag').files.length)return error('Bitte das wowdiag-Archiv auswählen.');
|
||||
metadata.report={title:$('title').value.trim(),observations};append('wowdiag',$('wowdiag').files);append('wowsrc',$('wowsrc').files);
|
||||
} else {if(!$('case-package').files.length)return error('Bitte die abgeschlossene ZIP-Akte auswählen.');
|
||||
metadata.feedback={outcome:$('outcome').value,summary:$('title').value.trim(),observations};append('case_package',$('case-package').files);}
|
||||
append('attachments',$('attachments').files);form.append('metadata',JSON.stringify(metadata));
|
||||
const signature=JSON.stringify([metadata,chosen]);
|
||||
if(!submission || submission.signature!==signature)submission={signature,key:crypto.randomUUID()};
|
||||
const generation=session;sending=true;$('send').disabled=true;$('receipt').textContent='Auftrag wird übertragen …';
|
||||
try {const receipt=await api('/v1/submissions',{method:'POST',headers:{'Idempotency-Key':submission.key},body:form});
|
||||
if(generation!==session)return;
|
||||
$('receipt').textContent=`Angenommen: ${receipt.case_id} · ${receipt.status} · Auftrags-ID ${receipt.submission_id}`;await refresh();
|
||||
} catch(e) {if(generation===session){$('receipt').textContent='';error(`Übergabe fehlgeschlagen: ${e.message}. Bei unveränderten Eingaben wird derselbe Idempotenzschlüssel wiederverwendet.`);}}
|
||||
finally{sending=false;$('send').disabled=false;}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BugAnalysis · Übersicht</title><link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<script src="/assets/dashboard.js" defer></script></head>
|
||||
<body>
|
||||
<aside class="rail"><a class="brand" href="/">V<span>BA</span><small>BugAnalysis</small></a>
|
||||
<nav aria-label="Navigation"><a href="#overview" class="selected">Übersicht</a><a href="#cases">Fallakten</a><a href="#submit">Auftrag übergeben</a></nav>
|
||||
<div class="rail-bottom">ANALYSEBETRIEB<br><span id="connection">Nicht verbunden</span><button id="logout" type="button" hidden>Abmelden</button></div></aside>
|
||||
<main id="overview">
|
||||
<header><div><div class="eyebrow">BETRIEBSÜBERSICHT</div><h1>Analyse im Blick.</h1><p>Aktuelle Arbeit und messbarer Verbrauch.</p></div><div class="live"><span class="dot"></span><span id="updated">Warte auf Verbindung</span></div></header>
|
||||
<section class="login panel" id="login"><div><h2>Mit BugAnalysis verbinden</h2><p>API-Zugangsschlüssel eingeben. Er bleibt nur im Arbeitsspeicher dieses Tabs.</p></div>
|
||||
<form id="login-form"><label for="token">API-Zugangsschlüssel</label><div class="inline"><input id="token" type="password" autocomplete="off" required placeholder="Zugangsschlüssel"><button type="submit">Verbinden</button></div></form></section>
|
||||
<p id="error" class="error" role="alert" hidden></p>
|
||||
<div id="content" hidden>
|
||||
<section class="stats" aria-label="Kennzahlen">
|
||||
<article><span>Aktive Fälle</span><strong id="active-count">—</strong><small>Noch nicht abgeschlossen</small></article>
|
||||
<article><span>Erfasste Tokens</span><strong id="token-count">—</strong><small>Eingabe + Ausgabe inkl. Denken</small></article>
|
||||
<article><span>Korrekturanteil</span><strong id="correction-count">—</strong><small>Zusätzliche Korrekturen und Wiederholungen</small></article>
|
||||
<article><span>Abgeschlossene Fälle</span><strong id="finished-count">—</strong><small>Outbox, Review und übergebene Akten</small></article>
|
||||
</section>
|
||||
<section class="panel workers-panel"><div class="section-head"><div><div class="eyebrow">LIVE</div><h2>Aktive Worker</h2></div><span id="worker-count" class="badge">0 online</span></div>
|
||||
<div id="workers" aria-live="polite"></div><p class="hint">Der Balken zeigt abgeschlossene Stufen bis zum Codingauftrag, keine verbleibende Laufzeit.</p></section>
|
||||
<section class="charts"><article class="panel"><div class="section-head"><div><div class="eyebrow">VERTEILUNG</div><h2>Wofür Tokens verbraucht werden</h2></div></div>
|
||||
<div class="pie-layout"><canvas id="pie" width="360" height="360" role="img" aria-label="Tokenanteile nach Phase und Korrekturen"></canvas><ul id="legend" class="legend"></ul></div>
|
||||
<p class="hint">Korrekturen werden ausschließlich im eigenen Segment gezählt. Der erste reguläre Aufruf bleibt bei seiner Phase.</p></article>
|
||||
<article class="panel"><div class="section-head"><div><div class="eyebrow">PHASENVERGLEICH</div><h2>Eingabe und Ausgabe</h2></div></div><div class="keys"><span class="input-key">Eingabe</span><span class="output-key">Ausgabe inkl. Denken</span></div>
|
||||
<div id="bars" class="bars" aria-label="Tokenverbrauch nach Phase"></div><p class="hint" id="coverage"></p></article></section>
|
||||
<section class="panel" id="cases"><div class="section-head"><div><div class="eyebrow">FALLAKTEN</div><h2>Aktuelle Untersuchungen</h2></div><label class="filter">Projekt <select id="project-filter"><option value="">Alle freigegebenen Projekte</option></select></label></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>Fall</th><th>Projekt</th><th>Zustand</th><th>Revision</th><th>Fortschritt</th></tr></thead><tbody id="case-rows"></tbody></table></div></section>
|
||||
<section class="panel" id="submit"><div class="section-head"><div><div class="eyebrow">EINGANG</div><h2>Auftrag übergeben</h2><p>BugAnalysis erstellt und ergänzt die Fallakte.</p></div></div>
|
||||
<form id="submit-form"><div class="form-grid"><label>Auftragstyp<select id="kind"><option value="new_case">Neue Analyse</option><option value="case_return">Abgeschlossene Akte zurückgeben</option></select></label>
|
||||
<label>Projekt<select id="project" required></select></label><label>Bug-ID<input id="case-id" required placeholder="BUG-000020" pattern="[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"></label><label>Fallname / Rückmeldung<input id="title" required maxlength="240"></label></div>
|
||||
<label>Beobachtungen (eine pro Zeile)<textarea id="observations" required rows="3"></textarea></label>
|
||||
<div class="form-grid" id="new-files"><label>wowdiag<input id="wowdiag" type="file"></label><label>wowsrc (optional)<input id="wowsrc" type="file"></label></div>
|
||||
<div id="return-files" hidden><label>Abgeschlossene Case-ZIP<input id="case-package" type="file" accept=".zip"></label><label>Ergebnis<select id="outcome"><option value="patch_failed">Patch behebt den Fehler nicht</option><option value="validation_failed">Validierung fehlgeschlagen</option><option value="new_evidence">Neue Belege</option><option value="review_feedback">Review-Rückmeldung</option></select></label></div>
|
||||
<label>Zusätzliche Anhänge<input id="attachments" type="file" multiple></label><button id="send" type="submit">Auftrag einreichen</button><p id="receipt" role="status"></p></form></section>
|
||||
<footer>Verbrauch aller erhaltenen, freigegebenen Akten und Revisionen. Keine Google-Abrechnung. Fehlende Messwerte werden kenntlich gemacht.</footer>
|
||||
</div></main></body></html>
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Single-process, resumable analysis service. Importing never starts analysis."""
|
||||
from .config import WorkerConfig, WorkerError
|
||||
from .service import AnalysisWorker
|
||||
|
||||
__all__ = ['WorkerConfig', 'WorkerError', 'AnalysisWorker']
|
||||
@@ -0,0 +1,72 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import signal
|
||||
|
||||
from viano_bug_analysis.api.config import directory
|
||||
from viano_bug_analysis.lifecycle.storage import Workspace
|
||||
from viano_bug_analysis.lifecycle.errors import LockBusy
|
||||
from viano_bug_analysis.telemetry import Telemetry, TelemetryConfig
|
||||
from .config import WorkerConfig, code, load_project
|
||||
from .service import AnalysisWorker
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Resumable BugAnalysis service worker')
|
||||
parser.add_argument('command', choices=['check', 'run', 'status', 'retry'])
|
||||
parser.add_argument('--config', required=True)
|
||||
parser.add_argument('--once', action='store_true', help='one scheduler pass; may make paid model calls')
|
||||
parser.add_argument('--case-id')
|
||||
parser.add_argument('--package-id')
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == 'retry' and not (args.case_id and args.package_id):
|
||||
parser.error('retry requires --case-id and --package-id')
|
||||
try:
|
||||
config = WorkerConfig.load(args.config)
|
||||
if args.command == 'check':
|
||||
for project in config.intake.projects:
|
||||
load_project(project)
|
||||
if config.telemetry_config:
|
||||
TelemetryConfig.load(config.telemetry_config)
|
||||
result = {'status': 'CONFIG_READY', 'network_tested': False,
|
||||
'projects': [p.id for p in config.intake.projects]}
|
||||
elif args.command == 'status':
|
||||
path = config.intake.workspace / 'runtime/worker/status.json'
|
||||
result = json.loads(path.read_bytes()) if path.is_file() else {'status': 'NOT_STARTED'}
|
||||
if path.is_file():
|
||||
result['note'] = 'Last snapshot; process liveness is not guaranteed.'
|
||||
else:
|
||||
directory(config.intake.workspace)
|
||||
workspace = Workspace(config.intake.workspace)
|
||||
if args.command == 'retry':
|
||||
AnalysisWorker(config).retry(args.case_id, args.package_id)
|
||||
result = {'status': 'RELEASED', 'case_id': args.case_id, 'package_id': args.package_id}
|
||||
else:
|
||||
telemetry_config = TelemetryConfig.load(config.telemetry_config) if config.telemetry_config else None
|
||||
with Telemetry(workspace, telemetry_config) as telemetry:
|
||||
worker = AnalysisWorker(config, telemetry=telemetry)
|
||||
previous = {}
|
||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||
previous[signum] = signal.getsignal(signum)
|
||||
signal.signal(signum, lambda *_: worker.request_stop())
|
||||
try:
|
||||
asyncio.run(worker.run(once=args.once))
|
||||
finally:
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
result = {'status': 'STOPPED'}
|
||||
print(json.dumps(result, ensure_ascii=True))
|
||||
return 0
|
||||
except LockBusy:
|
||||
print(json.dumps({'status': 'BUSY', 'code': 'WORKER_BUSY'}))
|
||||
return 3
|
||||
except KeyboardInterrupt:
|
||||
print(json.dumps({'status': 'INTERRUPTED'}))
|
||||
return 130
|
||||
except Exception as error:
|
||||
print(json.dumps({'status': 'ERROR', 'code': code(getattr(error, 'code', None))}))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
import hashlib
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from viano_bug_analysis.api.config import APIConfig, APIError
|
||||
from viano_bug_analysis.lifecycle.storage import json_bytes
|
||||
from viano_bug_analysis.telemetry.safety import safe_code
|
||||
|
||||
WORKER_CODES = frozenset({'WORKER_CONFIG', 'WORKER_LLM_CONFIG_REQUIRED', 'WORKER_PROJECT_BINDING',
|
||||
'WORKER_CONFIG_CHANGED', 'WORKER_STORAGE_IDENTITY', 'WORKER_CASE_ERROR', 'WORKER_PROJECT_UNKNOWN',
|
||||
'WORKER_STORAGE_LOW', 'WORKER_CYCLE_ERROR', 'WORKER_BUSY', 'WORKER_RETRY_STATE', 'WORKER_NOT_FOUND'})
|
||||
|
||||
|
||||
def code(value):
|
||||
return value if isinstance(value, str) and value in WORKER_CODES else safe_code(value)
|
||||
|
||||
|
||||
class WorkerError(Exception):
|
||||
def __init__(self, code):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerConfig:
|
||||
intake: APIConfig
|
||||
poll_seconds: int = 5
|
||||
error_retry_seconds: int = 60
|
||||
max_inbox_per_pass: int = 20
|
||||
min_free_bytes: int = 512 * 1024**2
|
||||
telemetry_config: Path | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
for name in ('poll_seconds', 'error_retry_seconds', 'max_inbox_per_pass', 'min_free_bytes'):
|
||||
if type(getattr(self, name)) is not int or getattr(self, name) <= 0:
|
||||
raise WorkerError('WORKER_CONFIG')
|
||||
|
||||
@classmethod
|
||||
def load(cls, path):
|
||||
try:
|
||||
path = Path(path).resolve()
|
||||
data = tomllib.loads(path.read_text(encoding='utf-8'))
|
||||
if set(data) != {'worker'}:
|
||||
raise ValueError()
|
||||
row = dict(data['worker'])
|
||||
intake = APIConfig.load(path.parent / row.pop('intake_config'))
|
||||
if row.get('telemetry_config'):
|
||||
row['telemetry_config'] = (path.parent / row['telemetry_config']).resolve()
|
||||
return cls(intake=intake, **row)
|
||||
except (OSError, ValueError, TypeError, KeyError, APIError):
|
||||
raise WorkerError('WORKER_CONFIG') from None
|
||||
|
||||
|
||||
def binding(project):
|
||||
"""No secrets/config contents enter the runtime ledger, only file digests."""
|
||||
files = {name: hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
for name, path in project.settings.items()}
|
||||
return hashlib.sha256(json_bytes({'project': project.id, 'type': project.type,
|
||||
'profile': project.profile, 'planning_mode': project.planning_mode, 'files': files})).hexdigest()
|
||||
|
||||
|
||||
def load_project(project):
|
||||
from viano_bug_analysis.workflow import preflight
|
||||
from viano_bug_analysis.analysis import (HypothesisConfig, InvestigationConfig,
|
||||
DiagnosisConfig, ChangeRequestConfig, CorrectionConfig)
|
||||
from viano_bug_analysis.budget import BudgetConfig
|
||||
from viano_bug_analysis.tools.project_query import ProjectResearch
|
||||
if 'llm_config' not in project.settings:
|
||||
raise WorkerError('WORKER_LLM_CONFIG_REQUIRED')
|
||||
before = binding(project)
|
||||
gateway = preflight(project.settings['llm_config'])
|
||||
policies = {'planning_mode': project.planning_mode}
|
||||
for name, cls in (('analysis', HypothesisConfig), ('investigation', InvestigationConfig),
|
||||
('diagnosis', DiagnosisConfig), ('change_request', ChangeRequestConfig),
|
||||
('correction', CorrectionConfig), ('budget', BudgetConfig)):
|
||||
if path := project.settings.get(name + '_config'):
|
||||
policies[name] = cls.load(path)
|
||||
if path := project.settings.get('project_config'):
|
||||
research = ProjectResearch.load(path)
|
||||
if research.project.get('type') != project.type or research.project.get('profile') != project.profile:
|
||||
raise WorkerError('WORKER_PROJECT_BINDING')
|
||||
policies['project_research'] = research
|
||||
if before != binding(project):
|
||||
raise WorkerError('WORKER_CONFIG_CHANGED')
|
||||
return gateway, policies, before
|
||||
@@ -0,0 +1,288 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from viano_bug_analysis.api.config import directory
|
||||
from viano_bug_analysis.api.service import IntakeService
|
||||
from viano_bug_analysis.lifecycle import InboxProcessor, State
|
||||
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
|
||||
from viano_bug_analysis.lifecycle.errors import LockBusy
|
||||
from viano_bug_analysis.lifecycle.job import timestamp
|
||||
from viano_bug_analysis.lifecycle.locking import FileLock
|
||||
from viano_bug_analysis.lifecycle.storage import atomic_json, key
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
from viano_bug_analysis.workflow import drive
|
||||
from .config import WorkerError, binding, load_project, code as safe_code
|
||||
|
||||
class AnalysisWorker:
|
||||
"""One analysis step per eligible case/pass; case files remain authoritative."""
|
||||
def __init__(self, config, *, telemetry=None, loader=load_project, dispatcher=drive, clock=None):
|
||||
self.config, self.loader, self.dispatcher = config, loader, dispatcher
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.intake = IntakeService(config.intake)
|
||||
self.workspace = self.intake.workspace
|
||||
self.queue = InboxProcessor(self.workspace, telemetry=telemetry)
|
||||
self.store = self.queue.store
|
||||
self.telemetry = telemetry
|
||||
self.root = directory(self.workspace.runtime / 'worker')
|
||||
self.records = directory(self.root / 'cases')
|
||||
self.projects = {p.id: p for p in config.intake.projects}
|
||||
self.runtimes = {}
|
||||
self.stop_requested = False
|
||||
self.last_case = None
|
||||
self.last_inbox = None
|
||||
|
||||
def request_stop(self):
|
||||
self.stop_requested = True
|
||||
|
||||
def _record_path(self, manifest):
|
||||
return self.records / (key(manifest['case_id'] + ':' + manifest['package_id']) + '.json')
|
||||
|
||||
def _record(self, manifest):
|
||||
path = self._record_path(manifest)
|
||||
return json.loads(path.read_bytes()) if path.exists() else {}
|
||||
|
||||
def _save(self, manifest, status, *, code=None, due=None, config_hash=None):
|
||||
previous = self._record(manifest)
|
||||
record = {name: manifest[name] for name in ('case_id', 'package_id', 'package_generation', 'analysis_revision')}
|
||||
record.update(status=status, code=safe_code(code) if code else None,
|
||||
retry_not_before=due, updated_at=timestamp(),
|
||||
config_hash=config_hash or (previous.get('config_hash') if
|
||||
previous.get('analysis_revision') == manifest['analysis_revision'] else None))
|
||||
atomic_json(self._record_path(manifest), record)
|
||||
|
||||
def _status(self, status, *, code=None, case=None):
|
||||
record = {'status': status, 'updated_at': timestamp(), 'pid': os.getpid(),
|
||||
'code': safe_code(code) if code else None, 'case': case,
|
||||
'service_id': getattr(self.telemetry, 'service_id', None)}
|
||||
atomic_json(self.root / 'status.json', record)
|
||||
|
||||
def _due(self, value):
|
||||
return value is None or datetime.fromisoformat(value.replace('Z', '+00:00')) <= self.clock()
|
||||
|
||||
def _pause(self, manifest, code, config_hash=None, status='ERROR'):
|
||||
due = (self.clock() + timedelta(seconds=self.config.error_retry_seconds)).isoformat()
|
||||
self._save(manifest, status, code=code, due=due, config_hash=config_hash)
|
||||
|
||||
def _heads(self):
|
||||
# Inspect only the latest candidate, never reopen every historical ZIP.
|
||||
# Filenames select candidates; the manifest and CheckpointStore validate identity.
|
||||
for parent in sorted(self.workspace.cases.iterdir()):
|
||||
if not parent.is_dir():
|
||||
continue
|
||||
try:
|
||||
directory(parent)
|
||||
except Exception:
|
||||
self._status('DEGRADED', code='WORKER_STORAGE_IDENTITY')
|
||||
continue
|
||||
for folder in sorted(parent.iterdir()):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
try:
|
||||
directory(folder)
|
||||
except Exception:
|
||||
self._status('DEGRADED', code='WORKER_STORAGE_IDENTITY')
|
||||
continue
|
||||
paths = sorted(folder.glob('g*.zip'))
|
||||
if not paths:
|
||||
continue
|
||||
try:
|
||||
if paths[-1].is_symlink() or getattr(paths[-1].lstat(), 'st_file_attributes', 0) & 0x400:
|
||||
raise WorkerError('WORKER_STORAGE_IDENTITY')
|
||||
with PackageReader(paths[-1], self.store.limits) as reader:
|
||||
manifest = reader.manifest
|
||||
if folder != self.workspace.case_directory(manifest['case_id'], manifest['package_id']):
|
||||
raise WorkerError('WORKER_STORAGE_IDENTITY')
|
||||
latest = self.store.latest(manifest['case_id'], manifest['package_id'])
|
||||
with PackageReader(latest, self.store.limits) as reader:
|
||||
yield reader.manifest
|
||||
except Exception as error:
|
||||
# A broken case does not prevent other cases from progressing.
|
||||
self._status('DEGRADED', code=getattr(error, 'code', 'WORKER_CASE_ERROR'))
|
||||
|
||||
def _runtime(self, project):
|
||||
current = binding(project)
|
||||
runtime = self.runtimes.get(project.id)
|
||||
if runtime and runtime[2] != current:
|
||||
# Reload configuration only after restart. Avoid hot swaps mid-operation.
|
||||
raise WorkerError('WORKER_CONFIG_CHANGED')
|
||||
if runtime is None:
|
||||
runtime = self.loader(project)
|
||||
self.runtimes[project.id] = runtime
|
||||
return runtime
|
||||
|
||||
def retry(self, case_id, package_id):
|
||||
"""Release only a scheduler error; never bypass case/operation budgets."""
|
||||
with FileLock(self.workspace.runtime / 'locks/analysis-service.lock'):
|
||||
latest = self.store.latest(case_id, package_id)
|
||||
if not latest:
|
||||
raise WorkerError('WORKER_NOT_FOUND')
|
||||
with PackageReader(latest, self.store.limits) as reader:
|
||||
manifest = reader.manifest
|
||||
record = self._record(manifest)
|
||||
project = self.projects.get(manifest['project'].get('id'))
|
||||
if not project:
|
||||
raise WorkerError('WORKER_PROJECT_UNKNOWN')
|
||||
if (record.get('analysis_revision') == manifest['analysis_revision']
|
||||
and record.get('config_hash') not in {None, binding(project)}):
|
||||
raise WorkerError('WORKER_CONFIG_CHANGED')
|
||||
if manifest['state'] == State.BLOCKED:
|
||||
# Operator confirms the read-only research service is repaired.
|
||||
# No human-feedback, model-validation or exhausted LLM reset.
|
||||
if manifest['routing']['reason'] != 'RESEARCH_UNAVAILABLE' or not self._due(manifest['routing']['retry_not_before']):
|
||||
raise WorkerError('WORKER_RETRY_STATE')
|
||||
with PackageReader(latest, self.store.limits) as reader:
|
||||
resume = checkpoint_chain(reader)[0][1]['resume_state']
|
||||
latest = self.store.advance(case_id, package_id, resume,
|
||||
step_id=f"worker-operator-retry:{manifest['package_generation']}",
|
||||
details={'operation': 'worker-operator-retry'},
|
||||
expected_generation=manifest['package_generation'])
|
||||
with PackageReader(latest, self.store.limits) as reader:
|
||||
manifest = reader.manifest
|
||||
self._save(manifest, 'RELEASED')
|
||||
|
||||
def _admit(self):
|
||||
sources = sorted(self.workspace.inbox.glob('*.zip'))
|
||||
if self.last_inbox:
|
||||
sources.sort(key=lambda p: (p.name <= self.last_inbox, p.name))
|
||||
for source in sources[:self.config.max_inbox_per_pass]:
|
||||
if self.stop_requested:
|
||||
break
|
||||
self.last_inbox = source.name
|
||||
try:
|
||||
# Share the case lease with interactive trials, also during intake.
|
||||
try:
|
||||
with PackageReader(source, self.store.limits) as reader:
|
||||
case_id = reader.manifest['case_id']
|
||||
except Exception:
|
||||
self.queue.process(source) # normal validator/quarantine behavior
|
||||
continue
|
||||
with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(case_id) + '.lock')):
|
||||
result = self.queue.process(source)
|
||||
if result.status in {'ERROR', 'BUSY'}:
|
||||
self._status('DEGRADED', code=result.code)
|
||||
except LockBusy:
|
||||
continue
|
||||
|
||||
async def tick(self):
|
||||
self._status('RUNNING')
|
||||
if shutil.disk_usage(self.workspace.root).free < self.config.min_free_bytes:
|
||||
self._status('STORAGE_LOW', code='WORKER_STORAGE_LOW')
|
||||
return 0
|
||||
self._admit()
|
||||
heads = list(self._heads())
|
||||
heads.sort(key=lambda m: (m['case_id'], m['package_id']))
|
||||
# Rotate between passes so a repeatedly busy case cannot monopolize first place.
|
||||
if self.last_case:
|
||||
heads.sort(key=lambda m: ((m['case_id'], m['package_id']) <= self.last_case,
|
||||
m['case_id'], m['package_id']))
|
||||
count = 0
|
||||
for manifest in heads:
|
||||
if self.stop_requested:
|
||||
break
|
||||
identity = manifest['case_id'], manifest['package_id']
|
||||
dispatch_started = False
|
||||
try:
|
||||
with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(identity[0]) + '.lock')):
|
||||
latest = self.store.latest(*identity)
|
||||
with PackageReader(latest, self.store.limits) as reader:
|
||||
manifest = reader.manifest
|
||||
chain = checkpoint_chain(reader)
|
||||
record = self._record(manifest)
|
||||
same_revision = record.get('analysis_revision') == manifest['analysis_revision']
|
||||
same_generation = record.get('package_generation') == manifest['package_generation']
|
||||
if same_generation and record.get('status') in {'DELIVERED', 'ATTENTION'}:
|
||||
continue
|
||||
if same_generation and not self._due(record.get('retry_not_before')):
|
||||
continue
|
||||
project = self.projects.get(manifest['project'].get('id'))
|
||||
if not project or (project.type, project.profile) != (
|
||||
manifest['project'].get('type'), manifest['project'].get('profile')):
|
||||
self._pause(manifest, 'WORKER_PROJECT_UNKNOWN', status='ATTENTION')
|
||||
continue
|
||||
gateway, policies, config_hash = self._runtime(project)
|
||||
if same_revision and record.get('config_hash') not in {None, config_hash}:
|
||||
self._pause(manifest, 'WORKER_CONFIG_CHANGED', status='ATTENTION')
|
||||
continue
|
||||
if manifest['state'] == State.BLOCKED:
|
||||
cp = chain[0][1]
|
||||
if (manifest['routing']['reason'] == 'AWAITING_TEST_FEEDBACK'
|
||||
or cp['details'].get('automatic_retry') is False
|
||||
or manifest['routing']['reason'] in {'INVALID_LLM_RESPONSES', 'LLM_CONFIGURATION'}):
|
||||
self._save(manifest, 'BLOCKED', code=manifest['routing']['reason'], config_hash=config_hash)
|
||||
continue
|
||||
if not self._due(manifest['routing']['retry_not_before']):
|
||||
continue
|
||||
# An exhausted LLM operation cannot be made runnable by
|
||||
# changing its attempt id. It requires operator intervention.
|
||||
if manifest['routing']['reason'] == 'LLM_UNAVAILABLE':
|
||||
self._save(manifest, 'ATTENTION', code='LLM_RETRY_EXHAUSTED', config_hash=config_hash)
|
||||
continue
|
||||
latest = self.store.advance(*identity, cp['resume_state'],
|
||||
step_id=f"retry:{manifest['package_generation']}", expected_generation=manifest['package_generation'])
|
||||
with PackageReader(latest, self.store.limits) as reader:
|
||||
manifest = reader.manifest
|
||||
# Persist binding before a model call, not after it.
|
||||
self._save(manifest, 'DISPATCHING', config_hash=config_hash)
|
||||
self._status('ANALYZING', case={'case_id': identity[0], 'package_id': identity[1]})
|
||||
dispatch_started = True
|
||||
result = await self.dispatcher(self.store, gateway, *identity,
|
||||
max_dispatches=1, attempt_id='default', policies=policies)
|
||||
count += 1
|
||||
self.last_case = identity
|
||||
with PackageReader(self.store.latest(*identity), self.store.limits) as reader:
|
||||
current = reader.manifest
|
||||
status = result['status']
|
||||
if status in {'OUTPUT_READY', 'REVIEW', 'HANDED_OFF'}:
|
||||
self._save(current, 'DELIVERED', config_hash=config_hash)
|
||||
elif status in {'RETRY_PENDING', 'DEFERRED', 'BUSY'}:
|
||||
self._pause(current, result.get('code') or status, config_hash, status=status)
|
||||
if result.get('retry_not_before'):
|
||||
self._save(current, status, code=result.get('code'), due=result['retry_not_before'], config_hash=config_hash)
|
||||
elif status == 'ERROR':
|
||||
self._save(current, 'ATTENTION', code=result.get('code'), config_hash=config_hash)
|
||||
else:
|
||||
self._save(current, status, code=result.get('code'), config_hash=config_hash)
|
||||
except LockBusy:
|
||||
continue
|
||||
except Exception as error:
|
||||
if self.telemetry:
|
||||
self.telemetry.guard(self.telemetry.exception, error)
|
||||
try:
|
||||
# The phase may have committed before raising. Suppress the
|
||||
# actual latest generation, not the stale input generation.
|
||||
with PackageReader(self.store.latest(*identity), self.store.limits) as reader:
|
||||
manifest = reader.manifest
|
||||
if dispatch_started:
|
||||
self._save(manifest, 'ATTENTION', code=getattr(error, 'code', 'WORKER_CASE_ERROR'))
|
||||
else:
|
||||
self._pause(manifest, getattr(error, 'code', 'WORKER_CASE_ERROR'))
|
||||
except Exception:
|
||||
self._status('DEGRADED', code='WORKER_CASE_ERROR')
|
||||
self._status('IDLE')
|
||||
return count
|
||||
|
||||
async def run(self, *, once=False):
|
||||
with FileLock(self.workspace.runtime / 'locks/analysis-service.lock'):
|
||||
try:
|
||||
self.intake.recover()
|
||||
while not self.stop_requested:
|
||||
progressed = 0
|
||||
try:
|
||||
progressed = await self.tick()
|
||||
except Exception as error:
|
||||
self._status('DEGRADED', code=getattr(error, 'code', 'WORKER_CYCLE_ERROR'))
|
||||
if once:
|
||||
break
|
||||
if progressed:
|
||||
await asyncio.sleep(0)
|
||||
continue
|
||||
# Event-loop sleep keeps signal handling responsive.
|
||||
for _ in range(self.config.poll_seconds * 10):
|
||||
if self.stop_requested:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
self._status('STOPPED')
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Shared resumable analysis dispatch used by trials and the service worker."""
|
||||
import os
|
||||
from .analysis import (CaseInterpreter, HypothesisBuilder, HypothesisConfig, Investigator,
|
||||
DiagnosisBuilder, ChangeRequestBuilder, OutputValidator)
|
||||
from .analysis.correction import CorrectionConfig
|
||||
from .case import CaseBuilder
|
||||
from .lifecycle import State
|
||||
from .llm import GatewayConfig, LLMGateway
|
||||
from .llm.gateway import PRIVACY
|
||||
from .package import PackageReader
|
||||
|
||||
class TrialError(Exception):
|
||||
def __init__(self, code):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def preflight(path):
|
||||
"""Validate transport configuration and credentials without sending requests."""
|
||||
config = GatewayConfig.load(path)
|
||||
gateway = LLMGateway(config)
|
||||
eligible = [p for p in config.providers
|
||||
if p.enabled and p.privacy in PRIVACY[config.privacy_profile]]
|
||||
if not eligible:
|
||||
raise TrialError("LLM_PRIVACY_NO_PROVIDER")
|
||||
for provider in eligible:
|
||||
if provider.api_key_env:
|
||||
value = os.environ.get(provider.api_key_env)
|
||||
if not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
||||
raise TrialError("LLM_CREDENTIALS")
|
||||
return gateway
|
||||
|
||||
|
||||
def describe(path, status, *, code=None, exported=None, retry_not_before=None):
|
||||
with PackageReader(path) as reader:
|
||||
m = reader.manifest
|
||||
return {"status": status, "code": code, "package": str(path),
|
||||
"case_id": m["case_id"], "package_id": m["package_id"],
|
||||
"package_generation": m["package_generation"],
|
||||
"analysis_revision": m["analysis_revision"], "state": m["state"],
|
||||
"routing": m["routing"], "exported": str(exported) if exported else None,
|
||||
"retry_not_before": retry_not_before}
|
||||
|
||||
|
||||
async def drive(store, gateway, case_id, package_id, *, max_dispatches=100,
|
||||
attempt_id="default", policies=None, progress=None, stop_after=None):
|
||||
if type(max_dispatches) is not int or max_dispatches <= 0:
|
||||
raise TrialError("TRIAL_DISPATCH_LIMIT")
|
||||
if stop_after not in {None, "case", "interpretation"}:
|
||||
raise TrialError("TRIAL_STOP_PHASE")
|
||||
policies = policies or {}
|
||||
identity = (case_id, package_id)
|
||||
correction = policies.get("correction", CorrectionConfig())
|
||||
from .tools import InvestigationToolRouter
|
||||
project_research = policies.get("project_research")
|
||||
interpreter = CaseInterpreter(store, gateway, correction_config=correction, project_research=project_research,
|
||||
joint_config=policies.get("analysis", HypothesisConfig()) if policies.get("planning_mode") == "joint" else None)
|
||||
hypotheses = HypothesisBuilder(store, gateway, policies.get("analysis"), correction_config=correction)
|
||||
loop = Investigator(store, gateway, policies.get("investigation"),
|
||||
budget_config=policies.get("budget"),
|
||||
router=InvestigationToolRouter(project_research=project_research))
|
||||
diagnosis = DiagnosisBuilder(store, gateway, policies.get("diagnosis"))
|
||||
request = ChangeRequestBuilder(store, gateway, policies.get("change_request"))
|
||||
output = OutputValidator(store, gateway)
|
||||
for dispatch in range(max_dispatches):
|
||||
latest = store.latest(*identity)
|
||||
if latest is None:
|
||||
raise TrialError("CASE_NOT_FOUND")
|
||||
with PackageReader(latest, store.limits) as reader:
|
||||
state = reader.manifest["state"]
|
||||
artifacts = dict(reader.manifest["artifacts"])
|
||||
routing = reader.manifest["routing"]
|
||||
# A reopened case retains prior artifacts. They are history, not
|
||||
# completed stages of the new revision.
|
||||
revision = reader.manifest['analysis_revision']
|
||||
from .lifecycle.checkpoints import checkpoint_chain
|
||||
chain = checkpoint_chain(reader)
|
||||
for pointer, step in (
|
||||
('case', f'case-builder:v1:revision:{revision}:complete'),
|
||||
('case_interpretation', f'case-interpreter:v1:revision:{revision}:complete'),
|
||||
('hypotheses', f'hypothesis-builder:v1:revision:{revision}:complete'),
|
||||
('diagnosis', f'diagnosis:v1:{revision}:complete'),
|
||||
('change_request', f'change-request:v1:{revision}:complete')):
|
||||
if artifacts.get(pointer) and not any(cp['step_id'] == step for _, cp in chain):
|
||||
# Contracts identify current products even when newer phase
|
||||
# implementations use a different completion step name.
|
||||
import json
|
||||
doc = json.loads(reader.read_bytes(artifacts[pointer]))
|
||||
artifact_identity = doc.get('case', doc)
|
||||
if artifact_identity.get('analysis_revision') != revision:
|
||||
artifacts[pointer] = None
|
||||
if state == State.BLOCKED:
|
||||
return describe(latest, "BLOCKED", code=routing["reason"],
|
||||
retry_not_before=routing["retry_not_before"])
|
||||
if phase_complete(latest, stop_after):
|
||||
return describe(latest, "PHASE_COMPLETE")
|
||||
if progress:
|
||||
progress({"dispatch": dispatch + 1, "state": state})
|
||||
if state in {State.OUTPUT_READY, State.FAILED_TO_ANALYZE, State.HANDED_OFF}:
|
||||
result = await output.publish(*identity)
|
||||
elif state == State.BUILDING_CASE and artifacts.get("case"):
|
||||
result = await interpreter.interpret(*identity, attempt_id=attempt_id)
|
||||
elif state in {State.VALIDATED, State.REOPENED, State.BUILDING_CASE}:
|
||||
result = CaseBuilder(store, project_research=project_research).build(*identity)
|
||||
elif state == State.INTERPRETING_CASE:
|
||||
if artifacts.get("case_interpretation"):
|
||||
result = await hypotheses.build(*identity, attempt_id=attempt_id)
|
||||
else:
|
||||
result = await interpreter.interpret(*identity, attempt_id=attempt_id)
|
||||
elif state == State.GENERATING_HYPOTHESES:
|
||||
if artifacts.get("hypotheses"):
|
||||
result = await loop.step(*identity)
|
||||
else:
|
||||
result = await hypotheses.build(*identity, attempt_id=attempt_id)
|
||||
elif state == State.INVESTIGATING_HYPOTHESES:
|
||||
result = await loop.step(*identity)
|
||||
elif state == State.BUILDING_DIAGNOSIS:
|
||||
if not artifacts.get("diagnosis"):
|
||||
result = await diagnosis.build(*identity)
|
||||
elif not artifacts.get("change_request"):
|
||||
result = await request.build(*identity)
|
||||
else:
|
||||
result = await output.publish(*identity)
|
||||
else:
|
||||
raise TrialError("TRIAL_PHASE")
|
||||
status = getattr(result, "status", "COMPLETED")
|
||||
if status == "INCONCLUSIVE":
|
||||
result = await output.publish(*identity)
|
||||
status = result.status
|
||||
if status in {"BLOCKED", "RETRY_PENDING", "DEFERRED", "ERROR", "BUSY",
|
||||
"OUTPUT_READY", "REVIEW", "HANDED_OFF"}:
|
||||
return describe(result.package, status, code=getattr(result, "code", None),
|
||||
exported=getattr(result, "exported", None),
|
||||
retry_not_before=getattr(result, "retry_not_before", None))
|
||||
latest = store.latest(*identity)
|
||||
return describe(latest, "PHASE_COMPLETE" if phase_complete(latest, stop_after) else "YIELDED")
|
||||
|
||||
|
||||
def phase_complete(path, stop_after):
|
||||
if stop_after is None:
|
||||
return False
|
||||
from .case.contracts import read_json
|
||||
with PackageReader(path) as reader:
|
||||
manifest = reader.manifest
|
||||
if stop_after == "case":
|
||||
ref = manifest["artifacts"].get("case")
|
||||
if manifest["state"] != State.BUILDING_CASE or not ref:
|
||||
return False
|
||||
doc = read_json(reader, ref, "case", 8 * 1024 * 1024)["case"]
|
||||
else:
|
||||
ref = manifest["artifacts"].get("case_interpretation")
|
||||
if manifest["state"] != State.INTERPRETING_CASE or not ref:
|
||||
return False
|
||||
doc = read_json(reader, ref, "case-interpretation", 2 * 1024 * 1024)
|
||||
return all(doc[field] == manifest[field] for field in ("case_id", "package_id", "analysis_revision"))
|
||||
@@ -0,0 +1,55 @@
|
||||
// Real Chromium smoke test; Node built-ins only. No paid model calls.
|
||||
import {spawn} from 'node:child_process';
|
||||
import {mkdtemp, mkdir, writeFile, rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
const port=18765, debugPort=18766;
|
||||
const pause=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
const output=path.resolve('service-data/ui-preview');await mkdir(output,{recursive:true});
|
||||
const profile=await mkdtemp(path.join(tmpdir(),'vba-browser-'));
|
||||
const fixtureRoot=await mkdtemp(path.join(tmpdir(),'vba-ui-data-'));
|
||||
let browser,server,ws; const errors=[];
|
||||
async function until(fn, milliseconds=15000){const start=Date.now();while(Date.now()-start<milliseconds){try{const v=await fn();if(v)return v;}catch{}await pause(100);}throw new Error('Timed out waiting for UI');}
|
||||
try {
|
||||
server=spawn(path.resolve('.venv/Scripts/python.exe'),['tests/ui_dashboard_server.py'],{windowsHide:true,env:{...process.env,VBA_UI_TEST_PORT:String(port),VBA_UI_TEST_WORKSPACE:fixtureRoot},stdio:'ignore'});
|
||||
await until(async()=> (await fetch(`http://127.0.0.1:${port}/healthz`)).ok);
|
||||
browser=spawn(process.env.BROWSER_PATH||'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',[
|
||||
'--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check',
|
||||
`--user-data-dir=${profile}`,`--remote-debugging-port=${debugPort}`,'about:blank'],{windowsHide:true,stdio:'ignore'});
|
||||
const tabs=await until(async()=>await (await fetch(`http://127.0.0.1:${debugPort}/json`)).json());
|
||||
ws=new WebSocket(tabs.find(t=>t.type==='page').webSocketDebuggerUrl);
|
||||
await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject;});
|
||||
let next=0;const pending=new Map();
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(new Error(m.error.message)):p.resolve(m.result);}
|
||||
if(m.method==='Runtime.exceptionThrown')errors.push(m.params.exceptionDetails.text);
|
||||
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error')errors.push(m.params.entry.text);};
|
||||
const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});ws.send(JSON.stringify({id,method,params}));});
|
||||
const evaluate=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
|
||||
await send('Runtime.enable');await send('Log.enable');await send('Page.enable');
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});
|
||||
await send('Page.navigate',{url:`http://127.0.0.1:${port}/`});
|
||||
await until(()=>evaluate("document.readyState === 'complete' && typeof render === 'function'"));
|
||||
await evaluate("document.getElementById('token').value='ui_fixture_"+'x'.repeat(40)+"';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
|
||||
await until(()=>evaluate("document.getElementById('token-count').textContent==='37.800'"));
|
||||
const state=await evaluate("({workers:document.querySelectorAll('.worker').length,cases:document.querySelectorAll('#case-rows tr').length,active:document.getElementById('active-count').textContent,correction:document.getElementById('correction-count').textContent,overflow:document.documentElement.scrollWidth>innerWidth,stored:localStorage.length,progress:document.querySelector('.worker [role=progressbar]').getAttribute('aria-valuenow')})");
|
||||
if(state.workers!==1||state.cases!==1||state.active!=='1'||state.stored!==0||state.overflow||state.progress!=='57')throw new Error(JSON.stringify(state));
|
||||
await writeFile(path.join(output,'desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:390,height:1200,deviceScaleFactor:1,mobile:true});await pause(200);
|
||||
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Mobile horizontal overflow');
|
||||
await writeFile(path.join(output,'mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
|
||||
await evaluate("document.getElementById('project-filter').value='viano-core';document.getElementById('project-filter').dispatchEvent(new Event('change')); ");
|
||||
if(await evaluate("document.getElementById('token-count').textContent")!=='37.800')throw new Error('Project aggregation differs');
|
||||
await evaluate("document.getElementById('kind').value='case_return';document.getElementById('kind').dispatchEvent(new Event('change'));");
|
||||
if(!await evaluate("document.getElementById('new-files').hidden&&!document.getElementById('return-files').hidden"))throw new Error('Return form not selected');
|
||||
await evaluate("document.getElementById('logout').click()");
|
||||
if(!await evaluate("document.getElementById('content').hidden&&!document.getElementById('login').hidden&&document.getElementById('token-count').textContent==='—'"))throw new Error('Logout retained visible case data');
|
||||
if(errors.filter(e=>!e.includes('404')).length)throw new Error(errors.join('\n'));
|
||||
console.log(JSON.stringify({status:'PASS',...state,console_errors:errors.filter(e=>!e.includes('404'))}));
|
||||
} finally {
|
||||
ws?.close();browser?.kill();server?.kill();await pause(400);
|
||||
for(const [folder,prefix] of [[profile,'vba-browser-'],[fixtureRoot,'vba-ui-data-']]) {
|
||||
const target=path.resolve(folder),relative=path.relative(path.resolve(tmpdir()),target);
|
||||
if(!relative||relative.startsWith('..')||path.isAbsolute(relative)||!path.basename(target).startsWith(prefix))throw new Error('Unsafe temporary cleanup');
|
||||
await rm(target,{recursive:true,force:true,maxRetries:5,retryDelay:200});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
pytest.importorskip('starlette')
|
||||
pytest.importorskip('httpx')
|
||||
pytest.importorskip('multipart')
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project, IntakeLimits
|
||||
from viano_bug_analysis.api.app import create_app
|
||||
from viano_bug_analysis.api.config import APIError
|
||||
from viano_bug_analysis.lifecycle.checkpoints import CheckpointStore
|
||||
from viano_bug_analysis.lifecycle.storage import Workspace
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
|
||||
TOKEN = 'test_token_' + 'x' * 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('TEST_INTAKE_TOKEN', TOKEN)
|
||||
config = APIConfig(tmp_path / 'service', (Project('core', 'wow', 'vcore'),),
|
||||
(Client('capture', 'TEST_INTAKE_TOKEN', ('core',)),), limits=IntakeLimits(min_free_bytes=1))
|
||||
app = create_app(config)
|
||||
return TestClient(app), app.state.intake, config
|
||||
|
||||
|
||||
def data(kind='new_case'):
|
||||
result = {'kind': kind, 'project_id': 'core', 'profile_id': 'vcore', 'case_id': 'BUG-000020',
|
||||
'report': {'observations': ['The observed bug persists.']}}
|
||||
if kind == 'case_return':
|
||||
result['feedback'] = {'outcome': 'patch_failed', 'summary': 'Still broken',
|
||||
'observations': ['The tested patch did not fix it.']}
|
||||
return result
|
||||
|
||||
|
||||
def post(client, source, *, metadata=None, key='first', role='wowdiag', token=TOKEN):
|
||||
return client.post('/v1/submissions', headers={'Authorization': 'Bearer ' + token,
|
||||
'Idempotency-Key': key}, data={'metadata': json.dumps(metadata or data())},
|
||||
files={role: ('../untrusted.bin', source.read_bytes(), 'application/octet-stream')})
|
||||
|
||||
|
||||
def test_create_replay_conflict_and_private_status(api, source_pair):
|
||||
client, service, _ = api
|
||||
source = source_pair[0]
|
||||
response = post(client, source)
|
||||
assert response.status_code == 201, response.text
|
||||
receipt = response.json()
|
||||
assert receipt['status'] == 'QUEUED'
|
||||
assert post(client, source).status_code == 200
|
||||
assert len(list(service.workspace.inbox.glob('*.zip'))) == 1
|
||||
changed = data() | {'report': {'title': 'Changed'}}
|
||||
assert post(client, source, metadata=changed).status_code == 409
|
||||
assert post(client, source, key='different').status_code == 409
|
||||
assert post(client, source, token='bad').status_code == 401
|
||||
assert client.get('/v1/submissions/' + receipt['submission_id']).status_code == 401
|
||||
with PackageReader(next(service.workspace.inbox.glob('*.zip'))) as reader:
|
||||
assert reader.manifest['project']['id'] == 'core'
|
||||
assert reader.manifest['state'] == 'NEW'
|
||||
assert b'The observed bug persists.' in reader.read_bytes(
|
||||
'intake/submissions/' + receipt['submission_id'] + '/report.json')
|
||||
|
||||
|
||||
def test_return_preserves_history_and_increments_revision_once(api, source_pair, tmp_path):
|
||||
client, service, _ = api
|
||||
first = post(client, source_pair[0]).json()
|
||||
inbox = next(service.workspace.inbox.glob('*.zip'))
|
||||
# Standalone terminal case simulates transfer back from a different deployment.
|
||||
from viano_bug_analysis.package import PackageWriter
|
||||
terminal = tmp_path / 'terminal.zip'
|
||||
PackageWriter().write(inbox, terminal, manifest_updates={'state': 'OUTPUT_READY',
|
||||
'analysis_revision': 1, 'history': {'analysis_revisions': 1},
|
||||
'routing': {'destination': 'outbox'}})
|
||||
inbox.unlink()
|
||||
response = post(client, terminal, metadata=data('case_return'), key='return', role='case_package')
|
||||
assert response.status_code == 201, response.text
|
||||
returned = next(service.workspace.inbox.glob('*.zip'))
|
||||
with PackageReader(terminal) as old, PackageReader(returned) as new:
|
||||
assert new.manifest['state'] == 'REOPENED'
|
||||
assert new.manifest['analysis_revision'] == 2
|
||||
assert new.manifest['history']['feedback'] == 1
|
||||
assert new.manifest['package_id'] == first['package_id']
|
||||
for ref in old.files:
|
||||
if ref != 'manifest.json':
|
||||
assert old.read_bytes(ref) == new.read_bytes(ref)
|
||||
index = json.loads(new.read_bytes(new.manifest['artifacts']['case_inputs']))
|
||||
assert any(b'The tested patch did not fix it.' in new.read_bytes(ref) for ref in index['reports'])
|
||||
assert post(client, terminal, metadata=data('case_return'), key='return', role='case_package').status_code == 200
|
||||
assert post(client, terminal, metadata=data('case_return'), key='return2', role='case_package').status_code == 409
|
||||
from viano_bug_analysis.case import CaseBuilder
|
||||
isolated = CheckpointStore(Workspace(tmp_path / 'analysis'))
|
||||
isolated.intake(returned)
|
||||
built = CaseBuilder(isolated).build('BUG-000020', first['package_id'])
|
||||
with PackageReader(built.package) as reader:
|
||||
case = json.loads(reader.read_bytes(built.case_ref))
|
||||
assert any(o['text'] == 'The tested patch did not fix it.'
|
||||
for o in case['human_observations']['observations'])
|
||||
|
||||
|
||||
def test_active_case_cannot_be_returned(api, source_pair):
|
||||
client, service, _ = api
|
||||
post(client, source_pair[0])
|
||||
source = next(service.workspace.inbox.glob('*.zip'))
|
||||
assert post(client, source, metadata=data('case_return'), key='return', role='case_package').status_code == 409
|
||||
|
||||
|
||||
def test_limits_and_contract(api, source_pair):
|
||||
client, _, config = api
|
||||
assert client.get('/healthz').json() == {'status': 'OK'}
|
||||
assert client.get('/v1/schema', headers={'Authorization': 'Bearer ' + TOKEN}).status_code == 200
|
||||
assert post(client, source_pair[0], metadata=data() | {'budget': 99}).status_code == 422
|
||||
assert post(client, source_pair[0], metadata=data() | {'project_id': 'elsewhere'}).status_code == 403
|
||||
tiny = replace(config, workspace=config.workspace.parent / 'tiny',
|
||||
limits=replace(config.limits, max_body_bytes=100))
|
||||
limited = TestClient(create_app(tiny))
|
||||
assert post(limited, source_pair[0]).status_code == 413
|
||||
|
||||
|
||||
def test_recover_prepared_after_publication_interruption(api, source_pair, monkeypatch):
|
||||
client, service, _ = api
|
||||
publish = service._publish
|
||||
def interrupted(receipt):
|
||||
raise OSError('simulated crash')
|
||||
monkeypatch.setattr(service, '_publish', interrupted)
|
||||
with pytest.raises(OSError):
|
||||
service.submit(service.config.clients[0], 'crash', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
|
||||
monkeypatch.setattr(service, '_publish', publish)
|
||||
service.recover()
|
||||
assert len(list(service.workspace.inbox.glob('*.zip'))) == 1
|
||||
result, replay = service.submit(service.config.clients[0], 'crash', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
|
||||
assert replay and result['status'] == 'QUEUED'
|
||||
|
||||
|
||||
def test_published_receipt_crash_replay_after_worker_consumes(api, source_pair, monkeypatch):
|
||||
import viano_bug_analysis.api.service as module
|
||||
client, service, _ = api
|
||||
original = module.atomic_json
|
||||
def interrupted(path, value):
|
||||
if value.get('status') == 'QUEUED':
|
||||
raise OSError('crash after publication')
|
||||
original(path, value)
|
||||
monkeypatch.setattr(module, 'atomic_json', interrupted)
|
||||
with pytest.raises(OSError):
|
||||
service.submit(service.config.clients[0], 'crash2', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
|
||||
inbox = next(service.workspace.inbox.glob('*.zip'))
|
||||
service.store.intake(inbox)
|
||||
inbox.unlink()
|
||||
monkeypatch.setattr(module, 'atomic_json', original)
|
||||
service.recover()
|
||||
assert not list(service.workspace.inbox.glob('*.zip'))
|
||||
result, replay = service.submit(service.config.clients[0], 'crash2', json.dumps(data()).encode(), [('wowdiag', source_pair[0])])
|
||||
assert replay and result['status'] == 'VALIDATED'
|
||||
|
||||
|
||||
def test_file_limit_and_partial_upload_cleanup(api, source_pair):
|
||||
_, _, config = api
|
||||
tiny = replace(config, workspace=config.workspace.parent / 'files',
|
||||
limits=replace(config.limits, max_file_bytes=5))
|
||||
app = create_app(tiny)
|
||||
response = post(TestClient(app), source_pair[0])
|
||||
assert response.status_code == 413
|
||||
assert not list(app.state.intake.staging.iterdir())
|
||||
|
||||
|
||||
def test_return_with_stale_managed_head_is_rejected(api, source_pair, tmp_path):
|
||||
client, service, _ = api
|
||||
first = post(client, source_pair[0]).json()
|
||||
inbox = next(service.workspace.inbox.glob('*.zip'))
|
||||
from viano_bug_analysis.package import PackageWriter
|
||||
terminal = tmp_path / 'terminal.zip'
|
||||
PackageWriter().write(inbox, terminal, manifest_updates={'state': 'OUTPUT_READY',
|
||||
'analysis_revision': 1, 'history': {'analysis_revisions': 1}, 'routing': {'destination': 'outbox'}})
|
||||
service.store.intake(terminal)
|
||||
inbox.unlink()
|
||||
response = post(client, terminal, metadata=data('case_return'), key='stale', role='case_package')
|
||||
assert response.status_code == 409
|
||||
head = service.store.latest('BUG-000020', first['package_id'])
|
||||
response = post(client, head, metadata=data('case_return'), key='fresh', role='case_package')
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()['status'] == 'QUEUED' # not the previous OUTPUT_READY result
|
||||
auth = {'Authorization': 'Bearer ' + TOKEN}
|
||||
assert client.get('/v1/submissions/' + response.json()['submission_id'] + '/package', headers=auth).status_code == 409
|
||||
|
||||
|
||||
def test_scope_invalid_json_and_mismatched_source(api, source_pair):
|
||||
client, service, _ = api
|
||||
response = post(client, source_pair[0], metadata=data() | {'case_id': 'BUG-000021'})
|
||||
assert response.status_code == 422
|
||||
assert not list(service.workspace.inbox.glob('*.zip'))
|
||||
raw = client.post('/v1/submissions', headers={'Authorization': 'Bearer ' + TOKEN,
|
||||
'Idempotency-Key': 'duplicate-json'}, data={'metadata': '{"kind":"new_case","kind":"case_return"}'},
|
||||
files={'wowdiag': ('source', source_pair[0].read_bytes())})
|
||||
assert raw.status_code == 422
|
||||
first = post(client, source_pair[0]).json()
|
||||
stranger = Client('stranger', 'OTHER', ('other-project',))
|
||||
with pytest.raises(APIError) as exc:
|
||||
service.status(first['submission_id'], stranger)
|
||||
assert exc.value.status == 404
|
||||
|
||||
|
||||
def test_pair_and_attachments_are_retained(api, source_pair):
|
||||
client, service, _ = api
|
||||
response = client.post('/v1/submissions', headers={'Authorization': 'Bearer ' + TOKEN,
|
||||
'Idempotency-Key': 'paired'}, data={'metadata': json.dumps(data())}, files=[
|
||||
('wowdiag', ('diag', source_pair[0].read_bytes())),
|
||||
('wowsrc', ('src', source_pair[1].read_bytes())),
|
||||
('attachments', ('a', b'opaque-human-notes'))])
|
||||
assert response.status_code == 201, response.text
|
||||
with PackageReader(next(service.workspace.inbox.glob('*.zip'))) as reader:
|
||||
index = json.loads(reader.read_bytes(reader.manifest['artifacts']['case_inputs']))
|
||||
assert any(reader.read_bytes(ref) == b'opaque-human-notes' for ref in index['attachments'])
|
||||
@@ -0,0 +1,30 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project, IntakeService, IntakeLimits
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
from viano_bug_analysis.worker import AnalysisWorker, WorkerConfig
|
||||
from viano_bug_analysis.worker.config import binding
|
||||
from test_case_interpreter import gateway, FixtureAdapter
|
||||
|
||||
|
||||
def test_api_submission_is_admitted_and_built_without_manual_trial(source_pair, tmp_path):
|
||||
client = Client('capture', 'UNUSED_API_TOKEN', ('core',))
|
||||
project = Project('core', 'wow', 'vcore')
|
||||
config = APIConfig(tmp_path / 'service', (project,), (client,), limits=IntakeLimits(min_free_bytes=1))
|
||||
service = IntakeService(config)
|
||||
data = {'kind': 'new_case', 'project_id': 'core', 'profile_id': 'vcore', 'case_id': 'BUG-000020'}
|
||||
receipt, _ = service.submit(client, 'capture1', json.dumps(data).encode(), [('wowdiag', source_pair[0])])
|
||||
def load(p):
|
||||
return gateway(FixtureAdapter()), {}, binding(p)
|
||||
worker = AnalysisWorker(WorkerConfig(config, min_free_bytes=1), loader=load)
|
||||
asyncio.run(worker.run(once=True))
|
||||
assert not list(worker.workspace.inbox.glob('*.zip'))
|
||||
latest = worker.store.latest('BUG-000020', receipt['package_id'])
|
||||
with PackageReader(latest) as reader:
|
||||
assert reader.manifest['state'] == 'BUILDING_CASE'
|
||||
assert reader.manifest['artifacts']['case']
|
||||
status = service.status(receipt['submission_id'], client)
|
||||
assert status['status'] == 'BUILDING_CASE'
|
||||
assert status['analysis_revision'] == 1
|
||||
assert status['worker']['status'] == 'YIELDED'
|
||||
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
pytest.importorskip('starlette')
|
||||
pytest.importorskip('httpx')
|
||||
pytest.importorskip('multipart')
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project
|
||||
from viano_bug_analysis.api.app import create_app
|
||||
from viano_bug_analysis.api.dashboard import aggregate, case_view
|
||||
from viano_bug_analysis.llm.accounting import classification
|
||||
from viano_bug_analysis.lifecycle.storage import atomic_json
|
||||
from test_case_builder import case_setup
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation,rounds,prompt,expected', [
|
||||
('case-interpretation:v1:default',1,'case_plan_v2','regular'),
|
||||
('case-interpretation:v1:correction-interpretation-1',1,'case_plan_v2','correction'),
|
||||
('hypotheses:v1:default',2,'hypotheses_v4','correction'),
|
||||
('investigation:v1:H1:1:0:token',1,'investigation_lean_v21','regular'),
|
||||
('investigation:v1:H1:1:1:token',1,'investigation_lean_v21','correction'),
|
||||
('investigation-startup-review:v1:0:token',1,'startup','regular'),
|
||||
('investigation-startup-review:v1:1:token',1,'startup','correction'),
|
||||
('entity-resolution:v1:plan:1',1,'entity_resolution_plan_v4','regular'),
|
||||
('entity-resolution:v1:assessment:2',1,'entity_resolution_assessment_v4','correction'),
|
||||
('diagnosis:v1:early:2:attempt:1',1,'diagnosis_v8','regular'),
|
||||
('change-request:v1:attempt:2',1,'change_request_v9','correction'),
|
||||
('other',1,'unknown','unknown'),
|
||||
])
|
||||
def test_call_classification(operation, rounds, prompt, expected):
|
||||
assert classification(operation,rounds,prompt) == expected
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, docs, manifest):
|
||||
self.docs, self.manifest = docs, manifest
|
||||
self.files = docs
|
||||
self._archive = SimpleNamespace(getinfo=lambda ref: SimpleNamespace(file_size=len(json.dumps(docs[ref]))))
|
||||
def read_bytes(self, ref):
|
||||
return json.dumps(self.docs[ref]).encode()
|
||||
|
||||
|
||||
def test_correction_slice_disjoint_derived_excluded_and_thinking_not_doubled(monkeypatch):
|
||||
import viano_bug_analysis.api.dashboard as module
|
||||
docs = {'case': {'case': {'analysis_revision': 1}, 'bug': {'reports':[{'title':'Testfall'}]}},
|
||||
'request': {'prompt_version':'investigation_lean_v21'}}
|
||||
chain = []
|
||||
for i, category in enumerate(('regular', 'correction', 'regular')):
|
||||
ref = f'call{i}'
|
||||
docs[ref] = {'rounds':1, 'phase':'INVESTIGATING_HYPOTHESES', 'request_ref':'request',
|
||||
'derived': i==2, 'outcome': {'usage_classification':category,
|
||||
'metadata':{'input_tokens':100,'output_tokens':40,'reasoning_tokens':30,'cached_input_tokens':20}}}
|
||||
chain.append(('cp'+str(i), {'details':{'operation':'llm-round','result_ref':ref}, 'created_at':'2026-10-21T10:00:00Z'}))
|
||||
# Duplicate checkpoint provenance does not charge a provider call twice.
|
||||
chain.append(chain[0])
|
||||
monkeypatch.setattr(module,'checkpoint_chain',lambda reader:chain)
|
||||
reader=Reader(docs,{'case_id':'BUG-1','package_id':'one','analysis_revision':1,'package_generation':10,
|
||||
'state':'INVESTIGATING_HYPOTHESES','artifacts':{'case':'case'},'project':{'id':'p'},'updated_at':'now'})
|
||||
view=case_view(reader)
|
||||
assert len(view['calls'])==2
|
||||
usage=aggregate([view]);buckets={b['id']:b for b in usage['buckets']}
|
||||
assert buckets['investigation']['input_tokens']==100
|
||||
assert buckets['correction']['input_tokens']==100
|
||||
assert sum(b['input_tokens']+b['output_tokens'] for b in buckets.values())==280
|
||||
assert sum(b['reasoning_tokens'] for b in buckets.values())==60
|
||||
|
||||
|
||||
def test_unknown_usage_is_not_reported_as_complete():
|
||||
call={'bucket':'unknown','category':'unknown','phase':'diagnosis','day':'2026-10-21',
|
||||
'input_tokens':None,'output_tokens':5,'reasoning_tokens':None,'cached_input_tokens':None}
|
||||
result=aggregate([{'calls':[call]}])
|
||||
assert result['unknown_usage_calls']==1
|
||||
assert next(b for b in result['buckets'] if b['id']=='unknown')['output_tokens']==5
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def project_manifest(manifest):
|
||||
manifest['project']={'id':'p','type':'wow','profile':'vcore'}
|
||||
|
||||
|
||||
def test_overview_scope_assets_progress_and_completed_case_hidden(case_setup, monkeypatch):
|
||||
import viano_bug_analysis.api.dashboard as module
|
||||
store,builder,*_=case_setup
|
||||
built=builder.build('case-A','stable-package')
|
||||
token='dashboard_test_'+'x'*40;monkeypatch.setenv('DASHBOARD_TOKEN',token)
|
||||
config=APIConfig(store.workspace.root,(Project('p','wow','vcore'),Project('other','wow','vcore')),
|
||||
(Client('allowed','DASHBOARD_TOKEN',('p',)),))
|
||||
app=create_app(config);client=TestClient(app);auth={'Authorization':'Bearer '+token}
|
||||
assert client.get('/v1/overview').status_code==401
|
||||
html=client.get('/')
|
||||
assert html.status_code==200 and 'Content-Security-Policy' in html.headers
|
||||
assert 'renderPie' in client.get('/assets/dashboard.js').text
|
||||
assert client.get('/assets/missing.js').status_code==404
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'LIVE','service_id':'test-service'})
|
||||
directory=store.workspace.runtime/'worker';directory.mkdir(exist_ok=True)
|
||||
atomic_json(directory/'status.json',{'status':'ANALYZING','service_id':'test-service','case':{'case_id':'case-A','package_id':'stable-package'}})
|
||||
data=client.get('/v1/overview',headers=auth).json()
|
||||
assert data['workers'][0]['case']['title']=='Generic problem'
|
||||
assert data['workers'][0]['case']['progress']==29 # intake + completed casebuild / seven stages
|
||||
assert data['cases'][0]['project_id']=='p'
|
||||
assert token not in json.dumps(data) and str(store.workspace.root) not in json.dumps(data)
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'LIVE','service_id':'unrelated-trial'})
|
||||
assert client.get('/v1/overview',headers=auth).json()['workers']==[]
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'LIVE','service_id':'test-service'})
|
||||
stranger=Client('other','DASHBOARD_TOKEN',('other',))
|
||||
hidden=module.Dashboard(app.state.intake).overview(stranger)
|
||||
assert hidden['cases']==[] and hidden['workers']==[]
|
||||
assert sum(b['calls'] for b in hidden['usage']['buckets'])==0
|
||||
for state in ('INTERPRETING_CASE','GENERATING_HYPOTHESES','INVESTIGATING_HYPOTHESES','BUILDING_DIAGNOSIS','OUTPUT_READY'):
|
||||
store.advance('case-A','stable-package',state,step_id='test:'+state)
|
||||
completed=client.get('/v1/overview',headers=auth).json()
|
||||
assert completed['workers']==[]
|
||||
monkeypatch.setattr(module,'read_status',lambda _: {'observer':'HEARTBEAT_STALE'})
|
||||
assert client.get('/v1/overview',headers=auth).json()['workers']==[]
|
||||
def corrupted(_):
|
||||
from viano_bug_analysis.telemetry.config import TelemetryError
|
||||
raise TelemetryError('TELEMETRY_READ')
|
||||
monkeypatch.setattr(module,'read_status',corrupted)
|
||||
response=client.get('/v1/overview',headers=auth)
|
||||
assert response.status_code==200 and response.json()['workers']==[]
|
||||
@@ -0,0 +1,283 @@
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project
|
||||
from viano_bug_analysis.lifecycle import State
|
||||
from viano_bug_analysis.lifecycle.locking import FileLock
|
||||
from viano_bug_analysis.lifecycle.errors import LockBusy
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
from viano_bug_analysis.worker import AnalysisWorker, WorkerConfig, WorkerError
|
||||
from viano_bug_analysis.worker.config import binding
|
||||
from test_case_builder import case_setup
|
||||
from test_case_interpreter import gateway, FixtureAdapter
|
||||
|
||||
CASE = ('case-A', 'stable-package')
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def project_manifest(manifest):
|
||||
manifest['project'] = {'id': 'test', 'type': 'wow', 'profile': 'vcore'}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(case_setup):
|
||||
store = case_setup[0]
|
||||
project = Project('test', 'wow', 'vcore', planning_mode='separate')
|
||||
config = WorkerConfig(APIConfig(store.workspace.root, (project,),
|
||||
(Client('capture', 'TEST_TOKEN', ('test',)),)), min_free_bytes=1)
|
||||
def loader(p):
|
||||
return gateway(FixtureAdapter()), {'planning_mode': 'separate'}, binding(p)
|
||||
return config, loader, store
|
||||
|
||||
|
||||
def test_real_case_build_then_interpret_and_restart(setup):
|
||||
config, loader, store = setup
|
||||
worker = AnalysisWorker(config, loader=loader)
|
||||
assert asyncio.run(worker.tick()) == 1
|
||||
with PackageReader(store.latest(*CASE)) as reader:
|
||||
assert reader.manifest['state'] == State.BUILDING_CASE
|
||||
assert reader.manifest['artifacts']['case']
|
||||
# Fresh worker object has no in-memory queue. It recovers from managed packages.
|
||||
resumed = AnalysisWorker(config, loader=loader)
|
||||
assert asyncio.run(resumed.tick()) == 1
|
||||
with PackageReader(store.latest(*CASE)) as reader:
|
||||
assert reader.manifest['state'] == State.INTERPRETING_CASE
|
||||
assert reader.manifest['artifacts']['case_interpretation']
|
||||
|
||||
|
||||
def test_scheduler_retry_is_durable_and_not_busy_loop(setup):
|
||||
config, loader, store = setup
|
||||
now = datetime.now(timezone.utc)
|
||||
calls = []
|
||||
async def dispatch(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return {'status': 'RETRY_PENDING', 'code': 'LLM_CONNECTION',
|
||||
'retry_not_before': (now + timedelta(minutes=5)).isoformat()}
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=dispatch, clock=lambda: now)
|
||||
assert asyncio.run(worker.tick()) == 1
|
||||
again = AnalysisWorker(config, loader=loader, dispatcher=dispatch, clock=lambda: now)
|
||||
assert asyncio.run(again.tick()) == 0
|
||||
assert len(calls) == 1 and calls[0]['attempt_id'] == 'default'
|
||||
after = AnalysisWorker(config, loader=loader, dispatcher=dispatch,
|
||||
clock=lambda: now + timedelta(minutes=6))
|
||||
assert asyncio.run(after.tick()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason,automatic', [
|
||||
('AWAITING_TEST_FEEDBACK', True), ('INVALID_LLM_RESPONSES', True),
|
||||
('LLM_UNAVAILABLE', True), ('RESEARCH_UNAVAILABLE', False)])
|
||||
def test_worker_never_silently_bypasses_block(setup, reason, automatic):
|
||||
config, loader, store = setup
|
||||
store.advance(*CASE, State.BUILDING_CASE, step_id='start')
|
||||
blocked = store.advance(*CASE, State.BLOCKED, step_id='block', reason=reason,
|
||||
retry_not_before='2020-01-01T00:00:00Z', details={'automatic_retry': automatic})
|
||||
async def forbidden(*args, **kwargs):
|
||||
raise AssertionError('blocked case was dispatched')
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=forbidden)
|
||||
assert asyncio.run(worker.tick()) == 0
|
||||
assert store.latest(*CASE) == blocked
|
||||
if reason != 'RESEARCH_UNAVAILABLE':
|
||||
with pytest.raises(WorkerError, match='WORKER_RETRY_STATE'):
|
||||
worker.retry(*CASE)
|
||||
else:
|
||||
worker.retry(*CASE)
|
||||
with PackageReader(store.latest(*CASE)) as reader:
|
||||
assert reader.manifest['state'] == 'BUILDING_CASE'
|
||||
|
||||
|
||||
def test_explicitly_permitted_due_retry_resumes(setup):
|
||||
config, loader, store = setup
|
||||
store.advance(*CASE, State.BUILDING_CASE, step_id='start')
|
||||
store.advance(*CASE, State.BLOCKED, step_id='block', reason='RESEARCH_UNAVAILABLE',
|
||||
retry_not_before='2020-01-01T00:00:00Z', details={'automatic_retry': True})
|
||||
seen = []
|
||||
async def dispatch(store, gateway, *identity, **kwargs):
|
||||
with PackageReader(store.latest(*identity)) as reader:
|
||||
seen.append(reader.manifest['state'])
|
||||
return {'status': 'YIELDED'}
|
||||
assert asyncio.run(AnalysisWorker(config, loader=loader, dispatcher=dispatch).tick()) == 1
|
||||
assert seen == ['BUILDING_CASE']
|
||||
|
||||
|
||||
def test_one_service_and_trial_lease_exclusion(setup):
|
||||
config, loader, store = setup
|
||||
worker = AnalysisWorker(config, loader=loader)
|
||||
with FileLock(worker.workspace.runtime / 'locks/analysis-service.lock'):
|
||||
with pytest.raises(LockBusy):
|
||||
asyncio.run(worker.run(once=True))
|
||||
from viano_bug_analysis.lifecycle.storage import key
|
||||
with FileLock(worker.workspace.runtime / 'locks' / ('trial-' + key(CASE[0]) + '.lock')):
|
||||
assert asyncio.run(worker.tick()) == 0
|
||||
|
||||
|
||||
def test_exception_backoff_and_explicit_scheduler_release(setup):
|
||||
config, loader, store = setup
|
||||
calls = []
|
||||
async def broken(*args, **kwargs):
|
||||
calls.append(1)
|
||||
raise ValueError('secret data must not appear in runtime state')
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=broken)
|
||||
asyncio.run(worker.tick())
|
||||
asyncio.run(worker.tick())
|
||||
assert len(calls) == 1
|
||||
record = next(worker.records.glob('*.json')).read_text()
|
||||
assert 'secret data' not in record
|
||||
worker.retry(*CASE)
|
||||
asyncio.run(worker.tick())
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_stop_requested_during_dispatch_finishes_step(setup):
|
||||
config, loader, store = setup
|
||||
async def dispatch(*args, **kwargs):
|
||||
worker.request_stop()
|
||||
return {'status': 'YIELDED'}
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=dispatch)
|
||||
asyncio.run(worker.run())
|
||||
assert json.loads((worker.root / 'status.json').read_bytes())['status'] == 'STOPPED'
|
||||
assert next(worker.records.glob('*.json')).exists()
|
||||
|
||||
|
||||
def test_config_binding_persisted_before_dispatch_and_detects_restart_change(setup):
|
||||
config, loader, store = setup
|
||||
async def dispatch(*args, **kwargs):
|
||||
assert next(worker.records.glob('*.json')).exists()
|
||||
return {'status': 'YIELDED'}
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=dispatch)
|
||||
asyncio.run(worker.tick())
|
||||
changed = replace(config.intake.projects[0], planning_mode='joint')
|
||||
updated = replace(config, intake=replace(config.intake, projects=(changed,)))
|
||||
other = AnalysisWorker(updated, loader=loader, dispatcher=dispatch)
|
||||
assert asyncio.run(other.tick()) == 0
|
||||
record = json.loads(next(other.records.glob('*.json')).read_bytes())
|
||||
assert record['status'] == 'ATTENTION' and record['code'] == 'WORKER_CONFIG_CHANGED'
|
||||
|
||||
|
||||
def test_unknown_project_held_without_model_call(setup):
|
||||
config, loader, store = setup
|
||||
changed = replace(config.intake.projects[0], id='other')
|
||||
updated = replace(config, intake=APIConfig(config.intake.workspace, (changed,),
|
||||
(Client('capture', 'TEST_TOKEN', ('other',)),)))
|
||||
def forbidden(project):
|
||||
raise AssertionError('unknown project')
|
||||
worker = AnalysisWorker(updated, loader=forbidden)
|
||||
assert asyncio.run(worker.tick()) == 0
|
||||
assert json.loads(next(worker.records.glob('*.json')).read_bytes())['code'] == 'WORKER_PROJECT_UNKNOWN'
|
||||
|
||||
|
||||
def test_terminal_result_not_republished_every_pass(setup):
|
||||
config, loader, store = setup
|
||||
calls = []
|
||||
async def dispatch(*args, **kwargs):
|
||||
calls.append(1)
|
||||
return {'status': 'OUTPUT_READY'}
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=dispatch)
|
||||
asyncio.run(worker.tick())
|
||||
asyncio.run(worker.tick())
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_reopened_revision_does_not_skip_new_interpretation(setup):
|
||||
config, loader, store = setup
|
||||
worker = AnalysisWorker(config, loader=loader)
|
||||
asyncio.run(worker.tick())
|
||||
asyncio.run(worker.tick())
|
||||
for state in ('GENERATING_HYPOTHESES', 'INVESTIGATING_HYPOTHESES', 'BUILDING_DIAGNOSIS', 'OUTPUT_READY', 'HANDED_OFF'):
|
||||
store.advance(*CASE, state, step_id='test:' + state)
|
||||
store.advance(*CASE, 'REOPENED', step_id='test:reopen')
|
||||
asyncio.run(worker.tick())
|
||||
with PackageReader(store.latest(*CASE)) as reader:
|
||||
assert reader.manifest['analysis_revision'] == 2
|
||||
assert reader.manifest['state'] == 'BUILDING_CASE'
|
||||
# Old interpretation is deliberately retained in the append-only package.
|
||||
old = json.loads(reader.read_bytes(reader.manifest['artifacts']['case_interpretation']))
|
||||
assert old['analysis_revision'] == 1
|
||||
asyncio.run(worker.tick())
|
||||
with PackageReader(store.latest(*CASE)) as reader:
|
||||
doc = json.loads(reader.read_bytes(reader.manifest['artifacts']['case_interpretation']))
|
||||
assert doc['analysis_revision'] == 2
|
||||
|
||||
|
||||
def test_multiple_projects_and_poison_case_do_not_starve_other_cases(setup, case_setup, tmp_path):
|
||||
from viano_bug_analysis.package.factory import PackageFactory
|
||||
config, loader, store = setup
|
||||
with PackageReader(case_setup[2]) as reader:
|
||||
manifest = reader.manifest
|
||||
contents = {ref: reader.read_bytes(ref) for ref in reader.files if ref != 'manifest.json'}
|
||||
manifest.update(case_id='case-B', package_id='other-package', project={'id': 'second', 'type': 'wow', 'profile': 'vcore'})
|
||||
PackageFactory().create(store.workspace.inbox / 'other.zip', manifest, contents)
|
||||
project2 = Project('second', 'wow', 'vcore', planning_mode='separate')
|
||||
intake = replace(config.intake, projects=config.intake.projects + (project2,))
|
||||
seen = []
|
||||
def load(project):
|
||||
return project.id, {'project_id': project.id}, binding(project)
|
||||
async def dispatch(store, gateway, case_id, package_id, **kwargs):
|
||||
seen.append((case_id, gateway, kwargs['policies']['project_id']))
|
||||
if case_id == 'case-A':
|
||||
raise ValueError('broken first case')
|
||||
return {'status': 'YIELDED'}
|
||||
worker = AnalysisWorker(replace(config, intake=intake), loader=load, dispatcher=dispatch)
|
||||
asyncio.run(worker.tick())
|
||||
assert seen == [('case-A', 'test', 'test'), ('case-B', 'second', 'second')]
|
||||
asyncio.run(worker.tick())
|
||||
assert [v[0] for v in seen].count('case-A') == 1
|
||||
assert [v[0] for v in seen].count('case-B') == 2
|
||||
|
||||
|
||||
def test_cli_check_status_and_empty_once_do_not_need_api_token(tmp_path, monkeypatch, capsys):
|
||||
from viano_bug_analysis.worker.__main__ import main
|
||||
import viano_bug_analysis.worker.__main__ as cli
|
||||
monkeypatch.delenv('ABSENT_API_TOKEN', raising=False)
|
||||
intake = tmp_path / 'api.toml'
|
||||
intake.write_text('''[api]
|
||||
workspace = "service"
|
||||
[[projects]]
|
||||
id = "test"
|
||||
type = "wow"
|
||||
profile = "vcore"
|
||||
[[clients]]
|
||||
id = "capture"
|
||||
token_env = "ABSENT_API_TOKEN"
|
||||
projects = ["test"]
|
||||
''')
|
||||
config = tmp_path / 'worker.toml'
|
||||
config.write_text('[worker]\nintake_config = "api.toml"\nmin_free_bytes = 1\n')
|
||||
loaded = []
|
||||
monkeypatch.setattr(cli, 'load_project', lambda p: loaded.append(p.id))
|
||||
assert main(['check', '--config', str(config)]) == 0
|
||||
assert loaded == ['test']
|
||||
assert main(['run', '--once', '--config', str(config)]) == 0
|
||||
assert main(['status', '--config', str(config)]) == 0
|
||||
assert 'STOPPED' in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_storage_floor_pauses_without_dispatch(setup, monkeypatch):
|
||||
import viano_bug_analysis.worker.service as module
|
||||
from collections import namedtuple
|
||||
config, loader, store = setup
|
||||
Usage = namedtuple('Usage', 'total used free')
|
||||
worker = AnalysisWorker(config, loader=loader)
|
||||
monkeypatch.setattr(module.shutil, 'disk_usage', lambda *_: Usage(100, 100, 0))
|
||||
assert asyncio.run(worker.tick()) == 0
|
||||
assert json.loads((worker.root / 'status.json').read_bytes())['status'] == 'STORAGE_LOW'
|
||||
|
||||
|
||||
def test_exception_after_checkpoint_holds_actual_latest_generation(setup):
|
||||
config, loader, store = setup
|
||||
calls = []
|
||||
async def dispatch(store, gateway, *identity, **kwargs):
|
||||
calls.append(1)
|
||||
store.advance(*identity, 'BUILDING_CASE', step_id='commit-before-error')
|
||||
raise ValueError('unexpected after checkpoint')
|
||||
worker = AnalysisWorker(config, loader=loader, dispatcher=dispatch)
|
||||
asyncio.run(worker.tick())
|
||||
asyncio.run(worker.tick())
|
||||
assert len(calls) == 1
|
||||
record = json.loads(next(worker.records.glob('*.json')).read_bytes())
|
||||
with PackageReader(store.latest(*CASE)) as reader:
|
||||
assert record['package_generation'] == reader.manifest['package_generation']
|
||||
assert record['status'] == 'ATTENTION'
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Synthetic, disposable UI fixture; never uses model or database providers."""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from viano_bug_analysis.api import APIConfig, Client, Project
|
||||
from viano_bug_analysis.api.app import create_app
|
||||
from viano_bug_analysis.lifecycle import Workspace, CheckpointStore
|
||||
from viano_bug_analysis.lifecycle.job import timestamp
|
||||
from viano_bug_analysis.lifecycle.storage import atomic_json, json_bytes
|
||||
from viano_bug_analysis.package.factory import PackageFactory
|
||||
from viano_bug_analysis.case import CaseBuilder
|
||||
import viano_bug_analysis.api.dashboard as dashboard
|
||||
|
||||
TOKEN = 'ui_fixture_' + 'x' * 40
|
||||
|
||||
|
||||
def seed(root, case_id, title, completed=False):
|
||||
workspace = Workspace(root)
|
||||
store = CheckpointStore(workspace)
|
||||
now = timestamp()
|
||||
manifest = {'format':'viano-bug-case-package','format_version':1,'case_id':case_id,
|
||||
'package_id':'preview-'+case_id,'package_generation':0,'analysis_revision':0,
|
||||
'project':{'id':'viano-core','type':'wow','profile':'vcore'},'created_at':now,'updated_at':now,
|
||||
'state':'NEW','routing':{'destination':'inbox','reason':None,'retry_count':0,'retry_not_before':None},
|
||||
'artifacts':{'case':None,'diagnosis':None,'change_request':None,'case_inputs':'inputs.json'},
|
||||
'history':{'analysis_revisions':0,'reproductions':0,'evidence':0,'handoffs':0,'feedback':0,'events_ref':None},'integrity':{}}
|
||||
contents={'inputs.json':json_bytes({'format':'viano-case-inputs','format_version':1,'reports':['report.json']}),
|
||||
'report.json':json_bytes({'format':'viano-bug-report','format_version':1,'title':title,
|
||||
'observations':[{'text':'Synthetic UI fixture observation'}]})}
|
||||
path=workspace.inbox/(case_id+'.zip');PackageFactory().create(path,manifest,contents)
|
||||
store.intake(path);path.unlink()
|
||||
identity=case_id,manifest['package_id'];CaseBuilder(store).build(*identity)
|
||||
for state,pointer in [('INTERPRETING_CASE','case_interpretation'),('GENERATING_HYPOTHESES','hypotheses')]:
|
||||
ref=pointer+'.json';store.advance(*identity,state,step_id=state,
|
||||
artifacts={pointer:ref},additions={ref:json_bytes({'analysis_revision':1})})
|
||||
store.advance(*identity,'INVESTIGATING_HYPOTHESES',step_id='investigate')
|
||||
for i,(category,inp,out) in enumerate([('regular',12000,3000),('correction',3000,900)]):
|
||||
ref=f'call{i}.json';request=f'request{i}.json'
|
||||
call={'rounds':1,'phase':'INVESTIGATING_HYPOTHESES','request_ref':request,'outcome':{
|
||||
'usage_classification':category,'metadata':{'input_tokens':inp,'output_tokens':out,
|
||||
'reasoning_tokens':out//2,'cached_input_tokens':1000}}}
|
||||
store.advance(*identity,'INVESTIGATING_HYPOTHESES',step_id=f'call{i}',
|
||||
details={'operation':'llm-round','result_ref':ref},
|
||||
additions={ref:json_bytes(call),request:json_bytes({'prompt_version':'investigation_lean_v21'})})
|
||||
if completed:
|
||||
store.advance(*identity,'BUILDING_DIAGNOSIS',step_id='diagnosis')
|
||||
store.advance(*identity,'OUTPUT_READY',step_id='done')
|
||||
return identity
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
with tempfile.TemporaryDirectory(prefix='vba-dashboard-', dir=os.environ.get('VBA_UI_TEST_WORKSPACE')) as temporary:
|
||||
root=Path(temporary)
|
||||
identity=seed(root,'BUG-000020','Wolpertinger fehlen in Dun Morogh')
|
||||
seed(root,'BUG-000017','Leuchtbrille – Kontrolllauf',completed=True)
|
||||
config=APIConfig(root,(Project('viano-core','wow','vcore'),),
|
||||
(Client('preview','VBA_UI_TEST_TOKEN',('viano-core',)),))
|
||||
os.environ['VBA_UI_TEST_TOKEN']=TOKEN
|
||||
dashboard.read_status=lambda _: {'observer':'LIVE','service_id':'fixture-worker'}
|
||||
folder=root/'runtime/worker';folder.mkdir(exist_ok=True)
|
||||
atomic_json(folder/'status.json',{'status':'ANALYZING','service_id':'fixture-worker',
|
||||
'case':{'case_id':identity[0],'package_id':identity[1]}})
|
||||
uvicorn.run(create_app(config),host='127.0.0.1',port=int(os.environ.get('VBA_UI_TEST_PORT','8765')),access_log=False)
|
||||
Reference in new issue
Block a user