Compare commits

...
3 Commits
40 changed files with 3108 additions and 128 deletions

No files matched your search

+8 -2
View File
@@ -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).
+21
View File
@@ -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"]
+8
View File
@@ -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"
+22
View File
@@ -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
+169
View File
@@ -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
```
+169
View File
@@ -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.
+40
View File
@@ -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.
+101
View File
@@ -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.
+3
View File
@@ -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"]
+6
View File
@@ -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']
+27
View File
@@ -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())
+190
View File
@@ -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)
+133
View File
@@ -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
+23
View File
@@ -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
+189
View File
@@ -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 []
+105
View File
@@ -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)
+209
View File
@@ -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 {}})
+16
View File
@@ -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'
+2
View File
@@ -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,
+13
View File
@@ -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'})
+1 -125
View File
@@ -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):
+1
View File
@@ -0,0 +1 @@
"""Local web console assets; no external analytics, fonts or chart dependencies."""
+3
View File
@@ -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}
+140
View File
@@ -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;}
});
+39
View File
@@ -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']
+72
View File
@@ -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())
+86
View File
@@ -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
+288
View File
@@ -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')
+155
View File
@@ -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"))
+55
View File
@@ -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});
}
}
+212
View File
@@ -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'
+122
View File
@@ -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']==[]
+283
View File
@@ -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'
+66
View File
@@ -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)