Compare commits

...
19 Commits
Author SHA1 Message Date
Nico 72e1ade0a9 Archive BUG-000022 evaluation and deferred final-review findings 2026-10-25 00:37:42 +02:00
Nico aec2bcdb21 Record completed BUG-000021 field result and usage snapshot 2026-10-24 23:03:02 +02:00
Nico 61889740e6 Add audited operator approval for one entity repair attempt 2026-10-24 22:22:16 +02:00
Nico 4cbba4c1e4 Repair only invalid entity assessments with precise scoped feedback 2026-10-24 21:18:51 +02:00
Nico 33603db9bd Add guarded Linux deployment updater 2026-10-24 18:37:13 +02:00
Nico c4225ebf04 Report independent interpretation citation errors in one repair 2026-10-24 18:20:21 +02:00
Nico a9b49bbb13 Fix multi-database entity resolution capture validation 2026-10-24 17:59:01 +02:00
Nico 9c9d9d70d6 Support multiple allowlisted databases in project research tools 2026-10-24 12:33:41 +02:00
Nico 9408af5356 Document VPS installation and first setup on repository homepage 2026-10-23 15:36:11 +02:00
Nico 085a5c4026 Add packaged user manual and configuration guides to web console 2026-10-23 15:18:02 +02:00
Nico b42bafb711 Unify processed case listing with blocked states and package downloads 2026-10-23 13:59:15 +02:00
Nico b8c0d7ec40 Add capture worker v0.4 with durable BugAnalysis API handoff 2026-10-23 13:33:28 +02:00
Nico cbf112b0f8 Add recoverable encrypted integration keys and web management 2026-10-23 08:46:24 +02:00
Nico ca4ab48fdc Add Linux deployment installer, supervised services and readiness checks 2026-10-22 22:56:33 +02:00
Nico 203e765992 Add SQLite web users, privileged account CLI and session editing claims 2026-10-22 18:56:59 +02:00
Nico 9fc5c1805c Add human test request inbox and authenticated feedback resume 2026-10-22 13:14:01 +02:00
Nico ac179883c6 Add scoped operator controls and durable worker release queue 2026-10-22 09:46:56 +02:00
Nico c478518425 Add readable case details and completed results browser 2026-10-21 23:05:13 +02:00
Nico cbb69a2115 Show all analysis phases in token chart legend and preview 2026-10-21 21:39:26 +02:00
119 changed files with 12815 additions and 180 deletions

No files matched your search

+352 -2
View File
@@ -1,5 +1,348 @@
# VianoBugAnalysis
BugAnalysis untersucht gemeldete Softwarefehler anhand von Diagnosepaketen,
Sourcecode und lesenden Datenbankabfragen. Ein dauerhaft laufender Worker erstellt
Diagnosen und Aufträge für Codingagenten beziehungsweise menschliche Entwickler.
Die Weboberfläche bietet Benutzeranmeldung, Betriebsübersicht, Testerantworten,
API-Schlüssel und Paketdownloads. Das aktuelle Projektprofil ist für WoW/Vcore
ausgelegt. BugAnalysis installiert selbst keine Bugfixes auf dem Spielserver.
## Installation auf einem Linux-VPS
Diese Anleitung ist direkt auf der Repository-Startseite lesbar. Sie setzt einen
Linux-Server mit systemd und einen SSH-Zugang mit sudo-Rechten voraus. Die
Paketbefehle unten gelten für Debian/Ubuntu; auf anderen Distributionen die
entsprechenden Pakete installieren. **Python 3.11 oder neuer ist erforderlich.**
### 1. Voraussetzungen installieren
```sh
sudo apt-get update
sudo apt-get install python3 python3-venv python3-pip git nginx ca-certificates curl
python3 --version
```
Ist die Python-Version älter als 3.11, zuerst eine unterstützte Python-Version
bereitstellen und diese für den Installer verwenden. Datenbankserver und
World-Daten werden vom Installer nicht eingerichtet oder importiert. Ein
erreichbarer MariaDB-Datenbestand für die Recherche muss separat vorhanden
sein, beispielsweise eine isolierte Testdatenbank aus einem Backup.
### 2. Repository in das vorgesehene Verzeichnis klonen
**Der Installer und die systemd-Units erwarten derzeit genau
`/opt/viano-bug-analysis`.** Nicht einen beliebigen Ordnernamen verwenden.
Git legt den Zielordner an; er muss dafür noch nicht existieren oder leer sein.
```sh
sudo git clone https://gitea.viano-software.de/Nico/VianoBugAnalysis /opt/viano-bug-analysis
cd /opt/viano-bug-analysis
```
Das Klonen mit sudo erzeugt einen root-eigenen Checkout. Der Installer verlangt,
dass Programmdateien root gehören und weder für Gruppe noch andere Benutzer
schreibbar sind. Bei einem privaten Repository die Git-Anmeldung interaktiv
durchführen; keine Zugangsdaten in die URL schreiben.
### 3. Installationsplan prüfen und installieren
```sh
python3 deploy/install.py --dry-run
sudo python3 deploy/install.py
```
Der erste Befehl zeigt nur den Plan. Der zweite erstellt den Dienstbenutzer
`buganalysis`, die virtuelle Python-Umgebung, Konfigurationsvorlagen und beide
systemd-Units. Er startet oder aktiviert noch keinen Dienst und sendet keine
LLM-Anfrage. Vorhandene Konfigurationsdateien und Units werden nicht überschrieben.
| Verzeichnis | Inhalt |
| --- | --- |
| `/opt/viano-bug-analysis` | Programmcode und `.venv`; root-verwaltet |
| `/etc/viano-bug-analysis` | Konfiguration, geschützte Environment-Dateien und Webnutzerdatei |
| `/var/lib/viano-bug-analysis` | Fallakten, Inbox, Outbox, Review und Laufzeitdaten |
| `/srv/viano-bug-analysis/viano-core` | Separater Source-Checkout des untersuchten Vcore-Projekts |
Der Linux-Dienstbenutzer ist kein Webkonto. Webkonten werden in Schritt 6
gesondert angelegt. Ein Programm zur automatischen Erfassung benötigt später
einen Integrationsschlüssel, ebenfalls kein Webpasswort oder Cloud-API-Key.
### 4. Vcore-Sourcecode und lesenden Datenbankzugang bereitstellen
BugAnalysis benötigt zusätzlich zu seinem eigenen Repository den Sourcecode des
untersuchten Projekts. Für Vcore beispielsweise:
```sh
sudo git clone --branch master --single-branch https://gitea.viano-software.de/Nico/Viano-Core /srv/viano-bug-analysis/viano-core
sudo git -C /srv/viano-bug-analysis/viano-core rev-parse HEAD
```
Die Ausgabe des zweiten Befehls ist der vollständige Commit. Der für die Analyse
gewünschte Stand muss lokal vorhanden sein und wird in `project.toml` festgelegt.
Der aktuelle Branchkopf ist nur dann passend, wenn genau dieser Stand untersucht
werden soll. Der Worker klont oder aktualisiert den Sourcecode nicht automatisch.
Ein DB-Administrator richtet einen Benutzer mit ausschließlich passenden
SELECT-Rechten auf die World-Daten ein. Keine Schreibrechte und keinen
allgemeinen Administrationszugang verwenden. Für entfernte Datenbanken einen
geschützten Netzwerkweg bereitstellen; der aktuelle DB-Provider richtet selbst
kein TLS ein. Die vollständige Tabellenfreigabe steht in der Projektvorlage.
### 5. Konfigurationen und Zugangsdaten eintragen
Die Konfiguration liegt außerhalb des Git-Checkouts. Beispiele öffnen:
```sh
sudoedit /etc/viano-bug-analysis/api.toml
sudoedit /etc/viano-bug-analysis/project.toml
sudoedit /etc/viano-bug-analysis/llm.toml
sudoedit /etc/viano-bug-analysis/worker.env
```
| Datei | Beim ersten Einrichten prüfen |
| --- | --- |
| `api.toml` | Projekt-ID, Profilzuordnung und Konfigurationspfade; HTTP-Bindung auf `127.0.0.1:8080` lassen |
| `project.toml` | Sourcepfad und vollständigen Commit, DB-Host/Port/Name/Benutzer, Snapshotkennung und Tabellenfreigaben |
| `llm.toml` | Anbieter, tatsächlich verfügbares Modell, Cache- und Timeout-Einstellungen; verwendete Schlüssel-Umgebungsvariable |
| `worker.env` | Cloud-API-Key und DB-Passwort |
| `worker.toml` | Verweis auf `api.toml`, Polling und Speicherreserve; Vorgaben zunächst übernehmen |
| `investigation.bounded.toml`, `budget.human-tests.toml` | Untersuchungs- und Budgetregeln; Vorgaben zunächst übernehmen |
| `telemetry.toml` | Betriebsbeobachtung; Vorgaben zunächst übernehmen |
In `project.toml` die Null-Commit- und `CONFIGURE_ME`-Platzhalter ersetzen. Die
vollständige Vorlage mit Relations-, Enrichment- und Werkzeugregeln beibehalten;
die Projektdatei nicht auf DB-Adresse und Sourcepfad zusammenkürzen.
Für mehrere Datenbanken derselben MariaDB-Instanz kann in `project.toml` ein
zusätzlicher Abschnitt ergänzt werden:
```toml
[database.additional_databases]
cata_hotfixes = ["*"]
cata_locale = ["*"]
```
`[database].database` bleibt beispielsweise `cata_world`, `user` kann
`buganalysis` sein. Für den DB-Benutzer müssen entsprechende SELECT-Grants
vorliegen. `["*"]` erlaubt ausdrücklich alle sichtbaren Tabellen der jeweiligen
DB. Alternativ konkrete Tabellennamen auflisten. Nicht konfigurierte Datenbanken
bleiben gesperrt. Einzeldatenbank-Konfigurationen bleiben gültig.
Vollständige Mehrdatenbank-Vorlage:
[project.vcore-multidb.example.toml](config/project.vcore-multidb.example.toml).
Bei Verwendung der gelieferten Gemini-/DB-Vorlagen enthält `worker.env`:
```text
GEMINI_API_KEY="HIER_DEN_CLOUD_API_KEY_EINTRAGEN"
VIANO_DB_PASSWORD="HIER_DAS_DB_PASSWORT_EINTRAGEN"
```
Das sind Platzhalter, keine gültigen Zugangsdaten. Keine `export`-Anweisungen
verwenden. Die Datei wird durch systemd geladen und bleibt root-geschützt mit
Modus 0600. Secrets nicht ins Repository oder in Shell-Befehlsargumente schreiben.
**Wenn Integrationsschlüssel über die Weboberfläche verwaltet werden sollen:**
Den ausgelieferten `[[clients]]`-Beispielblock für `wow-capture-worker` vollständig
aus `api.toml` entfernen. Dazu gehören seine `id`-, `token_env`- und `projects`-
Zeilen. Die `[[projects]]`-Projektdefinition bleibt bestehen. `api.env` darf dann
zunächst nur Kommentare enthalten; ein `VBA_INTAKE_TOKEN` ist auf der
BugAnalysis-Seite nicht erforderlich. Andernfalls erwartet die Startprüfung für
den vorhandenen Beispielclient einen manuell konfigurierten Token in `api.env`.
Details: [Projektkonfiguration](docs/project-configuration.md),
[Modell-/Cachekonfiguration](docs/llm-context-caching.md) und
[Linux-Betrieb](docs/linux-deployment.md).
### 6. Persönliches Webkonto anlegen
`meinname` durch den gewünschten Benutzernamen ersetzen. Das Passwort wird
verdeckt abgefragt; es gehört nicht in die Befehlszeile.
```sh
sudo /opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.api create_user meinname --config /etc/viano-bug-analysis/api.toml --service-group buganalysis
```
Alle Webnutzer besitzen dieselben Bedienrechte. Nutzer werden ausschließlich per
privilegierter Konsole angelegt, geändert oder gelöscht; es gibt keine öffentliche
Registrierung. Weitere Befehle: [Benutzerkonten](docs/user-accounts.md).
### 7. Bereitschaft ohne Modellanfrage prüfen
Die Prüfungen laufen mit den Rechten und Environment-Dateien der späteren Dienste:
```sh
sudo systemd-run --wait --pipe --collect \
--property=User=buganalysis --property=Group=buganalysis \
--property=EnvironmentFile=/etc/viano-bug-analysis/api.env \
/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.deployment check \
--role api --config /etc/viano-bug-analysis/api.toml
sudo systemd-run --wait --pipe --collect \
--property=User=buganalysis --property=Group=buganalysis \
--property=EnvironmentFile=/etc/viano-bug-analysis/worker.env \
--setenv=GIT_CONFIG_GLOBAL=/etc/viano-bug-analysis/gitconfig \
/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.deployment check \
--role worker --config /etc/viano-bug-analysis/worker.toml
```
Erwartet wird jeweils `READY` mit `model_requests: 0`. Geprüft werden unter
anderem Speicher, Schreib-/Leserechte, Webnutzer, Konfiguration, Git-Stand und
DB-Schemata. Die Prüfung sendet keine kostenpflichtige Modellanfrage; sie bestätigt
deshalb nicht die Gültigkeit des Cloud-Keys oder die Modellverfügbarkeit.
Bei `NOT_READY` zunächst den gemeldeten Fehler beheben.
### 8. API starten und HTTPS einrichten
```sh
sudo systemd-analyze verify /etc/systemd/system/viano-bug-analysis-api.service /etc/systemd/system/viano-bug-analysis-worker.service
sudo systemctl enable --now viano-bug-analysis-api.service
curl --fail http://127.0.0.1:8080/healthz
```
Der Health-Aufruf auf dem VPS sollte `{"status":"OK"}` liefern. Die API bleibt
an Loopback gebunden. Für die öffentliche Webadresse eine Domain mit passendem
DNS-Eintrag und gültigem TLS-Zertifikat bereitstellen. Zertifikatsbeschaffung
und Firewallfreigaben an die vorhandene VPS-/Nginx-Einrichtung anpassen.
Auf Debian/Ubuntu kann eine eigene Nginx-Site aus der Vorlage angelegt werden:
```sh
sudo cp /etc/viano-bug-analysis/nginx.conf.example /etc/nginx/sites-available/viano-bug-analysis
sudoedit /etc/nginx/sites-available/viano-bug-analysis
sudo ln -s /etc/nginx/sites-available/viano-bug-analysis /etc/nginx/sites-enabled/viano-bug-analysis
sudo nginx -t
sudo systemctl enable --now nginx
sudo systemctl reload nginx
```
**Vor `nginx -t` alle Beispieldomains und Zertifikatpfade ersetzen; die Zertifikate
müssen bereits vorhanden sein.** Bei bereits vorhandener Site keinen zweiten
Symlink anlegen, sondern die bestehende Einrichtung prüfen. Andere VPS-Sites
nicht entfernen. Erst über die fertige HTTPS-Adresse öffentlich anmelden.
Für einen ersten lokalen Zugriff ohne fertige Domain ist ein SSH-Tunnel möglich.
Auf deinem eigenen Rechner ausführen, mit deinem SSH-Benutzer und VPS-Host:
```sh
ssh -L 8080:127.0.0.1:8080 benutzer@vps-host
```
Danach lokal `http://127.0.0.1:8080/` öffnen, während der Tunnel läuft. Der Port auf
dem eigenen Rechner muss frei sein. Für den regulären externen Erfassungsworker
weiterhin die öffentliche HTTPS-Adresse einrichten.
### 9. Erfassungsworker verbinden und Analysebetrieb einschalten
Auf der Weboberfläche mit deinem Konto anmelden. Unter **API-Schlüssel** einen
Schlüssel für den Erfassungsworker und das Projekt `viano-core` erstellen.
Den Schlüssel im Environment des **wowdiag-Workers** als `VBA_INTAKE_TOKEN`
hinterlegen. Dessen `[buganalysis]`-Abschnitt mit URL, Projekt und Profil einrichten
und bewusst aktivieren. Anleitung und Vorlagen:
[Erfassungsworker v0.4](deploy/capture-worker/README.md).
Der Erfassungsworker ist ein eigener Dienst auf dem Erfassungssystem. Er wird
nicht durch den BugAnalysis-Installer installiert oder gestartet. Die Datenbank-
Zugänge dieser beiden Programme sind ebenfalls voneinander unabhängig.
Anschließend den Analyseworker aktivieren:
```sh
sudo systemctl enable --now viano-bug-analysis-worker.service
sudo systemctl status viano-bug-analysis-api.service viano-bug-analysis-worker.service
```
**Ab diesem Zeitpunkt können vorhandene und neue Inbox-Aufträge kostenpflichtige
Modellaufrufe auslösen.** Mit einem kontrollierten echten Auftrag beginnen. In der
Weboberfläche Annahme und Fortschritt verfolgen, gegebenenfalls Testanfragen
beantworten und unter **Bearbeitete Fälle** das Ergebnis herunterladen.
Ergebnisse gehen in die Outbox, gescheiterte Analysen ins Review; blockierte Fälle
bleiben zur Fortsetzung erhalten. Ein Download bestätigt keine fachliche Übergabe.
### 10. Betrieb, Sicherung und Updates
```sh
sudo journalctl -u viano-bug-analysis-api -u viano-bug-analysis-worker -f
sudo systemctl stop viano-bug-analysis-worker.service
```
Der Worker beendet beim Stoppen den aktuellen Dispatch kontrolliert. Das kann
während einer laufenden Anfrage dauern. Vor Code-/Profiländerungen anhalten;
Konfigurationsbindungen laufender Fälle nicht beiläufig ändern.
Mindestens `/etc/viano-bug-analysis` und `/var/lib/viano-bug-analysis` gesichert
aufbewahren. Dazu zählen die Nutzer-SQLite sowie Integrationsschlüssel-SQLite und
deren separate Serverschlüsseldatei unter `runtime/integration-secrets`. Dieses
Verzeichnis ist kein beliebig löschbarer Cache. Für eine einfache konsistente
Dateisicherung beide Dienste anhalten. Zugriffe auf Backups mit Secrets schützen.
Für reguläre Codeupdates steht ein Update-Skript bereit:
```sh
sudo python3 /opt/viano-bug-analysis/deploy/update.py
```
Eine Vorschau ohne Netzwerkzugriff oder Änderungen ist mit `--dry-run` möglich.
Das Skript benötigt root und einen sauberen, root-eigenen Checkout mit Tracking-Branch.
Es holt den Remote-Stand, prüft Fast-forward, stoppt zuerst den Worker kontrolliert
und danach die API, installiert das aktualisierte Paket in die vorhandene venv
und startet nur die zuvor aktiven Dienste. Deren Startprüfungen testen die
Bereitschaft ohne LLM-Aufruf. Vorher inaktive Dienste werden nicht aktiviert.
Auch bei identischem Commit wird das Paket erneut installiert, etwa nach einem
vorherigen manuellen Pull. Parallele Updates sind gesperrt.
Konfigurationen, Zugangsdaten, Fallakten, Apache, Source-Checkout und Capture-Worker
werden nicht verändert. Das Skript erstellt keine Datensicherung und führt keine
projektspezifischen Migrationen aus.
Scheitert Installation oder Dienststart, bleiben beide Dienste gestoppt. Es gibt
keinen automatischen Rollback von Code oder Daten. Nach Behebung der Ursache das
Paket erneut installieren (auch mit diesem Skript möglich) und die gewünschten
Dienste ausdrücklich starten. Falls das Skript im bisherigen Checkout noch fehlt,
einmal vorab den folgenden Befehl ausführen und danach das Skript starten:
```sh
sudo git -C /opt/viano-bug-analysis pull --ff-only
```
Releasehinweise und geänderte Konfigurations-/Unit-Vorlagen vorher prüfen.
Das Updatebeispiel ersetzt keine gezielte Übernahme geänderter systemd-Units
mit anschließendem `systemctl daemon-reload`. Vorlagen werden vom Installer
absichtlich nicht über bestehende Konfigurationen kopiert.
Die Einrichtung ist abgeschlossen, wenn Anmeldung, automatischer Auftragseingang,
Analyse, Ergebnisdownload, menschliche Rückfrage und Wiederanlauf nach einem
Dienst-/Serverneustart geprüft wurden. Die native Linux-/HTTPS-Prüfung erfolgt auf
deinem Zielserver; lokale Windows-Entwicklungstests ersetzen sie nicht.
### Häufige Probleme bei der Ersteinrichtung
| Meldung / Verhalten | Zuerst prüfen |
| --- | --- |
| Installer verlangt root-eigenen Checkout | Pfad `/opt/viano-bug-analysis`, Eigentümer und Schreibrechte des Codes |
| `DEPLOY_MACHINE_CREDENTIALS` | Unbenutzten `[[clients]]`-Beispielblock entfernen oder dessen env-Token korrekt einrichten |
| `LLM_CREDENTIALS` | `worker.env`, Name der Schlüsselvariable in `llm.toml` und Laden der Datei durch systemd |
| `PROJECT_DATABASE_UNAVAILABLE` | DB-Adresse/Port, Netzwerkweg, Passwort und DB-Benutzer |
| Sourceprüfung schlägt fehl | Lokaler Checkout, tatsächlicher Commit, Leserechte und `/etc/viano-bug-analysis/gitconfig` |
| `nginx -t` meldet fehlendes Zertifikat | Zertifikat bereitstellen und die Beispielpfade im Site-Block ersetzen |
| Anmeldung funktioniert, aber kein Fall wird analysiert | Analyseworker läuft, Projektbindung stimmt, Auftrag ist tatsächlich angenommen |
Dienstfehler über `journalctl` prüfen. Ein fehlgeschlagener Start wird durch
systemd erneut versucht; nach einer Korrektur lässt sich der betreffende Dienst
mit `sudo systemctl restart DIENSTNAME` gezielt neu starten. Dabei den echten
Namen `viano-bug-analysis-api.service` beziehungsweise
`viano-bug-analysis-worker.service` einsetzen.
## Weiterführende Anleitungen
- [Verwendung und Funktionsweise](docs/user-guide.md) – auch im Webreiter Benutzeranleitung.
- [Projekt-, Source-, DB- und Modellkonfiguration](docs/project-configuration.md).
- [Übergabe-API und Rückgaben](docs/intake-api.md).
- [Analyseworker](docs/analysis-worker.md).
- [Benutzerkonten](docs/user-accounts.md).
- [Integrationsschlüssel](docs/integration-keys.md).
- [Linux-Betrieb im Detail](docs/linux-deployment.md).
## Entwicklung und technische Dokumentation
Project-independent foundations for reading, validating and extending ZIP case
packages. The authoritative design is [docs/architecture.md](docs/architecture.md).
The implemented v1 contract is documented in
@@ -17,6 +360,13 @@ 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.
The [Linux deployment guide](docs/linux-deployment.md) covers systemd services,
protected credentials, HTTPS and non-billable readiness checks.
The [integration key manager](docs/integration-keys.md) lets signed-in users
create, reveal and revoke project-scoped machine credentials.
The web tab **Benutzeranleitung** includes the [user guide](docs/user-guide.md)
and operator documentation, including project/source/database configuration.
After editing bundled documentation, run `python scripts/build_user_manual.py`.
Requires Python 3.11 or newer.
@@ -68,8 +418,8 @@ Durable investigation budget accounting, configurable costs/rewards and stopping
rules: [budget-manager-v1.md](docs/budget-manager-v1.md), with [budget configuration](config/budget.example.toml).
Case-local asynchronous test requests, confirmed feedback and budget-preserving
event-driven resume: [async-tests-v1.md](docs/async-tests-v1.md). Web UI and external
bot/client adapters are future integrations.
event-driven resume: [async-tests-v1.md](docs/async-tests-v1.md). Human test requests
can be answered through the web console; external bot adapters are separate integrations.
Read-only packaged source/database queries and the strict tool allowlist:
[investigation-tools-v1.md](docs/investigation-tools-v1.md).
+14
View File
@@ -3,6 +3,11 @@ workspace = "../service-data/service"
host = "127.0.0.1"
port = 8080
[auth]
session_seconds = 3600
# Production Linux: root-owned file, read-only for the service group.
# users_database = "/etc/viano-bug-analysis/users.sqlite3"
[[projects]]
id = "viano-core"
type = "wow"
@@ -14,8 +19,17 @@ planning_mode = "joint"
# project_config = "project.local.toml"
# llm_config = "llm.early-review.local.toml"
# investigation_config = "investigation.bounded.toml"
# budget_config = "budget.human-tests.toml"
[[clients]]
id = "wow-capture-worker"
token_env = "VBA_INTAKE_TOKEN"
projects = ["viano-core"]
# Optional privileged machine integration, not a human login.
# [[clients]]
# id = "automation-operator"
# token_env = "VBA_OPERATOR_TOKEN"
# projects = ["viano-core"]
# operator = true
# Human accounts are created only by the privileged create_user console command.
+5
View File
@@ -0,0 +1,5 @@
# Human requests are available when an authenticated web feedback adapter exists.
# Frozen per new analysis revision; existing revisions retain their policy.
[budget]
[budget.action_costs]
"testing.request_human" = 40
+6
View File
@@ -19,6 +19,12 @@ snapshot_id = "backup-sha256:4964f277ef5cf7f7f98bf4378971dcb55180bac813d49bcda6d
# Explicit allowlist: no account or character tables.
tables = ["creature_template", "creature_template_locale", "creature", "quest_template", "quest_template_addon", "smart_scripts", "conditions", "game_event", "game_event_creature", "spell_script_names", "gameobject_template", "gameobject", "creature_equip_template", "item_script_names", "creature_queststarter", "spell_dbc"]
# Optional additional databases on this same connection; enable explicitly:
# [database.additional_databases]
# cata_hotfixes = ["*"]
# cata_locale = ["*"]
# Use individual table names instead of "*" for narrower access.
[limits]
max_rows = 50
max_result_bytes = 65536
+265
View File
@@ -0,0 +1,265 @@
# Operator-controlled bindings. The model cannot override these settings.
relations_file = "project-relations.vcore.toml"
[project]
type = "wow"
profile = "vcore"
[source]
path = "/srv/viano-bug-analysis/viano-core"
url = "https://gitea.viano-software.de/Nico/Viano-Core"
commit = "0000000000000000000000000000000000000000"
[database]
host = "127.0.0.1"
port = 3306
database = "cata_world"
user = "buganalysis"
password_env = "VIANO_DB_PASSWORD"
snapshot_id = "CONFIGURE_ME"
# Explicit allowlist: no account or character tables.
tables = ["*"]
# Explicit database scope: no auth, character or locale-reference access.
[database.additional_databases]
cata_hotfixes = ["*"]
cata_locale = ["*"]
[limits]
max_rows = 50
max_result_bytes = 65536
max_files = 20000
max_file_bytes = 2097152
max_search_bytes = 134217728
max_lines = 80
timeout_seconds = 15
max_cached_queries = 256
[enrichment]
max_queries = 24
max_depth = 3
max_relationships = 128
[[enrichment.texts]]
table = "quest_template"
id_column = "ID"
context_role = "quest_definition"
columns = ["LogTitle", "LogDescription", "QuestDescription"]
[[enrichment.queries]]
kind = "source.schema"
text = "CREATE TABLE `creature`"
path_prefix = "sql/base/"
case_sensitive = true
offset = 0
limit = 1
[[enrichment.queries]]
kind = "source.schema"
text = "CREATE TABLE `quest_template`"
path_prefix = "sql/base/"
case_sensitive = true
offset = 0
limit = 1
# Deterministic entity resolution before the first LLM call; no diagnosis.
[[enrichment.entities]]
entity_type = "creature"
table = "creature_template"
column = "entry"
name_column = "name"
columns = ["entry", "name", "subname", "ScriptName", "AIName"]
limit = 1
[[enrichment.entities]]
entity_type = "quest"
table = "quest_template"
column = "ID"
name_column = "LogTitle"
columns = ["ID", "LogTitle", "LogDescription", "QuestDescription", "StartItem", "RequiredNpcOrGo1", "RequiredNpcOrGo2", "RequiredNpcOrGo3", "RequiredNpcOrGo4", "RequiredItemId1", "RequiredItemId2", "RequiredItemId3", "RequiredItemId4", "RequiredItemId5", "RequiredItemId6", "RequiredSpell"]
limit = 1
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "StartItem"
relation = "quest_start_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredItemId1"
relation = "quest_required_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredItemId2"
relation = "quest_required_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "ID"
relation = "quest_starter_records"
table = "creature_queststarter"
column = "quest"
columns = ["id", "quest"]
[[enrichment.relationships]]
from_table = "creature_queststarter"
from_id_column = "quest"
from_column = "id"
relation = "quest_giver"
entity_type = "creature"
table = "creature_template"
column = "entry"
columns = ["entry", "name", "AIName", "ScriptName"]
[[enrichment.relationships]]
from_table = "creature_template"
from_id_column = "entry"
from_column = "entry"
when_column = "AIName"
when_value = "SmartAI"
relation = "creature_smartai_records"
table = "smart_scripts"
column = "entryorguid"
columns = ["entryorguid", "source_type", "id", "link", "event_type", "event_param1", "event_param2", "event_param3", "event_param4", "action_type", "action_param1", "action_param2", "action_param3", "action_param4", "action_param5", "action_param6", "target_type", "target_param1", "target_param2", "target_param3", "comment"]
filters = [{column = "source_type", operator = "eq", value = 0}]
limit = 10
[[enrichment.relationships]]
from_table = "creature_template"
from_id_column = "entry"
from_column = "ScriptName"
relation = "creature_script_binding"
kind = "source.symbol"
path_prefix = "src/server/scripts/"
limit = 5
[[enrichment.relationships]]
from_table = "item_script_names"
from_id_column = "Id"
from_column = "ScriptName"
relation = "item_script_binding"
kind = "source.symbol"
path_prefix = "src/server/scripts/"
limit = 5
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredSpell"
relation = "quest_required_spell"
entity_type = "spell"
table = "spell_script_names"
column = "spell_id"
columns = ["spell_id", "ScriptName"]
[[enrichment.relationships]]
from_table = "spell_script_names"
from_id_column = "spell_id"
from_column = "ScriptName"
relation = "spell_script_binding"
kind = "source.symbol"
path_prefix = "src/server/scripts/"
limit = 5
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredItemId3"
relation = "quest_required_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredItemId4"
relation = "quest_required_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredItemId5"
relation = "quest_required_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
[[enrichment.relationships]]
from_table = "quest_template"
from_id_column = "ID"
from_column = "RequiredItemId6"
relation = "quest_required_item"
entity_type = "item"
table = "item_script_names"
column = "Id"
columns = ["Id", "ScriptName"]
# Semantic name lookup runs in Case Interpretation, never in the deterministic CaseBuilder.
[entity_resolution]
enabled = true
max_searches = 4
max_candidates = 8
max_invalid_responses = 3
[[entity_resolution.catalogs]]
id = "creatures"
entity_type = "creature"
table = "creature_template"
id_column = "entry"
name_column = "name"
columns = ["entry", "name", "subname", "AIName", "ScriptName", "minlevel", "maxlevel", "faction", "unit_flags"]
[[entity_resolution.catalogs]]
id = "localized_creatures"
entity_type = "creature"
table = "creature_template_locale"
id_column = "entry"
name_column = "Name"
columns = ["entry", "locale", "Name", "Title"]
# Larger read-only source tool; registration naming is project-specific.
[source_analysis]
enabled = true
context_lines = 8
max_searches = 4
max_matches = 12
max_files = 8
max_blocks = 12
max_total_lines = 160
registration_prefixes = ["AddSC_"]
# Context-aware source selection. Domain markers belong to this adapter.
protocol = 2
max_seed_terms = 8
max_candidate_matches = 50
max_weak_hits = 8
code_prefixes = ["src/"]
schema_prefixes = ["sql/base/"]
historical_prefixes = ["sql/old/", "sql/archive/"]
context_fact_names = ["zone_name", "area_name", "world_event_name"]
type_markers = {creature = ["NPC", "CREATURE"], quest = ["QUEST"], spell = ["SPELL"], item = ["ITEM"], gameobject = ["GO", "GAMEOBJECT"]}
entity_tables = [{table = "creature_template", entity_type = "creature", id_column = "entry", script_columns = ["ScriptName"]}]
focus_roles = ["diagnostic_focus", "explicit_quest"]
+73
View File
@@ -0,0 +1,73 @@
# wowdiag-worker v0.4 – BugAnalysis-Übergabe
Erweiterung des vorgelegten v0.3-Workers. Die ursprüngliche Queue-Claim-Logik und
DB-Struktur bleiben erhalten. Keine Änderung der aktiven `config.toml`, kein
Dienststart und keine Datenbankmigration durch diese Dateien.
Nach erfolgreicher wowdiag-/wowsrc-Erzeugung wird ein Versandauftrag dauerhaft
in einer separaten lokalen SQLite-Outbox gespeichert. `diagnostic_runs.completed`
bedeutet weiterhin **Erfassung abgeschlossen**, nicht Analyse abgeschlossen oder
API-Zustellung bestätigt. Die Versandquittung ist über `--handoff-status` sichtbar.
Die API übernimmt den Fall anschließend in ihren eigenen Analyseworker.
## Einrichtung
1. Bestehende `wowdiag_worker.py` sichern und die v0.4-Datei sowie
`buganalysis_handoff.py` nebeneinander installieren. Python 3.11 und PyMySQL
bleiben die Voraussetzungen; der HTTP-Client benötigt keine neue Bibliothek.
2. Den Abschnitt aus `buganalysis.config.example.toml` in die vorhandene
Worker-Konfiguration übernehmen. Tatsächliche URL und Projektzuordnung setzen.
Der URL muss die Serverwurzel sein; HTTPS ist für entfernte Server erforderlich.
3. Einen Integrationsschlüssel in BugAnalysis für das Projekt erzeugen und im
Dienst-Environment als `VBA_INTAKE_TOKEN` hinterlegen. Unter Linux eine geschützte
systemd-EnvironmentFile verwenden; den Schlüssel nicht in Skriptargumente,
Repository oder Worker-Logs schreiben. Bestehende Unit um EnvironmentFile
ergänzen und anschließend kontrolliert neu starten.
4. SQLite-Outbox und Diagnosearchive bis zur bestätigten Zustellung erhalten.
Das Outbox-Verzeichnis muss für den Worker schreibbar sein und einen lokalen
Datenträger mit SQLite-Dateisperren verwenden. Die Datei nicht zwischen
unabhängigen Installationen teilen.
Ohne aktivierten `[buganalysis]`-Abschnitt bleibt der bisherige Ablauf erhalten.
Das Flag bedeutet ausschließlich Erfassung plus Übergabe; der Worker löst keine
Modellanfrage selbst aus. Ein laufender BugAnalysis-Worker kann nach Annahme
allerdings sofort eine kostenpflichtige Analyse beginnen.
## Wiederholungen und Status
```sh
python3 wowdiag_worker.py --config config.toml --handoff-status
python3 wowdiag_worker.py --config config.toml --retry-handoffs
```
Diese beiden Befehle führen keine Erfassung aus. Der normale Dauerdienst sendet
fällige Aufträge vor dem nächsten Capture; `--once` versucht sie auch danach.
Netzwerkfehler, Serverausfälle, temporäre Ablehnungen und fehlende/ungültige
Zugangsdaten bleiben `pending`. Nach Behebung werden dieselben Inhalte mit
demselben Idempotenzschlüssel versandt. Auch eine verlorene Erfolgsantwort legt
dadurch keinen zweiten Fall an. Es werden keine HTTP-Redirects verfolgt.
Unveränderliche Metadaten, Ziel-URL, Archivpfade und Inhaltsprüfwerte stehen in der
Outbox; Zugangsdaten stehen dort nicht. Archivänderung oder endgültiger HTTP-
Konflikt wird `attention` und benötigt Betreiberprüfung. Ein Lease verhindert
gleichzeitigen regulären Versand durch mehrere Worker-Prozesse. Ein Prozessabbruch
lässt den Auftrag nach Ablauf des Lease erneut versuchen. Es gibt keine automatische
Neuerzeugung von Archiven oder ein Zurücksetzen der ursprünglichen Queue-Claims.
Bereits vor dem Update abgeschlossene Erfassungen werden nicht automatisch
nachträglich eingesendet. Ein erneuter neuer Auftrag für dieselbe Bug-ID wird von
BugAnalysis bewusst abgewiesen, wenn der Fall bereits existiert. Rückgaben nach
einem Patch erfolgen über den separaten `case_return`-Vertrag und nicht durch
automatisches Umdeuten neuer Captures.
Der Übergabeumschlag nennt die Bug-ID und den Erfassungs-Commit. Die wirklichen
Testerbeobachtungen, Titel und Beziehungen liest der BugAnalysis-Importer direkt
aus den unveränderten Archiven. Der Worker erfindet keine Beobachtungen und gibt
den Erfassungsstand nicht als Stand der ursprünglichen Fehlermeldung aus.
## Lokale Prüfung im BugAnalysis-Checkout
`python -m pytest tests/profiles/vcore/test_capture_handoff.py` prüft den Versand gegen die echte
lokale Intake-API mit synthetischen Archiven. Kein echter Server, keine Cloud-
Anfrage, keine Änderung der Diagnose-Queue. Ein Server-Feldtest mit einem neuen
Bug und Dienstneustart steht nach der Konfiguration noch aus.
@@ -0,0 +1,12 @@
# Add this section to the EXISTING worker configuration. Do not replace its DB/path settings.
[buganalysis]
enabled = true
url = "https://buganalysis.example.org"
project_id = "viano-core"
profile_id = "vcore"
# Keep this name stable and unique for this capture installation / queue database.
producer_id = "vcore-testserver"
token_env = "VBA_INTAKE_TOKEN"
outbox = "buganalysis-outbox.sqlite3"
timeout_seconds = 120
retry_seconds = 60
@@ -0,0 +1,162 @@
"""Durable, streaming BugAnalysis intake client. Python stdlib only."""
from contextlib import contextmanager
import hashlib
import http.client
import json
import os
from pathlib import Path
import re
import secrets
import sqlite3
import time
from urllib.parse import urlsplit
class HandoffError(Exception):
pass
def settings(raw,base):
if raw and type(raw.get('enabled',False)) is not bool:raise HandoffError('HANDOFF_CONFIG')
if not raw or not raw.get('enabled',False):return None
value=dict(raw)
allowed={'enabled','url','project_id','profile_id','token_env','outbox','timeout_seconds','retry_seconds','producer_id'}
if set(value)-allowed:raise HandoffError('HANDOFF_CONFIG')
value.setdefault('profile_id','vcore');value.setdefault('token_env','VBA_INTAKE_TOKEN')
value.setdefault('timeout_seconds',120);value.setdefault('retry_seconds',60)
for field in ('project_id','profile_id','producer_id'):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}',str(value.get(field,''))):raise HandoffError('HANDOFF_CONFIG')
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',value['token_env']):raise HandoffError('HANDOFF_CONFIG')
parsed=urlsplit(value.get('url',''))
if (parsed.scheme not in {'https','http'} or not parsed.hostname or parsed.username or parsed.password
or parsed.query or parsed.fragment or parsed.path not in {'','/'}
or (parsed.scheme=='http' and parsed.hostname not in {'127.0.0.1','localhost','::1'})):
raise HandoffError('HANDOFF_URL')
if any(type(value[f]) is not int or not 1<=value[f]<=3600 for f in ('timeout_seconds','retry_seconds')):
raise HandoffError('HANDOFF_CONFIG')
value['outbox']=str((base/Path(value.get('outbox','buganalysis-outbox.sqlite3'))).resolve())
return value
def digest(path):
h=hashlib.sha256()
with Path(path).open('rb') as stream:
for chunk in iter(lambda:stream.read(1024*1024),b''):h.update(chunk)
return h.hexdigest()
class Outbox:
def __init__(self,config,*,clock=time.time):
self.config=config;self.clock=clock;self.path=Path(config['outbox'])
self.path.parent.mkdir(parents=True,exist_ok=True)
if not self.path.exists():
try:
fd=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(fd)
except FileExistsError:pass
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS deliveries (job TEXT PRIMARY KEY, idempotency TEXT NOT NULL UNIQUE, payload TEXT NOT NULL,status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_try REAL NOT NULL DEFAULT 0, code TEXT, receipt TEXT, lease_until REAL NOT NULL DEFAULT 0)')
@contextmanager
def db(self):
conn=sqlite3.connect(self.path,timeout=10);conn.row_factory=sqlite3.Row
try:
with conn:yield conn
finally:conn.close()
def stage(self,run_id,bug_id,diag,src,revision):
files=[]
for role,path in (('wowdiag',diag),('wowsrc',src)):
path=Path(path).resolve()
files.append({'role':role,'path':str(path),'size':path.stat().st_size,'sha256':digest(path)})
# The adapters import actual observations/title from the unchanged archives.
# Do not invent an observation merely to populate envelope metadata.
metadata={'kind':'new_case','project_id':self.config['project_id'],'profile_id':self.config['profile_id'],
'case_id':f'BUG-{bug_id:06d}','report':{'title':f'BUG-{bug_id:06d}'},'versions':{'capture_server':revision}}
payload=json.dumps({'url':self.config['url'],'metadata':metadata,'files':files},sort_keys=True)
job=f"{self.config['producer_id']}:{run_id}"
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row=db.execute('SELECT payload FROM deliveries WHERE job=?',(job,)).fetchone()
if row:
if row['payload']!=payload:raise HandoffError('HANDOFF_JOB_CONTENT_CHANGED')
return
db.execute('INSERT INTO deliveries(job,idempotency,payload,status) VALUES (?,?,?,?)',
(job,'capture-'+secrets.token_hex(24),payload,'pending'))
def status(self):
with self.db() as db:
return [dict(r) for r in db.execute('SELECT job,status,attempts,next_try,code,receipt FROM deliveries ORDER BY job')]
def flush(self,*,send=None,force=False,maximum=5):
send=send or upload
for _ in range(maximum):
now=self.clock()
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row=db.execute("SELECT * FROM deliveries WHERE status='pending' AND lease_until<=? AND (next_try<=? OR ?) ORDER BY next_try,job LIMIT 1",(now,now,int(force))).fetchone()
if not row:return
# Lease prevents concurrent --retry-handoffs and worker submissions.
db.execute('UPDATE deliveries SET lease_until=?,attempts=attempts+1 WHERE job=?',
(now+self.config['timeout_seconds']+60,row['job']))
status='pending';receipt=None
try:
payload=json.loads(row['payload'])
for file in payload['files']:
path=Path(file['path'])
if not path.is_file() or path.stat().st_size!=file['size'] or digest(path)!=file['sha256']:
raise HandoffError('HANDOFF_ARCHIVE_CHANGED')
code,receipt=send(self.config,payload,row['idempotency'])
if code=='OK':status='sent'
elif code.startswith('HTTP_') and code not in {'HTTP_401','HTTP_403','HTTP_408','HTTP_425','HTTP_429','HTTP_500','HTTP_502','HTTP_503','HTTP_504','HTTP_507'}:status='attention'
except HandoffError as error:
code=str(error)
if code=='HANDOFF_ARCHIVE_CHANGED':status='attention'
except Exception:
# Never persist arbitrary exception text or HTTP bodies (may contain secrets).
code='HANDOFF_TRANSPORT'
with self.db() as db:
db.execute('UPDATE deliveries SET status=?,next_try=?,code=?,receipt=?,lease_until=0 WHERE job=?',
(status,self.clock()+self.config['retry_seconds'],code,json.dumps(receipt) if receipt else None,row['job']))
print(json.dumps({'handoff':row['job'],'status':status,'code':code}),flush=True)
if status=='pending':return # avoid retrying this request repeatedly in the same pass
def upload(config,payload,idempotency):
token=os.environ.get(config['token_env'],'')
if not re.fullmatch(r'[A-Za-z0-9_-]{32,256}',token):raise HandoffError('HANDOFF_CREDENTIALS')
target=urlsplit(payload['url']);boundary='vba-'+secrets.token_hex(24)
prefix=(f'--{boundary}\r\nContent-Disposition: form-data; name="metadata"\r\n\r\n'+json.dumps(payload['metadata'])+'\r\n').encode()
parts=[]
for file in payload['files']:
role=file['role']
header=f'--{boundary}\r\nContent-Disposition: form-data; name="{role}"; filename="{role}.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n'.encode()
parts.append((header,file))
ending=f'--{boundary}--\r\n'.encode()
length=len(prefix)+len(ending)+sum(len(header)+file['size']+2 for header,file in parts)
connection=(http.client.HTTPSConnection if target.scheme=='https' else http.client.HTTPConnection)(target.hostname,target.port,timeout=config['timeout_seconds'])
started=time.monotonic()
def transmit(chunk):
remaining=config['timeout_seconds']-(time.monotonic()-started)
if remaining<=0:raise HandoffError('HANDOFF_TIMEOUT')
if connection.sock:connection.sock.settimeout(remaining)
connection.send(chunk)
try:
connection.request('POST','/v1/submissions',headers={'Authorization':'Bearer '+token,
'Idempotency-Key':idempotency,'Content-Type':'multipart/form-data; boundary='+boundary,
'Content-Length':str(length)},body=None)
transmit(prefix)
for header,file in parts:
transmit(header)
with Path(file['path']).open('rb') as stream:
for chunk in iter(lambda:stream.read(1024*1024),b''):transmit(chunk)
transmit(b'\r\n')
transmit(ending)
response=connection.getresponse();body=response.read(65537)
if response.status not in {200,201}:return f'HTTP_{response.status}',None
if len(body)>65536:raise HandoffError('HANDOFF_RECEIPT_INVALID')
try:data=json.loads(body)
except ValueError:raise HandoffError('HANDOFF_RECEIPT_INVALID') from None
if (not isinstance(data,dict) or not isinstance(data.get('submission_id'),str)
or data.get('case_id')!=payload['metadata']['case_id']):raise HandoffError('HANDOFF_RECEIPT_INVALID')
return 'OK',{field:data[field] for field in ('submission_id','case_id','status') if field in data}
finally:connection.close()
+29
View File
@@ -0,0 +1,29 @@
[database]
host = "127.0.0.1"
port = 3306
user = "vcore_worker"
password = "CHANGE_ME"
database = "vcore_dev"
[worker]
poll_seconds = 10
job_timeout_seconds = 900
[paths]
python = "/usr/bin/python3"
wowdiag_script = "/home/wow_admin/vcore-tools/wowdiag/wowdiag.py"
wowdiag_config = "/home/wow_admin/vcore-tools/wowdiag/config.toml"
wowsrc_script = "/home/wow_admin/vcore-tools/wowsrc/wowsrc.py"
wowsrc_config = "/home/wow_admin/vcore-tools/wowsrc/config.toml"
log_dir = "/home/wow_admin/vcore-data/diagnostics/worker-logs"
[buganalysis]
enabled = false
url = "https://buganalysis.example.org"
project_id = "viano-core"
profile_id = "vcore"
producer_id = "vcore-testserver"
token_env = "VBA_INTAKE_TOKEN"
outbox = "buganalysis-outbox.sqlite3"
timeout_seconds = 120
retry_seconds = 60
+617
View File
@@ -0,0 +1,617 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import socket
import subprocess
import sys
import time
import tomllib
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import pymysql
from buganalysis_handoff import Outbox, settings as handoff_settings
WORKER_VERSION = "0.4"
ARCHIVE_RE = re.compile(r"^Archive created:\s*(.+?)\s*$", re.MULTILINE)
WOWDIAG_PACKAGE_RE = re.compile(r"^Diagnostic package created:\s*(.+?)\s*$", re.MULTILINE)
WOWSRC_PACKAGE_RE = re.compile(r"^wowsrc package created:\s*(.+?)\s*$", re.MULTILINE)
@dataclass(frozen=True)
class Config:
host: str
port: int
user: str
password: str
database: str
poll_seconds: int
timeout: int
python: str
wowdiag_script: Path
wowdiag_config: Path
wowsrc_script: Path
wowsrc_config: Path
log_dir: Path
buganalysis: Optional[dict] = None
@dataclass(frozen=True)
class ToolContext:
diagnostics_root: Path
source_context_root: Path
core_source: Path
@dataclass(frozen=True)
class Job:
run_id: int
bug_id: int
claim_token: str
def load_config(path: Path) -> Config:
with path.open("rb") as f:
d = tomllib.load(f)
db, w, p = d["database"], d.get("worker", {}), d["paths"]
return Config(
str(db.get("host", "127.0.0.1")),
int(db.get("port", 3306)),
str(db["user"]),
str(db["password"]),
str(db.get("database", "vcore_dev")),
max(1, int(w.get("poll_seconds", 10))),
max(30, int(w.get("job_timeout_seconds", 900))),
str(p.get("python", sys.executable)),
Path(p["wowdiag_script"]).resolve(),
Path(p["wowdiag_config"]).resolve(),
Path(p["wowsrc_script"]).resolve(),
Path(p["wowsrc_config"]).resolve(),
Path(p["log_dir"]).resolve(),
handoff_settings(d.get('buganalysis'),path.parent),
)
def load_tool_context(cfg: Config) -> ToolContext:
with cfg.wowdiag_config.open("rb") as f:
diag = tomllib.load(f)
with cfg.wowsrc_config.open("rb") as f:
src = tomllib.load(f)
diag_paths = diag["paths"]
src_paths = src["paths"]
diag_core = Path(diag_paths["core_source"]).resolve()
src_core = Path(src_paths["core_source"]).resolve()
if diag_core != src_core:
raise RuntimeError(
"wowdiag and wowsrc use different core_source paths: "
f"wowdiag={diag_core}, wowsrc={src_core}"
)
return ToolContext(
diagnostics_root=Path(diag_paths["diagnostics"]).resolve(),
source_context_root=Path(src_paths["output_root"]).resolve(),
core_source=diag_core,
)
def connect(cfg: Config):
return pymysql.connect(
host=cfg.host,
port=cfg.port,
user=cfg.user,
password=cfg.password,
database=cfg.database,
charset="utf8mb4",
autocommit=False,
cursorclass=pymysql.cursors.DictCursor,
)
def identity() -> str:
return f"{socket.gethostname()}:{os.getpid()}"
def claim_one(conn, claimed_by: str) -> Optional[Job]:
token = uuid.uuid4().hex
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE diagnostic_runs
SET status='running', claim_token=%s, claimed_by=%s,
claimed_at=NOW(), started_at=NOW(), error_message=NULL
WHERE status='queued'
ORDER BY diagnostic_run_id ASC
LIMIT 1
""",
(token, claimed_by),
)
if cur.rowcount != 1:
conn.rollback()
return None
conn.commit()
with conn.cursor() as cur:
cur.execute(
"""SELECT diagnostic_run_id, bug_id, claim_token
FROM diagnostic_runs
WHERE claim_token=%s AND status='running' LIMIT 1""",
(token,),
)
row = cur.fetchone()
if not row:
raise RuntimeError("Claim succeeded but claimed row could not be resolved")
return Job(
int(row["diagnostic_run_id"]),
int(row["bug_id"]),
str(row["claim_token"]),
)
except Exception:
conn.rollback()
raise
def mark_completed(
conn,
job: Job,
wowdiag_archive: Path,
wowdiag_version: str,
core_revision: str,
):
"""
Keep the existing diagnostic_runs schema contract intact.
output_path remains the wowdiag archive. The paired wowsrc archive is
generated in the configured wowsrc output_root and is recorded in the
worker log. A future DB migration can add dedicated wowsrc columns without
changing the pipeline execution semantics introduced here.
"""
with conn.cursor() as cur:
cur.execute(
"""
UPDATE diagnostic_runs
SET status='completed', finished_at=NOW(), output_path=%s,
error_message=NULL, wowdiag_version=%s, core_revision=%s
WHERE diagnostic_run_id=%s AND claim_token=%s AND status='running'
""",
(
str(wowdiag_archive),
wowdiag_version[:64],
core_revision[:64],
job.run_id,
job.claim_token,
),
)
if cur.rowcount != 1:
conn.rollback()
raise RuntimeError("Could not finalize job: claim ownership/status changed")
conn.commit()
def mark_failed(conn, job: Job, message: str):
with conn.cursor() as cur:
cur.execute(
"""
UPDATE diagnostic_runs
SET status='failed', finished_at=NOW(), error_message=%s
WHERE diagnostic_run_id=%s AND claim_token=%s AND status='running'
""",
(message[:65535], job.run_id, job.claim_token),
)
if cur.rowcount != 1:
conn.rollback()
raise RuntimeError("Could not mark job failed: claim ownership/status changed")
conn.commit()
def validated_path_from_match(
stdout: str,
regex: re.Pattern[str],
root: Path,
description: str,
must_be_dir: bool,
) -> Optional[Path]:
m = regex.search(stdout)
if not m:
return None
p = Path(m.group(1).strip()).resolve()
try:
p.relative_to(root)
except ValueError as exc:
raise RuntimeError(f"{description} outside configured output root: {p}") from exc
if must_be_dir:
if not p.is_dir():
raise RuntimeError(f"{description} directory does not exist: {p}")
elif not p.is_file():
raise RuntimeError(f"{description} file does not exist: {p}")
return p
def package_from_stdout(
stdout: str,
regex: re.Pattern[str],
root: Path,
description: str,
) -> Optional[Path]:
return validated_path_from_match(
stdout, regex, root, description, must_be_dir=True
)
def archive_from_stdout(stdout: str, root: Path, description: str) -> Optional[Path]:
p = validated_path_from_match(
stdout, ARCHIVE_RE, root, description, must_be_dir=False
)
if p and p.suffixes[-2:] != [".tar", ".gz"]:
raise RuntimeError(f"unexpected archive type: {p}")
return p
def fallback_archive(root: Path, pattern: str, started: float) -> Optional[Path]:
found = []
for p in root.glob(pattern):
try:
st = p.stat()
except OSError:
continue
if st.st_mtime >= started - 2:
found.append((st.st_mtime, p.resolve()))
return sorted(found, reverse=True)[0][1] if found else None
def manifest_metadata(package_dir: Path, expected_tool: str) -> tuple[str, str]:
manifest_path = package_dir / "manifest.json"
if not manifest_path.is_file():
raise RuntimeError(f"{expected_tool} manifest not found: {manifest_path}")
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as exc:
raise RuntimeError(f"Could not read {expected_tool} manifest: {exc}") from exc
tool = str(manifest.get("tool") or "").strip()
if tool and tool != expected_tool:
raise RuntimeError(
f"Unexpected manifest tool in {manifest_path}: {tool!r}"
)
version = str(manifest.get("tool_version") or "").strip()
revision = str(manifest.get("core_revision") or "").strip()
if not version:
raise RuntimeError(f"{expected_tool} manifest contains no tool_version")
if not revision:
raise RuntimeError(f"{expected_tool} manifest contains no core_revision")
return version, revision
def run_command(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str, str]:
r = subprocess.run(
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return r.returncode, r.stdout or "", r.stderr or ""
def write_log(
cfg: Config,
job: Job,
diag_cmd: list[str],
diag_rc: Optional[int],
diag_out: str,
diag_err: str,
src_cmd: Optional[list[str]] = None,
src_rc: Optional[int] = None,
src_out: str = "",
src_err: str = "",
wowdiag_archive: Optional[Path] = None,
wowsrc_archive: Optional[Path] = None,
failure: Optional[BaseException] = None,
):
cfg.log_dir.mkdir(parents=True, exist_ok=True)
p = cfg.log_dir / f"DIAG-{job.run_id:06d}.log"
parts = [
f"worker_version={WORKER_VERSION}",
f"diagnostic_run_id={job.run_id}",
f"bug_id={job.bug_id}",
f"claim_token={job.claim_token}",
f"wowdiag_archive={wowdiag_archive or ''}",
f"wowsrc_archive={wowsrc_archive or ''}",
"",
"WOWDIAG COMMAND:",
repr(diag_cmd),
f"returncode={diag_rc}",
"",
"WOWDIAG STDOUT:",
diag_out or "",
"",
"WOWDIAG STDERR:",
diag_err or "",
]
if src_cmd is not None:
parts += [
"",
"WOWSRC COMMAND:",
repr(src_cmd),
f"returncode={src_rc}",
"",
"WOWSRC STDOUT:",
src_out or "",
"",
"WOWSRC STDERR:",
src_err or "",
]
if failure:
parts += ["", "ERROR:", str(failure)]
p.write_text("\n".join(parts), encoding="utf-8")
def execute_pipeline(cfg: Config, ctx: ToolContext, job: Job):
if not cfg.wowdiag_script.is_file():
raise RuntimeError(f"wowdiag script not found: {cfg.wowdiag_script}")
if not cfg.wowdiag_config.is_file():
raise RuntimeError(f"wowdiag config not found: {cfg.wowdiag_config}")
if not cfg.wowsrc_script.is_file():
raise RuntimeError(f"wowsrc script not found: {cfg.wowsrc_script}")
if not cfg.wowsrc_config.is_file():
raise RuntimeError(f"wowsrc config not found: {cfg.wowsrc_config}")
ctx.diagnostics_root.mkdir(parents=True, exist_ok=True)
ctx.source_context_root.mkdir(parents=True, exist_ok=True)
diag_cmd = [
cfg.python,
str(cfg.wowdiag_script),
"--config",
str(cfg.wowdiag_config),
"bug",
str(job.bug_id),
]
src_cmd: Optional[list[str]] = None
diag_out = diag_err = src_out = src_err = ""
diag_rc: Optional[int] = None
src_rc: Optional[int] = None
wowdiag_archive: Optional[Path] = None
wowsrc_archive: Optional[Path] = None
try:
diag_started = time.time()
diag_rc, diag_out, diag_err = run_command(
diag_cmd, cfg.wowdiag_script.parent, cfg.timeout
)
if diag_rc != 0:
raise RuntimeError(
f"wowdiag exited with code {diag_rc}; "
f"stderr: {diag_err.strip() or '<empty>'}"
)
diag_package = package_from_stdout(
diag_out,
WOWDIAG_PACKAGE_RE,
ctx.diagnostics_root,
"wowdiag package",
)
if not diag_package:
raise RuntimeError(
"wowdiag exited successfully but no package directory could be identified"
)
wowdiag_archive = (
archive_from_stdout(diag_out, ctx.diagnostics_root, "wowdiag archive")
or fallback_archive(
ctx.diagnostics_root,
f"BUG-{job.bug_id:06d}-*.tar.gz",
diag_started,
)
)
if not wowdiag_archive:
raise RuntimeError(
"wowdiag exited successfully but no archive could be identified"
)
wowdiag_version, diag_revision = manifest_metadata(diag_package, "wowdiag")
# wowsrc deliberately consumes the exact wowdiag archive generated by
# this same queue job. This prevents it from silently picking up an
# older package for the same bug.
src_cmd = [
cfg.python,
str(cfg.wowsrc_script),
"--config",
str(cfg.wowsrc_config),
"bug",
str(job.bug_id),
"--diag",
str(wowdiag_archive),
]
src_started = time.time()
src_rc, src_out, src_err = run_command(
src_cmd, cfg.wowsrc_script.parent, cfg.timeout
)
if src_rc != 0:
raise RuntimeError(
f"wowsrc exited with code {src_rc}; "
f"stderr: {src_err.strip() or '<empty>'}"
)
src_package = package_from_stdout(
src_out,
WOWSRC_PACKAGE_RE,
ctx.source_context_root,
"wowsrc package",
)
if not src_package:
raise RuntimeError(
"wowsrc exited successfully but no package directory could be identified"
)
wowsrc_archive = (
archive_from_stdout(src_out, ctx.source_context_root, "wowsrc archive")
or fallback_archive(
ctx.source_context_root,
f"WOWSRC-BUG-{job.bug_id:06d}-*.tar.gz",
src_started,
)
)
if not wowsrc_archive:
raise RuntimeError(
"wowsrc exited successfully but no archive could be identified"
)
_, src_revision = manifest_metadata(src_package, "wowsrc")
if diag_revision != src_revision:
raise RuntimeError(
"wowdiag and wowsrc were generated from different core revisions: "
f"wowdiag={diag_revision}, wowsrc={src_revision}"
)
write_log(
cfg,
job,
diag_cmd,
diag_rc,
diag_out,
diag_err,
src_cmd,
src_rc,
src_out,
src_err,
wowdiag_archive,
wowsrc_archive,
)
return wowdiag_archive, wowsrc_archive, wowdiag_version, diag_revision
except Exception as exc:
write_log(
cfg,
job,
diag_cmd,
diag_rc,
diag_out,
diag_err,
src_cmd,
src_rc,
src_out,
src_err,
wowdiag_archive,
wowsrc_archive,
exc,
)
raise
def process_one(cfg: Config, ctx: ToolContext) -> bool:
conn = connect(cfg)
try:
job = claim_one(conn, identity())
if not job:
return False
print(
f"Claimed DIAG-{job.run_id} for BUG-{job.bug_id} as {identity()}",
flush=True,
)
try:
diag_archive, src_archive, version, revision = execute_pipeline(
cfg, ctx, job
)
if cfg.buganalysis:
Outbox(cfg.buganalysis).stage(job.run_id,job.bug_id,diag_archive,src_archive,revision)
mark_completed(conn, job, diag_archive, version, revision)
print(
f"Completed DIAG-{job.run_id}: "
f"wowdiag={diag_archive} wowsrc={src_archive}",
flush=True,
)
except Exception as exc:
mark_failed(conn, job, str(exc))
print(
f"FAILED DIAG-{job.run_id}: {exc}",
file=sys.stderr,
flush=True,
)
return True
finally:
conn.close()
def main() -> int:
ap = argparse.ArgumentParser(
prog="wowdiag-worker",
description="Vcore diagnostic_runs -> wowdiag -> wowsrc worker",
)
ap.add_argument(
"--config",
default=str(Path(__file__).with_name("config.toml")),
)
ap.add_argument(
"--once",
action="store_true",
help="Process at most one queued job and exit",
)
ap.add_argument('--retry-handoffs',action='store_true',help='Retry queued API deliveries only; no capture jobs')
ap.add_argument('--handoff-status',action='store_true',help='List API delivery status; no capture jobs')
args = ap.parse_args()
cfg = load_config(Path(args.config).resolve())
outbox=Outbox(cfg.buganalysis) if cfg.buganalysis else None
if args.handoff_status or args.retry_handoffs:
if not outbox:
print('BugAnalysis handoff is disabled.',file=sys.stderr);return 1
if args.retry_handoffs:outbox.flush(force=True,maximum=100)
print(json.dumps(outbox.status()));return 0
ctx = load_tool_context(cfg)
print(f"Vcore diagnostic worker v{WORKER_VERSION} ({identity()})", flush=True)
if args.once:
if outbox:outbox.flush()
if not process_one(cfg, ctx):
print("No queued diagnostic job found.", flush=True)
if outbox:outbox.flush()
return 0
while True:
try:
if outbox:outbox.flush()
if not process_one(cfg, ctx):
time.sleep(cfg.poll_seconds)
except KeyboardInterrupt:
print("Worker stopped.", flush=True)
return 0
except Exception as exc:
print(f"Worker loop error: {exc}", file=sys.stderr, flush=True)
time.sleep(cfg.poll_seconds)
if __name__ == "__main__":
raise SystemExit(main())
+104
View File
@@ -0,0 +1,104 @@
"""Install the fixed Linux service layout. Never enables or starts services."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tomllib
ROOT = Path(__file__).resolve().parents[1]
CONFIG = Path('/etc/viano-bug-analysis')
STATE = Path('/var/lib/viano-bug-analysis')
SOURCE = Path('/srv/viano-bug-analysis')
def project_template(root=ROOT):
text=(root/'config/project.example.toml').read_text(encoding='utf-8')
replacements={
'path = "../service-data/research/viano-core"':'path = "/srv/viano-bug-analysis/viano-core"',
'port = 33079':'port = 3306',
'password_file = "../service-data/research/world-db/reader.local.txt"':'password_env = "VIANO_DB_PASSWORD"',
}
for old,new in replacements.items():
if text.count(old)!=1:raise ValueError('Deployment template changed; review replacements')
text=text.replace(old,new)
import re
text=re.sub(r'^commit = "[0-9a-f]{40}"$', 'commit = "'+'0'*40+'"',text,flags=re.M)
text=re.sub(r'^snapshot_id = .*$', 'snapshot_id = "CONFIGURE_ME"',text,flags=re.M)
tomllib.loads(text)
return text
def templates(root=ROOT):
files={name:(root/'deploy/linux'/name).read_text(encoding='utf-8')
for name in ('api.toml','worker.toml','gitconfig','nginx.conf.example')}
for target,source in {
'llm.toml':'llm.gemini-cache.example.toml',
'investigation.bounded.toml':'investigation.bounded.toml',
'budget.human-tests.toml':'budget.human-tests.toml',
'project-relations.vcore.toml':'project-relations.vcore.toml',
'telemetry.toml':'telemetry.example.toml',
}.items():files[target]=(root/'config'/source).read_text(encoding='utf-8')
files['project.toml']=project_template(root)
for name in ('api','worker'):
files[name+'.env']=(root/'deploy/linux'/(name+'.env.example')).read_text(encoding='utf-8')
return files
def main(argv=None):
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dry-run',action='store_true')
args=parser.parse_args(argv)
files=templates()
if args.dry_run:
print(json.dumps({'status':'PLAN','code':'/opt/viano-bug-analysis',
'config':str(CONFIG),'state':str(STATE),'source':str(SOURCE),
'config_files':sorted(files),'existing_files':'preserved','services_started':False}))
return 0
if sys.platform!='linux' or os.geteuid()!=0:
parser.error('Installation requires Linux and root; use --dry-run elsewhere')
if ROOT!=Path('/opt/viano-bug-analysis') or ROOT.is_symlink():
parser.error('Place a root-owned checkout at /opt/viano-bug-analysis first')
# Root executes packaging code: reject an untrusted checkout/venv.
for base in (ROOT,ROOT/'src',ROOT/'deploy',ROOT/'config',ROOT/'pyproject.toml',ROOT/'.venv'):
if not base.exists():continue
paths=[base,*base.rglob('*')] if base.is_dir() and base!=ROOT else [base]
for path in paths:
stat=path.stat()
if path.is_symlink() and base!=ROOT/'.venv':raise RuntimeError('Symlink in installation sources')
if stat.st_uid!=0 or stat.st_mode&0o022:raise RuntimeError('Installation sources must be root-owned and not group/world writable')
def run(*command):subprocess.run(command,check=True)
import grp
import pwd
try:group=grp.getgrnam('buganalysis')
except KeyError:
run('groupadd','--system','buganalysis');group=grp.getgrnam('buganalysis')
try:user=pwd.getpwnam('buganalysis')
except KeyError:
run('useradd','--system','--gid','buganalysis','--home-dir',str(STATE),'--no-create-home','--shell','/usr/sbin/nologin','buganalysis')
user=pwd.getpwnam('buganalysis')
if user.pw_uid==0 or user.pw_gid!=group.gr_gid:raise RuntimeError('Unexpected service account identity')
for path,uid in ((CONFIG,0),(CONFIG/'auth',0),(STATE,user.pw_uid),(SOURCE,0)):
if path.is_symlink():raise RuntimeError('Symlink in deployment directories')
path.mkdir(parents=True,exist_ok=True)
os.chown(path,uid,group.gr_gid);os.chmod(path,0o750)
def install(path,text,mode):
if path.is_symlink():raise RuntimeError('Symlink at deployment target')
if path.exists():return
with path.open('x',encoding='utf-8',newline='\n') as stream:stream.write(text)
os.chown(path,0,group.gr_gid);os.chmod(path,mode)
for name,text in files.items():install(CONFIG/name,text,0o600 if name.endswith('.env') else 0o640)
for role in ('api','worker'):
name=f'viano-bug-analysis-{role}.service'
install(Path('/etc/systemd/system')/name,(ROOT/'deploy'/name).read_text(encoding='utf-8'),0o644)
if not (ROOT/'.venv/bin/python').exists():run(sys.executable,'-m','venv',str(ROOT/'.venv'))
run(str(ROOT/'.venv/bin/python'),'-m','pip','install',str(ROOT)+'[api,research]')
run('systemctl','daemon-reload')
print(json.dumps({'status':'INSTALLED','existing_files':'preserved','services_started':False,
'next':'Configure project, secrets, source checkout, web user and HTTPS; run readiness checks before enabling services'}))
return 0
if __name__=='__main__':raise SystemExit(main())
+3
View File
@@ -0,0 +1,3 @@
# Read by systemd as root, not by the web user. chmod 0600, owner root.
# Configure once; never commit a filled copy.
# VBA_INTAKE_TOKEN="replace-with-a-random-32-to-256-character-base64url-token"
+25
View File
@@ -0,0 +1,25 @@
[api]
workspace = "/var/lib/viano-bug-analysis"
host = "127.0.0.1"
port = 8080
[auth]
users_database = "/etc/viano-bug-analysis/auth/users.sqlite3"
session_seconds = 3600
[[projects]]
id = "viano-core"
type = "wow"
profile = "vcore"
adapter = "vcore"
planning_mode = "joint"
[projects.settings]
project_config = "project.toml"
llm_config = "llm.toml"
investigation_config = "investigation.bounded.toml"
budget_config = "budget.human-tests.toml"
[[clients]]
id = "wow-capture-worker"
token_env = "VBA_INTAKE_TOKEN"
projects = ["viano-core"]
+2
View File
@@ -0,0 +1,2 @@
[safe]
directory = /srv/viano-bug-analysis/viano-core
+29
View File
@@ -0,0 +1,29 @@
# Replace domain and certificate paths. Install manually after nginx -t.
# Root path on its own HTTPS hostname; upstream must remain loopback-only.
server {
listen 80;
server_name buganalysis.example.org;
return 308 https://buganalysis.example.org$request_uri;
}
server {
listen 443 ssl;
server_name buganalysis.example.org;
ssl_certificate /etc/letsencrypt/live/buganalysis.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/buganalysis.example.org/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 768m;
client_body_timeout 120s;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_cache off;
proxy_no_cache 1;
}
}
+3
View File
@@ -0,0 +1,3 @@
# Read by systemd as root. chmod 0600, owner root. No shell export statements.
# GEMINI_API_KEY="your-cloud-key"
# VIANO_DB_PASSWORD="your-read-only-database-password"
+7
View File
@@ -0,0 +1,7 @@
[worker]
intake_config = "api.toml"
poll_seconds = 5
error_retry_seconds = 60
max_inbox_per_pass = 20
min_free_bytes = 536870912
telemetry_config = "telemetry.toml"
+107
View File
@@ -0,0 +1,107 @@
"""Update an existing Linux deployment; preserve configuration and case data."""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
ROOT = Path('/opt/viano-bug-analysis')
SERVICES = ('viano-bug-analysis-worker.service', 'viano-bug-analysis-api.service')
def run(*args, check=True):
result = subprocess.run(args, check=False, text=True, stdout=subprocess.PIPE)
if check and result.returncode:
if result.stdout:
print(result.stdout, flush=True)
raise subprocess.CalledProcessError(result.returncode, args)
return result.stdout.strip()
def trusted(root):
for base in (root, root/'.git', root/'src', root/'deploy', root/'pyproject.toml', root/'.venv'):
if not base.exists():
raise RuntimeError('Installation is incomplete')
paths = [base, *base.rglob('*')] if base.is_dir() and base != root else [base]
for path in paths:
stat = path.stat()
if path.is_symlink() and base != root/'.venv':
raise RuntimeError('Symlink in installation sources')
if stat.st_uid != 0 or stat.st_mode & 0o022:
raise RuntimeError('Installation must be root-owned and not group/world writable')
def update(root=ROOT, execute=run):
def git(*args):
return execute('git', '-C', str(root), *args)
if git('status', '--porcelain'):
raise RuntimeError('Checkout has local changes; update refused')
old = git('rev-parse', 'HEAD')
upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}')
# Fetch changes objects/refs only, while services still use the installed code.
git('fetch', '--prune')
target = git('rev-parse', upstream)
git('merge-base', '--is-ancestor', old, target)
active = []
for service in SERVICES:
state = execute('systemctl', 'show', '--property=ActiveState', '--value', service)
if state in {'active', 'activating', 'reloading'}:
active.append(service)
elif state not in {'inactive', 'failed'}:
raise RuntimeError('Service is changing state; retry later')
stopped = False
try:
# Stop the worker first; its unit waits for the current dispatch to finish.
stopped = True
for service in SERVICES:
execute('systemctl', 'stop', service)
git('merge', '--ff-only', target)
trusted(root)
execute(str(root/'.venv/bin/python'), '-m', 'pip', 'install', str(root)+'[api,research]')
# Existing units/configs are intentionally not overwritten. ExecStartPre
# runs readiness checks with the real service restrictions and secrets.
for service in reversed(active):
execute('systemctl', 'start', service)
execute('systemctl', 'is-active', '--quiet', service)
except Exception:
if stopped:
# A partly installed package must never be left serving traffic.
execute('systemctl', 'stop', *SERVICES)
raise
return {'status':'UPDATED', 'previous_commit':old, 'commit':target,
'services_restarted':active, 'configuration':'preserved', 'case_data':'preserved'}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--dry-run', action='store_true', help='Show the procedure without fetching or changing anything')
args = parser.parse_args(argv)
if args.dry_run:
print(json.dumps({'status':'PLAN', 'root':str(ROOT), 'steps':[
'require root-owned clean checkout and tracking branch', 'fetch and require fast-forward',
'stop worker and API', 'fast-forward to fetched commit', 'install into existing venv',
'restart previously active services with readiness checks'],
'configurations_and_units':'preserved', 'case_data':'preserved'}))
return 0
if sys.platform != 'linux' or os.geteuid() != 0:
parser.error('Update requires Linux and root')
if Path(__file__).absolute().parents[1] != ROOT:
parser.error('Run the updater from /opt/viano-bug-analysis/deploy/update.py')
import fcntl
try:
trusted(ROOT)
# One updater at a time; kernel releases the lock on process exit.
with (ROOT/'.git/viano-update.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
result = update()
print(json.dumps(result))
return 0
except Exception:
print(json.dumps({'status':'ERROR', 'code':'UPDATE_FAILED',
'instruction':'Inspect command output. After an installation/start failure both services remain stopped; fix the cause, reinstall the package and start the required services. No automatic rollback or case changes.'}))
return 1
if __name__ == '__main__':
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=Viano BugAnalysis web and intake API
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=buganalysis
Group=buganalysis
WorkingDirectory=/var/lib/viano-bug-analysis
EnvironmentFile=/etc/viano-bug-analysis/api.env
Environment=PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
ExecStartPre=/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.deployment check --role api --config /etc/viano-bug-analysis/api.toml
ExecStart=/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.api serve --config /etc/viano-bug-analysis/api.toml
Restart=always
RestartSec=10
KillSignal=SIGTERM
TimeoutStartSec=60
TimeoutStopSec=180
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/viano-bug-analysis
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+18 -3
View File
@@ -2,21 +2,36 @@
Description=Viano BugAnalysis analysis worker
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=buganalysis
Group=buganalysis
WorkingDirectory=/opt/viano-bug-analysis
WorkingDirectory=/var/lib/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
Environment=PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 GIT_CONFIG_GLOBAL=/etc/viano-bug-analysis/gitconfig GIT_TERMINAL_PROMPT=0
ExecStartPre=/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.deployment check --role worker --config /etc/viano-bug-analysis/worker.toml
ExecStart=/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.worker run --config /etc/viano-bug-analysis/worker.toml
Restart=always
RestartSec=10
KillSignal=SIGTERM
# Finish the current checkpointed dispatch; operator can still force-stop.
TimeoutStopSec=infinity
TimeoutStartSec=300
UMask=0077
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/viano-bug-analysis
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
CapabilityBoundingSet=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
+40
View File
@@ -148,11 +148,51 @@ löschen oder Untersuchungsbudgets zu verbrauchen. Die konfigurierbare
20-GB-Vorgabe für Entwicklungs-/Testdaten bleibt davon unabhängig. Der Worker
löscht keine Paketgenerationen und keine Inhalte erhaltener Akten.
Die [Web-Betriebssteuerung](web-console.md#betriebssteuerung) kann neue Fälle je
Projekt pausieren, während begonnene Analysen abschließen. Freigabeaufträge werden
dauerhaft gespeichert und zwischen Dispatches unter derselben Fall-Lease geprüft.
Damit ist für Web-Freigaben kein Worker-Stopp nötig. CLI-Freigaben benötigen
weiterhin den gestoppten Dienst. Alle fachlichen Retry- und Budgetregeln gelten
für beide Wege; ein veralteter Webauftrag entsperrt keinen neueren Paketstand.
Ctrl+C bzw. SIGTERM fordert einen geordneten Stopp an. Der laufende Dispatch darf
noch abschließen; erst danach wird die Prozess-Lease freigegeben. Ein Dispatch
kann mehrere interne Schritte/Modellanfragen enthalten und entsprechend dauern.
Bei hartem Beenden greift beim nächsten Start der Checkpoint-Wiederanlauf.
## Freigabe nach einer technischen Korrektur
Bei `ENTITY_RESOLUTION_INVALID_RESPONSES` bietet die aktuelle Fallansicht die
Aktion **Einen Reparaturversuch freigeben**. Vorher die technische Ursache beheben
und im Pflichtfeld kurz dokumentieren (keine Secrets). Die Freigabe wird über den
authentifizierten Betreiberzugang mit CSRF-Schutz vorgemerkt. Maschinenclients
dürfen sie nicht auslösen. Der Worker prüft Zustand, Projektbindung und aktuelle
Paketgeneration erneut. Doppelte oder veraltete Aufträge gewähren keinen weiteren Versuch.
Jede Freigabe erlaubt genau einen zusätzlichen Antwortversuch in der betroffenen
Namensauflösungsstufe (Suchplan oder Bewertung), ohne die bisherigen Versuche zu
löschen. Eine gültige Reparatur kann anschließend den regulären Ablauf fortsetzen.
Scheitert sie, bleibt der Fall erneut gesperrt. Weitere Versuche benötigen eine neue
ausdrückliche Freigabe. Akteur, Begründung, betroffener Antwort-Hash und Code-Hash
werden dauerhaft als Checkpoint in der Fallakte gespeichert. Revision, gültiger
Suchplan, Recherchebelege und Untersuchungsbudgets werden nicht zurückgesetzt.
Die Freigabe selbst führt keine Modellanfrage aus; die anschließende Bearbeitung
kann reguläre LLM-Kosten verursachen.
Alternativ bei gestopptem Worker als root unter Linux:
```sh
/opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.worker repair --config /etc/viano-bug-analysis/worker.toml --case-id BUG-000021 --package-id api-PAKETIDENTITAET --reason "Technische Korrektur installiert; Namensbewertung gezielt reparieren"
```
Danach den Worker wieder starten. Der CLI-Befehl benötigt keinen Cloud-Schlüssel,
weil er nur eine Freigabe protokolliert. Die vorhandene Projektkonfiguration muss
unverändert gebunden sein. Diese Freigabe ist derzeit bewusst auf ausgeschöpfte
Namensauflösung beschränkt. Sie umgeht weder menschliche Rückfragen noch
fachliche Budgetgrenzen und öffnet keine erfolgreich abgeschlossenen Fälle oder
`FAILED_TO_ANALYZE`-Revisionen. Für andere Fehlergründe bleiben die jeweiligen
Retry- beziehungsweise Review-Verfahren maßgeblich.
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
+6 -5
View File
@@ -1,8 +1,9 @@
# Asynchronous test handoff v1
This addition prepares the existing project for externally performed human and
automated tests. No web UI, HTTP feedback endpoint, notifications, bot steering,
client automation, Tool Router or Investigation Loop is implemented here.
This contract supports externally performed human and automated tests. The human
HTTP adapter and dedicated web page are now documented in
[Web console](web-console.md#menschliche-testanfragen). Automated execution,
notifications, bot steering and client automation are not provided by that page.
`docs/architecture.md` remains unchanged and authoritative: VianoBugAnalysis
does not steer labs. It requests observations through a future external adapter.
@@ -10,8 +11,8 @@ does not steer labs. It requests observations through a future external adapter.
`AsyncTestCoordinator` is imported from `viano_bug_analysis.lifecycle.async_tests`.
It is a trusted controller service, not a callable operation exposed directly to
an LLM. The future router must validate requests before calling it. The future
web adapter must authenticate/authorize users and confirm feedback before calling
an LLM. The investigation controller validates requests before calling it. The
web adapter authenticates/authorizes users and confirms feedback before calling
`confirm_feedback`; a payload claiming `confirmed = true` is not authentication.
User-supplied observations are untrusted data, not executable instructions.
+5 -2
View File
@@ -4,7 +4,8 @@ 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.
Aufnahme und Verarbeitung. Die [Weboberfläche](web-console.md) bietet Übersicht,
Auftragsübergabe, Testerantworten und Ergebnisdownloads.
## Lokaler Start
@@ -17,7 +18,9 @@ $env:VBA_INTAKE_TOKEN = [Convert]::ToHexString([System.Security.Cryptography.Ran
```
Der API-Token ist ein eigener Zugangsschlüssel, kein Gemini-Key. Programme erhalten
je einen Client und einen Token aus einer serverseitigen Umgebungsvariable. Der
vorzugsweise einen Schlüssel aus der [Web-Schlüsselverwaltung](integration-keys.md).
Alternativ bleiben Clients mit Token aus einer serverseitigen Umgebungsvariable
unterstützt; der Beispielstart oben zeigt diese Variante. 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
+45
View File
@@ -0,0 +1,45 @@
# Integrationsschlüssel
Angemeldete Webnutzer finden unter **API-Schlüssel** eine eigene Verwaltungsseite.
Programmname und mindestens ein Projekt auswählen, dann den Schlüssel erstellen.
**Anzeigen** ruft ihn bei Bedarf erneut ab; **Verbergen** entfernt ihn aus dem
Eingabefeld. **Kopieren** verwendet die Zwischenablage (HTTPS oder localhost).
**Widerrufen** sperrt ihn sofort für nachfolgende API-Anfragen. Bereits laufende
Anfragen werden dadurch nicht abgebrochen. Ein widerrufener Schlüssel wird nicht
mehr angezeigt; für einen Ersatz einen neuen Schlüssel erstellen.
Alle angemeldeten menschlichen Nutzer haben dieselben Verwaltungsrechte.
Maschinenzugänge können weder Schlüssel verwalten noch andere Schlüssel anzeigen.
Webmutationen einschließlich Anzeigen benötigen die vorhandene CSRF-Absicherung.
Das aufrufende Programm sendet `Authorization: Bearer <Schlüssel>` an die
Übergabe-API. Neue Schlüssel erlauben Auftragsübergabe einschließlich Rückgaben
und lesenden Zugriff auf die ausgewählten Projekte. Sie erlauben keine
Betriebssteuerung oder Beantwortung menschlicher Testanfragen. Zugriff auf weitere
Projekte erfordert einen neuen Schlüssel. Bestehende env-basierte Integrationen
bleiben unverändert nutzbar und erscheinen nicht in der SQLite-Schlüsselliste.
## Speicherung und Sicherung
- SQLite unter `<workspace>/runtime/integrations/keys.sqlite3` speichert Metadaten,
SHA-256-Prüfwerte und mit Fernet authentifiziert verschlüsselte Schlüssel.
- Der separate Serverschlüssel entsteht beim ersten Erstellen automatisch unter
`<workspace>/runtime/integration-secrets/master.key`, Dateimodus 0600.
Nutzerkonten und ihre root-verwaltete SQLite-Datei werden nicht verändert.
- Mit `VIANO_INTEGRATION_KEY_FILE` kann die API stattdessen eine separate
Schlüsseldatei verwenden. Vorhandene Dateien müssen einen gültigen Fernet-Key
enthalten und für den API-Dienst lesbar sein; fehlt die Datei beim ersten
Erstellen, muss der Dienst sie im gewählten Verzeichnis anlegen dürfen.
- SQLite und Serverschlüssel getrennt geschützt sichern. Das Laufzeitverzeichnis
enthält damit dauerhaft notwendige Integrationsdaten und darf bei einer
Bereinigung von Sessions nicht pauschal gelöscht werden. Unter Windows passende
Dateisystem-ACLs verwenden; POSIX-Modusbits ersetzen dort keine ACLs.
- Geht der Serverschlüssel verloren oder wird er unpassend ausgetauscht, können
Schlüssel nicht mehr angezeigt werden. Das System ersetzt ihn bei bestehenden
Einträgen nicht stillschweigend. Vorhandene Schlüssel können anhand ihrer
Prüfwerte weiterhin authentifiziert und über die Weboberfläche widerrufen werden.
Zur Wiederherstellung die richtige Schlüsseldatei aus der Sicherung zurückspielen.
Nach einem Update die API-Abhängigkeiten neu installieren (`.[api]` umfasst jetzt
`cryptography`) und den API-Dienst neu starten. Eine Datenbankmigration per Hand
ist nicht nötig. Keine Änderung oder Neueingabe des Gemini-Schlüssels erforderlich.
+158
View File
@@ -0,0 +1,158 @@
# Linux-Betrieb
API und Analyseworker laufen als getrennte systemd-Dienste. Ein Auftragseingang
weckt keinen neuen Prozess: Der dauerhaft laufende Worker übernimmt Inbox-Aufträge.
Die Weboberfläche ist eine alternative Bedienung desselben Arbeitsablaufs.
Die Installation startet keine Dienste und führt keine Modellanfrage aus.
## Voraussetzungen und Verzeichnisse
Linux mit systemd, Python 3.11 oder neuer inklusive venv/pip, Git und Nginx.
Diese Programme über die Paketverwaltung des Servers installieren. Die aktuelle
Projektvorlage ist für WoW/Vcore eingerichtet; sie ist kein allgemeiner Adapter.
| Pfad | Zweck und Rechte |
| --- | --- |
| `/opt/viano-bug-analysis` | Root-eigener Checkout und virtuelle Python-Umgebung; Dienst darf nur lesen |
| `/etc/viano-bug-analysis` | Root-eigene Konfiguration, Gruppe `buganalysis`, Verzeichnis 0750, Dateien 0640 |
| `/etc/viano-bug-analysis/*.env` | Zugangsdaten, root:buganalysis 0600; systemd liest sie vor dem Benutzerwechsel |
| `/etc/viano-bug-analysis/auth/users.sqlite3` | Root-verwaltete Nutzer, Gruppe `buganalysis` darf lesen |
| `/var/lib/viano-bug-analysis` | Dienst-eigene Fallakten, Laufzeitdaten und Websessions |
| `/srv/viano-bug-analysis/viano-core` | Root-eigener Source-Checkout, Dienst hat Leserechte |
Die env-Dateien sind durch Betriebssystemrechte geschützte Klartextdateien, kein
verschlüsselter Tresor. Sie gehören nicht ins Repository oder in Diagnosepakete.
API und Worker erhalten unterschiedliche Dateien: Nur der Worker erhält Cloud-
und DB-Zugangsdaten. Die Diensthärtung und EnvironmentFile-Nutzung folgen der
[systemd-Dokumentation](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html).
Die [Webverwaltung für Integrationsschlüssel](integration-keys.md) ergänzt die
env-basierte Maschinenanmeldung. Ihre SQLite und separate Schlüsseldatei müssen
ebenfalls gesichert werden; beide liegen standardmäßig unter der Fallablage.
## Installation
Den vertrauenswürdigen BugAnalysis-Checkout als root unter
`/opt/viano-bug-analysis` ablegen. Code und Unterverzeichnisse dürfen nicht für
andere Benutzer schreibbar sein. Dann:
```sh
cd /opt/viano-bug-analysis
python3 deploy/install.py --dry-run
sudo python3 deploy/install.py
```
Der Installer legt den Dienstbenutzer `buganalysis`, Konfigurationen, venv und
Units an. Er installiert die Python-Abhängigkeiten und lädt systemd neu.
Bestehende Zielkonfigurationen und Units werden erhalten. Updates an bereits
installierten Units daher bewusst mit den versionierten Vorlagen abgleichen.
Bei Installationsfehlern bleiben vorhandene Dateien erhalten; erneutes Ausführen
setzt die Einrichtung fort. Der Installer verändert keine bestehende Datenbank.
## Einmalige Konfiguration
Mit `sudoedit` die Dateien unter `/etc/viano-bug-analysis` bearbeiten:
1. `project.toml`: DB-Adresse/Port, Datenbank und nur lesenden Benutzer einstellen,
tatsächliche Snapshotkennung und vollständigen Source-Commit eintragen.
Die ausgelieferten Null-Commit- und `CONFIGURE_ME`-Werte sind Platzhalter.
2. Den Vcore-Checkout als root unter `/srv/viano-bug-analysis/viano-core` anlegen;
der konfigurierte Commit muss lokal vorhanden und für `buganalysis` lesbar sein.
Der Dienst lädt keine Commits nach. `gitconfig` vertraut ausschließlich diesem
konkreten Root-Checkout, nicht beliebigen Repositories.
3. `worker.env`: `GEMINI_API_KEY=...` und `VIANO_DB_PASSWORD=...` eintragen.
Keine `export`-Anweisungen verwenden. Für Sonderzeichen gelten die
EnvironmentFile-Quotingregeln von systemd. Werte nicht als Shellargumente übergeben.
4. `api.env`: `VBA_INTAKE_TOKEN=...` für den Erfassungsworker setzen
(32–256 Zeichen aus Buchstaben, Ziffern, `_` und `-`). Dies ist ein separater
Integrationsschlüssel, kein Gemini-Key. Wird keine externe API-Integration
benötigt, den zugehörigen `[[clients]]`-Block aus `api.toml` entfernen.
5. `llm.toml`: gewünschtes Modell, Anbieter und Cacheeinstellungen kontrollieren.
Investigation-, Budget- und Telemetrie-Dateien sind separat konfigurierbar.
Die DB-Verbindung benötigt einen Benutzer mit ausschließlich passenden
SELECT-Rechten auf die freigegebenen World-Tabellen. Diese Rechte legt der
DB-Administrator an; BugAnalysis vergibt keine Grants. Der Readiness-Test belegt
Lesbarkeit, nicht die Abwesenheit zusätzlicher Schreibrechte. Für entfernte DBs
einen geschützten Netzwerkweg verwenden: Der aktuelle DB-Provider konfiguriert
selbst kein TLS. Zugang zu Produktionsdaten ist für die lokale Entwicklung
weiterhin nicht erforderlich.
Webnutzer anlegen; das Passwort wird verdeckt abgefragt:
```sh
sudo /opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.api create_user NAME --config /etc/viano-bug-analysis/api.toml --service-group buganalysis
```
Nutzerverwaltung bleibt root vorbehalten. Siehe [Nutzerverwaltung](user-accounts.md).
Keine Nutzer werden automatisch angelegt. Nach der Installation besitzen die
env-Dateien Modus 0600; diese Rechte nach manuellen Änderungen erhalten.
## Kostenfreie Bereitschaftsprüfung
Die Prüfung unter der tatsächlichen Dienstidentität samt env-Datei ausführen:
```sh
sudo systemd-run --wait --pipe --collect --property=User=buganalysis --property=Group=buganalysis --property=EnvironmentFile=/etc/viano-bug-analysis/api.env /opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.deployment check --role api --config /etc/viano-bug-analysis/api.toml
sudo systemd-run --wait --pipe --collect --property=User=buganalysis --property=Group=buganalysis --property=EnvironmentFile=/etc/viano-bug-analysis/worker.env --setenv=GIT_CONFIG_GLOBAL=/etc/viano-bug-analysis/gitconfig /opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.deployment check --role worker --config /etc/viano-bug-analysis/worker.toml
```
Erwartet: `READY`, `model_requests: 0`. Geprüft werden Konfiguration,
Zugangsdaten-Präsenz, Speicherreserve, Schreibbarkeit der Laufzeitablage,
Webnutzer/Sessions beziehungsweise Git-Objekte und die freigegebenen DB-Schemata.
Cloud-Key-Gültigkeit und Modellverfügbarkeit sind damit noch nicht getestet.
`--offline` überspringt Git-/DB-Zugriffe und meldet nur `CONFIG_READY`.
Fehlerausgaben enthalten kontrollierte Fehlercodes statt Zugangsdaten.
## HTTPS und Start
Die API bleibt auf `127.0.0.1:8080`. `nginx.conf.example` enthält eine
Reverse-Proxy-Vorlage: Domain und Zertifikatpfade anpassen, gültiges Zertifikat
bereitstellen und als Nginx-Site installieren. Die Vorlage übernimmt die
Forwarded-Header kontrolliert; die API vertraut ihnen ausschließlich vom
Loopback-Proxy. So verwenden Anmeldung und Cookies das öffentliche HTTPS-Schema.
Die Proxyparameter sind in der
[Nginx-Dokumentation](https://nginx.org/en/docs/http/ngx_http_proxy_module.html) beschrieben.
Vor dem ersten Start auf dem Linux-Zielsystem prüfen:
```sh
sudo systemd-analyze verify /etc/systemd/system/viano-bug-analysis-api.service /etc/systemd/system/viano-bug-analysis-worker.service
sudo nginx -t
sudo systemctl reload nginx
sudo systemctl enable --now viano-bug-analysis-api.service viano-bug-analysis-worker.service
sudo systemctl status viano-bug-analysis-api.service viano-bug-analysis-worker.service
```
Sobald der Worker aktiviert ist, kann er vorhandene Aufträge verarbeiten und
kostenpflichtige Modellaufrufe senden. Erst mit leerem Eingang starten, dann einen
kontrollierten Feldtest über die Übergabe-API oder Weboberfläche übergeben.
Anmeldung, Auftragsannahme, Fortschritt, menschliche Testanfrage und Outbox dabei
gemeinsam prüfen. Die automatisierte Einrichtung ersetzt diesen Feldtest nicht.
## Laufender Betrieb
Die Dienste starten beim Booten und nach unerwartetem Prozessende erneut.
Fehlende Startvoraussetzungen werden nach zehn Sekunden erneut geprüft.
Externe DB und Sourcebereitstellung werden nicht von diesen Units installiert.
API und Worker können unabhängig voneinander laufen.
```sh
sudo journalctl -u viano-bug-analysis-api -u viano-bug-analysis-worker -f
sudo systemctl stop viano-bug-analysis-worker
```
Der Worker beendet beim Stoppen den aktuellen Dispatch kontrolliert; seine Unit
setzt dafür kein hartes Stop-Zeitlimit. Ein Stop kann daher während einer
langlaufenden Anfrage dauern. Für Code-/Profiländerungen den Worker zuerst
anhalten. Profilbindungen laufender Fälle nicht beiläufig ändern: Unterschiede
werden als Aufmerksamkeit erfordernder Zustand behandelt, Budgets nicht erneuert.
Codeänderungen danach in die root-eigene venv installieren und Dienste neu starten.
Backups umfassen die Fallablage, Konfiguration einschließlich geschützter Secrets
und Nutzer-SQLite. Für eine einfache konsistente Dateisicherung beide Dienste
anhalten. Sessions sind keine dauerhaft nötigen Fachdaten. Produktive Fallaufbewahrung
separat festlegen; das 20-GB-Entwicklungslimit ist kein Produktionslimit.
Unter Windows bleibt die vorhandene PowerShell-Umgebungsvariable für Entwicklung
unterstützt. Dort installiert dieser Installer keine Dienste; `--dry-run` ist möglich.
+163
View File
@@ -0,0 +1,163 @@
# Projekt und Recherchezugriffe konfigurieren
Die Projektkonfiguration wird über Dateien verwaltet, nicht über die Webseite.
Sie legt fest, welche Werkzeuge ein Auftrag verwenden darf. Ein aufrufendes
Programm kann weder Repository noch DB-Zugänge oder Budgets mit einem Auftrag
überschreiben. Aktuell ist das Profil `wow/vcore` mit dem Importadapter `vcore`
implementiert; andere Projekttypen erfordern entsprechende Adapter und Profile.
## Zuordnung in api.toml
Ein Projektelement ordnet eine feste Projekt-ID einem Profil und dessen
Konfigurationsdateien zu. Relative Pfade beziehen sich auf die API-Konfigurationsdatei.
Der Erfassungsworker muss genau dieselbe Projekt-/Profilzuordnung senden und
sein Integrationsschlüssel muss für das Projekt freigegeben sein.
```toml
[[projects]]
id = "viano-core"
type = "wow"
profile = "vcore"
adapter = "vcore"
planning_mode = "joint"
[projects.settings]
project_config = "project.toml"
llm_config = "llm.toml"
investigation_config = "investigation.bounded.toml"
budget_config = "budget.human-tests.toml"
```
Die Linux-Vorlagen stehen unter `deploy/linux/`; der Installer legt die
Konfiguration unter `/etc/viano-bug-analysis` an. Er überschreibt bestehende
Dateien nicht. Die ausführliche Vcore-Projektvorlage steht unter
`config/project.example.toml`, die Relationsregeln unter
`config/project-relations.vcore.toml`.
## Source und Datenbank in project.toml
```toml
relations_file = "project-relations.vcore.toml"
[project]
type = "wow"
profile = "vcore"
[source]
path = "/srv/viano-bug-analysis/viano-core"
url = "https://gitea.viano-software.de/Nico/Viano-Core"
commit = "VOLLSTAENDIGEN_40_STELLIGEN_COMMIT_EINTRAGEN"
[database]
host = "127.0.0.1"
port = 3306
database = "cata_world"
user = "viano_reader"
password_env = "VIANO_DB_PASSWORD"
snapshot_id = "KENNUNG_DER_TATSAECHLICHEN_DATENGRUNDLAGE"
tables = ["creature_template", "creature"]
```
Das Beispiel zeigt nur den Aufbau und enthält absichtlich ungültige Platzhalter.
Die beiden Tabellen sind keine vollständige Vcore-Freigabeliste. Für das echte
Profil die vollständige Vorlage einschließlich Limits, Enrichment und
Analysewerkzeugregeln verwenden und passend prüfen.
Der Source-Checkout muss lokal vorhanden sein und den konfigurierten Commit
enthalten. Die URL allein klont oder aktualisiert kein Repository. Der Worker
liest Git-Objekte dieses festen Stands und benötigt keine Schreibrechte auf den
Checkout. Die Linux-Vorlage enthält eine gezielte Git-Vertrauenskonfiguration
für den root-eigenen Checkout. Neue Source-Stände bewusst bereitstellen und
deren vollständigen Commit konfigurieren.
Der DB-Administrator legt einen Benutzer mit ausschließlich passenden SELECT-
Rechten an. Zusätzlich begrenzt `tables` die zugänglichen Tabellen. Diese
Freigabeliste ersetzt keine serverseitigen Rechte. Account- oder Charakterdaten
gehören nicht zur standardmäßigen World-Recherche. Für entfernte DBs einen
geschützten Netzwerkweg nutzen; der aktuelle Provider konfiguriert selbst kein TLS.
Das Passwort steht entweder in der durch `password_env` benannten
Umgebungsvariable oder einer separaten geschützten Datei über `password_file`.
Nur eine dieser Varianten setzen. Unter Linux lädt der Worker beispielsweise
`VIANO_DB_PASSWORD` aus seiner root-geschützten `worker.env`.
Passwörter nicht in Fallakten, LLM-Prompts oder Versionsverwaltung ablegen.
Die LLM bekommt Werkzeugergebnisse statt Zugangsdaten.
## Mehrere Datenbanken pro Projekt
Eine Projektverbindung kann mehrere Datenbanken derselben MariaDB-Instanz
ansprechen. Host, Port, DB-Benutzer und Passwortbezug werden gemeinsam verwendet.
`database` benennt die Standarddatenbank; unqualifizierte Tabellennamen beziehen
sich ausschließlich auf diese. Zusätzliche Datenbanken werden ausdrücklich
freigegeben, etwa für Vcore:
```toml
# Im vorhandenen [database]-Abschnitt: database = "cata_world", user = "buganalysis"
[database.additional_databases]
cata_hotfixes = ["*"]
cata_locale = ["*"]
```
Den neuen Abschnitt beispielsweise am Ende der vorhandenen Datei ergänzen.
`[database]` nicht ein zweites Mal anlegen und die übrigen Projektregeln behalten.
Die vollständige alternative Vorlage steht unter
`config/project.vcore-multidb.example.toml`.
`["*"]` gibt ausdrücklich alle metadaten-sichtbaren Tabellen der genannten DB
frei. Alternativ eine nichtleere Liste konkreter Tabellen verwenden. Auch die
Standarddatenbank unterstützt `tables = ["*"]`; die bisherige konkrete Liste
bleibt genauso gültig. Ein leeres Array ist keine pauschale Freigabe.
Die tatsächlich gewährten DB-Rechte bleiben zusätzlich maßgeblich.
Nicht aufgeführte Datenbanken sind selbst bei weitergehenden DB-Grants gesperrt.
In diesem Beispiel sind `cata_auth`, `cata_characters` und
`cata_locale_reference` nicht für Recherche freigegeben.
Werkzeuge verwenden in zusätzlichen DBs qualifizierte Tabellennamen wie
`cata_locale.NAME_DER_TABELLE`. `database.tables` entdeckt die zugänglichen Namen
über alle freigegebenen DBs; Suchtext und Seitengrenzen gelten weiterhin.
Gleichnamige Tabellen werden nicht verwechselt. Die Datenbank steht in der
Belegherkunft und bei Mehrdatenbank-Ergebnissen auch in den Ergebniseinträgen.
Die LLM erhält keine Serveradresse, Anmeldenamen oder Passwörter.
Namenskataloge, Enrichment-Regeln und Beziehungsprofile können ebenfalls
qualifizierte Tabellen referenzieren. Die Beziehungsanalyse führt die
konfigurierten Schritte innerhalb einer gemeinsamen lesenden Transaktion aus;
sie prüft die Transaktionsfähigkeit der beteiligten Tabellen. Dieses Format
konfiguriert keine unterschiedlichen DB-Server oder getrennten Zugangsdaten
innerhalb derselben Projektverbindung.
Die Bereitschaftsprüfung prüft alle freigegebenen Tabellen einschließlich
Wildcard-Inventaren. Eine nicht erreichbare oder nicht lesbare zusätzliche DB
wird nicht stillschweigend ausgelassen. Bestehende Einzel-DB-Konfigurationen
benötigen keine Änderung. Bei aktiven Fallakten trotzdem die bestehende
Konfigurationsbindung beachten; ein Update setzt keine Budgets zurück.
## Weitere Konfigurationen
| Datei | Zuständigkeit |
| --- | --- |
| api.toml | HTTP-Bindung, Workspace, Projekte, Nutzerdatei und optionale env-basierte Maschinenclients |
| worker.toml | Verweis auf api.toml, Polling, Wiederholungsabstände, Speicherreserve und Telemetrie |
| llm.toml | Anbieter, Modell, Schlüssel-Environment, Timeouts, Ausgabe-/Denkeinstellungen und Cache |
| investigation.bounded.toml | Untersuchungsmodus und begrenztes Kontextfenster |
| budget.human-tests.toml | Fachliche Budgetregeln und menschliche Testanfragen |
| telemetry.toml | Heartbeats und lokale Betriebsbeobachtung |
| project-relations.vcore.toml | Projektspezifische Beziehungen für Recherchewerkzeuge |
Die versionierte Modellvorlage `config/llm.gemini-cache.example.toml` verwendet
`GEMINI_API_KEY`. Das ist unabhängig vom Integrationsschlüssel für externe
Auftragsübergaben. Die API erhält `api.env`; der Analyseworker erhält
`worker.env` mit Cloud- und DB-Zugangsdaten.
## Prüfen und ändern
Der Befehl `python -m viano_bug_analysis.deployment check` prüft mit Rolle und
Konfigurationspfad die Betriebsbereitschaft ohne Modellanfrage. Die
[Linux-Anleitung](linux-deployment.md) zeigt die vollständigen Befehle unter der
tatsächlichen Dienstidentität. Die Prüfung umfasst Git-Objekte und freigegebene
DB-Schemata; die Gültigkeit des Cloud-Schlüssels wird nicht durch einen bezahlten
Modellaufruf getestet. `--offline` prüft keine Git-/DB-Verbindung.
Vor Code- oder Profiländerungen den Worker kontrolliert anhalten und die
Auswirkungen auf laufende Akten prüfen. Der Worker bindet Fälle an ihren
Konfigurationsstand. Eine Änderung ist kein Weg zum Zurücksetzen von Budgets;
abweichende Bindungen können eine Betreiberprüfung erfordern. Bestehende Dateien
und Zugangsdaten vor Änderungen sichern.
+1 -1
View File
@@ -36,7 +36,7 @@ und `arguments` gemäß `project-query-v1.schema.json`.
| `source.registration` | Symbolvorkommen als Ausgangspunkt einer Registrierungsprüfung |
| `source.schema` | Literale Suche ausschließlich in SQL-Dateien |
| `source.read` | Begrenzten Zeilenbereich einer bekannten Datei lesen |
| `database.tables` | Namen in der konfigurierten Tabellen-Allowlist finden |
| `database.tables` | Namen in den konfigurierten Datenbanken und Tabellenfreigaben finden |
| `database.schema` | Tatsächliche Spalten, Typen, Nullbarkeit und Schlüssel lesen |
| `database.rows` | Spalten auswählen, explizite Filter und begrenzte Seite lesen |
| `database.count` | Vollständige Trefferzahl für dieselben Filter bestimmen |
@@ -0,0 +1,13 @@
# BUG-000021: independent citation diagnostics
Date: 2026-10-24. Input: operator-supplied `BUG-000021-Fallakte.zip`, analysis revision 1, review reason `MODEL_VALIDATION_EXHAUSTED`. No cloud calls were executed for this investigation or patch.
The interpretation-only fallback omitted the identity evidence for Furien in `/relevant_entities/1` and independently cited a report in `/described_behavior/0`, where local-description facts are required. The existing statement collector diagnosed only the first issue. The next response correctly repaired the requested entity citation, but final validation rejected the previously undisclosed description citation.
The collector now reports both independent paths. Interpretation-only fallback rejections persist their diagnostics just like joint and statement-repair outputs. Feedback uses the saved diagnostics. Historical rejections without this diagnostic field retain the legacy collector, and statement-patch validation accepts the exact legacy or current deterministic issue list, preserving request and parent-call binding. No source rule or final validation was relaxed.
The curated rejection fixture is `tests/fixtures/llm_rejections/20261024/bug21-independent-citation-errors/`. It includes a rejected interpretation, source-validation context and analysis, not a full package or run dump. The independent usage report is `docs/test-results/token-usage/bug21-first-field-review-20261024.json`.
Validation: 92 passed, 1 skipped across joint-v2, early correction and entity-resolution tests; one subsequently added frozen-feedback regression passed separately. The unchanged operator package also passes `correction.validate_review`, including replay of its rejected calls. An additional joint-planning pipeline test fails at `BLOCKED` instead of `OUTPUT_READY`; the same test fails on the pre-patch HEAD source extracted to a temporary directory. The other 26 tests in that correction/joint-planning run pass; one is skipped.
The case detail page now distinguishes failed analysis from successful handoff. Recovery of an already exhausted revision uses the existing case-return contract with `review_feedback`, not a fabricated failed patch and not an in-place alteration of rejected responses. This starts a new analysis revision with the original archives and full history retained. It does not grant extra attempts to the exhausted revision. This operator recovery has not yet been executed on the VPS, and a new revision may repeat planning work. There was no hypothesis investigation budget initialized in this field run.
@@ -0,0 +1,34 @@
# BUG-000021: field-confirmed visibility mechanism and analysis review
Date: 2026-10-25. Evidence: final revision-2 generation-123 case package reviewed previously, followed by operator-supplied SQL outputs, screenshots and in-game experiments. No model calls, DB writes or code changes were executed by the assistant for this review. The earlier completion and usage reports remain unchanged.
## Confirmed operator experiments
- Furien exists as creature 35286, spawn 367807. GM visibility and NPC inspection confirmed the original live spawn.
- The original PhaseId is 169. The inspected Vcore source defines DEFAULT_PHASE as 169; InitDbPhaseShift treats it as Unphased. Both the NPC and the player's reported ordinary phase state had Flags 8. This contradicts the automatic diagnosis's unverified premise that 169 is a nondefault inaccessible phase.
- creature_addon is empty for this spawn. creature_template_addon has StandState 7 and aura 49414; the live aura list identifies Generic Quest Invisibility 1, aura type 18.
- Removing 49414 from the NPC makes him visible without GM mode. Keeping the original NPC aura and adding detection aura 49416 to the player also makes him visible.
- There was no matching spell_area rule for area 405/2404 or the Horde breadcrumb quest. Existing uses of 49416 were confined to other area/quest conditions.
- Operator inserted spell_area (spell=49416, area=405, quest_start=0, quest_end=0, aura_spell=0, racemask=946, gender=2, flags=3, quest_start_status=0, quest_end_status=0), reloaded spell_area, removed the manually applied player aura and left/reentered Desolace. Operator reports automatic Horde visibility, and invisibility to an Alliance character.
- This supports the proposed zone-wide Horde detection rule for the tested scenario. Original retail conditions, all quest-progress states, aura removal on exit and the entire downstream quest chain were not all separately reported as tested. It is not proof that this is the only correct rule.
- Jankie (36410) and mount templates have no static creature rows; an Alliance field test did not find her even in GM mode. No fix applied. This is reserved for a subsequent independent BugAnalysis field test.
## What BugAnalysis did correctly
Intake, project binding, read-only DB tools, actor-audited technical release, bounded name-assessment repair, case continuation, early diagnosis and differentiated coding/human handoff worked. Furien's identity and existing spawn were found correctly. H1 missing-spawn was rejected using actual complete query evidence. H2 appropriately included visibility as a candidate mechanism. Placeholders and human verification prevented a fabricated target PhaseId from becoming an executable hardcoded instruction.
## What failed
The automatic workflow never identified the invisibility aura. Original archive members include an empty creature_addon result but no direct creature_template_addon result for 35286. Numeric 49414 strings exist in historical SQL-reference ID lists; these are not captured evidence of Furien's aura. The relationship tool projects template/script, spawn and event-link information, not template addon/auras or effective visibility. This is a coverage gap, not a reason to treat omitted fields as absent; the read-only DB tool could retrieve them.
The H2 initial brief labels the mere existence of phase-related schema columns as supporting causal evidence. Later the model explicitly calls PhaseId 169 nondefault without checking its core semantics. The next brief presents exclusion of the test player as a fact although no phase-membership measurement was obtained. The root-cause claim thus grew from a real column value through an unverified interpretation into an alleged runtime observation.
Early review used the same spawn records to reject H1 and countercheck H2. Showing that a spawn exists is not a discriminating test of whether phase state or a separate invisibility aura hides it. Neither the source DEFAULT_PHASE definition nor the target's aura state was examined. Final diagnosis repeated the unsupported player-phase assertion and accepted the early conclusion. Contract-valid evidence references establish provenance of the row, not entailment of every causal clause attached to it.
The coding request correctly uses {{TARGET_PHASE_ID}}, but already mandates changing the NPC phase and prohibits changes to other spawn fields. The human task asks which phase is intended; it does not adequately question the unsupported repair mechanism itself. A placeholder does not repair an incorrect causal diagnosis.
## Generalizable improvement candidates, not implemented here
Distinguish captured values, interpreted mechanisms and unverified runtime prerequisites; do not promote schema-only results to causal support. Verify numeric field semantics in the actual source before classifying values as abnormal. Expand visibility research to relevant effective addon/aura sources, keeping spawn-specific versus template precedence explicit. Require a countercheck that probes the selected mechanism rather than only eliminating another hypothesis. Have final review test causal prerequisites and allow a focused tool/human follow-up when they are missing. Review whether the chosen intervention follows from the evidence, separately from whether parameters are known. These measures do not require excluding every alternative, exhausting budgets or disabling early completion.
The run is an infrastructure success and a useful partially correct investigation, but not a successful automatic root-cause diagnosis or repair recommendation. The visibility fix was derived by subsequent human-guided investigation. No claim is made that a single example establishes overall accuracy or that the system should memorize Furien/169/49416 as a special case.
@@ -0,0 +1,15 @@
# BUG-000021 first completed VPS field case
Final input: operator-supplied `BUG-000021-Fallakte.zip`, revision 2, generation 123, OUTPUT_READY/outbox. This analysis read the package without changing it or invoking a model. Earlier blocked snapshots remain separate. The independent usage snapshot is `token-usage/bug21-final-g00000123-20261024.json`.
The explicit operator grant was consumed by entity assessment attempt 4. Its model response repaired only the localized search, changing no_match to ambiguous. The complete base-name identification of Furien (35286) was retained unchanged. Joint planning then encountered a recorded LLM_TIMEOUT/LLM_UNAVAILABLE and resumed successfully in round 2. Thus the apparent period without phase progress has a recorded provider-timeout/retry explanation; it is not evidence of a deadlocked worker.
Research produced six evidence records. The creature relationship tool found a single spawn, GUID 367807, on map 1, zone 405, area 2404, with no game-event link. This refutes the initial missing-spawn hypothesis within the query scope. An attempted startup query requested unverified column `phaseId` and was rejected as PROJECT_DATABASE_COLUMN_UNKNOWN; a schema query followed. The subsequent row query retrieved PhaseId 169, PhaseGroup 0, phaseUseFlags 0, phaseMask 1 and coordinates (189.557,1770.96,91.2064). Other queries obtained creature_questender and conditions schemas and discovered phase table names. They did not retrieve questender row contents, phase definitions or character phase membership.
The model selected H2 (phasing) and requested early diagnosis. The final diagnosis accepted this, rejected H1 and left H3 unresolved. It produced an assigned SQL migration-template coding request using {{TARGET_PHASE_ID}}, together with a human-dev request to verify intended phasing and questender relations. No executable patch was generated or tested. Final output validation status is valid, which establishes contract conformance, not factual correctness of the root-cause conclusion.
Quality assessment: the phasing lead is plausible and backed by actual spawn data. However, the diagnosis claims the reproduction demonstrates that the character was not in phase 169. No character phase-membership observation was located in the supplied reproduction/case records; the research queries did not establish it either. The claim should be treated as an inference, not a measured fact. Likewise the relevant core phasing semantics and intended quest progression were not investigated. The supported suspicion is useful, but the confirmed label and assertions that alternatives were isolated are stronger than the reviewed evidence supports. The coding request allows only an NPC PhaseId update even though the diagnosis explicitly leaves open that the quest should grant the existing phase; human verification is therefore material to the selected repair direction, not merely selection of a numeric placeholder. No program behavior was changed on the basis of this single case.
Operator release to completion: 836.891499 seconds (13 minutes 56.9 seconds), excluding prior manual downtime. Revision-2 recorded model time totals 240.523818 seconds; timeout usage/time is not available in metadata. Revision-2 measured usage: 190037 input, 28659 output including reasoning. Clean usage retaining first regular calls: 116270 input, 27488 output. First metered joint-planning call is classified regular even though its stored label says correction. Entire case including failed revision 1: 268661 input and 42049 output, plus one unmetered timeout. Do not add earlier snapshots to these cumulative totals.
Costs use the saved USD price coefficients, with input/cache ambiguity explicitly bounded. Revision-2 known-usage range is USD 0.129899625–0.207924225; clean range USD 0.116366625–0.178580025. These ranges exclude any unmetered timeout charges and unknown storage, and are not EUR billing totals. No current provider-account balance was supplied. See the JSON for per-call and per-phase estimates and unknown values.
@@ -0,0 +1,13 @@
# BUG-000021 revision 2: scoped entity-assessment repair
Input: operator-provided `BUG-000021-Fallakte (1).zip`, revision 2, generation 43. No paid calls were executed for diagnosis or testing. The original package is unchanged.
All three assessments correctly selected Furien, entry 35286, from the complete base-name lookup. Each incorrectly used `no_match` for a nonempty, truncated German substring lookup containing unrelated harpy names. The validator correctly requires zero rows for `no_match`; previous feedback only supplied the generic `ENTITY_RESOLUTION_SELECTION` code.
The repair now freezes durable, index-specific diagnostics containing the actual status, row count, truncation, allowed candidates and citation constraints. Each structurally and semantically valid sibling entry is retained exactly. Only invalid entries are requested in the model response, with original search indices constrained by the local schema. The program merges them with frozen entries and runs the unchanged full assessment validation. Malformed patches retain the previously frozen entries and receive another bounded repair attempt; they cannot unlock an accepted sibling. No identity is selected automatically and no status is silently changed by string matching on the reason.
Feedback is saved before the call. Existing calls without version-2 feedback retain their exact original request. The projection is also applied when checking completed calls and replaying rejections, so persisted full assessments remain bound to the original partial response and its saved request. Limits and attempt budgets are unchanged. This patch does not silently reopen the already exhausted revision 2 or grant it another attempt.
Curated corpus: `tests/fixtures/llm_rejections/20261024/bug21-localized-no-match/`, approximately 18 KB. Usage snapshot: `docs/test-results/token-usage/bug21-revision2-resolution-blocked-20261024.json`; first-field-run snapshot remains separate. Missing cache/think-token values remain unknown; output totals do not add reasoning twice.
Validation: 60 tests passed across partial entity repair, legacy entity resolution and multidatabase research. Coverage includes the real rejected answer, strict rejection of invalid replacement sources/candidates/status, two-search completion, malformed patch recovery, restart without re-querying research, and portable replay without further model calls. Linux field verification remains pending deployment.
@@ -0,0 +1,29 @@
# BUG-000022: Officer Jankie field analysis
Date: 2026-10-25. Input: `BUG-000022-Fallakte.zip`, revision 1, generation 79, OUTPUT_READY/outbox. No paid calls or code changes were performed for this evaluation. The final package was read directly, without a retained extracted workspace. Usage is archived separately at `token-usage/bug22-final-g00000079-20261025.json`.
## Hypotheses and actual research
H1: missing world spawn. Startup `creature_spawn_context` found template 36410, empty AIName/ScriptName and zero creature rows. H2: existing spawn hidden by phase/quest conditions. A direct creature query for 36410 independently returned zero rows with complete filtered coverage. H3: dynamic summon/movement/despawn. The first requested source.context call omitted required path_prefix and was rejected with INVESTIGATION_OUTPUT; the next call used source.search for 36410. The rejected query is in the curated corpus at `tests/fixtures/llm_rejections/20261025/bug22-source-context-prefix/`.
After startup assessment, H1 became strongly_supported, H2/H3 weakened. H1 then repeated source.search for the same number with a different limit. Both captures show the same historical SQL/numeric lexical matches and partial coverage with skipped files. A smart_scripts query for entryorguid 36410 returned zero rows. The controller accepted an early diagnosis request. Final diagnosis selected missing spawn, rejected H2/H3 and produced a coding SQL template with placeholders for GUID, coordinates and orientation, plus a human request to obtain position/mount requirements.
There are five evidence records: one relationship query, one direct creature query, two lexical source searches and one smart_scripts query. No direct current creature_questender/queststarter row query was made during investigation; SQL-history references were found. No actual spawn/fix was applied by this run. The operator's earlier DB and GM observations independently support the absence diagnosis, but the final success of a patch remains untested.
## Assessment
This is a much more defensible repair candidate than BUG-000021's unverified PhaseId interpretation: the selected missing-static-spawn premise was actually tested with complete query results, and the proposed insertion addresses it directly. Correct identity, sensible initial alternatives, investigation of script possibility, early finish and differentiated placeholder/human handoff are positives. It is reasonable to try this repair within the downstream coding/build/human-test feedback system without proving universal absence of every possible dynamic caller.
Two qualifications matter. Empty own-template script bindings and no own SmartAI rows do not logically exclude summons by external scripts, spells or other entries. The diagnosis acknowledges uninspected C++ in scope_limits but still calls H3 refuted; its confidence language exceeds its coverage. Event-link nodes were skipped because there were no parent spawn GUIDs; that is not an independent investigation of all event logic.
The coding request's only explicit validation requirement is that the new creature row exists. It should also require the original symptom to be checked: a normal Alliance character at the destination can see Jankie and turn in quest 28531. A valid INSERT alone is not proof of behavioral repair. Mount/follow-up behavior remains an explicit human clarification. Coordinates are correctly not invented. The human document's emphasis on authoritative/reference coordinates is stronger than necessary for an operator-approved suitable position; this is secondary to functional correctness.
## Deferred work, explicitly not implemented
User requested retaining the final-review improvement for later evaluation: verify the selected cause's load-bearing premise using targeted evidence/source semantics or a concrete follow-up, rather than adopting investigation narration. Distinguish value capture from semantic abnormality and unmeasured player state. Do not require exhaustive exclusion of every alternative, all budgets to be spent, or guaranteed patch success. BUG-000021's standard PhaseId=169 assumption is the motivating counterexample; BUG-000022 illustrates a reasonable repair trial with narrower coverage language needed. Also retain the source.context missing-prefix case for later form/default-recovery work. No analysis prompts, validators or tool behavior were changed here.
## Metrics
Thirteen measured model calls, one additional startup correction. Total: 106317 input and 21897 output including thinking. Clean: 102446 input and 21265 output. The first joint-planning call is regular despite its recorded correction label; it is counted once. Recorded model time totals 207.939304 seconds; revision checkpoint span 436.431866 seconds (7m16.4s), excluding unrecorded queue time. Investigation prompt message contents ranged from 8865 to 13098 characters.
Saved pricing yields USD 0.0948699–0.150581025 for known total usage, clean USD 0.091244325–0.14695545. Missing cache/storage values prevent an exact total and remain unknown. USD estimates are not EUR billing. No updated provider-account balance was supplied. Full phase and per-call accounting, version/config hashes and cache limitations are in the JSON report.
@@ -0,0 +1,37 @@
# wowdiag-worker-Anschluss – 2026-10-23
Ausgangslage: Nutzer stellte `Z:/Dokumente/Projekte/wowdiag-worker/` bereit.
Quellcode bestätigt WORKER_VERSION 0.3; README/Beispielkonfiguration waren älter.
Die echte Konfiguration wurde nicht gelesen oder geändert. Keine echten Capture-
Jobs, Testserverabfragen, Cloudanfragen oder Dienststarts.
Ausbau v0.4 unter `deploy/capture-worker`: unveränderte Claim-Verarbeitung,
optionale API-Übergabe nach erfolgreicher Erfassung, lokale SQLite-Outbox,
Streaming-Upload beider Archive, feste Idempotenz über Wiederholungen/Neustarts,
Inhaltsprüfung, begrenzte Requests, HTTPS für entfernte Hosts, keine Redirects,
keine Secrets in Quittungen/Logs, separate Status-/Retry-Befehle.
Die tatsächlichen Testerbeobachtungen bleiben in den Originalarchiven.
Lokale Prüfungen:
```text
python -m pytest tests/profiles/vcore/test_capture_handoff.py tests/profiles/vcore/test_intake_api.py tests/test_integration_keys.py -q
```
Prüffälle: URL-Konfiguration, persistente Outbox, Wiederholungsidentität,
Parallel-Lease, Archivänderung, Wiederholung nach verlorener Erfolgsantwort
gegen echte lokale Uvicorn-/Intake-API mit synthetischen Archiven sowie
Staging vor Capture-Abschluss und deaktivierter Übergabe.
15 Tests bestanden; CLI-Hilfe und `git diff --check` ebenfalls erfolgreich.
Feldprüfung mit tatsächlichem Erfassungsserver
steht aus. Keine Token-/Cache-/Abrechnungsmetriken, da keine LLM-Aufrufe.
Speicher vor Tests: Trials 16.939.129.428 Bytes, Fehlerkorpus 2.471.041 Bytes,
pytest-Temporärdateien ca. 4,2 MB. Nur kleine synthetische Archive erzeugt;
keine vollständigen Bug-Paketarchive kopiert. Unterhalb des 20-GB-Budgets.
Nach erfolgreichen Prüfungen wurde v0.4 mit Modul, Anleitung und Beispieldateien
in den vom Nutzer genannten Ordner auf Z: zurückgeschrieben. Vorhandene
Quelldatei, README und Beispielkonfiguration wurden jeweils als
`.before_api_v0_4` gesichert. Die aktive `config.toml` blieb unverändert.
Kein externer Dienst gestartet; Übergabe ist erst nach bewusster Konfiguration aktiv.
@@ -0,0 +1,36 @@
# Menschliche Rückfragen im Web – 2026-10-22
Ziel: Die vorhandene menschliche Testanforderung bedienbar und für neue
Dienstanalysen freigegeben machen. Eigene Seite mit offenen Anfragen, Projekt,
Bug, Zeit, Projektfilter und Sortierungen; Anzeige des Testauftrags und bestätigtes
Textfeedback. Separate Testerrechte bzw. Betreiberrechte erforderlich.
Neue Budgetdatei `config/budget.human-tests.toml`: menschliche Tests 40 fachliche
Einheiten, keine Freigabe automatisierter Tests. Die ignorierte lokale
API-Konfiguration verweist darauf. Vorhandene eingefrorene Revisionen unverändert.
Antworten benutzen den bestehenden AsyncTestCoordinator und dessen Budget-/Identitäts-
und Resume-Prüfungen, keine eigene Zustandsabkürzung. Serverseitige Bestätigung
unter der authentifizierten Clientkennung. Keine kostenpflichtigen LLM-Aufrufe.
Prüfung mit synthetischen Daten:
- API-/Coordinator-/Dashboard-/Worker-Tests: beide Antwortausgänge,
Projektberechtigung, fehlende Rechte, falsche Anfrage, fehlende Bestätigung,
identische bzw. widersprüchliche Wiederholung, unveränderte Budgetkonten und
Fortsetzung in derselben Revision.
Zusammen 51 Tests bestanden.
- Schemavertrag: ohne Budgetfreigabe kein Testzweig, bei menschlicher Freigabe
ausschließlich `human` als Testexecutor.
- Echter Headless-Edge: eigene Seite, Fall-/Projektanzeige, Sortierauswahl,
Auftragsanzeige und Antwortformular, Mobilansicht ohne horizontalen Überlauf,
Datenentfernung bei Abmeldung. Feedbackwirkung separat mit echter Coordinator-
und Budget-Fixture getestet, UI-Anfrage ist ausdrücklich eine Anzeige-Fixture.
- Vor Tests Test-Workspaces 16.939.129.428 Bytes; kleine Fixtures und temporäre
pytest-/Browserdaten bleiben mit Reserve innerhalb des 20-GB-Budgets.
- Breitere Untersuchungstests: 72 bestanden, 26 alte Tests in
`test_investigator.py` scheitern am vorhandenen `INVESTIGATION_PROMPT_LIMIT`.
Vollständige Gegenprobe dieses Moduls mit deaktivierter neuer Schemafilterung:
dieselben 26 Fehler, 6 bestanden. Keine Erweiterung der Zeichengrenze vorgenommen.
Grenzen: Textfeedback; Dateianhänge und automatisierte Testausführung sind keine
Bestandteile dieser Oberfläche. Alte Revisionen benötigen ihre ursprüngliche Policy.
@@ -0,0 +1,33 @@
# Webverwaltung für Integrationsschlüssel – 2026-10-23
Ergebnis: eigene Webseite mit Programmname, Projektauswahl, Erstellen,
erneutem Anzeigen, Verbergen, Kopieren und Widerrufen. API-Schlüssel werden
authentifiziert verschlüsselt in separater SQLite gespeichert; die separate
Serverschlüsseldatei wird beim ersten Erstellen angelegt. Bestehende env-Zugänge
bleiben nutzbar. Menschliche Nutzerverwaltung unverändert ausschließlich per CLI.
Lokale Prüfung ohne LLM-Aufrufe:
```text
python -m pytest tests/test_integration_keys.py tests/test_web_auth.py tests/test_deployment.py tests/test_human_web.py tests/test_dashboard.py tests/profiles/vcore/test_intake_api.py -q
43 passed, 1 dependency deprecation warning
node tests/dashboard-smoke.mjs
PASS, console_errors: []
```
Abgedeckt: Session-/CSRF-Pflicht, keine Maschinenverwaltung von Schlüsseln,
Projektbeschränkung, kein Operatorzugriff, kein Klartext in SQLite oder Listen,
Wiederabruf nach neuer Store-Instanz, sofortiger Widerruf auch für bereits laufende
API-Prozesse, fehlende Masterdatei ohne stille Neuerzeugung, bestehende Web-/API-
Regressionen. Echter Headless-Edge-Test für Erstellen/Anzeigen/Verbergen/Widerrufen
und Löschen sichtbarer Schlüsseldaten beim Logout. Kopieren nutzt die native
Clipboard-API; deren Interaktion wurde nicht automatisiert geprüft.
Die Entwicklungsversion der neuen API-Abhängigkeit cryptography wurde lokal
installiert. Keine realen Schlüssel erzeugt oder ausgegeben; Browser nutzt
temporäre isolierte Testdaten, die nach dem Test entfernt werden.
Vor Testbeginn Trials 16.939.129.428 Bytes, Fehlerkorpus 2.471.041 Bytes;
kleine synthetische Testdaten bleiben innerhalb des 20-GB-Budgets.
Keine kostenpflichtigen Modellaufrufe, keine neuen Token-/Cache-/Kostenwerte.
Kein Deployment und kein Push. Anleitung: `docs/integration-keys.md`.
@@ -0,0 +1,31 @@
# Linux-Betriebspaket – lokale Prüfung 2026-10-22
Ziel: getrennte API-/Worker-Dienste, persistente geschützte Zugangsdaten,
Installation ohne automatische Aktivierung und Bereitschaftsprüfung ohne LLM.
Änderungen: `deploy/install.py`, Linux-Konfigurationsvorlagen, beide systemd-Units,
`viano_bug_analysis.deployment` sowie explizite Loopback-Proxy-Vertrauensgrenze
beim API-Start. Anleitung: `docs/linux-deployment.md`.
Validierung auf Windows mit Python aus `.venv`:
```text
python -m pytest tests/test_deployment.py tests/test_web_auth.py tests/test_worker.py tests/profiles/vcore/test_worker_intake.py -q
31 passed, 1 dependency deprecation warning
git diff --check: keine Whitespacefehler
```
Prüfumfang: Installer-Dry-Run ohne Seiteneffekte, TOML-Vorlagen und Dateiverweise,
getrennte env-Dateien und Unit-Härtung, API-Start mit/ohne erforderliche
Zugangsdaten, Worker-Leseprüfung mit kontrollierten Git-/DB-Testdoubles,
Offline-Modus, keine Modellanfrage, sichere Fehlerausgabe, HTTPS-Login-Cookie
über Uvicorns Proxy-Middleware, bestehende Nutzer-/Worker-/Intake-Tests.
Vor den Tests: Trial-Workspaces 16.939.129.428 Bytes, kuratiertes Fehlerkorpus
2.471.041 Bytes. Nur kleine synthetische Tempdateien; keine Paketkopien oder neuen
LLM-Trials. Das Entwicklungsbudget von 20 GB bleibt eingehalten.
Keine Cloud-/DB-Zugangsdaten im Bericht. Keine realen LLM-Aufrufe oder neuen
Abrechnungskosten. Keine Installation, Dienstaktivierung oder Serveränderung.
Native systemd-/Nginx-Prüfung und produktiver End-to-End-Test stehen aus:
Auf dieser Windows-Umgebung ist kein nutzbares Linux/systemd vorhanden.
@@ -0,0 +1,66 @@
# Mehrere Recherche-Datenbanken pro Projekt – 2026-10-24
Ausbau des SQL-Providers um ausdrücklich konfigurierte zusätzliche Datenbanken
derselben MariaDB-Verbindung. Standarddatenbank und alte Einzel-DB-Konfigurationen
bleiben gültig. Zusätzliche Tabellen verwenden `datenbank.tabelle`; es gibt keine
automatische Suche nach gleichnamigen Tabellen in anderen DBs.
`[database.additional_databases]` unterstützt konkrete Tabellenlisten oder
ausdrücklich `["*"]` für metadaten-sichtbare Tabellen einer benannten DB.
Die Standardliste `tables` unterstützt dieselbe ausdrückliche Wildcard.
Keine implizite Freigabe anderer DBs, keine beliebigen SQL-Befehle, keine
Schreiboperationen. Separate Server/Zugänge innerhalb einer Projektverbindung
sind nicht Bestandteil dieses Formats.
Angepasst: SQL-Auflösung und Metadaten-Caches, Werkzeug-/Capture-/Zustandsschemata,
Belegherkunft, DB-Beziehungsanalyse über mehrere Schemas in einer lesenden
Repeatable-Read-Transaktion, Entity-Namenskataloge, Enrichment-Tabellenvergleiche,
Toolhinweise und vollständige Bereitschaftsprüfung. Vcore-Mehrdatenbankvorlage,
README, Projektanleitung und gebündelte Webanleitung aktualisiert.
## Erfolgreiche lokale Prüfungen
```text
python -m pytest tests/test_multidatabase_research.py tests/test_context_expansion.py tests/test_entity_resolution.py tests/test_database_fact_grounding.py tests/test_deployment.py tests/test_user_manual.py -q
84 passed, 1 dependency deprecation warning
```
Davon 14 Mehrdatenbank-Testfälle einschließlich parametrisierter Ablehnungen.
Reale SQL-Selektion über getrennte SQLite-Attach-Schemas; MariaDB-Metadaten und
Transaktionsbefehle werden im Adapter simuliert. Geprüft: identische Tabellennamen
mit verschiedenen Daten, korrekte Schema-/Belegzuordnung, Abweisung nicht
freigegebener Schemas vor Verbindungsaufbau, Wildcard-Entdeckung und Pagination,
keine Auth-/Charakter-/Locale-Reference-Freigabe, gemeinsame Beziehungstransaktion,
Entity-/Enrichment-Regeln, vollständige Paket-/Evidence-Validierung,
Mehrdatenbank-Startprüfung mit fehlender DB und vollständige Vcore-Vorlage.
Keine Secrets oder Host-/Benutzerdaten im Provider-Binding.
## Bekannte Fehler und Gegenprobe
Zusätzlicher Lauf über Mehrdatenbank-, Relations-, ProjectResearch-, Deployment-
und Manual-Tests vor dem letzten Vorlagentest: 73 bestanden, 1 übersprungen,
5 fehlgeschlagen. Dieselben fünf Tests scheitern auch am unveränderten HEAD
9408af5 (52 bestanden, 1 übersprungen). Die Gegenprobe nutzte eine temporäre
separate Baseline-Sourcekopie; Arbeitsdateien wurden nicht zurückgesetzt.
- test_database_relations.py::test_hybrid_loop_uses_relations_with_fixture_llm_and_can_resume
- test_project_research.py::test_model_loop_uses_project_tools_and_compact_previous_result[True]
- test_project_research.py::test_model_loop_uses_project_tools_and_compact_previous_result[unknown]
- test_project_research.py::test_model_loop_uses_project_tools_and_compact_previous_result[upgrade]
- test_project_research.py::test_started_project_loop_keeps_legacy_prompt_protocol
Diese Alt-Schleifenprüfungen bleiben offen. Die harte Promptgrenze wurde nicht
gelockert; es wurden keine Budgets zurückgesetzt oder Tests zum Bestehen verändert.
## Messumfang und Grenzen
Alle Modellantworten in lokalen Workflow-Fixtures sind Testdoubles; keine echten
LLM-Anfragen. Keine Cloudkosten oder neuen Token-/Cache-Abrechnungsmetriken.
Keine Verbindung zum VPS, keine Änderungen an echten DBs, kein Dienststart.
Echte MariaDB-Verbindungen und Serverrechte werden nach dem Update mit der
Bereitschaftsprüfung auf dem Zielserver kontrolliert.
Vor Testbeginn Trials 16.939.129.428 Bytes, Fehlerkorpus 2.471.041 Bytes und kleine
pytest-Temporärdaten. Nur kleine Source-/SQL-/Case-Fixtures; das 20-GB-Budget bleibt
eingehalten. Temporäre Baseline-Kopie nach Gegenprobe entfernt. Keine vollständigen
LLM-Testarchive angelegt. `docs/architecture.md` unverändert, kein Push.
@@ -0,0 +1,28 @@
# Betriebssteuerung – 2026-10-22
Umfang: projektbezogene Pause neuer Analysen mit Weiterlauf begonnener Fälle,
dauerhafte Fortsetzungsaufträge zwischen Worker-Dispatches, getrennte Betreiberrechte,
Aktionsprotokoll sowie Workerstatus im Web. Keine Prompt-/Budgetänderungen und keine
Änderung der maßgeblichen Designspezifikation.
Eingaben: künstliche CaseBuilder-/Worker-/API-Fixtures und synthetischer UI-Server.
Keine kostenpflichtigen Modellanfragen und keine externen DB-/Repositoryzugriffe.
Prüfungen:
- Worker-, Dashboard-, vcore-Intake- und API-Tests: 50 bestanden.
- Persistente Pause nach Worker-Neustart, Weiterlauf begonnener Fälle,
aktuelle und veraltete Freigaben, Ablehnung von Modellvalidierungssperren.
- API-Berechtigungen, Projektscope, Protokollsichtbarkeit, ungültige/zu große
Steuerungsnachrichten und unbekannte Zusatzfelder.
- Echter Headless-Edge: Pause/Wiederzulassung, Aktionsprotokoll, Desktop/Mobil,
bisherige Diagramme/Falldetails/Abmeldung: PASS, keine Browserfehler/Überläufe.
Nach zwei Fehlern beim Test-Abschluss wurde geordnetes Browserbeenden ergänzt;
der abschließende Test endet erfolgreich einschließlich temporärer Bereinigung.
- Test-Workspaces vor Testbeginn: 16.939.129.428 Bytes; zusammen mit kleinen
Fixtures und temporären lokalen Tests unter 20 GB. Keine alten Akten gelöscht.
Grenzen: HTTP-Annahme bleibt während einer Pause verfügbar; Pause stoppt neue
Analysen an Worker-Grenzen, keine laufenden Modellaufrufe. Freigaben sind asynchron
und können bei erneuter Prüfung abgelehnt werden. Ein gestoppter Worker wird durch
die Weboberfläche nicht als Betriebssystemdienst gestartet.
@@ -0,0 +1,15 @@
# Explicit technical recovery for exhausted entity resolution
The authenticated case detail page now supports a reasoned approval for one additional response attempt after a software correction. Scope is currently `BLOCKED / ENTITY_RESOLUTION_INVALID_RESPONSES` during interpretation, covering plan and assessment. Other technical scheduling failures retain their existing retry paths; human feedback, completed results, final review and investigation-budget limits cannot be bypassed by this action.
Approval is durable intent, not a model call. The worker holds its regular case lock, rechecks generation and project configuration binding, verifies exhaustion and appends a manual-resume checkpoint. Actor, reason, rejected-call hash, implementation hash, stage and next attempt number are recorded. Previous attempts, revision, captures and accepted plan are retained. The entity resolver verifies these grants on replay and extends only that stage's attempt range, by one per explicit grant. A rejected extra attempt blocks again. Grants are not automatic and repeated same-generation web submissions are deduplicated or rejected as stale.
Validation on 2026-10-24, no paid model calls:
- 99 passed across operator repair, worker, entity resolution, lifecycle checkpoints and manual packaging tests.
- 25 passed in the dashboard/operator repair test invocation (includes tests overlapping the previous invocation).
- Node syntax check and real Edge browser smoke passed, including rendering and dispatch arguments of the repair control; no console errors.
- Manual package --check passed after rebuilding the bundled user manual.
- An unchanged copy of the operator-supplied BUG-000021 revision-2 ZIP was admitted into a temporary workspace and explicitly approved. It transitioned to INTERPRETING_CASE in the same revision without a model call. Original file and VPS data were not modified. The stale source generation was correctly rejected first, then the current imported generation was used.
Tests cover one-attempt exhaustion, second explicit grant, plan/capture reuse for assessment recovery, original-byte preservation, portable replay without another model call, CSRF, machine-client rejection, stale generation, invalid reason, queue deduplication and rejection of unrelated block reasons. Native Linux field verification remains pending deployment. No architecture specification was changed.
@@ -0,0 +1,31 @@
# Bearbeitete Fälle – 2026-10-23
Die bisher auf terminale Zustände begrenzte Ergebnisliste wurde zur gemeinsamen
Liste bearbeiteter Fälle erweitert: OUTPUT_READY, HANDED_OFF, BLOCKED und
FAILED_TO_ANALYZE. Filter für Kategorie und Projekt, Suche, Sortierung nach
Aktualisierung/Kategorie/Projekt, Routingziel, Sperrgrund und linksseitiger
Iconknopf für den bestehenden projektgeschützten Paketdownload. Blockierte Fälle
werden nicht als abgeschlossen gewertet. Analysezustände/Budgets unverändert.
Lokale Validierung:
```text
python -m pytest tests/test_dashboard.py tests/test_web_auth.py tests/test_human_web.py -q
26 passed, 1 dependency deprecation warning
node tests/dashboard-smoke.mjs
PASS, console_errors: [], overflow: false
node --check src/viano_bug_analysis/web/case-detail.js
PASS
```
Echter Browser prüft die gemeinsame Liste mit Ergebnis und blockiertem Fall,
linken Downloadknöpfen, Testerantwort-Hinweis, Kategorieauswahl und bestehender
Detailnavigation. Mobile Ansicht visuell kontrolliert. Bestehende Tests sichern
projektgebundene Detail-/ZIP-Endpunkte und Anmeldung ab. Routingdaten werden aus
dem Manifest übernommen; die Liste scannt nicht separat das Exportverzeichnis.
Vor Testbeginn Trials 16.939.129.428 Bytes, Fehlerkorpus 2.471.041 Bytes, nur kleine
temporäre UI-/Unit-Testdaten. Entwicklungsbudget weiterhin unter 20 GB.
Keine LLM-/Cloud-Aufrufe oder Kosten, keine Änderung produktiver Fälle/Dienste.
Serverinstallation, HTTPS, echter automatischer Feldtest und Betriebssicherung
bleiben Aufgaben auf dem Linux-Zielsystem.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,215 @@
{
"case_id": "BUG-000021",
"snapshot": "review-after-entity-origin-fix",
"result": "FAILED_TO_ANALYZE",
"reason": "MODEL_VALIDATION_EXHAUSTED",
"recorded_at": "2026-10-24",
"scope": "Six saved model calls; no new model calls executed.",
"calls": [
{
"ref": "llm/operations/6c0a36f93bea41b87241a30769bd92bb6dc387869b46d3a013ec595cfc7e9548/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "022e6abbf1925482838f81b0a3a8b1994afe54835adc0510707b4a3ea00a88be",
"phase": "INTERPRETING_CASE",
"usage_classification": "regular",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 71.552436,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.00036,
"input_tokens": 15626,
"model": "gemini-3.8-flash",
"output_tokens": 96,
"prompt_version": "entity_resolution_plan_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": null
}
},
{
"ref": "llm/operations/88adb8b462df2c473b421f617b086dd65351532f28e81a7a1052e1f24da87dea/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "aae23ca176952a643fb9a5f5546e2f003eb0af9ce1fe1b3c6e3d4d4a852ad33f",
"phase": "INTERPRETING_CASE",
"usage_classification": "regular",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 4.446479,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.00103875,
"input_tokens": 18384,
"model": "gemini-3.8-flash",
"output_tokens": 277,
"prompt_version": "entity_resolution_assessment_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": null
}
},
{
"ref": "llm/operations/e6f77de4396a9e8dc19665d9f8d2544febad7983aa8797141760c691a4d2a805/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "3715adca8b12d9e2b10dedc65c07ff801e659bda71db5042fc6e63b0e9420e8b",
"phase": "INTERPRETING_CASE",
"usage_classification": "correction",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": 16341,
"cost_currency": "USD",
"elapsed_seconds": 4.580154,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": 0.001225575,
"estimated_cost": 0.004089074999999999,
"estimated_input_cost": 0.00160725,
"estimated_output_cost": 0.00125625,
"input_tokens": 18484,
"model": "gemini-3.8-flash",
"output_tokens": 335,
"prompt_version": "entity_resolution_assessment_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": 2143
}
},
{
"ref": "llm/operations/572afccbd4034d9c5485045cb4bb28b61fdd40e48dc8a3fab67cfc17e997cd65/round-0001.json",
"status": "ERROR",
"code": "LLM_RESPONSE",
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "33b5c731797a37d26f4d7ac5f560573bbe576db6a51fe099f2952ecfa818c33f",
"phase": "INTERPRETING_CASE",
"usage_classification": "correction",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 26.257507,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.01366875,
"input_tokens": 11100,
"model": "gemini-3.8-flash",
"output_tokens": 3645,
"prompt_version": "case_plan_v2",
"provider": "gemini-native",
"reasoning_tokens": 783,
"uncached_input_tokens": null
}
},
{
"ref": "llm/operations/cdf9fddd5dccf7c6859f2f4c5aa65e46778d6d44e446cf579fa6b6f462b9b85c/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "6038fe06c92ae2093575231052b86d9c9336db7c1be6db290d38788247acac54",
"phase": "INTERPRETING_CASE",
"usage_classification": "correction",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 36.071492,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.02161875,
"input_tokens": 10867,
"model": "gemini-3.8-flash",
"output_tokens": 5765,
"prompt_version": "case_plan_interpretation_repair_v1",
"provider": "gemini-native",
"reasoning_tokens": 4138,
"uncached_input_tokens": null
}
},
{
"ref": "llm/operations/2cd36795173fe6d547ff571814187ce1b12b3a7ec6accd4ebd98869d46bad248/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "6ca3f5d95072a753690196dd2bb30d8df60d75c6d3fc41c64f4e736dfb64ab18",
"phase": "INTERPRETING_CASE",
"usage_classification": "correction",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 24.407819,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.01227,
"input_tokens": 4163,
"model": "gemini-3.8-flash",
"output_tokens": 3272,
"prompt_version": "case_plan_statement_repair_v2",
"provider": "gemini-native",
"reasoning_tokens": 3064,
"uncached_input_tokens": null
}
}
],
"pricing": {
"currency": "USD",
"source": "saved per-call metadata; local example rates match output 3.75/million, input .75/million, cache .075/million",
"captured_at": "2026-10-24",
"storage": "saved estimate 0; no additional estimate"
},
"totals": {
"input_tokens": 78624,
"output_tokens_including_reasoning": 13390,
"reasoning_tokens": "unknown for entity resolution calls; included in output_tokens elsewhere"
},
"account_balance_eur": "unknown",
"clean_accounting_note": "First case_plan_v2 call mislabeled correction; count as regular. Entity assessment second attempt and two interpretation repair calls are corrections.",
"limitations": [
"Cache token counts missing for five calls; exact total USD estimate unknown.",
"Reasoning must not be added again to output_tokens."
],
"total_balance": {
"input_tokens": 78624,
"output_tokens_including_reasoning": 13390,
"model_seconds": 167.315887,
"estimated_usd": "unknown (missing cache accounting)",
"per_call_output_cost_usd": 0.05021250000000001
},
"clean_balance": {
"input_tokens": 45110,
"output_tokens_including_reasoning": 4018,
"model_seconds": 102.256422,
"estimated_usd": "unknown (missing cache accounting)",
"per_call_output_cost_usd": 0.015067500000000001
}
}
@@ -0,0 +1,150 @@
{
"case_id": "BUG-000021",
"analysis_revision": 2,
"package_generation": 43,
"result": "BLOCKED",
"reason": "ENTITY_RESOLUTION_INVALID_RESPONSES",
"recorded_at": "2026-10-24",
"scope": "revision 2 only; prior snapshot retained separately",
"calls": [
{
"ref": "llm/operations/2bb2e79d81a427683503f00f994096c132e4f3996cdd1250c22e65b5345c47b6/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "498c1d4c6fb3b32da6ad143ba78b533fbc7ce88d84ed656a26c659ede7a60e40",
"classification": "regular",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 2.865948,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.00043875,
"input_tokens": 20304,
"model": "gemini-3.8-flash",
"output_tokens": 117,
"prompt_version": "entity_resolution_plan_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": null
}
},
{
"ref": "llm/operations/f3b089e666430152253a7ad993e97035bd9b964286b1fad8fdc3df2f1815ee5c/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "a88dd9acdf5bdbfac2eb2f6962d20e77dcaa6c6e52d23ba2cc180bc11e1cc950",
"classification": "regular",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": null,
"cost_currency": "USD",
"elapsed_seconds": 3.737966,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": null,
"estimated_cost": null,
"estimated_input_cost": null,
"estimated_output_cost": 0.0010275,
"input_tokens": 23234,
"model": "gemini-3.8-flash",
"output_tokens": 274,
"prompt_version": "entity_resolution_assessment_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": null
}
},
{
"ref": "llm/operations/3237e2db6c942e4528382afeac6bbed66cce7c9b54a7c2bc8e1601ca9943b1fb/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "aa49d9159ae7894e5870e2c0abebded94877f7f23201741c79066ab37264d623",
"classification": "correction",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": 21188,
"cost_currency": "USD",
"elapsed_seconds": 5.118418,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": 0.0015891,
"estimated_cost": 0.0042336000000000006,
"estimated_input_cost": 0.0016095,
"estimated_output_cost": 0.001035,
"input_tokens": 23334,
"model": "gemini-3.8-flash",
"output_tokens": 276,
"prompt_version": "entity_resolution_assessment_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": 2146
}
},
{
"ref": "llm/operations/4a0ee3b438d0e207215ca4a5dddc522f11ac8756afc4a85b3c768815a69ce1e8/round-0001.json",
"status": "SUCCESS",
"code": null,
"config_hash": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_hash": "61283f3a158d361b9842431609b826d5d353b3de4c7b387a8d97fb413791d234",
"classification": "correction",
"metadata": {
"attempts": [],
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"cached_input_tokens": 21288,
"cost_currency": "USD",
"elapsed_seconds": 5.437512,
"estimated_cache_storage_cost": 0.0,
"estimated_cached_input_cost": 0.0015965999999999999,
"estimated_cost": 0.0043251,
"estimated_input_cost": 0.00153225,
"estimated_output_cost": 0.00119625,
"input_tokens": 23331,
"model": "gemini-3.8-flash",
"output_tokens": 319,
"prompt_version": "entity_resolution_assessment_v4",
"provider": "gemini-native",
"reasoning_tokens": null,
"uncached_input_tokens": 2043
}
}
],
"model": "gemini-3.8-flash",
"pricing": {
"currency": "USD",
"input_per_million": 0.75,
"cached_input_per_million": 0.075,
"output_including_reasoning_per_million": 3.75,
"source": "saved API metadata and configured pricing",
"captured_at": "2026-10-24"
},
"account_balance_eur": "unknown",
"limits": "Missing cache counts are unknown, not zero; reasoning tokens unknown and not added to output. No new cloud calls.",
"total": {
"input_tokens": 90203,
"output_tokens": 986,
"model_seconds": 17.159844,
"estimated_cost_usd": "unknown (missing cache counts)",
"output_cost_usd": 0.0036975000000000003,
"cache_storage_cost_usd": 0.0
},
"clean": {
"input_tokens": 43538,
"output_tokens": 391,
"model_seconds": 6.603914,
"estimated_cost_usd": "unknown (missing cache counts)",
"output_cost_usd": 0.00146625,
"cache_storage_cost_usd": 0.0
}
}
@@ -0,0 +1,659 @@
{
"case_id": "BUG-000022",
"package_id": "api-04e95ed5d97043810706ce14ff101d8cb29778bea835bd190d7bbdb5578e9f8b",
"analysis_revision": 1,
"package_generation": 79,
"result": "OUTPUT_READY",
"recorded_at": "2026-10-25",
"scope": "Final package, all recorded model calls once; joint interpretation/hypotheses counted once. No new model calls.",
"package_sha256": "33ad04882c92fdb37df9a6452b7bb30243a511cd28ea55b36a4d6600aeff8361",
"pricing": {
"model": "gemini-3.8-flash",
"currency": "USD",
"recorded_at": "2026-10-25",
"source": "saved per-call cost metadata and configured table, not independently refreshed vendor pricing",
"input_per_million": 0.75,
"cache_input_per_million": 0.075,
"output_including_reasoning_per_million": 3.75,
"storage": "reported estimates separately; missing values unknown"
},
"classification_note": "First case_plan_v2 response is a regular joint call despite recorded correction label. Additional startup response is excluded from clean balance.",
"account_balance_eur": null,
"account_status": "No EUR balance supplied",
"runtime": {
"first_revision_checkpoint": "2026-10-24T22:14:39.788595+00:00",
"completed": "2026-10-24T22:21:56.220461+00:00",
"checkpoint_span_seconds": 436.431866,
"note": "Checkpoint span includes local overhead, excludes any unrecorded queue waiting before first revision checkpoint"
},
"total": {
"calls": 13,
"measured_calls": 13,
"input_tokens": 106317,
"output_including_reasoning_tokens": 21897,
"known_reasoning_tokens": 13570,
"reasoning_complete": false,
"known_cached_input_tokens": 16697,
"cache_complete": false,
"model_seconds_known_sum": 207.939304,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.09486990000000001,
0.15058102499999998
],
"cache_storage_cost_usd": null,
"phases": {
"diagnosis": {
"calls": 1,
"measured_calls": 1,
"input_tokens": 20463,
"output_including_reasoning_tokens": 2188,
"known_reasoning_tokens": 1013,
"reasoning_complete": true,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 14.279153,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.009739725000000001,
0.02355225
],
"cache_storage_cost_usd": 0.0
},
"entity_resolution": {
"calls": 2,
"measured_calls": 2,
"input_tokens": 32272,
"output_including_reasoning_tokens": 422,
"known_reasoning_tokens": 0,
"reasoning_complete": false,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 42.703409,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.004002899999999999,
0.0257865
],
"cache_storage_cost_usd": 0.0
},
"handoff": {
"calls": 1,
"measured_calls": 1,
"input_tokens": 15533,
"output_including_reasoning_tokens": 4177,
"known_reasoning_tokens": 2808,
"reasoning_complete": true,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 28.14314,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.016828725,
0.0273135
],
"cache_storage_cost_usd": 0.0
},
"investigation": {
"calls": 8,
"measured_calls": 8,
"input_tokens": 27305,
"output_including_reasoning_tokens": 10148,
"known_reasoning_tokens": 8480,
"reasoning_complete": true,
"known_cached_input_tokens": 16697,
"cache_complete": false,
"model_seconds_known_sum": 90.76304400000001,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.04488525,
0.04726327500000001
],
"cache_storage_cost_usd": null
},
"joint_planning": {
"calls": 1,
"measured_calls": 1,
"input_tokens": 10744,
"output_including_reasoning_tokens": 4962,
"known_reasoning_tokens": 1269,
"reasoning_complete": true,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 32.050558,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.019413299999999998,
0.026665499999999998
],
"cache_storage_cost_usd": 0.0
}
}
},
"clean": {
"calls": 12,
"measured_calls": 12,
"input_tokens": 102446,
"output_including_reasoning_tokens": 21265,
"known_reasoning_tokens": 13030,
"reasoning_complete": false,
"known_cached_input_tokens": 14256,
"cache_complete": false,
"model_seconds_known_sum": 200.814912,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.091244325,
0.14695545
],
"cache_storage_cost_usd": null,
"phases": {
"diagnosis": {
"calls": 1,
"measured_calls": 1,
"input_tokens": 20463,
"output_including_reasoning_tokens": 2188,
"known_reasoning_tokens": 1013,
"reasoning_complete": true,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 14.279153,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.009739725000000001,
0.02355225
],
"cache_storage_cost_usd": 0.0
},
"entity_resolution": {
"calls": 2,
"measured_calls": 2,
"input_tokens": 32272,
"output_including_reasoning_tokens": 422,
"known_reasoning_tokens": 0,
"reasoning_complete": false,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 42.703409,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.004002899999999999,
0.0257865
],
"cache_storage_cost_usd": 0.0
},
"handoff": {
"calls": 1,
"measured_calls": 1,
"input_tokens": 15533,
"output_including_reasoning_tokens": 4177,
"known_reasoning_tokens": 2808,
"reasoning_complete": true,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 28.14314,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.016828725,
0.0273135
],
"cache_storage_cost_usd": 0.0
},
"investigation": {
"calls": 7,
"measured_calls": 7,
"input_tokens": 23434,
"output_including_reasoning_tokens": 9516,
"known_reasoning_tokens": 7940,
"reasoning_complete": true,
"known_cached_input_tokens": 14256,
"cache_complete": false,
"model_seconds_known_sum": 83.63865200000001,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.041259675,
0.0436377
],
"cache_storage_cost_usd": null
},
"joint_planning": {
"calls": 1,
"measured_calls": 1,
"input_tokens": 10744,
"output_including_reasoning_tokens": 4962,
"known_reasoning_tokens": 1269,
"reasoning_complete": true,
"known_cached_input_tokens": 0,
"cache_complete": false,
"model_seconds_known_sum": 32.050558,
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.019413299999999998,
0.026665499999999998
],
"cache_storage_cost_usd": 0.0
}
}
},
"calls": [
{
"ref": "llm/operations/6c0a36f93bea41b87241a30769bd92bb6dc387869b46d3a013ec595cfc7e9548/round-0001.json",
"operation_key": "6c0a36f93bea41b87241a30769bd92bb6dc387869b46d3a013ec595cfc7e9548",
"analysis_revision": 1,
"phase": "entity_resolution",
"prompt_version": "entity_resolution_plan_v4",
"prompt_characters": 33168,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "645b6f2cd80737162108331f6fcf9da8f086dcacc635b3375dec16c9c71e7f4b",
"usage": {
"input_tokens": 14980,
"output_tokens": 112,
"reasoning_tokens": null,
"cached_input_tokens": null,
"uncached_input_tokens": null,
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"elapsed_seconds": 39.040508
},
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.0015435,
0.011655
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/88adb8b462df2c473b421f617b086dd65351532f28e81a7a1052e1f24da87dea/round-0001.json",
"operation_key": "88adb8b462df2c473b421f617b086dd65351532f28e81a7a1052e1f24da87dea",
"analysis_revision": 1,
"phase": "entity_resolution",
"prompt_version": "entity_resolution_assessment_v4",
"prompt_characters": 39785,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "94cd9df23849fa4c59d4cfa06b69627c3978b912ba16d6c90a7bafa1ff84d5ae",
"usage": {
"input_tokens": 17292,
"output_tokens": 310,
"reasoning_tokens": null,
"cached_input_tokens": null,
"uncached_input_tokens": null,
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"elapsed_seconds": 3.662901
},
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.0024593999999999996,
0.0141315
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/572afccbd4034d9c5485045cb4bb28b61fdd40e48dc8a3fab67cfc17e997cd65/round-0001.json",
"operation_key": "572afccbd4034d9c5485045cb4bb28b61fdd40e48dc8a3fab67cfc17e997cd65",
"analysis_revision": 1,
"phase": "joint_planning",
"prompt_version": "case_plan_v2",
"prompt_characters": 33025,
"classification": "regular",
"recorded_classification": "correction",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "1fcd4e5cad0b961240236d3146b60de4a07bc14962e98626b020b457bafc2fdf",
"usage": {
"input_tokens": 10744,
"output_tokens": 4962,
"reasoning_tokens": 1269,
"cached_input_tokens": null,
"uncached_input_tokens": null,
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"elapsed_seconds": 32.050558
},
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.019413299999999998,
0.026665499999999998
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/ca6cdff81de3cbae0492c26802daa287b11bf4fabefad477a9b64f1b6db30112/round-0001.json",
"operation_key": "ca6cdff81de3cbae0492c26802daa287b11bf4fabefad477a9b64f1b6db30112",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_startup_v1",
"prompt_characters": 8865,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "18e1a41a7c31942556f3c7be6793762fce43220ee6d671866c0d14d6dc2f3c7f",
"usage": {
"input_tokens": 2697,
"output_tokens": 393,
"reasoning_tokens": 318,
"cached_input_tokens": 2441,
"uncached_input_tokens": 256,
"cache_created_tokens": 2441,
"cache_status": "created",
"elapsed_seconds": 5.655612
},
"estimated_cost_usd": 0.0018488250000000001,
"known_usage_usd_range": [
0.0018488250000000001,
0.0018488250000000001
],
"cache_storage_cost_usd": 0.00010170833333333334
},
{
"ref": "llm/operations/54ef9b495e10fe49a2ffad5909c89efdbfe7ef7645b601865d12e5223b0651bc/round-0001.json",
"operation_key": "54ef9b495e10fe49a2ffad5909c89efdbfe7ef7645b601865d12e5223b0651bc",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_startup_v1",
"prompt_characters": 11950,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "6269dd79ef6176ff248f206613f469444e080b423cc8b7a12518f9701f61ac60",
"usage": {
"input_tokens": 3566,
"output_tokens": 677,
"reasoning_tokens": 561,
"cached_input_tokens": 2441,
"uncached_input_tokens": 1125,
"cache_created_tokens": 0,
"cache_status": "reused",
"elapsed_seconds": 6.459214
},
"estimated_cost_usd": 0.0035655750000000005,
"known_usage_usd_range": [
0.0035655750000000005,
0.0035655750000000005
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/71fd77c20b2af9bb2d842927c7a39cc8e1cf3e15541f387a26054fdb1047b6e4/round-0001.json",
"operation_key": "71fd77c20b2af9bb2d842927c7a39cc8e1cf3e15541f387a26054fdb1047b6e4",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_startup_v1",
"prompt_characters": 12860,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "3ab7cf7e3d8e9137d558c15c2012140f0a0618f8cca9e70f7eef14bf02bb0c41",
"usage": {
"input_tokens": 3815,
"output_tokens": 1001,
"reasoning_tokens": 875,
"cached_input_tokens": 2441,
"uncached_input_tokens": 1374,
"cache_created_tokens": 0,
"cache_status": "reused",
"elapsed_seconds": 7.44411
},
"estimated_cost_usd": 0.004967325,
"known_usage_usd_range": [
0.004967325,
0.004967325
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/7b8f9ecd4cb00c7e1d8bbd314d9ab15032701b76a0d5cf4256b895312b03a731/round-0001.json",
"operation_key": "7b8f9ecd4cb00c7e1d8bbd314d9ab15032701b76a0d5cf4256b895312b03a731",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_startup_v1",
"prompt_characters": 13098,
"classification": "correction",
"recorded_classification": "correction",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "747d5e6358c32804aabf93970ef4889b3c1bc809254b05efaeb0df61e1897873",
"usage": {
"input_tokens": 3871,
"output_tokens": 632,
"reasoning_tokens": 540,
"cached_input_tokens": 2441,
"uncached_input_tokens": 1430,
"cache_created_tokens": 0,
"cache_status": "reused",
"elapsed_seconds": 7.124392
},
"estimated_cost_usd": 0.0036255750000000002,
"known_usage_usd_range": [
0.0036255750000000002,
0.0036255750000000002
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/0270771b422d04af95c20c38558476ded170af96569985af436406482d8d043a/round-0001.json",
"operation_key": "0270771b422d04af95c20c38558476ded170af96569985af436406482d8d043a",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_startup_review_v1",
"prompt_characters": 11179,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "7a6617bb65d9d93dd8aa0e8d85b629909e7fb9c39ec270db8bb60511e3dde5b7",
"usage": {
"input_tokens": 3523,
"output_tokens": 2374,
"reasoning_tokens": 1773,
"cached_input_tokens": null,
"uncached_input_tokens": null,
"cache_created_tokens": null,
"cache_status": "fallback",
"elapsed_seconds": 18.078619
},
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.009166725,
0.011544750000000001
],
"cache_storage_cost_usd": null
},
{
"ref": "llm/operations/c619a7901de5674f54e8371759fba596a7746f67d62499b198780813912e5c31/round-0001.json",
"operation_key": "c619a7901de5674f54e8371759fba596a7746f67d62499b198780813912e5c31",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_project_v20",
"prompt_characters": 9612,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "f50e57d329485610f657a47c5d22a120750a049ed368a54ba6127671ad942b09",
"usage": {
"input_tokens": 2920,
"output_tokens": 291,
"reasoning_tokens": 198,
"cached_input_tokens": 2497,
"uncached_input_tokens": 423,
"cache_created_tokens": 2497,
"cache_status": "created",
"elapsed_seconds": 4.425924
},
"estimated_cost_usd": 0.001595775,
"known_usage_usd_range": [
0.001595775,
0.001595775
],
"cache_storage_cost_usd": 0.00010404166666666667
},
{
"ref": "llm/operations/b5de43dfa1122fa86decfa538047ee813621839eded8ed15356d341f3439e383/round-0001.json",
"operation_key": "b5de43dfa1122fa86decfa538047ee813621839eded8ed15356d341f3439e383",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_project_v20",
"prompt_characters": 12076,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "7cb224250e9557d857268001dcecf8cba78fa509081eb13e8fe22ed66a85379f",
"usage": {
"input_tokens": 4132,
"output_tokens": 624,
"reasoning_tokens": 321,
"cached_input_tokens": 2511,
"uncached_input_tokens": 1621,
"cache_created_tokens": 2511,
"cache_status": "created",
"elapsed_seconds": 7.509486
},
"estimated_cost_usd": 0.0037440750000000004,
"known_usage_usd_range": [
0.0037440750000000004,
0.0037440750000000004
],
"cache_storage_cost_usd": 0.00010462500000000001
},
{
"ref": "llm/operations/6849c974442865e5ca3e8f146ef4520c275f2e37a25fb7caa498f17089ed6dc1/round-0001.json",
"operation_key": "6849c974442865e5ca3e8f146ef4520c275f2e37a25fb7caa498f17089ed6dc1",
"analysis_revision": 1,
"phase": "investigation",
"prompt_version": "investigation_project_v20",
"prompt_characters": 9159,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "ff3c7cd769e708cf4cb6ff98e19f2aff507841f046e9d178076c1b6339102560",
"usage": {
"input_tokens": 2781,
"output_tokens": 4156,
"reasoning_tokens": 3894,
"cached_input_tokens": 1925,
"uncached_input_tokens": 856,
"cache_created_tokens": 1925,
"cache_status": "created",
"elapsed_seconds": 34.065687
},
"estimated_cost_usd": 0.016371375,
"known_usage_usd_range": [
0.016371375,
0.016371375
],
"cache_storage_cost_usd": 8.020833333333334e-05
},
{
"ref": "llm/operations/25febf70da1f602d9d830c61c59e4504617a8bc05beac14676d7fd4e9f6589f9/round-0001.json",
"operation_key": "25febf70da1f602d9d830c61c59e4504617a8bc05beac14676d7fd4e9f6589f9",
"analysis_revision": 1,
"phase": "diagnosis",
"prompt_version": "diagnosis_early_v8",
"prompt_characters": 48403,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "569ec836dd3f078c5cbaf46f32f3ea5e330654219f6723a691133b1e732e746f",
"usage": {
"input_tokens": 20463,
"output_tokens": 2188,
"reasoning_tokens": 1013,
"cached_input_tokens": null,
"uncached_input_tokens": null,
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"elapsed_seconds": 14.279153
},
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.009739725000000001,
0.02355225
],
"cache_storage_cost_usd": 0.0
},
{
"ref": "llm/operations/17917402a4c04bd20ae3b0a2f256a39daa4c026a0afff5f80f408478611d4e03/round-0001.json",
"operation_key": "17917402a4c04bd20ae3b0a2f256a39daa4c026a0afff5f80f408478611d4e03",
"analysis_revision": 1,
"phase": "handoff",
"prompt_version": "change_request_v9",
"prompt_characters": 38336,
"classification": "regular",
"recorded_classification": "regular",
"status": "SUCCESS",
"code": null,
"model": "gemini-3.8-flash",
"provider": "gemini-native",
"config_sha256": "692c8bac07efc915a44a89ac8aab1b4caa3e125d8619745f13c2ee570cb9e054",
"request_sha256": "aaf9aaa1e371329ba165cd131f64e4cd574e98ef55e129defe9ecf0cee055342",
"usage": {
"input_tokens": 15533,
"output_tokens": 4177,
"reasoning_tokens": 2808,
"cached_input_tokens": null,
"uncached_input_tokens": null,
"cache_created_tokens": 0,
"cache_status": "below_minimum",
"elapsed_seconds": 28.14314
},
"estimated_cost_usd": null,
"known_usage_usd_range": [
0.016828725,
0.0273135
],
"cache_storage_cost_usd": 0.0
}
],
"limitations": [
"Unknown cached_input_tokens are not zero. USD bounds apply to known usage and exclude unreported charges.",
"Output includes thinking; known reasoning tokens must not be added again."
]
}
+42
View File
@@ -0,0 +1,42 @@
# Benutzeranleitung in der Weboberfläche – 2026-10-23
Ergebnis: eigener Reiter mit neun Dokumenten, Inhaltsverzeichnis, sicheren
Markdown-Darstellungen und Markdown-Download. Neue allgemeine Bedienungs- und
Funktionsbeschreibung sowie Projekt-/Source-/DB-/Modellkonfigurationsanleitung.
Vorhandene Einrichtungsanleitungen werden eingebunden, nicht als Serverpfade
verlinkt. Die Übergabe-API-Dokumentation wurde an den vorhandenen Webbetrieb
und die Web-Schlüsselverwaltung angepasst.
Quellen: `docs/` und `deploy/capture-worker/README.md`, feste Auswahlliste in
`scripts/build_user_manual.py`. Die generierte Webressource wird mit dem Paket
ausgeliefert. Ein Drift-Test verhindert unbemerkte Abweichungen von den Quellen.
Keine tatsächlichen Konfigurationen oder Zugangsdaten im Bundle.
Validierung:
```text
python -m pytest tests/test_user_manual.py tests/test_dashboard.py tests/test_web_auth.py tests/test_human_web.py -q
28 passed, 1 dependency deprecation warning
node tests/dashboard-smoke.mjs
PASS, console_errors: [], overflow: false
python scripts/build_user_manual.py --check
PASS
node --check src/viano_bug_analysis/web/user-manual.js
PASS
pip wheel --no-deps --no-index --no-build-isolation .
PASS, manual JSON and renderer included in wheel
```
Nach Ergänzung des Pflegehinweises die manuelspezifischen Tests erneut bestanden.
Browser prüft Login, Dokumentauswahl, Inhaltsverzeichnis, Linux-Befehle,
Konfigurationstabellen, HTML-/URL-Abwehr im Renderer und Logout. Desktop- und
Mobilansicht visuell kontrolliert. Quellen bleiben als Markdown separat pflegbar.
Die Darstellung unterstützt die im Korpus verwendeten Überschriften, Listen,
Tabellen, Codeblöcke, Inline-Code, Hervorhebungen und bekannte Dokumentverweise;
beliebiges HTML wird als Text angezeigt.
Vor lokalen Tests Trials 16.939.129.428 Bytes und Fehlerkorpus 2.471.041 Bytes;
kleine temporäre synthetische Testdaten und Paket-Builds halten das Gesamtbudget
weiter unter 20 GB. Temporäres Wheel nach Prüfung entfernt. Keine realen
Fallkopien, Modellanfragen, Cloudkosten, Dienststarts oder Serveränderungen.
`docs/architecture.md` unverändert. Kein Push.
@@ -0,0 +1,29 @@
# Falldetailansicht und Ergebnisliste – 2026-10-21
Ziel: Erhaltene Fallakten direkt im Browser lesen, ohne ZIP-Verzeichnisse manuell
zu durchsuchen. Keine Änderung an Analyseprompts, Budgets oder Architekturvorgaben.
Implementierung: projektgebundene Leseendpunkte, Ergebnisliste mit Suche und
Projektfilter, Revisionsauswahl, Beobachtungen, Interpretation, Hypothesen mit
Confidence und Befunden, Diagnose, Coding-/Human-Dev-Aufträge, API-Patchfeedback,
Sperrhinweise und vollständiger aktueller ZIP-Download.
Prüfungen:
- `pytest tests/test_dashboard.py -q`: 18 bestanden. Authentifizierung, Projektscope,
vollständiger ZIP-Download, ungültige Revisionen und wiedereröffnete Fallakte:
alte Interpretation bleibt in Revision 1, erscheint nicht in Revision 2.
- Zusammen mit `tests/test_worker.py`: 36 Tests bestanden.
- `node tests/dashboard-smoke.mjs`: PASS im echten unsichtbaren Edge-Browser,
Desktop und 390-Pixel-Mobilansicht, keine horizontalen Überläufe oder JS-Fehler.
Ergebnisnavigation, Coding-/Human-Aufträge und Datenentfernung bei Abmeldung.
- Browserdaten sind rein synthetisch; ihre 35.300 Tokens sind Anzeigeprüfwerte,
keine realen Modellmessungen. Keine kostenpflichtigen LLM-/DB-Anfragen.
- Vor Testbeginn: Test-Workspaces 16.939.129.428 Bytes, zusätzlich kleines
Fehlerkorpus und temporäre pytest-Daten deutlich unter 20 GB insgesamt.
Browser-/Server-Testverzeichnisse werden nach geprüften Pfaden entfernt.
Grenzen: Sperrhinweise sind Information, keine administrative Freigabeoberfläche.
Das Belegverzeichnis ersetzt nicht die vollständigen Recherchepayloads im ZIP.
Große Darstellungen werden gekennzeichnet gekürzt; Paketinhalt bleibt unverändert.
Die Revisionshistorie benötigt keine aufbewahrten älteren ZIP-Generationen.
@@ -0,0 +1,19 @@
# Kreisdiagramm über alle Phasen – 2026-10-21
Ausgangspunkt `58fc006`. Die tatsächliche Phasenzuordnung unterstützte bereits den
gesamten Ablauf, aber die erste Browservorschau enthielt ausschließlich synthetische
Untersuchungs-/Korrekturaufrufe. Die Vorschau wurde deshalb um Fallaufbau, Planung,
Hypothesen, Diagnose und Codingauftrag ergänzt. Die Legende zeigt diese Phasen nun
auch dann an, wenn noch kein Verbrauch gemessen wurde; Nullwerte erhalten keinen
erfundenen Flächenanteil.
Der neue Regressionstest prüft die Zuordnung aller sechs LLM-Phasenkategorien sowie
die zusätzliche, disjunkte Korrekturkategorie. Ein gemeinsamer Planungsaufruf wird
weiterhin nicht doppelt gezählt. Die Browserprüfung verlangt jede Phasenkategorie
in der Legende und prüft weiterhin Projektfilter, Mobilansicht und Abmeldung.
Die neue Vorschau enthält 35.300 synthetische Tokens und 22,1 % Korrekturanteil. Ein
abgeschlossener Testfall steuert auch Diagnose und Codingauftrag bei; der noch
laufende Testfall enthält keine erfundenen künftigen Abschlussaufrufe. Dies sind
ausschließlich Testdaten, keine tatsächlichen Providerkosten. Der vorherige
Prüfbericht bleibt als eigener Snapshot erhalten.
+40
View File
@@ -0,0 +1,40 @@
# Benutzeranmeldung und Bearbeitungssperren – 2026-10-22
Umfang: SQLite-Benutzer mit gesalzenen scrypt-Passworthashes, getrennte
Sitzungs-/Sperrendatenbank, Cookie-Anmeldung, CSRF-Schutz, Abmeldung,
Konsolenbefehle create_user/delete_user/change_pw_user mit Privilegienprüfung.
Alle Webbenutzer besitzen dieselben Rechte für alle Projekte. Keine Webverwaltung
von Benutzern und keine Benutzerklassen. Bearer-Tokens bleiben für Maschinen.
Konsole: Linux-root/sudo oder Windows-elevierte Administratorberechtigung.
Die Privilegienprüfung wurde mit simuliertem unprivilegiertem Aufrufer getestet;
Benutzerbefehle mit temporärer Datenbank unter kontrollierter Testfreigabe geprüft.
Keine realen Benutzerkonten wurden im Dienst-Workspace erzeugt.
Prüfungen:
- 57 lokale Tests bestanden: API, Benutzer/Sitzungen, menschliches Feedback,
Dashboard, Worker und vcore-Übergabe.
- Gleichzeitige SQLite-Übernahmen aus zwei Threads/Sitzungen: genau ein Gewinner.
- HTTP: ohne Bearbeiten kein Feedback, fremde Sitzung weder Übernahme noch Antwort.
- Abmeldung/Sitzungsablauf geben Sperren frei; Passwortwechsel/Löschung sperren
alte Sitzungen. Falsches altes Passwort wird abgewiesen.
- Alle Webbenutzer sehen alle Projekte/Betriebsaktionen. Fehlender CSRF-Header
wird abgewiesen; fremder Login-Origin wird abgewiesen; Anmeldeversuche begrenzt.
- Beide Feedbackausgänge bleiben möglich; unveränderte Budgets/Revision und
idempotente Antworten erhalten. Maschinen-Bearer können menschliche Antworten
nicht ohne Benutzersitzung bestätigen.
- Echter Headless-Edge mit ausschließlich temporärem Testbenutzer: normale
Anmeldung, UI-Projektsteuerung, Testanfrage zuerst schreibgeschützt, Bearbeiten
aktiviert Formular, Navigation/Diagramme/Mobilansicht/Logout: PASS.
Keine JS-Ausnahmen. Die erwartete 401 beim Prüfen einer noch nicht bestehenden
Sitzung wird separat vom Browserfehlercheck ausgenommen.
Testdaten vor Ausführung: Trials 16.939.129.428 Bytes, kleine Fixtures/temporäre
Dateien mit Reserve innerhalb des 20-GB-Budgets. Keine kostenpflichtigen
LLM-Anfragen, keine Änderung an Analysebudgets oder der Designspezifikation.
Deployment: Benutzerdatei in root-/administratorgeschütztem Verzeichnis mit
passenden Dienst-Leserechten betreiben; SQLite allein ersetzt keine OS-Dateirechte.
Native Linux-Dateirechte/Chown und reale Windows-UAC/NTFS-ACLs sind beim Server-
Deployment zu prüfen. Die API öffnet die Benutzerdatei ausschließlich read-only.
+103
View File
@@ -0,0 +1,103 @@
# Benutzeranmeldung und exklusive Bearbeitung
Webbenutzer melden sich mit Benutzername und Passwort an. Alle Benutzer besitzen
dieselben Rechte für alle Projekte. Es gibt weder Administrator-/Normalbenutzer-
Klassen noch eine Benutzerverwaltung im Web. Bestehende Bearer-Tokens bleiben
ausschließlich für externe API-Integrationen nutzbar und behalten deren bisherigen
Zugriffsgrenzen. Fehlende Token-Umgebungsvariablen deaktivieren die jeweilige
Integration; die Benutzeranmeldung benötigt keine Token-Zufallsgenerierung.
## Konsolenverwaltung
Die Befehle erfordern unter Linux effektive root-Rechte (normalerweise `sudo`),
unter Windows eine als Administrator gestartete Konsole. Sie benötigen keinen
Gemini-Schlüssel und senden keine Modellanfrage.
```bash
sudo .venv/bin/python -m viano_bug_analysis.api create_user nico --config config/api.local.toml --service-group buganalysis
sudo .venv/bin/python -m viano_bug_analysis.api change_pw_user nico --config config/api.local.toml
sudo .venv/bin/python -m viano_bug_analysis.api delete_user nico --config config/api.local.toml
```
Ohne Passwortargumente erfolgt eine verdeckte Abfrage. Die gewünschten expliziten
Argumentformen funktionieren ebenfalls:
```text
create_user NAME PASSWORT --config DATEI
delete_user NAME --config DATEI
change_pw_user NAME ALTES_PASSWORT NEUES_PASSWORT --config DATEI
```
Explizite Passwortargumente können in Shellhistorie/Prozessargumenten auftauchen;
für den normalen Betrieb die verdeckte Eingabe verwenden. Benutzernamen bestehen
aus 1–64 ASCII-Buchstaben/Ziffern sowie Punkt, Unterstrich oder Bindestrich und
beginnen mit einem Buchstaben oder einer Ziffer. Namen sind nicht case-sensitiv.
Beim Passwortwechsel wird das alte Passwort geprüft. Passwortwechsel und Löschung
entwerten bestehende Sitzungen und deren Bearbeitungssperren ohne API-Neustart.
Windows, in einer Administrator-PowerShell:
```powershell
.\.venv\Scripts\python.exe -m viano_bug_analysis.api create_user nico --config config/api.local.toml
```
## SQLite-Dateien und Betriebssystemrechte
Benutzer liegen standardmäßig in `<workspace>/auth/users.sqlite3`. Die API öffnet
diese Datei ausschließlich schreibgeschützt. Gesalzene scrypt-Hashes ersetzen
Klartextpasswörter. Sitzungen, Anmeldebegrenzung und Bearbeitungssperren liegen
separat in `<workspace>/runtime/web-sessions/sessions.sqlite3`; Sitzungstokens
werden dort nur als Hash gespeichert.
Produktionskonfiguration, Pfade relativ zur Konfigurationsdatei oder absolut:
```toml
[auth]
users_database = "/etc/viano-bug-analysis/users.sqlite3"
session_seconds = 3600
```
Die Benutzerdatei und ihr Verzeichnis gehören unter Linux root; CLI-Befehle setzen
0640 bzw. 0750. `--service-group buganalysis` erlaubt dem dedizierten Dienstkonto
das Lesen, nicht die Benutzerverwaltung. Verzeichnisvorfahren dürfen nicht durch
unvertrauenswürdige Benutzer austauschbar sein. Der Dienst benötigt Schreibzugriff
auf seinen Workspace und die Sitzungsdatei. Unter Windows entsprechend geschützte
NTFS-Verzeichnisse/ACLs verwenden: Administratoren verwalten die Benutzerdatei,
das Dienstkonto liest sie. Die Konsolenbefehle prüfen auch dort die erhöhte
Administratorberechtigung. Wer Programmcode oder Authentifizierungsdateien selbst
ändern kann, liegt außerhalb der Web-Zugriffsgrenze; deshalb sind diese OS-Rechte
Bestandteil der Servereinrichtung.
SQLite benötigt keinen MariaDB-Dienst. Für einen Umzug die Benutzerdatei und die
Projekt-/Workspace-Daten sichern; die separate Sitzungsdatei kann bei gestoppter
API bewusst verworfen werden, um alle Sitzungen und Sperren zu beenden.
## Sitzungen und Bearbeiten
Die Anmeldung erzeugt ein HttpOnly-/SameSite-Strict-Cookie; unter HTTPS zusätzlich
Secure. Entfernte Zugriffe über HTTPS betreiben. Mutationen benötigen den an die
Sitzung gebundenen CSRF-Header. Die Oberfläche hält nur diesen CSRF-Wert im
Arbeitsspeicher; Passwörter und Sitzungstokens werden nicht im Local Storage
gespeichert. Sitzungen gelten standardmäßig absolut eine Stunde, konfigurierbar
von 60 Sekunden bis 24 Stunden; regelmäßige Seitenaktualisierungen verlängern sie
nicht endlos. Nach einem Neuladen kann eine gültige Sitzung weiterverwendet werden.
Anmeldeversuche werden serverseitig zeitlich begrenzt.
Eine Testanfrage lässt sich zunächst nur ansehen. Erst **Bearbeiten** übernimmt
die Anfrage atomar für die aktuelle Sitzung. Eine zweite Sitzung erhält
`API_TEST_CLAIMED` und eine verständliche Meldung. Erst dann erscheinen Eingabefelder.
Die Serverprüfung verlangt dieselbe Sperre auch beim Absenden; das Ausblenden
von Feldern allein ist nicht die Zugriffskontrolle. Identische Wiederholungen
bleiben idempotent, abweichende zweite Antworten werden zurückgewiesen.
**Bearbeitung freigeben**, Abmeldung oder Sitzungsablauf machen die Anfrage für
andere Sitzungen verfügbar. Der Ablauf wird bei erneuter Übernahme anhand der
serverseitigen Zeit geprüft; es wird kein Hintergrundtimer benötigt. Zwei Tabs
derselben Sitzung gehören derselben Bearbeitung; zwei getrennte Sitzungen auch
desselben Benutzers konkurrieren um die Sperre.
HTTP: `POST /v1/auth/login`, `GET /v1/auth/session`, `POST /v1/auth/logout`.
Testübernahme/-freigabe: `POST /v1/test-requests/{key}/claim` mit
`{request_id,analysis_revision,action:"edit"|"release"}`. Feedback bestätigt wie
bisher die konkrete Anfrage und führt dieselbe Analyse-Revision fort. Bearer-
Clients können keine menschliche Bearbeitungssperre oder Antwort übernehmen.
+153
View File
@@ -0,0 +1,153 @@
# BugAnalysis verwenden
BugAnalysis untersucht gemeldete Softwarefehler und übergibt das Ergebnis an die
nächste zuständige Stelle. Das Programm erstellt selbst keinen fertigen Patch
und spielt keine Änderungen auf einen Server ein. Der aktuelle Erfassungsadapter
ist für WoW/Vcore eingerichtet.
## So arbeitet BugAnalysis
1. Eine Person oder ein Erfassungsprogramm übergibt Bugbeschreibung und
Diagnosearchive. Die Übergabe-API erstellt daraus eine Fallakte.
2. Ein dauerhaft laufender Analyseworker übernimmt den Auftrag und lädt die
Konfiguration des angegebenen Projekts.
3. Der Casebuilder wertet Beobachtungen und Telemetrie aus. Er kann relevante
Identitäten mit den konfigurierten Recherchewerkzeugen auflösen.
4. Die LLM interpretiert den Fall und entwickelt Hypothesen. Im gemeinsamen
Planungsmodus geschieht beides in einem Aufruf.
5. Die Untersuchung prüft Hypothesen mit Source- und DB-Werkzeugen. Eine knappe
Untersuchungsschleife sammelt Ergebnisse; Prüfungen und Konsolidierungen
übernehmen diese in den strukturierten Arbeitsstand. Werkzeuge lesen Daten;
Zugangsdaten erhält die LLM nicht.
6. Die finale Diagnose bewertet die Befunde und kann einen Frühabschluss
annehmen oder weitere Untersuchung verlangen. Fachliche Budgets begrenzen
einzelne Untersuchungen. Ein bestimmter Erfolg ist nicht garantiert.
7. Das Ergebnis enthält Diagnose und Aufträge für Codingagent und menschlichen
Entwickler. Ein Auftrag darf ausdrücklich erklären, dass keine Arbeit für
den jeweiligen Empfänger vorliegt. Fehlende Daten zur Umsetzung können als
menschlicher Auftrag übergeben werden, ohne die erfolgreiche Analyse zu sperren.
Ein Ergebnis bleibt eine begründete Analyse, keine Garantie für einen wirksamen
Fix. Nach Umsetzung und Tests können Rückmeldungen eine neue Analyserevision
derselben Akte auslösen. Behauptungen, Testerbeobachtungen und bestätigte Befunde
sollten bei der Bewertung auseinandergehalten werden.
## Anmeldung und Start
Die Webadresse im Browser öffnen und mit Benutzername und Passwort anmelden.
Benutzerkonten werden ausschließlich per privilegierter Konsole angelegt;
eine Selbstregistrierung gibt es nicht. Alle menschlichen Benutzer haben die
gleichen Rechte für alle Projekte. Externe Programme benutzen eigene
Integrationsschlüssel mit Projektfreigaben.
API und Analyseworker sind getrennte Dienste. Das Öffnen der Webseite startet
keinen Worker und sendet keine Modellanfrage. Der laufende Worker verarbeitet
Aufträge selbstständig; die Webseite ist die Übersicht und alternative Bedienung.
## Übersicht und laufende Fälle
Die Übersicht zeigt erreichbare Worker, den bearbeiteten Fall und dessen
Fortschrittsbalken. Der Balken folgt abgeschlossenen Phasen, nicht vergangener
Zeit. Während einer längeren Untersuchung kann er daher unverändert bleiben.
Abgeschlossene Fälle verschwinden aus der aktiven Workeransicht.
Die Diagramme zeigen gemessenen Tokenverbrauch. Korrektur- und Wiederholungsaufrufe
bilden eine eigene Kategorie. Denktokens sind bereits in den Ausgabetokens
enthalten und werden nicht doppelt addiert. Fehlende Messwerte werden als solche
ausgewiesen. Diese Zahlen sind keine verbindliche Cloud-Abrechnung und umfassen
nur die erhaltenen Akten im gewählten Messumfang.
Über Fallnummern lässt sich die Detailansicht öffnen: Beobachtungen, Einordnung,
Hypothesen, Befunde, Diagnose und Aufträge. Frühere Revisionen bleiben über die
Revisionsauswahl einsehbar. Die vollständigen Daten stehen im herunterladbaren ZIP.
## Neue Aufträge und Rückgaben
Unter **Auftrag übergeben** Projekt, Bug-ID, Fallname und Beobachtungen eingeben.
Für einen neuen Vcore-Fall das wowdiag-Archiv und möglichst das dazugehörige
wowsrc-Archiv derselben Erfassung auswählen. Die Bug-ID muss mit den Archiven
übereinstimmen. Zusätzliche Dateien lassen sich als Anhänge hinzufügen.
Nach dem Absenden bestätigt die API die Annahme. Das ist noch kein Analyseerfolg.
Bei einer unterbrochenen Übergabe verwendet das Formular bei unveränderten
Eingaben denselben Idempotenzschlüssel, damit kein doppelter Auftrag entsteht.
Für eine Rückgabe den Auftragstyp **Abgeschlossene Akte zurückgeben** wählen,
die aktuelle abgeschlossene Case-ZIP hochladen und Ergebnis sowie neue
Beobachtungen eintragen. Beispielsweise kann ein getesteter Patch den Fehler
nicht behoben haben. BugAnalysis ergänzt die Akte und öffnet eine neue Revision.
Blockierte oder noch laufende Akten sind keine Abschlussrückgaben.
Automatische Erfassung erfolgt über den wowdiag-Worker und dieselbe Übergabe-API.
Externe Programme erstellen keine internen Case-Manifeste selbst.
## Testanfragen beantworten
Die Seite **Testanfragen** listet offene menschliche Recherche- und Testfragen
mit Projekt. Nach Zeitpunkt oder Projekt sortieren, eine Anfrage auswählen und
zunächst **Bearbeiten** drücken. Erst dann wird das Antwortformular freigegeben.
Die Anfrage ist während der Session exklusiv gebunden. Andere Personen können
sie nicht gleichzeitig bearbeiten. Beobachtungen und verlangte Angaben sachlich
eintragen und absenden. Bei Sessionablauf oder Abmeldung wird die Bearbeitung
wieder freigegeben. Nach gültiger Antwort setzt der Worker die Analyse mit dem
vorhandenen Stand fort; fachliche Budgets werden nicht erneuert.
Eine Testanfrage während der Ursachenklärung ist etwas anderes als ein
Human-Dev-Auftrag im fertigen Ergebnis. Letzterer gehört zur weiteren Umsetzung.
## Bearbeitete Fälle und Downloads
Unter **Bearbeitete Fälle** stehen Ergebnisse, übergebene Fälle, blockierte Fälle
und gescheiterte Analysen in einer gemeinsamen Liste. Projekt, Kategorie und
Suchbegriff können kombiniert werden; die Sortierung ist separat wählbar.
Links lädt der Pfeilknopf die aktuelle vollständige Fallakte als ZIP herunter.
Ein Download ändert den Fallstatus nicht und bestätigt keine Übergabe.
| Zustand | Bedeutung |
| --- | --- |
| Ergebnis bereit / OUTPUT_READY | Analyseergebnis für die weitere Bearbeitung; Routingziel Outbox |
| Übergeben / HANDED_OFF | Die Akte wurde intern als übergeben markiert |
| Blockiert / BLOCKED | Analyse wartet, beispielsweise auf Testerantwort oder technische Behebung |
| Analyse gescheitert / FAILED_TO_ANALYZE | Analyse konnte nicht erfolgreich abschließen; Routingziel Review |
Blockiert bedeutet nicht abgeschlossen. Sperrgrund und Detailansicht erklären
die nächsten Schritte. Technische Fehler oder unzureichende Eingaben sind von
einem fachlichen Diagnoseergebnis zu unterscheiden. Die Ablagespalte zeigt das
Routingziel, keinen unabhängigen Verzeichnisbestand.
## Betrieb steuern
Die Betriebssteuerung in der Übersicht erlaubt das Pausieren neuer Fälle eines
Projekts. Bereits begonnene Fälle dürfen weiterlaufen. Geeignete technische
Fehlerfälle können über eine Fortsetzungsanforderung nach Behebung wieder
freigegeben werden. Diese Aktion ist kein beliebiger Neustart und setzt keine
Budgets zurück. Menschliche Testantworten werden auf ihrer eigenen Seite erfasst.
## Programme anbinden
Unter **API-Schlüssel** einen Programmnamen und die erlaubten Projekte auswählen.
Den erzeugten Schlüssel über **Anzeigen** oder **Kopieren** abrufen und im
aufrufenden Dienst geschützt hinterlegen. Schlüssel sind später erneut anzeigbar.
**Widerrufen** verhindert weitere authentifizierte Anfragen mit diesem Schlüssel.
Die Schlüssel dienen zur Auftragsübergabe und zum lesenden Projektzugriff;
Maschinenprogramme können darüber keine anderen Schlüssel verwalten oder
menschliche Testerantworten ausfüllen. Ein Integrationsschlüssel ist kein
Gemini- oder anderer Modellanbieter-Schlüssel.
## Einrichtung und Hilfe
- [Linux-Installation und Betrieb](linux-deployment.md)
- [Projekt-, Source-, DB- und Modellkonfiguration](project-configuration.md)
- [Übergabe-API und Rückgaben](intake-api.md)
- [Analyseworker und Wiederanlauf](analysis-worker.md)
- [Benutzerkonten](user-accounts.md)
- [Integrationsschlüssel und deren Sicherung](integration-keys.md)
- [Weboberfläche im Detail](web-console.md)
- [Erfassungsworker v0.4](capture-worker.md)
Vor dem ersten Feldbetrieb auf dem Zielserver Bereitschaftsprüfung, HTTPS,
Dienstneustart und Backups prüfen. Danach einen kontrollierten echten Auftrag
einschließlich Rückfrage und Ergebnisabholung durchführen. Die LLM-Zugriffe
erzeugen erst beim laufenden Analysebetrieb Kosten.
+152 -8
View File
@@ -6,13 +6,34 @@ Die Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert:
.\.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
Anschließend `http://127.0.0.1:8080/` öffnen und mit **Benutzername und Passwort**
anmelden. Benutzer werden ausschließlich über privilegierte Konsolenbefehle
angelegt, geändert und gelöscht, siehe [Benutzerverwaltung](user-accounts.md).
Alle Webbenutzer haben dieselben Rechte für alle Projekte. Die unten beschriebenen
Client-/Projektberechtigungen gelten weiterhin für externe API-Integrationen.
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.
## Benutzeranleitung
Der Reiter **Benutzeranleitung** enthält die allgemeine Bedienung und
Funktionsweise sowie Anleitungen für Linux, Projekt-/DB-/Sourcekonfiguration,
Übergabe-API, Analyseworker, Nutzerkonten, Integrationsschlüssel, Weboberfläche
und Erfassungsworker. Dokumentauswahl, Inhaltsverzeichnis und interne Verweise
erlauben Navigation ohne Server-Dateizugriff. Markdown-Downloads sind ebenfalls
möglich. Der Reiter benötigt eine angemeldete Sitzung; sein Aufruf startet keine
Analyse. Tabellen, Codebeispiele und Überschriften werden als sichere DOM-Elemente
dargestellt, eingebettetes HTML wird nicht ausgeführt.
Für Dokumentationsänderungen die ursprünglichen Markdown-Dateien unter `docs/`
beziehungsweise `deploy/capture-worker/README.md` bearbeiten und danach
`python scripts/build_user_manual.py` ausführen. Die feste Auswahlliste steht im
Skript; tatsächliche lokale Konfigurationen oder Secrets werden nicht gebündelt.
`python scripts/build_user_manual.py --check` und die Tests erkennen veraltete
Webfassungen. Das erzeugte `web/user-manual.json` wird mit dem Python-Paket
ausgeliefert, sodass keine lokale Kopie des Dokumentationsordners nötig ist.
## Worker und Fortschritt
Die Startseite listet lebende Worker und darunter den jeweils bearbeiteten Fall
@@ -38,6 +59,9 @@ Telemetrie-Heartbeat wird nicht als aktiver Worker dargestellt.
- Kreisdiagramm: bekannte Eingabe- und Ausgabetokens je Phase; zusätzliche
Korrektur-/Reparatur-/Wiederholungsaufrufe haben ein eigenes orangefarbenes Segment.
Die Legende zeigt Fallaufbau, Fallplanung, Hypothesen, Untersuchung, Diagnose und
Codingauftrag auch bei null gemessenen Tokens. Nullwerte erzeugen keinen
künstlichen Tortenanteil. Gemeinsame Planungsaufrufe bleiben einmal gezählt.
- 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
@@ -67,12 +91,130 @@ Die Oberfläche bietet ein einfaches Formular für neue wowdiag-/wowsrc-Aufträg
für abgeschlossene ZIP-Akten mit Testerfeedback. Es verwendet dieselbe Multipart-
API und denselben Idempotenzmechanismus wie externe Programme. Bei unveränderten
Eingaben verwendet eine Wiederholung denselben Schlüssel. Anhänge und Fallakten
werden nicht clientseitig bearbeitet. Vollständige Falldetailansichten und eine
Bedienoberfläche für administrative Worker-Eingriffe bleiben weitere Ausbauschritte.
werden nicht clientseitig bearbeitet. Betreiberaktionen stehen separat berechtigten
Clients zur Verfügung.
## Fallakte und Ergebnisse
Fallnamen verlinken auf eine lesbare Detailansicht mit Testerbeobachtungen,
Interpretation, Hypothesen, Confidence, stützenden und widersprechenden Befunden,
Diagnose sowie Coding- und Human-Dev-Auftrag. Befunde bleiben ausdrücklich
KI-Bewertungen mit Belegreferenzen. Das Belegverzeichnis zeigt Herkunft und
Abdeckungsinformationen; die vollständigen Recherchepayloads stehen im ZIP.
Ein expliziter Auftrag „Kein Auftrag“ wird von einem noch nicht erstellten Dokument
unterschieden. Die gemeinsame Liste **Bearbeitete Fälle** enthält Ergebnisse
(`OUTPUT_READY`, Ziel Outbox), bereits übergebene Fälle (`HANDED_OFF`), blockierte
Fälle (`BLOCKED`) und gescheiterte Analysen (`FAILED_TO_ANALYZE`, Ziel Review).
Blockierte Fälle bleiben ausdrücklich unvollständig und können weiterhin auch
in der Übersicht wartender Fälle erscheinen. Ein Sperrgrund wird angezeigt;
`AWAITING_TEST_FEEDBACK` erscheint als „Wartet auf Testerantwort“.
Projekt- und Kategoriefilter sowie Suche sind kombinierbar. Die Sortierung erlaubt
neueste/älteste Aktualisierung, Kategorie oder Projekt. Links in jeder Zeile lädt
ein beschrifteter Iconknopf die vollständige aktuelle Fallakte herunter, auch für
blockierte oder gescheiterte Analysen. Der Download ändert keinen Status und löst
keine Analyse aus. Die Ablagespalte zeigt das im Manifest gespeicherte Routingziel;
es ist keine unabhängige Bestandsprüfung des Outbox-Dateiverzeichnisses. Der Download
liest die verwaltete aktuelle Akte und funktioniert auch ohne separate Exportkopie.
Angezeigt wird der aktuelle Stand je Fall/Paket. Nach Wiedereröffnung erscheinen
aktive Fälle wieder in der laufenden Übersicht; ältere Revisionen bleiben in der
Detailansicht erhalten. Berechtigungen und Projektgrenzen gelten auch für Downloads.
Die Revisionsauswahl liest akzeptierte Artefakte aus der Checkpointhistorie der
aktuellen vollständigen Akte. Ergebnisse neuerer Revisionen erscheinen nicht in
älteren Ansichten. Über die Übergabe-API erfasste Patch-Rückmeldungen werden bis
zur gewählten Revision dargestellt. Bei gesperrten Fällen erscheinen Sperrgrund
und passende Betreiberhinweise. Für berechtigte Betreiber kann eine zulässige
Fortsetzung angefordert werden; Budgets werden dabei nicht zurückgesetzt.
## Betriebssteuerung
Alle angemeldeten Webbenutzer können die Betriebssteuerung verwenden. Externe
API-Clients erhalten standardmäßig keine Betreiberrechte. Ein eigener Client mit
eigenem Token und `operator = true` in der API-Konfiguration erhält Aktionen für
seine freigegebenen Projekte (Beispiel in `config/api.example.toml`). API nach einer
Rechteänderung neu starten. Der Gemini-Schlüssel ist dafür nicht geeignet.
- **Neue Fälle pausieren / wieder zulassen:** gilt je Projekt und bleibt nach
Neustart erhalten. HTTP-Aufträge werden weiterhin dauerhaft angenommen. Der
Worker lässt sie im Eingang bzw. wartet bei noch nicht begonnenen Fällen.
Begonnene Analysen laufen weiter. Eine Pause unterbricht keinen Modellaufruf.
- **Fortsetzung nach Fehlerbehebung anfordern:** in der aktuellen Falldetailansicht
bei passenden Schedulerfehlern bzw. `RESEARCH_UNAVAILABLE`. Die API bestätigt
nur `QUEUED`, nicht bereits eine Freigabe. Der lebende Worker prüft den Auftrag
zwischen Dispatches erneut; ein gestoppter Worker bearbeitet ihn nach Neustart.
- Veraltete Paketgeneration, geänderte Konfigurationsbindung, noch nicht fällige
Research-Sperre und unzulässige fachliche Sperren werden abgewiesen. Keine
Zurücksetzung von Hypothesenbudgets, Versuchszählern oder LLM-Operationen.
- Das Aktionsprotokoll dokumentiert Clientkennung, UTC-Zeit, Projekt, Aktion und
Ergebnis. Fortsetzungen haben getrennte Eingangs- und Ausführungseinträge mit
derselben Auftragskennung. Die Ansicht zeigt die letzten 100 berechtigten
Einträge; das vollständige Protokoll bleibt unter `runtime/worker/control/`.
Es enthält keine Tokens oder Konfigurationsinhalte. Erfolgreiche lokale
CLI-Freigaben erscheinen als `local-cli`.
- Workerdetails zeigen Erreichbarkeit, letzten Zustand, Aktivität und Fehlercode.
Ein alter Snapshot gilt nicht als lebender Worker. Globale Workerdetails sind
nur Betreibern mit Zugriff auf alle konfigurierten Projekte zugänglich.
`GET /v1/operations` liefert Rechte, projektbezogenen Pausenzustand und für Betreiber
Protokoll/Workerzustand. `POST /v1/operations` benötigt Betreiberrechte und JSON:
`{"action":"pause","project_id":"viano-core"}` bzw. `resume`, oder
`{"action":"retry","key":"<Schlüssel aus der Übersicht>","package_generation":42}`.
Bei gleichzeitigem Zugriff kann `API_CONTROL_BUSY` auftreten; nach Aktualisierung
erneut versuchen. Der Worker muss nach diesem Softwareupdate neu gestartet werden.
## Menschliche Testanfragen
Die eigene Seite **Testanfragen** listet offene menschliche Rückfragen mit
Eingangszeit, Projekt, Bug-ID, Falltitel und Frage. Projektfilter sowie Sortierung
„Älteste zuerst“, „Neueste zuerst“ und „Projekt, dann älteste zuerst“ ermöglichen
chronologisches oder projektweises Abarbeiten. Die Liste aktualisiert sich mit der
Übersicht; eine noch offene Eingabe wird dabei nicht überschrieben.
„Ansehen / beantworten“ zeigt Umgebung, Voraussetzungen, Testschritte, benötigte
Beobachtungen und angefragten Termin. Die Antwort erfasst die tatsächliche Umgebung,
Beobachtungen und „Test durchgeführt“ oder „Nicht durchführbar“. Die ausdrückliche
Bestätigung ist erforderlich. Textdaten bleiben untrusted observations, keine
auszuführenden Befehle. Diese erste Oberfläche nimmt Text, keine Dateianhänge an.
Menschliche Antworten erfordern eine angemeldete Benutzersitzung und die exklusive
Übernahme über **Bearbeiten**, siehe [Benutzeranmeldung](user-accounts.md).
Es gibt keine gesonderten Testerrollen für Webbenutzer. Menschliche Zugänge
ausschließlich mit `create_user` anlegen.
Die bestätigende Identität und Zeit stammen vom Server, nicht aus frei editierbaren
Formularfeldern. `GET /v1/test-requests` liefert ausschließlich freigegebene Projekte;
`POST /v1/test-requests/{case-key}` bestätigt die konkrete Anfrage/Revision.
Kollisionen, veraltete Anfragen und widersprüchliche Wiederholungen werden abgewiesen.
Eine identische erneute Übermittlung derselben Sitzung bleibt ohne doppelte Wirkung.
Eine bestätigte Antwort setzt `AWAITING_TEST_FEEDBACK` über den vorhandenen
Coordinator auf `INVESTIGATING_HYPOTHESES` zurück. Revision, Budget und ursprüngliche
Belege bleiben erhalten. Der laufende Worker verarbeitet die Rückmeldung regulär;
eine besondere Betreiberfreigabe ist dafür nicht erforderlich. Der Termin führt
auch nach Ablauf zu keiner automatischen Fortsetzung und verwirft die Anfrage nicht.
Die Werkzeugfreigabe erfolgt über `budget_config = "budget.human-tests.toml"` in
den vertrauenswürdigen Projekteinstellungen. Die lokale Dienstkonfiguration wurde
entsprechend ergänzt. Diese Policy erlaubt `testing.request_human` für 40 fachliche
Einheiten, automatisierte Tests bleiben ohne Eintrag gesperrt. Bei Trial-Läufen
die Datei ausdrücklich mit `--budget-config config/budget.human-tests.toml` angeben.
Bestehende Revisionen behalten ihre eingefrorene Policy; sie werden nicht migriert.
Nach Konfigurationsänderung API und Worker neu starten. Keine Modellanfrage wird
allein durch Öffnen oder Beantworten der Seite gestartet; der Worker läuft separat.
`GET /v1/cases/{key}?revision=N` liefert diese projektgebundene Ansicht.
`GET /v1/cases/{key}/package` lädt die vollständige aktuelle Akte einschließlich
aller erhaltenen Revisionen herunter, auch als Zwischenstand eines laufenden Falls.
Beide Endpunkte benötigen denselben Bearer-Token und verwenden `no-store`.
Der Schlüssel stammt aus der Übersicht, nicht aus einem lokalen Dateipfad.
Große Anzeigeinhalte werden begrenzt und als gekürzt gekennzeichnet. Der Download
bleibt vollständig; die Anzeige verändert keine Paketgeneration und löst keine
LLM-Aufrufe aus.
## Datenschutz und Betrieb
`GET /v1/overview` benötigt einen Bearer-Token und liefert nur freigegebene Projekte.
`GET /v1/overview` benötigt eine Benutzersitzung oder einen Maschinen-Bearer-Token.
Webbenutzer sehen alle Projekte; Maschinenclients nur ihre freigegebenen 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
@@ -94,7 +236,9 @@ 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
Tokenanzeige, Fortschritt, Ergebnisliste, Falldetails, Aufträge und Browserfehler.
Die API-Tests prüfen zusätzlich Projektzugriff, ZIP-Download und Revisionsisolation.
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
+2 -2
View File
@@ -12,7 +12,7 @@ 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 = ["starlette>=0.52.1,<0.53", "uvicorn>=0.35,<1", "python-multipart>=0.0.32,<1", "cryptography>=46,<47"]
api-test = ["httpx>=0.28,<1", "pytest>=8,<10"]
[tool.setuptools.packages.find]
@@ -21,7 +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"]
"viano_bug_analysis.web" = ["*.html", "*.css", "*.js", "user-manual.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+38
View File
@@ -0,0 +1,38 @@
"""Bundle selected, non-secret operator docs for installed web applications.
Edit the original Markdown files, then run this script. --check detects drift.
"""
import argparse
import json
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
DOCUMENTS=(
('user-guide','Verwendung und Funktionsweise','docs/user-guide.md'),
('linux-deployment','Linux-Installation und Betrieb','docs/linux-deployment.md'),
('project-configuration','Projekt-, Source-, DB- und Modellkonfiguration','docs/project-configuration.md'),
('intake-api','Übergabe-API und Rückgaben','docs/intake-api.md'),
('analysis-worker','Analyseworker und Wiederanlauf','docs/analysis-worker.md'),
('user-accounts','Benutzerkonten','docs/user-accounts.md'),
('integration-keys','Integrationsschlüssel','docs/integration-keys.md'),
('web-console','Weboberfläche im Detail','docs/web-console.md'),
('capture-worker','Erfassungsworker v0.4','deploy/capture-worker/README.md'),
)
TARGET=ROOT/'src/viano_bug_analysis/web/user-manual.json'
def bundle():
return {'format_version':1,'documents':[{'id':identifier,'title':title,
'markdown':(ROOT/path).read_text(encoding='utf-8')} for identifier,title,path in DOCUMENTS]}
def main():
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--check',action='store_true');args=parser.parse_args()
content=json.dumps(bundle(),ensure_ascii=False,indent=2)+'\n'
if args.check:
if not TARGET.exists() or TARGET.read_text(encoding='utf-8')!=content:
parser.exit(1,'User manual is stale; run python scripts/build_user_manual.py\n')
else:TARGET.write_text(content,encoding='utf-8',newline='\n')
if __name__=='__main__':main()
@@ -271,7 +271,9 @@ def feedback(reader, phase, failure):
instruction += " Return only hypotheses with interpretation_ids from the accepted interpretation_sources. The rejected joint transport used interpretation_paths; do not copy that field or regenerate interpretation."
if phase=="interpretation" and context.get("joint_generation",{}).get("version")==2:
from .joint_v2 import issues as statement_issues
local=statement_issues(payload,context)
# Frozen diagnostics preserve replay of requests produced before the
# comprehensive collector was introduced. New errors store the full list.
local=error.get('validation_issues',statement_issues(payload,context,comprehensive=False))
return {"code":error["code"],"issues":local,"instruction":"Replace only the listed statements. Do not attribute inferred beliefs to the tester. Review every hypothesis for direct AND implicit dependency on changed statements; missing references do not prove independence."}
return {"code":error["code"],"issues":issues[:32],"instruction":instruction}
@@ -26,6 +26,7 @@ def digest(value):
def policy(settings):
from viano_bug_analysis.tools.sql_database import is_table_reference
settings = copy.deepcopy(settings)
if set(settings) - {"enabled", "max_searches", "max_candidates", "max_invalid_responses", "catalogs"}:
raise InterpretationError("ENTITY_RESOLUTION_CONFIG")
@@ -41,7 +42,8 @@ def policy(settings):
for c in result["catalogs"]:
if (not isinstance(c, dict) or set(c) != {"id", "entity_type", "table", "id_column", "name_column", "columns"}
or any(not isinstance(c[k], str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", c[k])
for k in ("id", "entity_type", "table", "id_column", "name_column"))
for k in ("id", "entity_type", "id_column", "name_column"))
or not is_table_reference(c.get('table'))
or c["id"] in seen or not isinstance(c["columns"], list) or not 1 <= len(c["columns"]) <= 32
or any(not isinstance(v, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", v) for v in c["columns"])
or not {c["id_column"], c["name_column"]} <= set(c["columns"])):
@@ -125,19 +127,67 @@ def validate_assessment(payload, plan, captures, candidates, context):
if len(payload["resolutions"]) != len(plan["searches"]):
raise InterpretationError("ENTITY_RESOLUTION_ASSESSMENT_COUNT")
for index, item in enumerate(payload["resolutions"]):
if type(item["search_index"]) is not int or item["search_index"] != index:
raise InterpretationError("ENTITY_RESOLUTION_ASSESSMENT_INDEX")
allowed = set(context["sources"]) | {f"V{index+1:04d}"}
if not set(item["source_ids"]) <= allowed or not set(item["source_ids"]) & set(plan["searches"][index]["source_ids"]):
raise InterpretationError("ENTITY_RESOLUTION_SOURCE")
if f"V{index+1:04d}" not in item["source_ids"]:
raise InterpretationError("ENTITY_RESOLUTION_CAPTURE_SOURCE")
if item["status"] == "selected":
candidate = next((c for c in candidates if c["candidate_id"] == item["candidate_id"] and c["search_index"] == index), None)
if candidate is None or captures[index]["result"]["truncated"]:
raise InterpretationError("ENTITY_RESOLUTION_SELECTION")
elif item["candidate_id"] is not None or (item["status"] == "no_match" and captures[index]["result"]["items"]):
raise InterpretationError("ENTITY_RESOLUTION_SELECTION")
validate_resolution(item,index,plan,captures,candidates,context)
def validate_resolution(item,index,plan,captures,candidates,context):
if type(item['search_index']) is not int or item['search_index'] != index:
raise InterpretationError('ENTITY_RESOLUTION_ASSESSMENT_INDEX')
allowed=set(context['sources']) | {f'V{index+1:04d}'}
if not set(item['source_ids']) <= allowed or not set(item['source_ids']) & set(plan['searches'][index]['source_ids']):
raise InterpretationError('ENTITY_RESOLUTION_SOURCE')
if f'V{index+1:04d}' not in item['source_ids']:
raise InterpretationError('ENTITY_RESOLUTION_CAPTURE_SOURCE')
if item['status'] == 'selected':
candidate=next((c for c in candidates if c['candidate_id']==item['candidate_id'] and c['search_index']==index),None)
if candidate is None or captures[index]['result']['truncated']:
raise InterpretationError('ENTITY_RESOLUTION_SELECTION')
elif item['candidate_id'] is not None or (item['status']=='no_match' and captures[index]['result']['items']):
raise InterpretationError('ENTITY_RESOLUTION_SELECTION')
def assessment_feedback(document,plan,captures,candidates,context):
"""Freeze structurally valid, independently checked entries; never infer identity."""
try:validate(document,'entity-resolution-assessment')
except CaseBuildError:return None
if len(document['resolutions']) != len(plan['searches']):return None
issues=[]; locked=[]
for index,item in enumerate(document['resolutions']):
try:validate_resolution(item,index,plan,captures,candidates,context)
except InterpretationError as error:
result=captures[index]['result']
issue={'search_index':index,'path':f'/resolutions/{index}', 'code':error.code,
'capture_id':f'V{index+1:04d}', 'truncated':result['truncated'],
'returned_rows':len(result['items']), 'submitted_status':item['status'],
'required_original_source_ids':plan['searches'][index]['source_ids']}
if error.code=='ENTITY_RESOLUTION_SELECTION':
issue['allowed_candidate_ids']=[c['candidate_id'] for c in candidates if c['search_index']==index]
if item['status']=='no_match' and result['items']:
issue['instruction']='no_match requires zero returned rows. Use ambiguous when no candidate can be validly selected. Truncated results cannot support selected.'
elif item['status']=='selected' and result['truncated']:
issue['instruction']='Selection from a truncated search is forbidden. Return ambiguous with candidate_id null.'
else:issue['instruction']='Select only a candidate from this search and only from complete results; otherwise use ambiguous with candidate_id null.'
elif error.code=='ENTITY_RESOLUTION_CAPTURE_SOURCE':
issue['instruction']='Include capture_id in source_ids; a mention in reason is insufficient.'
elif error.code=='ENTITY_RESOLUTION_SOURCE':
issue['allowed_source_ids']=sorted(set(context['sources']) | {f'V{index+1:04d}'})
issue['instruction']='Cite only allowed sources and at least one required_original_source_id.'
else:issue['instruction']='Use the zero-based search_index matching this array position.'
issues.append(issue)
else:locked.append(copy.deepcopy(item))
return {'version':2,'issues':issues,'locked_resolutions':locked,
'instruction':'Return only corrected entries listed in issues, in their original order. Do not return or reconsider locked_resolutions; the program retains them unchanged.'}
def project_assessment(document, feedback):
if not feedback or feedback.get('version') != 2:return document
try:validate(document,'entity-resolution-assessment')
except CaseBuildError:raise InterpretationError('ENTITY_RESOLUTION_ASSESSMENT_SCHEMA') from None
expected=[i['search_index'] for i in feedback['issues']]
if [r['search_index'] for r in document['resolutions']] != expected:
raise InterpretationError('ENTITY_RESOLUTION_ASSESSMENT_INDEX')
combined=copy.deepcopy(feedback['locked_resolutions'])+copy.deepcopy(document['resolutions'])
return {'resolutions':sorted(combined,key=lambda r:r['search_index'])}
def resolution_guidance(context, candidates=()):
@@ -176,6 +226,7 @@ def resolution_guidance(context, candidates=()):
def request_for(stage, context, settings, plan=None, captures=None, candidates=None, errors=(), protocol_version=1):
partial_repair=False
schema = json.loads(files("viano_bug_analysis.schemas").joinpath(f"entity-resolution-{stage}-v1.schema.json").read_text())
if stage == "plan":
schema["properties"]["searches"]["maxItems"] = settings["max_searches"]
@@ -209,11 +260,24 @@ def request_for(stage, context, settings, plan=None, captures=None, candidates=N
data['identity_guidance'] = resolution_guidance(context, candidates or ())
if errors:
data["validation_feedback"] = errors[-1]
feedback=errors[-1].get('citation_corrections',{})
if stage=='assessment' and feedback.get('version')==2:
partial_repair=True
# Only invalid entries are generated. Valid entries remain bound to
# the preceding saved call and are merged locally before validation.
indexes=[issue['search_index'] for issue in feedback['issues']]
array=schema['properties']['resolutions']
all_items=array.get('prefixItems') or [copy.deepcopy(array['items']) for _ in plan['searches']]
array['prefixItems']=[all_items[i] for i in indexes]
array.update(minItems=len(indexes),maxItems=len(indexes),items=False)
if protocol_version >= 2:
# json_object providers receive messages only, not the internal schema.
data["output_schema"] = schema
system=files('viano_bug_analysis.llm.prompts').joinpath(f'entity_resolution_{stage}_v{protocol_version}.txt').read_text(encoding='utf-8')
if partial_repair:
system+='\nREPAIR SCOPE OVERRIDE: Return only the resolutions whose original search_index appears in validation_feedback.citation_corrections.issues, in that order. Do not return locked_resolutions. This overrides the earlier requirement to return every plan search. The program preserves and merges the locked entries and validates the complete assessment.'
return {"prompt_version": f"entity_resolution_{stage}_v{protocol_version}", "privacy": "configured", "output_schema": schema,
"messages": [{"role":"system", "content": files("viano_bug_analysis.llm.prompts").joinpath(f"entity_resolution_{stage}_v{protocol_version}.txt").read_text(encoding="utf-8")},
"messages": [{"role":"system", "content": system},
{"role":"user", "content": json_bytes(data).decode()}]}
@@ -283,7 +347,9 @@ class EntityResolver:
async def model_stage(self, context, settings, stage, validator, **args):
errors=[]
for attempt in range(1,settings["max_invalid_responses"]+1):
with PackageReader(self.store.latest(context['case']['case_id'],context['case']['package_id']),self.store.limits) as reader:
maximum=self.authorized_attempts(reader,context,settings,stage)
for attempt in range(1,maximum+1):
error_stage=f"error-{stage}-{attempt:04d}"
feedback_stage=f"feedback-{stage}-{attempt:04d}"
with PackageReader(self.store.latest(context['case']['case_id'],context['case']['package_id']),self.store.limits) as r:
@@ -299,7 +365,8 @@ class EntityResolver:
call_request=json.loads(r.read_bytes(saved['call_ref'].rsplit('/',1)[0]+'/request.json'))
request['privacy']=call_request['privacy']
call=self.checked_call(r,context,stage,saved['attempt'],request,saved['call_ref'])
if call['status'] != 'SUCCESS' or saved['document'] != call['outcome']['document']:
document=project_assessment(call['outcome'].get('document'),feedback) if stage=='assessment' and call['status']=='SUCCESS' else call['outcome'].get('document')
if call['status'] != 'SUCCESS' or saved['document'] != document:
raise InterpretationError('ENTITY_RESOLUTION_LLM_BINDING')
validator(saved['document'])
return saved
@@ -310,7 +377,7 @@ class EntityResolver:
rejected=self.checked_call(r,context,stage,attempt,request,previous['call_ref'])
if rejected['status'] == 'SUCCESS':
try:
validator(rejected['outcome']['document'])
validator(project_assessment(rejected['outcome']['document'],feedback) if stage=='assessment' else rejected['outcome']['document'])
except InterpretationError as e:
if e.code != previous['code']:
raise InterpretationError('ENTITY_RESOLUTION_HISTORY') from None
@@ -320,23 +387,28 @@ class EntityResolver:
raise InterpretationError('ENTITY_RESOLUTION_HISTORY')
errors.append(previous)
continue
if (feedback is None and not already_started and errors and stage == 'assessment'
and errors[-1]['code'] == 'ENTITY_RESOLUTION_CAPTURE_SOURCE'):
if (feedback is None and not already_started and errors and stage == 'assessment'):
with PackageReader(self.store.latest(context['case']['case_id'],context['case']['package_id']),self.store.limits) as r:
rejected=read_json(r,errors[-1]['call_ref'],'llm-call',
self.owner.gateway.config.max_response_bytes+self.owner.gateway.config.max_request_bytes+16384)
document=rejected['outcome']['document']
feedback={"instruction":"Add each missing capture ID to the structured source_ids array; mentioning it in reason is insufficient. Return the complete corrected assessment.",
"missing_capture_sources":[{"search_index":index,"required_source_id":f"V{index+1:04d}",
"field":f"/resolutions/{index}/source_ids",
"original_mention_source_ids":search['source_ids']}
for index,search in enumerate(args['plan']['searches'])
if f"V{index+1:04d}" not in document['resolutions'][index]['source_ids']]}
previous_feedback=self.read(r,context,f"feedback-{stage}-{errors[-1]['attempt']:04d}")
document=rejected['outcome'].get('document')
if rejected['status']=='SUCCESS':
try:document=project_assessment(document,previous_feedback)
except InterpretationError:document=None
feedback=assessment_feedback(document,args['plan'],args['captures'],args['candidates'],context)
if feedback is None and previous_feedback and previous_feedback.get('version')==2:
# A malformed patch must not unlock the already accepted entries.
feedback=copy.deepcopy(previous_feedback)
feedback['repair_error']=errors[-1]['code']
# Freeze feedback separately before the new call. Older attempts keep
# their original request hashes; resumed calls use this exact payload.
self.save(context,feedback_stage,feedback)
request_errors=[*errors[:-1],{**errors[-1],"citation_corrections":feedback}]
request=self.owner.gateway.validate_request(request_for(stage,context,settings,errors=request_errors,**args))
if feedback and feedback['issues']:
self.save(context,feedback_stage,feedback)
request_errors=[*errors[:-1],{**errors[-1],"citation_corrections":feedback}]
request=self.owner.gateway.validate_request(request_for(stage,context,settings,errors=request_errors,**args))
else:
feedback=None
i=context['case']
result=await self.owner.runner.run(i['case_id'],i['package_id'],operation_id=f"entity-resolution:v1:{stage}:{attempt}",request=request)
if result.status not in {'SUCCESS','ERROR'}:
@@ -346,11 +418,12 @@ class EntityResolver:
code=result.code
if result.status == 'SUCCESS':
try:
validator(call['outcome']['document'])
document=project_assessment(call['outcome']['document'],feedback) if stage=='assessment' else call['outcome']['document']
validator(document)
except InterpretationError as e:
code=e.code
else:
saved={'attempt':attempt,'document':call['outcome']['document'],'call_ref':result.result_ref}
saved={'attempt':attempt,'document':document,'call_ref':result.result_ref}
self.save(context,stage,saved)
return saved
if code not in {'LLM_OUTPUT_SCHEMA','LLM_RESPONSE','LLM_INCOMPLETE_OUTPUT'} and not (code or '').startswith('ENTITY_RESOLUTION_'):
@@ -360,6 +433,26 @@ class EntityResolver:
return self.owner._correction_result('REJECTED',self.store.latest(i['case_id'],i['package_id']),i['analysis_revision'],code=code)
return self.block(context,'ENTITY_RESOLUTION_INVALID_RESPONSES')
def authorized_attempts(self, reader, context, settings, stage):
maximum=settings['max_invalid_responses']
for _,checkpoint in reversed(checkpoint_chain(reader)):
details=checkpoint['details']
if (checkpoint['analysis_revision']!=context['case']['analysis_revision']
or details.get('operation')!='operator-entity-repair'):
continue
grant=details['repair']
if grant['stage']!=stage:continue
error=self.read(reader,context,f'error-{stage}-{maximum:04d}')
if (grant['attempt']!=maximum+1 or not error or grant['rejected_call_ref']!=error['call_ref']
or not isinstance(details.get('actor'),str) or not details['actor'].strip()
or not isinstance(details.get('reason'),str) or not details['reason'].strip()):
raise InterpretationError('ENTITY_RESOLUTION_HISTORY')
call=read_json(reader,error['call_ref'],'llm-call',4*1024**2)
if digest(call)!=grant['rejected_call_sha256']:
raise InterpretationError('ENTITY_RESOLUTION_HISTORY')
maximum+=1
return maximum
def read_error(self, reader, context, stage):
# Error filenames are attempt-specific; envelope stage remains versioned.
return self.read(reader,context,stage)
@@ -434,8 +527,16 @@ class EntityResolver:
captures=[]
for s,ref in zip(plan['searches'],search['capture_refs'],strict=True):
c=read_json(reader,ref,'project-capture',self.owner.limits.max_context_bytes)
if (ref != self.base(context)+'/captures/'+hashlib.sha256(reader.read_bytes(ref)).hexdigest()+'.json' or c['origin'] != initial['binding']
or c['query'] != query_for(s,initial['policy'])):
query=query_for(s,initial['policy'])
# Reconstruct the query-specific origin from the frozen binding, so
# completed cases remain verifiable without a live database provider.
binding=copy.deepcopy(initial['binding'])
if binding.get('kind') == 'mariadb' and (binding.get('additional_databases') or '.' in query['table']):
parts=query['table'].split('.')
database,table=(binding['database'],parts[0]) if len(parts)==1 else parts
binding.update(selected_database=database,selected_table=table)
if (ref != self.base(context)+'/captures/'+hashlib.sha256(reader.read_bytes(ref)).hexdigest()+'.json' or c['origin'] != binding
or c['query'] != query):
raise InterpretationError('ENTITY_RESOLUTION_CAPTURE_CHANGED')
captures.append(c)
if search['candidates'] != candidates_for(plan,captures,initial['policy']):
@@ -219,7 +219,7 @@ class CaseInterpreter:
def _reject(self, reader, common, attempt_id, code):
document = {**common, "format": "viano-case-interpretation-error", "code": code}
if common['prompt_version'] in {'case_plan_v2','case_plan_statement_repair_v2'}:
if common['prompt_version'] in {'case_plan_v2','case_plan_statement_repair_v2','case_plan_interpretation_repair_v1'}:
from .joint_v2 import issues
context=read_json(reader,common['context_ref'],'case-interpretation-context',self.limits.max_context_bytes)
call=read_json(reader,common['llm_result_ref'],'llm-call',self.gateway.config.max_request_bytes+self.gateway.config.max_response_bytes+16384)
@@ -7,6 +7,29 @@ PROMPT_VERSION="investigation_v1"
def output_schema(context=None, *, strict=False):
schema=_output_schema(context,strict=strict)
if context and 'budget_policy' in context:
costs=context['budget_policy']['action_costs']
allowed=[e for e in ('human','automated') if 'testing.request_'+e in costs]
def restrict(value):
if isinstance(value,list):
return [restrict(v) for v in value if not (isinstance(v,dict)
and v.get('properties',{}).get('kind',{}).get('const')=='test' and not allowed)]
if not isinstance(value,dict):return value
value={k:restrict(v) for k,v in value.items()}
properties=value.get('properties',{})
if properties.get('kind',{}).get('const')=='test':
properties['executor']['enum']=allowed
if not allowed:
for field in ('kind','intent'):
if 'enum' in properties.get(field,{}):
properties[field]['enum']=[v for v in properties[field]['enum'] if v!='test']
return value
schema=restrict(schema)
return schema
def _output_schema(context=None, *, strict=False):
if context and context.get("startup_explore"):
from .investigation_startup import explore_schema
return explore_schema(context)
+18 -4
View File
@@ -162,7 +162,7 @@ def validate_premises(raw, interpretation, context):
except (KeyError,IndexError,ValueError,TypeError):raise HypothesisError("HYPOTHESIS_SOURCE_UNKNOWN") from None
def issues(payload, context):
def issues(payload, context, *, comprehensive=True):
from .contracts import output_schema, entity_citation_issues
result={}
if not isinstance(payload,dict):return []
@@ -208,6 +208,17 @@ def issues(payload, context):
except (TypeError,KeyError,AttributeError):entity_issues=[]
for issue in entity_issues:
parts=issue["field"].split('/')[:2];add('/'+'/'.join(parts),issue["code"],constraint=issue.get("constraint"),allowed_source_ids=issue.get("allowed_source_ids",[]))
if comprehensive:
from .quality import quality_context, description_sources
if quality_context(context):
allowed=description_sources(context)
for i,statement in enumerate(payload.get('described_behavior',[])):
if isinstance(statement,dict) and isinstance(statement.get('source_ids'),list):
bad=[sid for sid in statement['source_ids'] if sid not in allowed]
if bad:
add(f'/described_behavior/{i}', 'INTERPRETATION_DESCRIPTION_SOURCES',
invalid_source_ids=bad, allowed_source_ids=sorted(allowed),
constraint='cite_local_description')
# Overlapping edits are replaced by the enclosing field; order cannot change their meaning.
for path in list(result):
if any(path.startswith(parent+'/') for parent in result if parent!=path):del result[path]
@@ -220,7 +231,9 @@ def repair_request(request,reader,failure,feedback):
source=failure[2];
try:original=interpretation_payload(source,context,reader)
except InterpretationError:return None
problems=issues(original,context)
problems=feedback.get('issues',issues(original,context))
if problems not in (issues(original,context),issues(original,context,comprehensive=False)):
raise InterpretationError('INTERPRETATION_PATCH_BINDING')
if not problems:return None
try:raw = candidate(reader,source,context,strict=False)
except (HypothesisError,InterpretationError):raw=None
@@ -311,8 +324,9 @@ def project(reader,call,context,depth=0):
from .joint_planning import interpretation_payload
previous,body=parent(reader,call,context,depth)
original=interpretation_payload(previous,context,reader,depth+1)
expected=issues(original,context)
if expected!=body["issues"]:raise InterpretationError("INTERPRETATION_PATCH_BINDING")
expected=body['issues']
if expected not in (issues(original,context),issues(original,context,comprehensive=False)):
raise InterpretationError("INTERPRETATION_PATCH_BINDING")
document=call["outcome"].get("document",{})
if not isinstance(document,dict) or set(document)-{'patches','reviews'}:
raise InterpretationError('INTERPRETATION_PATCH_SCOPE')
@@ -38,6 +38,10 @@ def instructions(context):
bindings = context['tool_schema'].get('x-project-bindings', {})
text = (HEADER + 'Use operation="project.query" for these arguments. Prefer a matching larger tool '
'before separate schema/search/read calls; primitive tools remain available.\n')
if (bindings.get('database') or {}).get('additional_databases'):
text+=('Database table references use configured_database.table. Unqualified tables use only '
'the default database. Use database.tables to discover allowed tables across configured '
'databases; never substitute an equally named table from another database.\n')
if bindings.get('relation_profiles'):
text += ('database.relations arguments: {"kind":"database.relations",'
'"profile":"NAME_FROM_relation_profiles","value":123}. '
+36 -2
View File
@@ -1,26 +1,60 @@
import argparse
import json
import getpass
import os
import sqlite3
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('command', choices=['check', 'serve','create_user','delete_user','change_pw_user'])
parser.add_argument('name',nargs='?')
parser.add_argument('password',nargs='?')
parser.add_argument('new_password',nargs='?')
parser.add_argument('--service-group',help='Linux group allowed to read the root-owned users database')
parser.add_argument('--config', required=True)
args = parser.parse_args()
try:
config = APIConfig.load(args.config)
if args.command in {'create_user','delete_user','change_pw_user'}:
from .users import Users,require_privileged
require_privileged()
gid=None
if args.service_group and os.name!='nt':
import grp
gid=grp.getgrnam(args.service_group).gr_gid
if not args.name:raise APIError('AUTH_USER_INVALID')
if args.command=='delete_user' and (args.password or args.new_password):raise APIError('AUTH_ARGUMENTS')
if args.command=='create_user' and args.new_password:raise APIError('AUTH_ARGUMENTS')
users=Users(config.users_database or config.workspace/'auth/users.sqlite3')
if args.command=='create_user':
users.manage(args.command,args.name,args.password if args.password is not None else getpass.getpass('Password: '))
elif args.command=='change_pw_user':
old=args.password if args.password is not None else getpass.getpass('Old password: ')
new=args.new_password if args.new_password is not None else getpass.getpass('New password: ')
users.manage(args.command,args.name,new,old)
else:users.manage(args.command,args.name)
if os.name!='nt':
os.chown(users.path,0,gid if gid is not None else -1)
os.chown(users.path.parent,0,gid if gid is not None else -1)
os.chmod(users.path,0o640);os.chmod(users.path.parent,0o750)
print(json.dumps({'status':'OK','command':args.command,'username':args.name}))
return 0
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)
uvicorn.run(create_app(config), host=config.host, port=config.port, access_log=False,
proxy_headers=True, forwarded_allow_ips='127.0.0.1')
return 0
except APIError as error:
print(json.dumps({'status': 'ERROR', 'code': error.code}))
return 1
except (OSError,sqlite3.Error,KeyError):
print(json.dumps({'status':'ERROR','code':'AUTH_STORAGE'}));return 1
if __name__ == '__main__':
+183 -9
View File
@@ -1,6 +1,8 @@
import asyncio
import functools
import hmac
import json
import sqlite3
import shutil
import tempfile
from pathlib import Path
@@ -18,28 +20,95 @@ from .config import APIError
from .contracts import schema
from .service import IntakeService
from .dashboard import Dashboard
from .cases import Cases
from .operations import Operations
from .test_requests import TestRequests
from .sessions import Sessions,COOKIE
from .keys import Keys
from viano_bug_analysis.lifecycle.errors import LockBusy
from viano_bug_analysis.lifecycle.errors import LifecycleError
from viano_bug_analysis.package import PackageReadError
from importlib.resources import files
def create_app(config):
service = IntakeService(config)
dashboard = Dashboard(service)
cases = Cases(service)
operations = Operations(service)
test_requests = TestRequests(service)
sessions=Sessions(config)
tokens = config.tokens()
keys=Keys(config)
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)
if header:
if header.startswith('Bearer ') and header.isascii():
for client, token in tokens:
if hmac.compare_digest(header[7:], token):return client
client=keys.authenticate(header[7:])
if client:return client
raise APIError('API_UNAUTHORIZED',401)
return sessions.authenticate(request)
async def small_json(request,maximum=4096):
if request.headers.get('content-type','').split(';')[0]!='application/json':
raise APIError('API_CONTENT_TYPE',415)
body=bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body)>maximum:raise APIError('API_BODY_LIMIT',413)
try:return json.loads(body)
except ValueError:raise APIError('API_BAD_REQUEST',422) from None
async def auth(request):
origin=request.headers.get('origin')
if origin and origin!=str(request.base_url).rstrip('/'):
raise APIError('AUTH_ORIGIN',403)
action=request.path_params['action']
if action=='login' and request.method=='POST':
data=await small_json(request,16384)
if not isinstance(data,dict) or set(data)!={'username','password'}:raise APIError('AUTH_LOGIN_INVALID',401)
token,info=await run_in_threadpool(sessions.login,data['username'],data['password'],request.client.host if request.client else 'unknown')
response=JSONResponse(info,headers={'Cache-Control':'no-store'})
response.set_cookie(COOKIE,token,max_age=config.session_seconds,httponly=True,
secure=request.url.scheme=='https',samesite='strict',path='/')
return response
if action not in {'session','logout'} or (action=='session' and request.method!='GET') or (action=='logout' and request.method!='POST'):
raise APIError('API_NOT_FOUND',404)
await run_in_threadpool(sessions.authenticate,request)
info=request.state.session_info
if action=='logout':
await run_in_threadpool(sessions.logout,request.state.session_id)
response=JSONResponse({'status':'OK'},headers={'Cache-Control':'no-store'});response.delete_cookie(COOKIE,path='/');return response
return JSONResponse({'username':info['username'],'csrf':info['csrf'],'expires_at':info['expires']},headers={'Cache-Control':'no-store'})
async def health(request):
return JSONResponse({'status': 'OK'})
async def manual(request):
authenticate(request)
data=json.loads(files('viano_bug_analysis.web').joinpath('user-manual.json').read_text(encoding='utf-8'))
return JSONResponse(data,headers={'Cache-Control':'no-store'})
async def integration_keys(request):
# Machine credentials can never manage or reveal credentials.
if request.headers.get('authorization'):raise APIError('AUTH_SESSION_REQUIRED',403)
await run_in_threadpool(sessions.authenticate,request)
identifier=request.path_params.get('identifier')
if identifier:
action=request.path_params['action']
if action not in {'reveal','revoke'}:raise APIError('API_NOT_FOUND',404)
result=await run_in_threadpool(keys.reveal if action=='reveal' else keys.revoke,identifier)
elif request.method=='GET':result={'keys':await run_in_threadpool(keys.listing)}
else:
data=await small_json(request)
result=await run_in_threadpool(keys.create,data,request.state.session_info['username'])
return JSONResponse(result,headers={'Cache-Control':'no-store'})
async def profiles(request):
client = authenticate(request)
return JSONResponse({'projects': [{'project_id': p.id, 'profile_id': p.profile,
@@ -55,14 +124,110 @@ def create_app(config):
response.headers['Cache-Control'] = 'no-store'
return response
async def case_detail(request):
client = authenticate(request)
revision = request.query_params.get('revision')
if revision is not None:
if not revision.isascii() or not revision.isdigit() or len(revision) > 7:
raise APIError('API_REVISION_INVALID', 422)
revision = int(revision)
try:
detail = await run_in_threadpool(cases.detail, request.path_params['handle'], client, revision)
except (LifecycleError, PackageReadError, ValueError, TypeError, KeyError):
raise APIError('API_CASE_UNAVAILABLE', 409) from None
response = JSONResponse(detail)
response.headers['Cache-Control'] = 'no-store'
return response
async def case_package(request):
client = authenticate(request)
try:
path = await run_in_threadpool(cases.head, request.path_params['handle'], client)
except (LifecycleError, PackageReadError, ValueError, TypeError, KeyError):
raise APIError('API_CASE_UNAVAILABLE', 409) from None
response = FileResponse(path, media_type='application/zip', filename='case.zip')
response.headers['Cache-Control'] = 'no-store'
return response
async def operation(request):
client = authenticate(request)
if request.method == 'POST':
if not client.operator:
raise APIError('API_FORBIDDEN',403)
if request.headers.get('content-type','').split(';')[0] != 'application/json':
raise APIError('API_CONTENT_TYPE',415)
body=bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body)>4096:
raise APIError('API_CONTROL_INVALID',413)
try:
data=json.loads(body)
except ValueError:
raise APIError('API_CONTROL_INVALID',422) from None
try:
result=await run_in_threadpool(operations.change,client,data)
except LockBusy:
raise APIError('API_CONTROL_BUSY',409) from None
except (LifecycleError,PackageReadError):
raise APIError('API_CASE_UNAVAILABLE',409) from None
except (ValueError,KeyError,TypeError):
raise APIError('API_CONTROL_UNAVAILABLE',503) from None
else:
try:
result=await run_in_threadpool(operations.view,client)
except (ValueError,KeyError,TypeError):
raise APIError('API_CONTROL_UNAVAILABLE',503) from None
return JSONResponse(result,headers={'Cache-Control':'no-store'})
async def tests(request):
client=authenticate(request)
try:
if request.method=='GET':
result=await run_in_threadpool(test_requests.listing,client)
else:
identifier=getattr(request.state,'session_id',None)
if not identifier:raise APIError('AUTH_SESSION_REQUIRED',403)
if not (client.operator or client.tester):
raise APIError('API_FORBIDDEN',403)
if request.headers.get('content-type','').split(';')[0]!='application/json':
raise APIError('API_CONTENT_TYPE',415)
body=bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body)>64*1024:
raise APIError('API_TEST_FEEDBACK_INVALID',413)
try:
data=json.loads(body)
except ValueError:
raise APIError('API_TEST_FEEDBACK_INVALID',422) from None
handle=request.path_params['handle']
if (not isinstance(data,dict) or not isinstance(data.get('request_id'),str)
or type(data.get('analysis_revision')) is not int):
raise APIError('API_TEST_FEEDBACK_INVALID',422)
if request.url.path.endswith('/claim'):
if set(data)!={'request_id','analysis_revision','action'} or not isinstance(data['action'],str) or data['action'] not in {'edit','release'}:
raise APIError('API_TEST_FEEDBACK_INVALID',422)
await run_in_threadpool(test_requests.pending,client,handle,data['request_id'],data['analysis_revision'])
await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],release=data['action']=='release'))
result={'status':'RELEASED' if data['action']=='release' else 'EDITING'}
else:
await run_in_threadpool(functools.partial(sessions.claim,identifier,handle,data['request_id'],data['analysis_revision'],check=True))
result=await run_in_threadpool(test_requests.answer,client,handle,data)
except LockBusy:
raise APIError('API_TEST_BUSY',409) from None
except (LifecycleError,PackageReadError) as exc:
raise APIError(getattr(exc,'code','API_CASE_UNAVAILABLE'),409) from None
return JSONResponse(result,headers={'Cache-Control':'no-store'})
async def web(request):
from starlette.responses import Response
name = request.path_params.get('asset', 'index.html')
if name not in {'index.html', 'dashboard.js', 'dashboard.css'}:
if name not in {'index.html', 'dashboard.js', 'dashboard.css', 'case-detail.js','operations.js','test-requests.js','integration-keys.js','user-manual.js'}:
raise APIError('API_NOT_FOUND', 404)
data = files('viano_bug_analysis.web').joinpath(name).read_bytes()
response = Response(data, media_type={'index.html': 'text/html', 'dashboard.js': 'text/javascript',
'dashboard.css': 'text/css'}[name])
'dashboard.css': 'text/css', 'case-detail.js':'text/javascript','operations.js':'text/javascript','test-requests.js':'text/javascript','integration-keys.js':'text/javascript','user-manual.js':'text/javascript'}[name])
response.headers.update({'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'Cache-Control': 'no-store'})
return response
@@ -135,11 +300,20 @@ def create_app(config):
return JSONResponse({'status': 'ERROR', 'code': 'API_STORAGE_ERROR'}, status_code=503)
app = Starlette(routes=[Route('/', web), Route('/assets/{asset}', web),
Route('/v1/auth/{action}',auth,methods=['GET','POST']),
Route('/v1/manual',manual),
Route('/v1/integration-keys',integration_keys,methods=['GET','POST']),
Route('/v1/integration-keys/{identifier}/{action}',integration_keys,methods=['POST']),
Route('/v1/overview', overview), Route('/healthz', health), Route('/v1/profiles', profiles),
Route('/v1/operations',operation,methods=['GET','POST']),
Route('/v1/test-requests',tests),Route('/v1/test-requests/{handle}',tests,methods=['POST']),
Route('/v1/test-requests/{handle}/claim',tests,methods=['POST']),
Route('/v1/cases/{handle}', case_detail), Route('/v1/cases/{handle}/package', case_package),
Route('/v1/schema', contract), Route('/v1/submissions', submit, methods=['POST']),
Route('/v1/submissions/{submission}', status), Route('/v1/submissions/{submission}/package', package)],
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error})
exception_handlers={APIError: error, HTTPException: http_error, OSError: storage_error,sqlite3.Error:storage_error})
app.state.intake = service
app.state.sessions=sessions
return BodyLimit(app, config.limits.max_body_bytes)
+232
View File
@@ -0,0 +1,232 @@
"""Read-only, project-scoped semantic views of complete portable case history."""
import re
import json
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 .config import APIError
from .dashboard import read_json, FINISHED
def case_key(case_id, package_id):
return key(case_id) + '.' + key(package_id)
def select(document, fields):
return {name: document[name] for name in fields if name in document}
def matches(doc, manifest, revision):
identity = doc.get('case', doc)
return (isinstance(identity, dict) and identity.get('analysis_revision') == revision
and identity.get('case_id', manifest['case_id']) == manifest['case_id']
and identity.get('package_id', manifest['package_id']) == manifest['package_id'])
def references(reader, chain, revision):
mapping = {
'case-built': {'case':'case_ref'},
'case-interpreted': {'case_interpretation':'interpretation_ref'},
'hypotheses-built': {'hypotheses':'hypotheses_ref'},
'investigation-state': {'investigation_state':'state_ref'},
'evidence-stored': {'evidence_index':'index_ref'},
'diagnosis-complete': {'diagnosis':'diagnosis_ref', 'failure_report':'failure_ref'},
'change-request-complete': {'change_request':'change_request_ref', 'decision':'decision_ref', 'human':'human_ref'},
}
result = {}
for _, cp in reversed(chain):
if cp['analysis_revision'] != revision:
continue
details = cp['details']
for pointer, name in mapping.get(details.get('operation'), {}).items():
if details.get(name):
result[pointer] = details[name]
# Newest pointers are also useful while a stage is in progress. Revision
# checks prevent old retained artifacts from masquerading as new results.
for pointer in ('case', 'case_interpretation', 'hypotheses', 'investigation_state', 'diagnosis', 'change_request', 'failure_report', 'evidence_index'):
ref = reader.manifest['artifacts'].get(pointer)
if ref and matches(read_json(reader, ref), reader.manifest, revision):
result[pointer] = ref
return result
def bounded(value, limit, depth=0):
"""Bound display excerpts, never the underlying case; report truncation."""
if depth > 8 or limit[1] <= 0:
limit[0] = True
return [] if isinstance(value, list) else {} if isinstance(value, dict) else '…'
if isinstance(value, str):
data = value.encode('utf-8')
maximum = min(48000, limit[1])
limit[1] -= min(len(data), maximum)
if len(value) > 12000 or len(data) > maximum:
limit[0] = True
return data[:maximum].decode('utf-8', errors='ignore')[:12000] + '…'
return value
if isinstance(value, list):
if len(value) > 100:
limit[0] = True
return [bounded(v, limit, depth+1) for v in value[:100]]
if isinstance(value, dict):
if len(value) > 100:
limit[0] = True
result = {}
for k,v in list(value.items())[:100]:
limit[1] -= len(k.encode('utf-8')) + 8
result[k] = bounded(v, limit, depth+1)
return result
return value
def hypotheses_view(reader, docs):
state = docs.get('investigation_state', {}).get('hypotheses', {})
proposals = {row['id']: row for row in docs.get('hypotheses', {}).get('hypotheses', [])}
result = []
for hid in sorted(set(state) | set(proposals), key=lambda s: int(s[1:]) if s[1:].isdigit() else 0):
current, initial = state.get(hid, {}), proposals.get(hid, {})
proposal = current.get('proposal', initial.get('proposal', {}))
brief_doc = read_json(reader, current.get('working_brief_ref'))
brief = brief_doc.get('brief', {})
if brief_doc and (not matches(brief_doc, reader.manifest, docs['investigation_state']['analysis_revision'])
or brief_doc.get('hypothesis_id') != hid):
brief = {}
# Findings are model interpretations with evidence references, not new observations.
findings = brief.get('findings', [])
review = next((n['causal_review'] for n in reversed(current.get('notes', [])) if 'causal_review' in n), {})
result.append({'id':hid, 'title':proposal.get('title', hid),
'confidence':current.get('confidence', initial.get('confidence', 'unassessed')),
'disposition':current.get('disposition', initial.get('status', 'open')),
'proposal': select(proposal, ('mechanism','rationale','expected_evidence','falsifying_evidence','open_questions')),
'support':[select(f, ('text','scope','evidence_refs')) for f in findings if f.get('direction') == 'support'],
'against':[select(f, ('text','scope','evidence_refs')) for f in findings if f.get('direction') == 'against'],
'review':select(review, ('effect','reason','support','against')),
'uncertainties':brief.get('uncertainties', []),
'questions':[select(q, ('id','text','resolved')) for q in current.get('questions', [])],
'evidence_ids':current.get('evidence_refs', []), 'iterations':current.get('iterations', 0)})
return result
def next_actions(state, reason, worker):
if state == 'FAILED_TO_ANALYZE':
return [{'kind':'download','text':'Die Analyse wurde ohne erfolgreich validierten Abschluss beendet. Fallakte und Fehlerdiagnostik zur Prüfung herunterladen.'},
{'kind':'return','text':'Nach Fehlerbehebung oder ergänzender Prüfung die unveränderte Akte mit einer Review-Rückmeldung zurückgeben. Ein gescheiterter Patch muss dafür nicht behauptet werden.'}]
if state in FINISHED:
return [{'kind':'download','text':'Die Analyse ist abgeschlossen. Aufträge und Fallakte an die nächste Instanz übergeben.'},
{'kind':'return','text':'Nach gescheitertem Patch oder neuen Beobachtungen die unveränderte Akte mit Rückmeldung übergeben.'}]
help_ = {
'AWAITING_TEST_FEEDBACK': 'Die angeforderte Testerantwort auf der Seite Testanfragen bestätigen. Die Untersuchung wird in derselben Revision fortgesetzt.',
'RESEARCH_UNAVAILABLE': 'Datenbank-/Repository-Dienst prüfen. Nach Behebung ist eine gezielte Betreiberfreigabe über den Worker möglich.',
'INVALID_LLM_RESPONSES': 'Die Antworten wurden fachlich oder technisch zurückgewiesen. Fehlerdiagnostik prüfen; keine Budgets zurücksetzen.',
'ENTITY_RESOLUTION_INVALID_RESPONSES': 'Die Namensauflösung hat ihre Antwortversuche ausgeschöpft. Nach einer technischen Korrektur kann der Betreiber mit Begründung genau einen zusätzlichen Reparaturversuch freigeben.',
'LLM_CONFIGURATION': 'Providerkonfiguration und Zugangsdaten durch den Betreiber prüfen.',
'LLM_UNAVAILABLE': 'Provider-Erreichbarkeit und ausgeschöpfte Wiederholungen prüfen. Eine neue Versuchskennung ist keine automatische Fehlerbehebung.',
}
if state == 'BLOCKED':
return [{'kind':'operator','text':help_.get(reason, 'Sperrgrund durch den Betreiber prüfen. Der Fall wird nicht unkontrolliert erneut gestartet.')}]
if worker.get('status') == 'ATTENTION':
return [{'kind':'operator','text':'Der Worker benötigt eine Betreiberprüfung. Nach Behebung nur die zulässige lokale Freigabe verwenden.'}]
return [{'kind':'wait','text':'Die Analyse läuft oder wartet auf den nächsten Worker-Schritt.'}]
class Cases:
def __init__(self, service):
self.service = service
def head(self, handle, client):
if not re.fullmatch(r'[0-9a-f]{64}\.[0-9a-f]{64}', handle):
raise APIError('API_NOT_FOUND', 404)
case_hash, package_hash = handle.split('.')
folder = self.service.workspace.cases / case_hash / package_hash
if (not folder.is_dir() or folder.is_symlink() or folder.parent.is_symlink()
or getattr(folder.lstat(), 'st_file_attributes', 0) & 0x400
or getattr(folder.parent.lstat(), 'st_file_attributes', 0) & 0x400):
raise APIError('API_NOT_FOUND', 404)
paths = sorted(folder.glob('g*.zip'), key=lambda p: int(p.stem[1:]))
if not paths or paths[-1].is_symlink() or getattr(paths[-1].lstat(), 'st_file_attributes', 0) & 0x400:
raise APIError('API_NOT_FOUND', 404)
with PackageReader(paths[-1]) as reader:
manifest = reader.manifest
if (manifest['project'].get('id') not in client.projects
or case_key(manifest['case_id'], manifest['package_id']) != handle):
raise APIError('API_NOT_FOUND', 404)
return self.service.store.latest(manifest['case_id'], manifest['package_id'])
def detail(self, handle, client, requested_revision=None):
path = self.head(handle, client)
with PackageReader(path) as reader:
m = reader.manifest
chain = checkpoint_chain(reader)
revisions = {}
for _, cp in reversed(chain):
revisions[cp['analysis_revision']] = {'revision':cp['analysis_revision'], 'state':cp['state'], 'updated_at':cp['created_at']}
revisions.setdefault(m['analysis_revision'], {'revision':m['analysis_revision'],'state':m['state'],'updated_at':m['updated_at']})
revision = m['analysis_revision'] if requested_revision is None else requested_revision
if revision not in revisions:
raise APIError('API_REVISION_NOT_FOUND', 404)
refs = references(reader, chain, revision)
docs = {p:read_json(reader, ref) for p,ref in refs.items() if p != 'human'}
docs = {p:d for p,d in docs.items() if p == 'decision' or matches(d, m, revision)}
case = docs.get('case', {})
reports = [select(r, ('title','summary')) for r in case.get('bug', {}).get('reports', [])]
observations = case.get('human_observations', {})
if not case and revision == m['analysis_revision']:
index = read_json(reader, m['artifacts'].get('case_inputs'))
source_reports = [read_json(reader, ref) for ref in index.get('reports', [])[:100]]
reports = [select(r, ('title','summary')) for r in source_reports]
observations = {name:[item for report in source_reports for item in report.get(name, [])]
for name in ('observations','tester_hypotheses')}
interpretation = docs.get('case_interpretation', {}).get('interpretation', {})
coding = docs.get('change_request', {})
human = docs.get('decision', {}).get('human_dev')
human_text = None
if not human and refs.get('human') in reader.files:
if reader._archive.getinfo(refs['human']).file_size <= 512*1024:
human_text = reader.read_bytes(refs['human']).decode('utf-8')
evidence = []
for entry in docs.get('evidence_index', {}).get('entries', [])[:100]:
record = read_json(reader, entry.get('record_ref'))
if not matches(record, m, revision):
continue
content = read_json(reader, record.get('content_ref'))
evidence.append(select(record, ('id','hypothesis_id','operation','duplicate_of')) |
select(content, ('kind','coverage','scope')))
feedback = []
for _, cp in reversed(chain):
d = cp['details']
if d.get('operation') != 'api-case-return' or cp['analysis_revision'] > revision:
continue
request = read_json(reader, f"intake/submissions/{d['submission_id']}/request.json")
value = request.get('metadata', {}).get('feedback')
if value:
feedback.append({'revision':cp['analysis_revision'],'received_at':cp['created_at'],
**select(value, ('outcome','summary','observations','patch_id','tested_build'))})
selected = next((cp for _, cp in chain if cp['analysis_revision'] == revision), None)
selected_state = revisions[revision]['state']
reason = selected['routing'].get('reason') if selected else m['routing'].get('reason')
worker = {}
ledger = self.service.workspace.runtime / 'worker/cases' / (key(m['case_id']+':'+m['package_id'])+'.json')
if ledger.is_file():
try:
value = json.loads(ledger.read_bytes())
except (OSError, ValueError):
value = {}
if value.get('analysis_revision') == revision:
worker = select(value, ('status','code','retry_not_before'))
result = {'key':handle, 'case_id':m['case_id'],'package_id':m['package_id'],
'project_id':m['project'].get('id'),'revision':revision,'current_revision':m['analysis_revision'],
'package_generation':m['package_generation'],'state':selected_state, 'reason':reason,
'revisions':sorted(revisions.values(), key=lambda r:r['revision'], reverse=True),
'reports':reports, 'observations':observations.get('observations', []),
'tester_assumptions':observations.get('tester_hypotheses', []),
'expected_behavior':case.get('expected_behavior', {}),
'interpretation':interpretation,'hypotheses':hypotheses_view(reader,docs),
'evidence':evidence, 'diagnosis':docs.get('diagnosis', {}).get('diagnosis'),
'review':select(docs.get('failure_report', {}), ('reason','remaining_questions','recommended_human_actions')),
'coding':select(coding, ('assignment','human_dev_assigned','request','diagnostic_caveats')),
'human':human, 'human_text':human_text, 'feedback':feedback, 'worker':worker,
'actions':next_actions(selected_state,reason,worker)}
limited=[len(docs.get('evidence_index', {}).get('entries', [])) > 100, 2*1024**2]
result=bounded(result,limited)
result['truncated']=limited[0]
return result
+17 -6
View File
@@ -66,6 +66,8 @@ class Client:
id: str
token_env: str
projects: tuple[str, ...]
operator: bool = False
tester: bool = False
@dataclass(frozen=True)
@@ -76,16 +78,19 @@ class APIConfig:
host: str = '127.0.0.1'
port: int = 8080
limits: IntakeLimits = field(default_factory=IntakeLimits)
users_database: Path | None = None
session_seconds: int = 3600
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
if (not ids or len(ids) != len(self.projects)
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):
or not isinstance(self.host, str) or not self.host
or type(self.session_seconds) is not int or not 60<=self.session_seconds<=86400):
raise APIError('API_CONFIG', 500)
for c in self.clients:
if (not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id)
if (type(c.operator) is not bool or type(c.tester) is not bool or not isinstance(c.id, str) or not IDENTIFIER.fullmatch(c.id)
or not isinstance(c.token_env, str)
or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', c.token_env)
or not c.projects or not set(c.projects) <= ids):
@@ -95,6 +100,8 @@ class APIConfig:
result = []
for client in self.clients:
token = os.environ.get(client.token_env, '')
if not token:
continue # Machine integrations are optional; web users need no 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):
@@ -107,7 +114,7 @@ class APIConfig:
try:
path = Path(path).resolve()
data = tomllib.loads(path.read_text(encoding='utf-8'))
if set(data) - {'api', 'projects', 'clients', 'limits'}:
if set(data) - {'api', 'projects', 'clients', 'limits','auth'}:
raise ValueError()
api = data['api']
if set(api) - {'workspace', 'host', 'port'}:
@@ -125,9 +132,13 @@ class APIConfig:
raise ValueError()
resolved[key] = target
projects.append(Project(**row, settings=resolved))
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data['clients']]
clients = [Client(**(dict(c) | {'projects': tuple(c['projects'])})) for c in data.get('clients',[])]
auth=data.get('auth',{})
if set(auth)-{'users_database','session_seconds'}:raise ValueError()
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', {})))
port=api.get('port', 8080), limits=IntakeLimits(**data.get('limits', {})),
users_database=(path.parent/auth['users_database']).resolve() if 'users_database' in auth else None,
session_seconds=auth.get('session_seconds',3600))
except (OSError, ValueError, TypeError, KeyError):
raise APIError('API_CONFIG', 500) from None
+4 -1
View File
@@ -100,9 +100,12 @@ def case_view(reader):
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')} | {
'key':key(manifest['case_id']) + '.' + key(manifest['package_id']),
'project_id': manifest['project'].get('id'), 'title': title,
'progress': round(sum(complete) / len(complete) * 100), 'calls': calls,
'updated_at': manifest['updated_at']}
'updated_at': manifest['updated_at'],
'destination':manifest.get('routing',{}).get('destination'),
'reason':manifest.get('routing',{}).get('reason')}
def aggregate(cases):
+79
View File
@@ -0,0 +1,79 @@
"""Recoverable integration credentials, separate from human accounts."""
import hashlib
import json
import os
from pathlib import Path
import secrets
import time
from cryptography.fernet import Fernet, InvalidToken
from .config import APIError, Client, directory
from .users import database
class Keys:
def __init__(self,config):
self.config=config
root=directory(config.workspace/'runtime/integrations')
self.path=root/'keys.sqlite3'
self.key_path=Path(os.environ.get('VIANO_INTEGRATION_KEY_FILE',str(config.workspace/'runtime/integration-secrets/master.key')))
if not self.path.exists():
try:
fd=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(fd)
except FileExistsError:pass
with database(self.path) as db:
db.execute('CREATE TABLE IF NOT EXISTS keys (id TEXT PRIMARY KEY,name TEXT NOT NULL,projects TEXT NOT NULL,digest TEXT UNIQUE NOT NULL,encrypted TEXT NOT NULL,created REAL NOT NULL,creator TEXT NOT NULL,revoked REAL)')
def cipher(self):
directory(self.key_path.parent)
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE')
if not self.key_path.exists():
if db.execute('SELECT 1 FROM keys LIMIT 1').fetchone():raise APIError('API_KEY_MASTER_MISSING',503)
fd=os.open(self.key_path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
with os.fdopen(fd,'wb') as stream:stream.write(Fernet.generate_key())
if self.key_path.is_symlink():raise APIError('API_KEY_STORAGE',503)
try:return Fernet(self.key_path.read_bytes().strip())
except ValueError:raise APIError('API_KEY_MASTER_INVALID',503) from None
def listing(self):
with database(self.path) as db:
return [{'id':r['id'],'name':r['name'],'projects':json.loads(r['projects']),
'created_at':r['created'],'created_by':r['creator'],'revoked_at':r['revoked']}
for r in db.execute('SELECT * FROM keys ORDER BY created DESC,id')]
def create(self,data,creator):
if (not isinstance(data,dict) or set(data)!={'name','projects'} or not isinstance(data['name'],str)
or not 1<=len(data['name'].strip())<=100 or any(ord(c)<32 for c in data['name'])
or not isinstance(data['projects'],list) or not data['projects']
or any(not isinstance(p,str) for p in data['projects'])
or not set(data['projects'])<={p.id for p in self.config.projects}):
raise APIError('API_KEY_INVALID',422)
cipher=self.cipher();identifier=secrets.token_hex(16);token=secrets.token_urlsafe(32)
with database(self.path) as db:
db.execute('INSERT INTO keys VALUES (?,?,?,?,?,?,?,NULL)',(identifier,data['name'].strip(),
json.dumps(sorted(set(data['projects']))),hashlib.sha256(token.encode()).hexdigest(),
cipher.encrypt(token.encode()).decode(),time.time(),creator))
return {'status':'CREATED','id':identifier}
def reveal(self,identifier):
with database(self.path) as db:row=db.execute('SELECT encrypted FROM keys WHERE id=? AND revoked IS NULL',(identifier,)).fetchone()
if not row:raise APIError('API_KEY_NOT_FOUND',404)
try:return {'key':self.cipher().decrypt(row['encrypted'].encode()).decode()}
except InvalidToken:raise APIError('API_KEY_MASTER_INVALID',503) from None
def revoke(self,identifier):
with database(self.path) as db:
if not db.execute('UPDATE keys SET revoked=COALESCE(revoked,?) WHERE id=?',(time.time(),identifier)).rowcount:
raise APIError('API_KEY_NOT_FOUND',404)
return {'status':'REVOKED'}
def authenticate(self,token):
if not token.isascii() or not 32<=len(token)<=256:return None
with database(self.path) as db:
row=db.execute('SELECT id,projects FROM keys WHERE digest=? AND revoked IS NULL',
(hashlib.sha256(token.encode()).hexdigest(),)).fetchone()
if row:
projects=tuple(p for p in json.loads(row['projects']) if p in {x.id for x in self.config.projects})
if projects:return Client('key-'+row['id'],'DATABASE_KEY',projects)
return None
+80
View File
@@ -0,0 +1,80 @@
"""Scoped operator endpoints; mutations are durable intent, never model calls."""
import json
from datetime import datetime, timezone
from .config import APIError
from .cases import Cases
from viano_bug_analysis.worker.control import Control
from viano_bug_analysis.package import PackageReader
from viano_bug_analysis.telemetry import read_status
class Operations:
def __init__(self, service):
self.service = service
self.control = Control(service.workspace)
def view(self, client):
value = self.control.read()
path = self.service.workspace.runtime / 'worker/status.json'
try:
status = json.loads(path.read_bytes()) if path.exists() else {}
if not isinstance(status,dict):
status={}
except (OSError,ValueError):
status={}
try:
telemetry = read_status(self.service.workspace.root)
live = (telemetry.get('observer') == 'LIVE' and bool(status.get('service_id'))
and telemetry.get('service_id') == status.get('service_id') and status.get('status') != 'STOPPED')
except Exception:
live = False
# A shared worker's case/error context may belong to another project.
# Non-operators see only scoped pause state; global health is operator-only.
full_scope = set(client.projects) == {p.id for p in self.service.config.projects}
worker = ({k:status.get(k) for k in ('status','updated_at','code')} | {'live':live}
if client.operator and full_scope else None)
return {'operator':client.operator, 'worker':worker,
'projects':[{'project_id':p,'paused':value['paused'].get(p,False)} for p in client.projects],
'events':[e for e in value['events'] if e['project_id'] in client.projects][-100:] if client.operator else [],
'pending':sum(c['project_id'] in client.projects for c in value['commands']) if client.operator else 0}
def change(self, client, data):
if not client.operator:
raise APIError('API_FORBIDDEN',403)
if not isinstance(data,dict):
raise APIError('API_CONTROL_INVALID',422)
action=data.get('action')
if not isinstance(action,str):
raise APIError('API_CONTROL_INVALID',422)
if action in {'pause','resume'} and set(data)=={'action','project_id'}:
project=data['project_id']
if project not in client.projects:
raise APIError('API_FORBIDDEN',403)
self.control.pause(client.id,project,action=='pause')
return {'status':'APPLIED'}
repair=action=='repair'
if (action=='retry' and set(data)=={'action','key','package_generation'} or
repair and set(data)=={'action','key','package_generation','reason'}):
if repair and (not isinstance(data['reason'],str) or not 1<=len(data['reason'].strip())<=1000):
raise APIError('API_CONTROL_INVALID',422)
if type(data['package_generation']) is not int or not isinstance(data['key'],str):
raise APIError('API_CONTROL_INVALID',422)
path=Cases(self.service).head(data['key'],client)
with PackageReader(path) as reader:
manifest=reader.manifest
if manifest['package_generation'] != data['package_generation']:
raise APIError('API_CONTROL_STALE',409)
if repair:
if manifest['state']!='BLOCKED' or manifest['routing']['reason']!='ENTITY_RESOLUTION_INVALID_RESPONSES':
raise APIError('API_CONTROL_RETRY_STATE',409)
elif (manifest['state']=='BLOCKED' and (manifest['routing']['reason']!='RESEARCH_UNAVAILABLE'
or (manifest['routing']['retry_not_before'] and datetime.fromisoformat(
manifest['routing']['retry_not_before'].replace('Z','+00:00')) > datetime.now(timezone.utc)))):
raise APIError('API_CONTROL_RETRY_STATE',409)
if manifest['state'] in {'OUTPUT_READY','HANDED_OFF','FAILED_TO_ANALYZE'}:
raise APIError('API_CONTROL_RETRY_STATE',409)
command=self.control.enqueue(client.id,manifest['project']['id'],manifest,
repair_reason=data['reason'].strip() if repair else None)
return {'status':'QUEUED','command_id':command}
raise APIError('API_CONTROL_INVALID',422)
+97
View File
@@ -0,0 +1,97 @@
import hashlib
import hmac
import os
import secrets
import time
from .config import APIError,Client,directory
from .users import Users,database
COOKIE='vba_session'
class Sessions:
def __init__(self,config,*,clock=time.time):
self.config=config;self.clock=clock
self.users=Users(config.users_database or config.workspace/'auth/users.sqlite3')
root=directory(config.workspace/'runtime/web-sessions')
self.path=root/'sessions.sqlite3'
if not self.path.exists():
try:
descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(descriptor)
except FileExistsError:pass
with database(self.path) as db:
db.executescript('''
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, username TEXT NOT NULL, version TEXT NOT NULL, csrf TEXT NOT NULL, expires REAL NOT NULL);
CREATE TABLE IF NOT EXISTS claims (handle TEXT NOT NULL, request_id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, PRIMARY KEY(handle,request_id,revision));
CREATE TABLE IF NOT EXISTS attempts (id TEXT PRIMARY KEY, count INTEGER NOT NULL, until REAL NOT NULL);
''')
@staticmethod
def digest(value):return hashlib.sha256(value.encode()).hexdigest()
def _prune(self,db):
db.execute('DELETE FROM sessions WHERE expires<=?',(self.clock(),))
# Password changes/deletion invalidate sessions and their editing claims.
for row in db.execute('SELECT id,username,version FROM sessions').fetchall():
user=self.users.get(row['username'])
if not user or user['version']!=row['version']:
db.execute('DELETE FROM sessions WHERE id=?',(row['id'],))
def login(self,name,password,remote):
if not isinstance(name,str) or not isinstance(password,str) or not 1<=len(name)<=64 or not 1<=len(password)<=1024:
raise APIError('AUTH_LOGIN_INVALID',401)
now=self.clock()
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE')
db.execute('DELETE FROM attempts WHERE until<=?',(now,))
keys=[('user:'+name.casefold(),10),('ip:'+remote,30)]
for value,maximum in keys:
identifier=self.digest(value)
row=db.execute('SELECT count FROM attempts WHERE id=?',(identifier,)).fetchone()
if row and row['count']>=maximum:raise APIError('AUTH_LOGIN_LIMIT',429)
for value,_ in keys:
db.execute('INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(id) DO UPDATE SET count=count+1',
(self.digest(value),now+900))
user=self.users.verify(name,password)
if not user:raise APIError('AUTH_LOGIN_INVALID',401)
token=secrets.token_urlsafe(32);csrf=secrets.token_urlsafe(32);expires=now+self.config.session_seconds
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE');self._prune(db)
db.execute('DELETE FROM attempts WHERE id=?',(self.digest('user:'+name.casefold()),))
db.execute('INSERT INTO sessions VALUES (?,?,?,?,?)',(self.digest(token),user['name'],user['version'],csrf,expires))
return token,{'username':user['name'],'csrf':csrf,'expires_at':expires}
def authenticate(self,request):
token=request.cookies.get(COOKIE,'')
if not token or len(token)>128:raise APIError('API_UNAUTHORIZED',401)
identifier=self.digest(token)
with database(self.path) as db:
row=db.execute('SELECT * FROM sessions WHERE id=? AND expires>?',(identifier,self.clock())).fetchone()
user=self.users.get(row['username']) if row else None
if not user or user['version']!=row['version']:raise APIError('API_UNAUTHORIZED',401)
if request.method not in {'GET','HEAD','OPTIONS'}:
csrf=request.headers.get('x-csrf-token','')
if not csrf.isascii() or not hmac.compare_digest(csrf,row['csrf']):
raise APIError('AUTH_CSRF',403)
request.state.session_id=identifier
request.state.session_info=dict(row)
# Human accounts deliberately have the same complete web permissions.
return Client('user-'+user['name'],'WEB_SESSION',tuple(p.id for p in self.config.projects),operator=True,tester=True)
def logout(self,identifier):
with database(self.path) as db:db.execute('DELETE FROM sessions WHERE id=?',(identifier,))
def claim(self,identifier,handle,request_id,revision,*,check=False,release=False):
with database(self.path) as db:
db.execute('BEGIN IMMEDIATE');self._prune(db)
if not db.execute('SELECT 1 FROM sessions WHERE id=?',(identifier,)).fetchone():
raise APIError('API_UNAUTHORIZED',401)
identity=(handle,request_id,revision)
row=db.execute('SELECT session_id FROM claims WHERE handle=? AND request_id=? AND revision=?',identity).fetchone()
if row and row['session_id']!=identifier:raise APIError('API_TEST_CLAIMED',409)
if check and not row:raise APIError('API_TEST_CLAIM_REQUIRED',409)
if release:
db.execute('DELETE FROM claims WHERE handle=? AND request_id=? AND revision=? AND session_id=?',identity+(identifier,))
elif not row:
db.execute('INSERT INTO claims VALUES (?,?,?,?)',identity+(identifier,))
@@ -0,0 +1,91 @@
"""Authenticated human observations resume the existing case-local test contract."""
from .config import APIError
from .cases import Cases, case_key
from .dashboard import read_json
from viano_bug_analysis.lifecycle.async_tests import AsyncTestCoordinator
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
from viano_bug_analysis.lifecycle.locking import FileLock
from viano_bug_analysis.lifecycle.storage import key
from viano_bug_analysis.lifecycle.job import timestamp
from viano_bug_analysis.package import PackageReader
class TestRequests:
def __init__(self,service):
self.service=service
self.cases=Cases(service)
def request(self,reader):
m=reader.manifest
value=read_json(reader,m['artifacts'].get('async_test_request'))
if (value.get('executor')!='human' or any(value.get(k)!=m[k]
for k in ('case_id','package_id','analysis_revision'))):
return None
return value
def pending(self,client,handle,request_id,revision):
with PackageReader(self.cases.head(handle,client)) as reader:
value=self.request(reader)
if (reader.manifest['state']!='BLOCKED' or reader.manifest['routing']['reason']!='AWAITING_TEST_FEEDBACK'
or not value or (value['request_id'],value['analysis_revision'])!=(request_id,revision)):
raise APIError('API_TEST_REQUEST_STALE',409)
def listing(self,client):
rows=[];errors=0
for folder in self.service.workspace.cases.glob('*/*'):
try:
handle=folder.parent.name+'.'+folder.name
path=self.cases.head(handle,client)
with PackageReader(path) as reader:
m=reader.manifest
if m['state']!='BLOCKED' or m['routing']['reason']!='AWAITING_TEST_FEEDBACK':
continue
request=self.request(reader)
if not request:
continue
ref=m['artifacts']['async_test_request']
cp=next(cp for _,cp in checkpoint_chain(reader) if cp['details'].get('operation')=='async-test-wait'
and cp['details'].get('request_ref')==ref)
case=read_json(reader,m['artifacts'].get('case'))
title=next((r.get('title') for r in case.get('bug',{}).get('reports',[]) if r.get('title')),m['case_id'])
rows.append({'key':handle,'case_id':m['case_id'],'project_id':m['project']['id'],
'title':title,'received_at':cp['created_at'],'request':request})
except APIError as error:
if error.status!=404:
errors+=1
except Exception:
errors+=1
return {'can_answer':client.operator or client.tester,'requests':sorted(rows,key=lambda r:(r['received_at'],r['key'])),
'read_errors':errors}
def answer(self,client,handle,data):
if not (client.operator or client.tester):
raise APIError('API_FORBIDDEN',403)
fields={'request_id','analysis_revision','outcome','observations','environment','confirmed'}
if (not isinstance(data,dict) or set(data)!=fields or data['confirmed'] is not True
or type(data['analysis_revision']) is not int
or data['outcome'] not in ('completed','unable_to_test')
or not isinstance(data['environment'],str) or not data['environment'].strip()
or len(data['environment'])>4000 or not isinstance(data['observations'],list)
or not 1<=len(data['observations'])<=100
or any(not isinstance(s,str) or not s.strip() or len(s)>12000 for s in data['observations'])):
raise APIError('API_TEST_FEEDBACK_INVALID',422)
path=self.cases.head(handle,client)
with PackageReader(path) as reader:
case_id=reader.manifest['case_id']
with FileLock(self.service.workspace.runtime/'locks'/('trial-'+key(case_id)+'.lock')):
path=self.cases.head(handle,client)
with PackageReader(path) as reader:
request=self.request(reader)
if not request or (request['request_id'],request['analysis_revision'])!=(data['request_id'],data['analysis_revision']):
raise APIError('API_TEST_REQUEST_STALE',409)
feedback={k:request[k] for k in ('case_id','package_id','analysis_revision','request_id','hypothesis_id','action_id','executor','format_version')}
feedback.update(format='viano-async-test-feedback',outcome=data['outcome'],observations=data['observations'],
environment=data['environment'],tester_id=client.id,attachments=[],
confirmation={'confirmed':True,'confirmed_by':client.id,'confirmed_at':timestamp()})
saved=read_json(reader,reader.manifest['artifacts'].get('async_test_feedback'))
# Network retries preserve the original server confirmation timestamp.
if saved and all(saved.get(k)==v for k,v in feedback.items() if k!='confirmation') and saved.get('confirmation',{}).get('confirmed_by')==client.id:
return {'status':'RESUMED','reused':True}
result=AsyncTestCoordinator(self.service.store).confirm_feedback(feedback)
return {'status':'RESUMED','reused':result.reused}
+84
View File
@@ -0,0 +1,84 @@
"""Portable password database. Only privileged console commands mutate users."""
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from .config import APIError,directory
@contextmanager
def database(path, *, readonly=False):
path=Path(path).absolute()
for part in [*path.parents,path]:
if part.is_symlink() or (part.exists() and getattr(part.lstat(),'st_file_attributes',0)&0x400):
raise APIError('AUTH_UNSAFE_STORAGE',500)
connection=sqlite3.connect(path.as_uri()+'?mode=ro' if readonly else str(path),uri=readonly,timeout=5)
connection.row_factory=sqlite3.Row
connection.execute('PRAGMA foreign_keys=ON')
try:
with connection:
yield connection
finally:
connection.close()
def password_hash(password,salt):
return hashlib.scrypt(password.encode('utf-8'),salt=salt,n=32768,r=8,p=1,maxmem=64*1024*1024).hex()
class Users:
def __init__(self,path):
self.path=Path(path)
def get(self,name):
if not self.path.exists():return None
with database(self.path,readonly=True) as db:
row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone()
return dict(row) if row else None
def verify(self,name,password):
row=self.get(name)
actual=password_hash(password,bytes.fromhex(row['salt']) if row else b'\0'*16)
return row if row and hmac.compare_digest(actual,row['password_hash']) else None
def manage(self,action,name,password=None,old_password=None):
if not isinstance(name,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}',name):
raise APIError('AUTH_USER_INVALID',400)
if action!='delete_user' and (not isinstance(password,str) or not 1<=len(password)<=1024):
raise APIError('AUTH_PASSWORD_INVALID',400)
if action=='change_pw_user' and (not isinstance(old_password,str) or len(old_password)>1024):
raise APIError('AUTH_PASSWORD_INVALID',400)
directory(self.path.parent)
if not self.path.exists():
descriptor=os.open(self.path,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o640);os.close(descriptor)
with database(self.path) as db:
db.execute('CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY COLLATE NOCASE, salt TEXT NOT NULL, password_hash TEXT NOT NULL, version TEXT NOT NULL)')
db.execute('BEGIN IMMEDIATE')
row=db.execute('SELECT * FROM users WHERE name=? COLLATE NOCASE',(name,)).fetchone()
if action=='create_user' and row:raise APIError('AUTH_USER_EXISTS',409)
if action!='create_user' and not row:raise APIError('AUTH_USER_NOT_FOUND',404)
if action=='delete_user':
db.execute('DELETE FROM users WHERE name=? COLLATE NOCASE',(name,));return
if action=='change_pw_user' and not hmac.compare_digest(password_hash(old_password,bytes.fromhex(row['salt'])),row['password_hash']):
raise APIError('AUTH_OLD_PASSWORD',403)
salt=secrets.token_bytes(16)
digest=password_hash(password,salt)
if action=='create_user':
db.execute('INSERT INTO users VALUES (?,?,?,?)',(name,salt.hex(),digest,secrets.token_hex(16)))
else:
db.execute('UPDATE users SET salt=?,password_hash=?,version=? WHERE name=? COLLATE NOCASE',
(salt.hex(),digest,secrets.token_hex(16),name))
def require_privileged():
if os.name=='nt':
import ctypes
allowed=bool(ctypes.windll.shell32.IsUserAnAdmin())
else:
allowed=os.geteuid()==0
if not allowed:raise APIError('AUTH_PRIVILEGED_REQUIRED',403)
+68
View File
@@ -0,0 +1,68 @@
"""Non-billable readiness checks for supervised Linux services."""
import argparse
import json
import re
import shutil
import tempfile
from .api.config import APIConfig,APIError,directory
from .api.sessions import Sessions
from .api.users import database
from .worker.config import WorkerConfig,load_project
def check(role,path,*,offline=False,loader=load_project):
checks=[]
try:
if role not in {'api','worker'}:raise APIError('DEPLOY_ROLE')
worker=WorkerConfig.load(path) if role=='worker' else None
config=worker.intake if worker else APIConfig.load(path)
if config.host not in {'127.0.0.1','::1','localhost'}:
raise APIError('DEPLOY_LOOPBACK_REQUIRED')
workspace=directory(config.workspace)
reserve=worker.min_free_bytes if worker else config.limits.min_free_bytes
if shutil.disk_usage(workspace).free<reserve:raise APIError('DEPLOY_STORAGE_LOW')
runtime=directory(workspace/'runtime')
with tempfile.TemporaryFile(dir=runtime) as probe:probe.write(b'readiness')
checks.append('workspace_writable_and_space_available')
if role=='api':
tokens=config.tokens()
if len(tokens)!=len(config.clients):raise APIError('DEPLOY_MACHINE_CREDENTIALS')
sessions=Sessions(config)
if not sessions.users.path.is_file():raise APIError('DEPLOY_USERS_MISSING')
with database(sessions.users.path,readonly=True) as db:
if not db.execute('SELECT 1 FROM users LIMIT 1').fetchone():raise APIError('DEPLOY_USERS_EMPTY')
checks+=['user_database_readable','session_database_ready','machine_credentials_configured']
else:
for project in config.projects:
gateway,policies,_=loader(project) # preflight only; never generate()
research=policies.get('project_research')
if not research or not research.source or not research.database:
raise APIError('DEPLOY_RESEARCH_REQUIRED')
checks.append('profile_and_cloud_credentials_configured')
if not offline:
research.source.inventory() # pinned Git objects, no fetch or checkout
for table in getattr(research.database,'schema_targets',research.database.tables):
research.database.query({'kind':'database.schema','table':table})
checks+=['pinned_source_readable','allowed_database_schemas_readable']
return {'status':'CONFIG_READY' if offline else 'READY','role':role,'checks':checks,
'research_tested':role=='worker' and not offline,'cloud_api_tested':False,'model_requests':0}
except Exception as error:
code=getattr(error,'code','DEPLOY_CHECK_FAILED')
if not isinstance(code,str) or not re.fullmatch('[A-Z][A-Z0-9_]{0,79}',code):code='DEPLOY_CHECK_FAILED'
return {'status':'NOT_READY','role':role,'checks':checks,'code':code,'model_requests':0}
def main(argv=None):
parser=argparse.ArgumentParser(description='Check service dependencies; never invokes an LLM')
parser.add_argument('command',choices=['check'])
parser.add_argument('--role',choices=['api','worker'],required=True)
parser.add_argument('--config',required=True)
parser.add_argument('--offline',action='store_true',help='skip Git object and database access')
args=parser.parse_args(argv)
result=check(args.role,args.config,offline=args.offline)
print(json.dumps(result))
return 0 if result['status'] in {'READY','CONFIG_READY'} else 1
if __name__=='__main__':raise SystemExit(main())
@@ -29,7 +29,7 @@ class AsyncTestResult:
class AsyncTestCoordinator:
"""Trusted controller API, never a public HTTP endpoint or direct LLM tool.
A future authenticated web adapter calls confirm_feedback after approval.
The authenticated web adapter calls confirm_feedback after confirmation.
No messages are sent and no clients/bots/labs are controlled here.
"""
@@ -345,7 +345,10 @@ class CheckpointStore:
resume=State.INVESTIGATING_HYPOTHESES
check_transition(manifest["state"], target, resume)
if manual_retry and (manifest["state"] != State.BLOCKED
or manifest["routing"]["reason"] != "INVALID_LLM_RESPONSES"
or not (manifest["routing"]["reason"] == "INVALID_LLM_RESPONSES"
or (manifest['routing']['reason']=='ENTITY_RESOLUTION_INVALID_RESPONSES'
and (details or {}).get('operation')=='operator-entity-repair'
and target==State.INTERPRETING_CASE))
or target != resume or additions or artifacts):
raise LifecycleError("INVALID_RETRY", "Manual repair retry requires an unchanged technical-response block")
if manifest["state"] == State.BLOCKED and not manual_retry and not self.retry_due(manifest):
@@ -593,8 +593,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
}
}
},
@@ -618,8 +618,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
},
"columns": {
"type": "array",
@@ -507,8 +507,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
}
}
},
@@ -532,8 +532,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
},
"columns": {
"type": "array",
@@ -1534,8 +1534,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
}
}
},
@@ -1559,8 +1559,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
},
"columns": {
"type": "array",
@@ -62,8 +62,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
}
}
},
@@ -87,8 +87,8 @@
},
"table": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"maxLength": 64
"pattern": "^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$",
"maxLength": 129
},
"columns": {
"type": "array",
@@ -5,9 +5,9 @@
"kind":{"enum":["source.files","database.tables"]},"text":{"type":"string","maxLength":200},
"offset":{"type":"integer","minimum":0,"maximum":20000},"limit":{"type":"integer","minimum":1}}},
{"type":"object","additionalProperties":false,"required":["kind","table"],"properties":{
"kind":{"const":"database.schema"},"table":{"$ref":"#/$defs/identifier"}}},
"kind":{"const":"database.schema"},"table":{"type":"string","pattern":"^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$","maxLength":129}}},
{"type":"object","additionalProperties":false,"required":["kind","table","columns","filters","offset","limit"],"properties":{
"kind":{"enum":["database.rows","database.count"]},"table":{"$ref":"#/$defs/identifier"},
"kind":{"enum":["database.rows","database.count"]},"table":{"type":"string","pattern":"^(?:[A-Za-z_][A-Za-z0-9_]{0,63}\\.)?[A-Za-z_][A-Za-z0-9_]{0,63}$","maxLength":129},
"columns":{"type":"array","uniqueItems":true,"maxItems":30,"items":{"$ref":"#/$defs/identifier"}},
"filters":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","additionalProperties":false,"required":["column","operator","value"],"properties":{
"column":{"$ref":"#/$defs/identifier"},"operator":{"enum":["eq","ne","lt","le","gt","ge","contains","in"]},
@@ -10,22 +10,32 @@ from .config import ToolError
class ContextExpansion:
def __init__(self, service, settings):
from .sql_database import is_table_reference
self.service = service
self.rules = settings.get("relationships", [])
self.text_rules = settings.get("texts", [])
self.name_rules = [r for r in settings.get("entities", []) if "name_column" in r]
for rule in self.name_rules:
if (any(not isinstance(rule.get(k),str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}",rule[k])
for k in ('entity_type','table','column','name_column'))
for k in ('entity_type','column','name_column'))
or not is_table_reference(rule.get('table'))
or not isinstance(rule.get('columns'),list)
or not {rule['column'],rule['name_column']} <= set(rule['columns'])):
raise ToolError("PROJECT_ENRICHMENT_CONFIG")
self.max_queries = settings.get("max_queries", 40)
self.max_depth = settings.get("max_depth", 2)
self.max_relationships = settings.get("max_relationships", 128)
if any(type(v) is not int or v <= 0 for v in (self.max_queries, self.max_depth, self.max_relationships)):
raise ToolError("PROJECT_ENRICHMENT_CONFIG")
def same_table(self,first,second):
if first==second:return True
provider=self.service.database
if not callable(getattr(provider,'resolve_table',None)):return False
try:return provider.resolve_table(first)==provider.resolve_table(second)
except ToolError:return False
def collect(self, queries):
queue = [(copy.deepcopy(q), 0) for q in queries]
seen, edges, entities = set(), [], []
@@ -51,7 +61,7 @@ class ContextExpansion:
names = []
if query["kind"] == "database.rows":
for rule in self.name_rules:
if rule["table"] != query["table"]:
if not self.same_table(rule['table'],query['table']):
continue
for index, item in enumerate(capture["result"]["items"]):
row = item.get("row", {})
@@ -68,7 +78,7 @@ class ContextExpansion:
if query["kind"] == "database.rows":
for rule in self.text_rules:
if rule["table"] != query["table"]:
if not self.same_table(rule['table'],query['table']):
continue
for i, item in enumerate(capture["result"]["items"]):
row = item.get("row", {})
@@ -93,7 +103,7 @@ class ContextExpansion:
if query["kind"] != "database.rows":
continue
for rule in self.rules:
if rule["from_table"] != query["table"]:
if not self.same_table(rule['from_table'],query['table']):
continue
for i, item in enumerate(capture["result"]["items"]):
row = item.get("row", {})
@@ -54,9 +54,10 @@ class DatabaseRelations:
for index, node in enumerate(profile['nodes']):
if not isinstance(node, dict) or set(node) - {'name', 'table', 'columns', 'match_column', 'parent', 'parent_column', 'group_by'}:
raise ToolError('PROJECT_RELATIONS_CONFIG')
for key in ('name', 'table', 'match_column'):
for key in ('name', 'match_column'):
identifier(node[key])
if node['name'] in seen or node['table'] not in provider.tables:
provider.resolve_table(node['table'])
if node['name'] in seen:
raise ToolError('PROJECT_RELATIONS_CONFIG')
columns = node['columns']
if (not isinstance(columns, list) or not 1 <= len(columns) <= 30
@@ -100,13 +101,16 @@ class DatabaseRelations:
cursor.execute('SET SESSION max_statement_time = %s', (provider.limits.timeout_seconds,))
cursor.execute('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ')
cursor.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
tables = sorted({n['table'] for n in profile['nodes']})
cursor.execute('SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES '
'WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN (' + ','.join('%s' for _ in tables) + ')',
(provider.database, *tables))
engines = {r['TABLE_NAME']: r['ENGINE'] for r in cursor.fetchall()}
if set(engines) != set(tables) or any(str(e).lower() not in {'innodb', 'xtradb'} for e in engines.values()):
raise ToolError('PROJECT_RELATIONS_SNAPSHOT_UNSUPPORTED')
tables={provider.resolve_table(n['table']) for n in profile['nodes']}
# One connection and one read-only snapshot, also across schemas.
for database in sorted({db for db,_ in tables}):
names=sorted(table for db,table in tables if db==database)
cursor.execute('SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES '
'WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN (' + ','.join('%s' for _ in names) + ')',
(database,*names))
engines = {r['TABLE_NAME']: r['ENGINE'] for r in cursor.fetchall()}
if set(engines)!=set(names) or any(str(e).lower() not in {'innodb','xtradb'} for e in engines.values()):
raise ToolError('PROJECT_RELATIONS_SNAPSHOT_UNSUPPORTED')
# Schema and rows are read through the same transaction/connection.
for node in profile['nodes']:
if time.monotonic() >= deadline:
@@ -146,6 +150,8 @@ class DatabaseRelations:
'returned_rows': len(rows), 'local_truncated': result['truncated'],
'upstream_complete': inherited, 'complete': complete,
'coverage': 'complete_configured_relation' if complete else 'partial_configured_relation'}
if provider.additional_databases or '.' in node['table']:
item['database']=provider.resolve_table(node['table'])[0]
if node.get('group_by'):
counts = {}
for row in rows:
@@ -31,8 +31,8 @@ class ProjectLimits:
class ProjectResearch:
"""Provider-independent boundary used by case enrichment and investigation.
Configurations are trusted operator inputs; case/model inputs cannot select
a URL, directory, credentials, database, branch, or arbitrary SQL.
Configurations are trusted operator inputs. Models can select only configured
database/table references, never URLs, directories, credentials or arbitrary SQL.
"""
def __init__(self, project, *, source=None, database=None, limits=None, relations=None, source_analysis=None):
@@ -131,7 +131,7 @@ class ProjectResearch:
def collect(self, arguments, *, source_context=None):
self.validate(arguments)
provider = self.database if arguments["kind"].startswith("database.") else self.source
binding = provider.binding()
binding = provider.binding_for(arguments) if callable(getattr(provider,'binding_for',None)) else provider.binding()
if arguments['kind'] == 'database.relations':
binding = {**binding, 'relation_profile': arguments['profile'],
'consistency': 'single_repeatable_read_read_only_transaction',
+87 -15
View File
@@ -16,6 +16,10 @@ def identifier(value):
return "`" + value + "`"
def is_table_reference(value):
return isinstance(value,str) and re.fullmatch(r'(?:[A-Za-z_][A-Za-z0-9_]{0,63}\.)?[A-Za-z_][A-Za-z0-9_]{0,63}',value) is not None
def scalar(value):
if isinstance(value, decimal.Decimal):
return str(value) # retain exact SQL decimal precision
@@ -30,25 +34,88 @@ def scalar(value):
class SQLDatabaseProvider:
def __init__(self, host, port, database, user, snapshot_id,
tables, *, password_env=None, password_file=None, limits=None, connection_factory=None):
tables, *, password_env=None, password_file=None, limits=None, connection_factory=None,
additional_databases=None):
self.limits = limits or ProjectLimits()
if (not isinstance(host, str) or type(port) is not int or not 0 < port < 65536
or not user or bool(password_env) == bool(password_file) or not snapshot_id or not tables):
raise ToolError("PROJECT_DATABASE_CONFIG")
identifier(database)
for table in tables:
identifier(table)
def allowlist(value):
if (not isinstance(value,(list,tuple)) or not value or any(not isinstance(t,str) for t in value)
or len(set(value))!=len(value)):
raise ToolError('PROJECT_DATABASE_CONFIG')
if list(value)!=['*']:
for table in value:identifier(table)
return tuple(value)
default_tables=allowlist(tables)
if additional_databases is not None and not isinstance(additional_databases,dict):
raise ToolError('PROJECT_DATABASE_CONFIG')
self.additional_databases={}
for name,allowed in (additional_databases or {}).items():
identifier(name)
if name==database:raise ToolError('PROJECT_DATABASE_CONFIG')
self.additional_databases[name]=allowlist(allowed)
self.table_allowlists={database:default_tables,**self.additional_databases}
self.host, self.port, self.database = host, port, database
self.user, self.password_env, self.snapshot_id = user, password_env, snapshot_id
self.password_file = Path(password_file) if password_file else None
self.tables = tuple(tables)
self.tables = default_tables
self.connection_factory = connection_factory
self._schemas = {}
def binding(self):
# No credentials or server address are written into portable case packages.
return {"kind":"mariadb", "database":self.database, "snapshot_id":self.snapshot_id,
result={"kind":"mariadb", "database":self.database, "snapshot_id":self.snapshot_id,
"tables":sorted(self.tables), "time_scope":"configured_snapshot_not_current_production"}
if self.additional_databases:
result['additional_databases']={name:sorted(tables) for name,tables in sorted(self.additional_databases.items())}
result['table_reference']='database.table; unqualified tables use the default database only'
return result
def binding_for(self,args):
result=self.binding()
if args.get('table'):
database,table=self.resolve_table(args['table'])
if self.additional_databases or '.' in args['table']:
result.update(selected_database=database,selected_table=table)
return result
def resolve_table(self,reference):
if not is_table_reference(reference):raise ToolError('PROJECT_DATABASE_IDENTIFIER')
parts=reference.split('.')
database,table=(self.database,parts[0]) if len(parts)==1 else parts
allowed=self.table_allowlists.get(database)
if allowed is None:raise ToolError('PROJECT_DATABASE_FORBIDDEN')
if table not in allowed and allowed!=('*',):raise ToolError('PROJECT_DATABASE_TABLE_FORBIDDEN')
return database,table
def _table_inventory(self):
result=[]
for database,allowed in sorted(self.table_allowlists.items()):
if allowed==('*',):
connection=self._connect()
try:
with connection.cursor() as cursor:
cursor.execute('SET SESSION max_statement_time = %s',(self.limits.timeout_seconds,))
cursor.execute('SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=%s ORDER BY TABLE_NAME LIMIT %s',
(database,self.limits.max_files+1))
names=[row['TABLE_NAME'] for row in cursor.fetchall()]
if not names:raise ToolError('PROJECT_DATABASE_TABLE_UNKNOWN')
if len(names)>self.limits.max_files:raise ToolError('PROJECT_DATABASE_TABLE_LIMIT')
except ToolError:raise
except Exception:raise ToolError('PROJECT_DATABASE_QUERY_FAILED') from None
finally:connection.close()
else:names=allowed
for table in sorted(names):
if not is_table_reference(table) or '.' in table:raise ToolError('PROJECT_DATABASE_IDENTIFIER')
reference=f'{database}.{table}' if self.additional_databases else table
result.append({'database':database,'table':reference})
return result
@property
def schema_targets(self):
return tuple(row['table'] for row in self._table_inventory())
def _connect(self):
if self.connection_factory:
@@ -75,8 +142,7 @@ class SQLDatabaseProvider:
def validate(self, args):
if args["kind"] == "database.tables":
return
if args["table"] not in self.tables:
raise ToolError("PROJECT_DATABASE_TABLE_FORBIDDEN")
self.resolve_table(args['table'])
for clause in args.get("filters", []):
if ((clause["operator"] == "in") != isinstance(clause["value"], list)
or (clause["operator"] == "contains" and not isinstance(clause["value"], str))
@@ -91,12 +157,13 @@ class SQLDatabaseProvider:
def _schema(self, table):
"""Bounded metadata preflight; does not return table data to the model."""
if table not in self._schemas:
database,name=self.resolve_table(table)
connection = self._connect()
try:
with connection.cursor() as cursor:
cursor.execute("SET SESSION max_statement_time = %s", (self.limits.timeout_seconds,))
cursor.execute("SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION", (self.database,table))
"WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION", (database,name))
schema = cursor.fetchall()
if not schema:
raise ToolError("PROJECT_DATABASE_TABLE_UNKNOWN")
@@ -132,11 +199,14 @@ class SQLDatabaseProvider:
def query(self, args):
self.validate(args)
if args["kind"] == "database.tables":
names = sorted(t for t in self.tables if args["text"].casefold() in t.casefold())
names = [row for row in self._table_inventory() if args['text'].casefold() in row['table'].casefold()]
page = names[args["offset"]:args["offset"]+args["limit"]]
truncated = len(names) > args["offset"]+len(page)
return {"items":[{"table":t} for t in page], "coverage":"configured_table_allowlist",
result={"items":page if self.additional_databases or self.tables==('*',) else [{'table':row['table']} for row in page], "coverage":"configured_table_allowlist",
"truncated":truncated, "next_offset":args["offset"]+len(page) if truncated else None}
if any(tables==('*',) for tables in self.table_allowlists.values()):
result['interpretation']='Configured databases only; wildcard catalogs include metadata-visible tables. Database grants may hide other tables.'
return result
connection = self._connect()
try:
with connection.cursor() as cursor:
@@ -156,25 +226,27 @@ class SQLDatabaseProvider:
def _query_cursor(self, cursor, args, *, schema=None):
# Metadata is constrained by the configured database/table, not arbitrary SQL.
database,name=self.resolve_table(args['table'])
scope={'database':database} if self.additional_databases or '.' in args['table'] else {}
if schema is None:
cursor.execute("SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION",
(self.database, args["table"]))
(database, name))
schema = cursor.fetchall()
names = [c["COLUMN_NAME"] for c in schema]
if not names:
raise ToolError("PROJECT_DATABASE_TABLE_UNKNOWN")
if args["kind"] == "database.schema":
return {"items":[{"table":args["table"], "columns":schema}], "coverage":"schema_only", "truncated":False}
return {"items":[{"table":args["table"], "columns":schema,**scope}], "coverage":"schema_only", "truncated":False}
requested = set(args["columns"]) | {f["column"] for f in args["filters"]}
if not requested <= set(names):
raise ToolError("PROJECT_DATABASE_COLUMN_UNKNOWN")
columns = args["columns"] or names[:30]
where, values = self.compile_filters(args["filters"])
table = identifier(args["table"])
table = identifier(database)+'.'+identifier(name) if '.' in args['table'] else identifier(name)
if args["kind"] == "database.count":
cursor.execute("SELECT COUNT(*) AS matched_rows FROM " + table + " WHERE " + where, values)
return {"items":cursor.fetchall(), "coverage":"complete_filtered_count", "truncated":False}
return {"items":[{**row,**scope} for row in cursor.fetchall()], "coverage":"complete_filtered_count", "truncated":False}
# Deterministic order uses unique key when possible, otherwise all selected columns.
order = [c["COLUMN_NAME"] for c in schema if c["COLUMN_KEY"] == "PRI"] or columns
sql = ("SELECT " + ",".join(identifier(c) for c in columns) + " FROM " + table + " WHERE " + where
@@ -182,6 +254,6 @@ class SQLDatabaseProvider:
cursor.execute(sql, (*values, args["limit"]+1, args["offset"]))
rows = cursor.fetchall()
truncated = len(rows) > args["limit"]
return {"items":[{"table":args["table"], "row":{k:scalar(v) for k,v in row.items()}} for row in rows[:args["limit"]]],
return {"items":[{"table":args["table"],**scope, "row":{k:scalar(v) for k,v in row.items()}} for row in rows[:args["limit"]]],
"coverage":"partial_filtered_rows" if truncated or args["offset"] else "complete_filtered_rows",
"truncated":truncated, "next_offset":args["offset"]+args["limit"] if truncated else None}
+135
View File
@@ -0,0 +1,135 @@
'use strict';
let selectedCase=null, detailLoading=false, detailHash=null;
const words={observed_behavior:'Beobachtetes Verhalten',expected_behavior:'Erwartetes Verhalten',tester_assumptions:'Testerannahmen',confirmed_technical_facts:'Bestätigte technische Fakten',behavior_gap:'Abweichung',first_known_divergence:'Erste bekannte Abweichung',failure_stage:'Fehlerstelle',contradictions:'Widersprüche',unknowns:'Offene Fragen',relevant_entities:'Relevante Entitäten',investigation_direction:'Untersuchungsrichtung',interpretation_summary:'Einordnung',described_behavior:'Beschriebenes Verhalten',provisional_behavior_gap:'Vorläufige Abweichung',mechanism:'Vermuteter Mechanismus',rationale:'Begründung',expected_evidence:'Erwartete Belege',falsifying_evidence:'Mögliche Gegenbelege',open_questions:'Offene Fragen',text:'Aussage',scope:'Geltungsbereich',reason:'Begründung',status:'Status',confidence:'Confidence',selected_hypothesis:'Gewählte Hypothese',root_cause:'Fehlerursache',evaluations:'Bewertungen der Hypothesen',remaining_uncertainty:'Verbleibende Unsicherheit',recommended_human_actions:'Empfohlene menschliche Maßnahmen',decision_basis:'Entscheidungsgrundlage',causal_claim:'Kausale Aussage',supporting_evidence:'Stützende Belege',counter_evidence:'Gegenbelege',scope_limits:'Geltungsgrenzen',title:'Aufgabe',instructions:'Anweisungen',expected_results:'Erwartetes Ergebnis',problem_summary:'Problem',required_change:'Erforderliche Änderung',implementation_intent:'Umsetzungsziel',affected_scope:'Betroffener Bereich',implementation_guidance:'Hinweise zur Umsetzung',validation_requirements:'Validierung',constraints:'Vorgaben',preserve:'Beibehalten',forbidden_changes:'Unzulässige Änderungen',do_not_fix_by:'Zu vermeidende Lösungswege',missing_information:'Fehlende Informationen',placeholders:'Platzhalter',diagnostic_caveats:'Diagnostische Einschränkungen',coverage:'Abdeckung',kind:'Art',operation:'Werkzeug',duplicate_of:'Bereits erfasster Beleg',hypothesis_id:'Hypothese',resolved:'Beantwortet',author:'Beobachter',patch_id:'Patch',tested_build:'Getesteter Build',summary:'Zusammenfassung',observations:'Beobachtungen',outcome:'Rückmeldung',entity_id:'Entität',required:'Erforderlich',description:'Beschreibung',value:'Wert',column:'Spalte',table:'Tabelle',source_ref:'Quelle',evidence_refs:'Belegreferenzen',source_ids:'Quellen',interpretation_ids:'Interpretationsreferenzen'};
const values={unassessed:'Unbewertet',weak:'Schwach',plausible:'Plausibel',supported:'Gestützt',strongly_supported:'Stark gestützt',weakened:'Geschwächt',confirmed:'Bestätigt',inconclusive:'Nicht abschließend geklärt',assigned:'Auftrag vorhanden',none:'Kein Auftrag',ready:'Bereit',ready_with_placeholders:'Vorlage mit Platzhaltern',refuted:'Verworfen',finished:'Abgeschlossen',open:'Offen',investigation_finished:'Untersuchung abgeschlossen',patch_failed:'Patch behebt den Fehler nicht',validation_failed:'Validierung fehlgeschlagen',new_evidence:'Neue Belege',review_feedback:'Review-Rückmeldung'};
function friendly(value){return typeof value==='string'?(values[value]||value):value===true?'Ja':value===false?'Nein':String(value);}
Object.assign(words,{completeness:'Vollständigkeit',statements:'Aussagen',observed_at:'Beobachtet am',received_at:'Eingegangen am',revision:'Revision',effect:'Wirkung',support:'Dafür',against:'Dagegen'});
Object.assign(values,{missing:'Nicht erfasst',complete:'Vollständig',partial:'Teilweise erfasst'});
function semantic(value,depth=0){
if(value===null||value===undefined)return text('p','Nicht angegeben.','hint');
if(Array.isArray(value)){
if(!value.length)return text('p','Keine Angaben.','hint');
const list=text('div','','semantic-list');value.forEach(v=>{const item=text('div','','semantic-item');item.append(semantic(v,depth+1));list.append(item);});return list;
}
if(typeof value!=='object')return text('p',friendly(value),'semantic-text');
if(typeof value.text==='string'){
const block=text('div','','statement');block.append(text('p',value.text,'semantic-text'));
for(const field of ['scope','author','observed_at'])if(value[field])block.append(text('small',`${words[field]||field}: ${value[field]}`));
for(const field of ['evidence_refs','source_ids'])if(value[field]?.length)block.append(text('div',value[field].join(' · '),'reference-list'));
return block;
}
const dl=text('dl','','semantic-fields');Object.entries(value).forEach(([key,child])=>{
if(key==='provenance'||key==='origin')return;
dl.append(text('dt',words[key]||key.replaceAll('_',' ')));const dd=document.createElement('dd');dd.append(semantic(child,depth+1));dl.append(dd);
});return dl;
}
function panel(title,content,expanded=true){
const card=text('details','','panel detail-panel');card.open=expanded;card.append(text('summary',title));
const body=text('div','','detail-body');body.append(content);card.append(body);return card;
}
function renderArchive(){
if(!overview)return;
const project=$('result-project').value,search=$('result-search').value.trim().toLocaleLowerCase('de-DE'),status=$('result-status').value,sort=$('result-sort').value;
const labels={OUTPUT_READY:'Ergebnis bereit',HANDED_OFF:'Übergeben',BLOCKED:'Blockiert / wartet',FAILED_TO_ANALYZE:'Analyse gescheitert / Review'};
const list=overview.cases.filter(c=>(finished.has(c.state)||c.state==='BLOCKED')&&(!status||status===c.state)&&(!project||project===c.project_id)&&(!search||`${c.case_id} ${c.title}`.toLocaleLowerCase('de-DE').includes(search))).sort((a,b)=>{
const tie=b.updated_at.localeCompare(a.updated_at)||a.key.localeCompare(b.key);
if(sort==='category')return labels[a.state].localeCompare(labels[b.state],'de')||tie;
if(sort==='project')return a.project_id.localeCompare(b.project_id,'de')||tie;
return sort==='oldest'?-tie:tie;
});
$('result-count').textContent=`${list.length} ${list.length===1?'Fall':'Fälle'}`;
const body=$('result-rows');body.replaceChildren();
list.forEach(c=>{const row=document.createElement('tr'),name=document.createElement('td'),link=text('a',c.case_id,'case-link');link.href=`#case/${c.key}`;
const action=document.createElement('td'),download=text('button','↓','package-icon');download.type='button';download.title=`Fallakte ${c.case_id} herunterladen`;download.setAttribute('aria-label',download.title);download.addEventListener('click',()=>downloadCase(c,download));action.append(download);
const category=text('td',labels[c.state]);if(c.reason)category.append(text('small',c.reason==='AWAITING_TEST_FEEDBACK'?'Wartet auf Testerantwort':c.reason));
name.append(link,text('small',c.title));row.append(action,name,text('td',c.project_id),category,text('td',({outbox:'Outbox',review:'Review',inbox:'Inbox',cases:'Fallablage'})[c.destination]||'Fallablage'),text('td',String(c.analysis_revision)),text('td',new Date(c.updated_at).toLocaleString('de-DE')));body.append(row);});
if(!list.length){const row=document.createElement('tr'),cell=text('td','Keine bearbeiteten Fälle für diese Auswahl.','empty');cell.colSpan=7;row.append(cell);body.append(row);}
}
function clearDetails(){selectedCase=null;detailHash=null;detailLoading=false;$('result-search').value='';$('result-status').value='';$('result-sort').value='newest';$('result-count').textContent='0 Fälle';}
async function downloadCase(data,button){
const generation=session,credential=token;button.disabled=true;error();
try{const response=await fetch(`/v1/cases/${data.key}/package`,{credentials:'same-origin',cache:'no-store'});
if(!response.ok){const issue=await response.json();if(response.status===401&&credential===token&&generation===session)logout(false);throw new Error(issue.code||`HTTP ${response.status}`);}
const blob=await response.blob();if(generation!==session)return;
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=`${data.case_id.replace(/[^A-Za-z0-9_.-]/g,'_')}-Fallakte.zip`;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);
}catch(e){if(generation===session)error(`Download fehlgeschlagen: ${e.message}`);}finally{button.disabled=false;}
}
function renderDetail(data){
const root=$('case-detail');root.replaceChildren();
const head=text('section','','panel detail-header'),top=text('div','','section-head'),info=document.createElement('div');
info.append(text('div',`${data.project_id} · ${data.case_id}`,'eyebrow'));
const title=data.reports.find(r=>r.title)?.title||data.case_id;info.append(text('h2',title));
info.append(text('p',`${names[data.state]||({OUTPUT_READY:'Ergebnis bereit',HANDED_OFF:'Übergeben',FAILED_TO_ANALYZE:'Menschliches Review'})[data.state]||data.state} · Revision ${data.revision}`));
const controls=text('div','','detail-controls'),label=text('label','Analyse-Revision'),select=document.createElement('select');select.id='revision-select';select.setAttribute('aria-label','Analyse-Revision');
data.revisions.forEach(r=>select.add(new Option(`Revision ${r.revision}${r.revision===data.current_revision?' · aktuell':''}`,r.revision)));
select.value=String(data.revision);select.addEventListener('change',()=>location.hash=`#case/${data.key}/revision/${select.value}`);label.append(select);controls.append(label);
const download=text('button','Fallakte herunterladen');download.type='button';download.addEventListener('click',()=>downloadCase(data,download));controls.append(download);
top.append(info,controls);head.append(top,text('p','Der Download enthält die vollständige aktuelle Akte einschließlich aller erhaltenen Revisionen.','hint'));
if(data.revision!==data.current_revision)head.append(text('p','Historische Ansicht: Ergebnisse anderer Revisionen werden nicht beigemischt.','history-note'));
if(data.truncated)head.append(text('p','Die Anzeige ist gekürzt. Die vollständigen Inhalte bleiben in der herunterladbaren Fallakte erhalten.','history-note'));
root.append(head);
const actions=text('div','','next-actions');
if(data.reason)actions.append(text('p',`Sperr-/Reviewgrund: ${data.reason}`));
if(data.worker.code)actions.append(text('p',`Workerhinweis: ${data.worker.code}`));
data.actions.forEach(a=>actions.append(text('p',a.text)));
if(data.reason==='AWAITING_TEST_FEEDBACK'){const link=text('a','Offene Testanfragen öffnen','case-link');link.href='#tests';actions.append(link);}
if(typeof addRetryControl==='function')addRetryControl(data,actions);
root.append(panel('Zustand und nächste Schritte',actions));
root.append(panel('Testerbeobachtungen',semantic({observations:data.observations,tester_assumptions:data.tester_assumptions,expected_behavior:data.expected_behavior})));
root.append(panel('Caseinterpretation',Object.keys(data.interpretation).length?semantic(data.interpretation):text('p','Für diese Revision liegt noch keine akzeptierte Interpretation vor.','hint'),false));
const hypotheses=text('div','','hypothesis-list');
data.hypotheses.forEach(h=>{const card=text('article','','hypothesis-card'),line=text('div','','hypothesis-heading');line.append(text('h3',`${h.id} · ${h.title}`),text('span',`Confidence: ${friendly(h.confidence)}`,'badge'));
card.append(line,text('p',`Status: ${friendly(h.disposition)} · Untersuchungsschritte: ${h.iterations}`,'hint'),semantic(h.proposal));
const evidence=text('div','','evidence-columns');for(const [key,title] of [['support','Befunde dafür'],['against','Befunde dagegen']]){const column=document.createElement('div');column.append(text('h4',title),semantic(h[key]));evidence.append(column);}card.append(evidence,text('p','Diese Befunde sind KI-Bewertungen der referenzierten Belege, keine zusätzlichen Messungen.','hint'));
if(Object.keys(h.review).length)card.append(panel('Letzte kausale Bewertung',semantic(h.review),false));
if(h.uncertainties.length)card.append(panel('Offene Unsicherheiten',semantic(h.uncertainties),false));
if(h.evidence_ids.length)card.append(text('p',`Belege: ${h.evidence_ids.join(' · ')}`,'reference-list'));
hypotheses.append(card);});
if(!data.hypotheses.length)hypotheses.append(text('p','Noch keine akzeptierten Hypothesen in dieser Revision.','hint'));
root.append(panel('Hypothesen und Bewertung',hypotheses));
root.append(panel('Belegverzeichnis',data.evidence.length?semantic(data.evidence):text('p','Noch keine erfassten Belege.','hint'),false));
root.append(panel('Diagnose',data.diagnosis?semantic(data.diagnosis):text('p','Noch keine finale Diagnose in dieser Revision.','hint')));
if(Object.keys(data.review).length)root.append(panel('Menschliches Review',semantic(data.review)));
const coding=document.createElement('div');
if(!Object.keys(data.coding).length)coding.append(text('p','Für diese Revision wurde noch kein Codingauftragsdokument erstellt.','hint'));
else{coding.append(text('p',data.coding.assignment==='none'?'Kein Codingauftrag vorhanden.':'Codingauftrag vorhanden.','assignment-status'));coding.append(semantic(data.coding.request||{}));if(data.coding.diagnostic_caveats)coding.append(panel('Diagnostische Einschränkungen',semantic(data.coding.diagnostic_caveats),false));}
root.append(panel('Codingauftrag',coding));
const human=document.createElement('div');
if(data.human&&['none','assigned'].includes(data.human.status)){human.append(text('p',data.human.status==='none'?'Kein Human-Dev-Auftrag vorhanden.':'Human-Dev-Auftrag vorhanden.','assignment-status'),semantic(data.human));}
else if(data.human_text)human.append(text('pre',data.human_text,'human-document'));
else human.append(text('p','Für diese Revision liegt noch kein Human-Dev-Auftragsdokument vor.','hint'));
root.append(panel('Human-Dev-Auftrag',human));
root.append(panel('Patch-Rückmeldungen und neue Beobachtungen',data.feedback.length?semantic(data.feedback):text('p','Bis zu dieser Revision liegen keine über die Übergabe-API eingereichten Rückmeldungen vor.','hint'),false));
}
async function loadDetail(hash){
const match=hash.match(/^#case\/([a-f0-9]{64}\.[a-f0-9]{64})(?:\/revision\/([0-9]+))?$/);if(!match||!token)return;
const generation=session;detailLoading=true;detailHash=hash;
if(!selectedCase||selectedCase.key!==match[1]||String(selectedCase.revision)!==match[2])$('case-detail').replaceChildren(text('p','Fallakte wird geladen …','panel'));
try{const data=await api(`/v1/cases/${match[1]}${match[2]!==undefined?'?revision='+match[2]:''}`);
if(generation!==session||location.hash!==hash)return;selectedCase=data;renderDetail(data);
}catch(e){if(generation===session&&location.hash===hash){$('case-detail').replaceChildren(text('p',`Fallakte nicht verfügbar: ${e.message}`,'panel'));}}
finally{if(generation===session&&detailHash===hash)detailLoading=false;}
}
function syncDetail(){
if(!token||!location.hash.startsWith('#case/'))return;
const current=overview?.cases.find(c=>c.key===selectedCase?.key);
if(!detailLoading&&(!selectedCase||detailHash!==location.hash||(!location.hash.includes('/revision/')&&current?.package_generation!==selectedCase.package_generation)))loadDetail(location.hash);
}
function route(){
if(typeof manualRoute==='function'&&manualRoute()){
if(typeof clearKeys==='function')clearKeys();return;
}
if($('manual-page'))$('manual-page').hidden=true;
if(typeof keysRoute==='function'&&keysRoute())return;
if($('keys-page')){$('keys-page').hidden=true;clearKeys();}
if(typeof testRoute==='function'&&testRoute())return;
if($('test-page'))$('test-page').hidden=true;
const detail=location.hash.startsWith('#case/'),results=location.hash==='#results',submissionPage=location.hash==='#submit';
$('overview-page').hidden=detail||results||submissionPage;$('results').hidden=!results;$('case-detail').hidden=!detail;$('submit').hidden=!submissionPage;
document.querySelector('header h1').textContent=detail?'Die Fallakte.':results?'Bearbeitete Fälle.':submissionPage?'Auftrag übergeben.':'Analyse im Blick.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',detail?a.hash==='#cases':a.hash===(results?'#results':submissionPage?'#submit':location.hash==='#cases'?'#cases':'#overview')));
if(detail)loadDetail(location.hash);if(results)renderArchive();
}
$('result-project').addEventListener('change',renderArchive);$('result-search').addEventListener('input',renderArchive);
$('result-status').addEventListener('change',renderArchive);$('result-sort').addEventListener('change',renderArchive);
window.addEventListener('hashchange',route);route();
+5
View File
@@ -1,3 +1,8 @@
: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}
.case-link{color:#4f61c7;font-weight:650;text-decoration:none}.case-link:hover{text-decoration:underline}.detail-panel>summary{font-size:18px;font-weight:650;cursor:pointer}.detail-body{margin-top:20px}.semantic-text{color:#45516a;white-space:pre-wrap;overflow-wrap:anywhere;margin:5px 0 12px}.semantic-fields{display:grid;grid-template-columns:minmax(130px,190px) minmax(0,1fr);gap:12px 22px;margin:0}.semantic-fields dt{font-size:12px;color:#7c8599;font-weight:600}.semantic-fields dd{margin:0;min-width:0}.semantic-fields .semantic-fields{grid-template-columns:130px minmax(0,1fr)}.semantic-list{display:grid;gap:10px}.semantic-item{border-left:2px solid #e6eaf6;padding-left:13px}.statement small{display:block;color:#9299a8}.reference-list{color:#7785ae;font-size:11px;overflow-wrap:anywhere}.hypothesis-card{border:1px solid #e6e9f1;border-radius:9px;padding:20px;margin-bottom:16px}.hypothesis-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.hypothesis-heading h3{font-size:16px;margin:0}.hypothesis-heading .badge{flex-shrink:0}.hypothesis-card .detail-panel{margin:16px 0 0;padding:14px;box-shadow:none}.hypothesis-card .detail-panel>summary{font-size:13px}.evidence-columns{display:grid;grid-template-columns:1fr 1fr;gap:26px;margin-top:18px}.evidence-columns h4{font-size:12px;color:#68758c}.evidence-columns>div{background:#f7f8fc;padding:16px;border-radius:7px;min-width:0}.detail-controls{display:flex;align-items:center;gap:16px}.detail-controls label{margin:0;min-width:170px}.detail-controls button{margin-top:20px}.history-note{background:#f1f3fd;color:#6472ae;padding:12px;border-radius:6px;font-size:12px}.assignment-status{font-weight:650;color:#34436a}.human-document{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.7 system-ui}.next-actions p{color:#5f6d85}#case-detail .panel{scroll-margin-top:20px}@media(max-width:900px){.semantic-fields,.semantic-fields .semantic-fields{grid-template-columns:1fr;gap:4px}.semantic-fields dd{margin-bottom:12px}.detail-controls{flex-wrap:wrap}}@media(max-width:700px){.hypothesis-heading{align-items:flex-start;flex-direction:column}.evidence-columns{grid-template-columns:1fr}.detail-controls{width:100%}.detail-controls button{margin-top:0}.hypothesis-card{padding:14px}.rail{flex-wrap:wrap}.rail nav{flex-wrap:wrap}}
#key-projects{border:1px solid var(--line);border-radius:8px;padding:12px;margin-bottom:14px}#key-projects label{display:flex;align-items:center;gap:10px;margin:8px 0}#key-projects input{width:auto;margin:0}#key-list button{margin:10px 8px 0 0}#key-list input{font-family:monospace}
#result-rows .package-icon{width:34px;height:34px;padding:0;font-size:22px;line-height:1;border-radius:8px}#result-rows td small{display:block;max-width:320px;overflow-wrap:anywhere;color:var(--muted);font-size:11px}
.manual-selector{display:flex;align-items:end;gap:18px;flex-wrap:wrap}.manual-selector label{flex:1;min-width:230px;margin:0}.manual-selector button{margin:0}.manual-layout{display:grid;grid-template-columns:250px minmax(0,1fr);gap:24px;align-items:start}.manual-outline{position:sticky;top:20px;max-height:85vh;overflow:auto;padding:18px}.manual-outline h2{font-size:14px;margin-bottom:14px}#manual-toc{display:grid;gap:5px}#manual-toc button{background:transparent;color:var(--muted);text-align:left;font-size:12px;padding:7px;border-radius:5px}#manual-toc button:hover{background:#eef0fd;color:var(--accent)}#manual-toc .manual-top-heading{font-weight:700;color:var(--ink)}.manual-body{line-height:1.75;overflow-wrap:anywhere;min-width:0}.manual-body h2{font-size:26px;margin:0 0 20px}.manual-body h3{margin:30px 0 10px;font-size:19px}.manual-body h4{margin:24px 0 8px}.manual-body h2,.manual-body h3,.manual-body h4{scroll-margin-top:20px}.manual-body p,.manual-body li{color:#45516a}.manual-body li{margin-bottom:8px}.manual-body a{color:#4f61c7}.manual-body pre{background:#f3f5fa;border:1px solid var(--line);border-radius:8px;padding:16px;overflow:auto;max-width:100%;white-space:pre;font-size:12px}.manual-body code{font-family:ui-monospace,Consolas,monospace}.manual-body p code,.manual-body li code{background:#eef0f7;padding:2px 4px;border-radius:3px}.manual-body td,.manual-body th{white-space:normal;min-width:120px;vertical-align:top}.manual-body ul,.manual-body ol{padding-left:24px}@media(max-width:1000px){.manual-layout{grid-template-columns:1fr}.manual-outline{position:static;max-height:260px}}@media(max-width:700px){.manual-body{padding:20px}.manual-body h2{font-size:22px}}
+37 -16
View File
@@ -5,13 +5,14 @@ const names = {NEW:'Eingang',VALIDATED:'Angenommen',BUILDING_CASE:'Fallaufbau',I
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;
let csrfToken='',logoutPending=Promise.resolve(),loginBusy=false;
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 credential=token,generation=session;
const response=await fetch(path,{...options,headers:{...options.headers,...(csrfToken?{'X-CSRF-Token':csrfToken}:{})},credentials:'same-origin',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}`); }
if(!response.ok) { if(response.status===401 && credential===token&&generation===session&&path!=='/v1/auth/login') logout(false); throw new Error(data.code || `HTTP ${response.status}`); }
return data;
}
function progress(value, label) {
@@ -19,12 +20,18 @@ function progress(value, label) {
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;
function logout(remote=true) {
if(remote&&token)logoutPending=fetch('/v1/auth/logout',{method:'POST',headers:{'X-CSRF-Token':csrfToken},credentials:'same-origin'}).catch(()=>{});
session++;token='';csrfToken=''; clearTimeout(timer); $('password').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());
['workers','case-rows','bars','legend','project','project-filter','result-project','result-rows','case-detail'].forEach(id=>$(id).replaceChildren());
if(typeof clearDetails==='function')clearDetails();
if(typeof clearOperations==='function')clearOperations();
if(typeof clearTests==='function')clearTests();
if(typeof clearKeys==='function')clearKeys();
if(typeof clearManual==='function')clearManual();
['active-count','token-count','correction-count','finished-count'].forEach(id=>$(id).textContent='—');
$('pie').getContext('2d').clearRect(0,0,360,360);
}
@@ -57,7 +64,8 @@ function renderPie(buckets) {
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 count=b.input_tokens+b.output_tokens;
if(b.id==='unknown'&&!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;
@@ -93,27 +101,40 @@ function render() {
$('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 link=text('a',c.case_id,'case-link');link.href=`#case/${c.key}`;
title.append(link,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);}
if(typeof renderArchive==='function')renderArchive();
if(typeof syncDetail==='function')syncDetail();
}
async function refresh() {
clearTimeout(timer); if(!token)return;const generation=session;
try {const response=await api('/v1/overview');if(generation!==session)return;overview=response;render();$('updated').textContent=`Aktualisiert ${new Date().toLocaleTimeString('de-DE')}`;
error(overview.read_errors?'Einige Akten konnten nicht gelesen werden. Die Verbrauchsdaten können unvollständig sein.':'');
if(typeof refreshOperations==='function')await refreshOperations();
if(typeof refreshTests==='function')await refreshTests();
} catch(e) {if(generation===session)error(`Aktualisierung fehlgeschlagen: ${e.message}`);}
if(token && generation===session)timer=setTimeout(refresh,4000);
}
async function connected(info,generation){
if(generation!==session)return;token='session';csrfToken=info.csrf;
const response=await api('/v1/profiles');if(generation!==session)return;projects=response.projects;
$('project').replaceChildren();$('project-filter').replaceChildren(new Option('Alle freigegebenen Projekte',''));$('result-project').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));$('result-project').add(new Option(p.project_id,p.project_id));});
$('login').hidden=true;$('content').hidden=false;$('logout').hidden=false;$('password').value='';
$('connection').textContent=info.username;document.body.classList.add('connected');if(typeof route==='function')route();await refresh();
}
$('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.');}
e.preventDefault();if(loginBusy)return;loginBusy=true;const generation=++session;error();
const password=$('password').value;$('password').value='';
try{await logoutPending;const info=await api('/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:$('username').value,password})});await connected(info,generation);
}catch(e){if(generation===session){token='';error(`Anmeldung fehlgeschlagen: ${e.message}`);}}finally{loginBusy=false;}
});
$('logout').addEventListener('click',logout);$('project-filter').addEventListener('change',render);
$('logout').addEventListener('click',()=>logout());$('project-filter').addEventListener('change',render);
fetch('/v1/auth/session',{credentials:'same-origin',cache:'no-store'}).then(async response=>{
if(response.ok&&session===0){const info=await response.json();await connected(info,0);}
}).catch(()=>{});
$('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();
+14 -6
View File
@@ -2,18 +2,19 @@
<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>
<script src="/assets/dashboard.js" defer></script><script src="/assets/integration-keys.js" defer></script><script src="/assets/user-manual.js" defer></script>
<script src="/assets/case-detail.js" defer></script><script src="/assets/operations.js" defer></script><script src="/assets/test-requests.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>
<nav aria-label="Navigation"><a href="#overview" class="selected">Übersicht</a><a href="#cases">Laufende Fälle</a><a href="#results">Bearbeitete Fälle</a><a href="#tests">Testanfragen</a><a href="#submit">Auftrag übergeben</a><a href="#keys">API-Schlüssel</a><a href="#manual">Benutzeranleitung</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>
<section class="login panel" id="login"><div><h2>Bei BugAnalysis anmelden</h2><p>Mit Benutzername und Passwort anmelden.</p></div><form id="login-form"><label>Benutzername<input id="username" autocomplete="username" required maxlength="64"></label><label>Passwort<input id="password" type="password" autocomplete="current-password" required maxlength="1024"></label><button type="submit">Anmelden</button></form></section>
<p id="error" class="error" role="alert" hidden></p>
<div id="content" hidden>
<section class="stats" aria-label="Kennzahlen">
<section class="panel" id="test-page" hidden><h2>Offene Testanfragen</h2><p class="hint">Beobachtungen ergänzen und die laufende Untersuchung fortsetzen.</p><div class="detail-controls"><label>Projekt<select id="test-project"></select></label><label>Sortierung<select id="test-sort"><option value="oldest">Älteste zuerst</option><option value="newest">Neueste zuerst</option><option value="project">Projekt, dann älteste zuerst</option></select></label></div><div id="test-list"></div><div id="test-answer"></div></section><div id="overview-page">
<section class="panel" id="operations"><h2>Betriebssteuerung</h2><div id="operation-status"></div><div id="operation-projects"></div><details id="operation-audit" hidden><summary>Aktionsprotokoll</summary><div id="operation-events"></div></details></section><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>
@@ -28,12 +29,19 @@
<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>
</div>
<section class="panel" id="results" hidden><div class="section-head"><div><div class="eyebrow">FALLABLAGE</div><h2>Bearbeitete Fälle</h2><p>Ergebnisse in der Outbox, übergebene, blockierte und gescheiterte Analysen. Blockiert bedeutet noch nicht abgeschlossen.</p></div><span id="result-count" class="badge">0 Fälle</span></div>
<div class="form-grid"><label>Projekt<select id="result-project"><option value="">Alle freigegebenen Projekte</option></select></label><label>Kategorie<select id="result-status"><option value="">Alle Kategorien</option><option value="OUTPUT_READY">Ergebnis bereit / Outbox</option><option value="HANDED_OFF">Übergeben</option><option value="BLOCKED">Blockiert / wartet</option><option value="FAILED_TO_ANALYZE">Analyse gescheitert / Review</option></select></label><label>Sortierung<select id="result-sort"><option value="newest">Zuletzt aktualisiert</option><option value="oldest">Älteste zuerst</option><option value="category">Kategorie</option><option value="project">Projekt</option></select></label><label>Fall suchen<input id="result-search" type="search" placeholder="Bug-ID oder Fallname"></label></div>
<div class="table-wrap"><table><thead><tr><th><span class="download-heading">Paket</span></th><th>Fall</th><th>Projekt</th><th>Kategorie / Grund</th><th>Ablage</th><th>Revision</th><th>Aktualisiert</th></tr></thead><tbody id="result-rows"></tbody></table></div></section>
<section id="case-detail" aria-live="polite" hidden></section>
<section class="panel" id="submit" hidden><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>
<section class="panel" id="keys-page" hidden><div class="eyebrow">INTEGRATIONEN</div><h2>API-Schlüssel</h2><p>Für Auftragsübergabe und lesenden Zugriff auf ausgewählte Projekte. Keine Betriebssteuerung oder Nutzerverwaltung.</p><form id="key-form"><label>Programmname<input id="key-name" maxlength="100" required placeholder="Erfassungsworker"></label><fieldset id="key-projects"><legend>Freigegebene Projekte</legend></fieldset><button type="submit">Schlüssel erstellen</button></form><p id="key-status" role="status"></p><div id="key-list"></div></section>
<section id="manual-page" hidden><div class="panel manual-selector"><label>Anleitung auswählen<select id="manual-document"></select></label><button id="manual-download" type="button" disabled>Markdown herunterladen</button></div><div class="manual-layout"><aside class="panel manual-outline" aria-label="Inhaltsverzeichnis"><h2>In diesem Dokument</h2><div id="manual-toc"></div></aside><article class="panel manual-body" id="manual-content" aria-live="polite"></article></div></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,39 @@
'use strict';
let keysBusy=false;
function clearKeys(){ $('key-list').replaceChildren();$('key-projects').replaceChildren();$('key-status').textContent='';$('key-name').value=''; }
function keysRoute(){
if(location.hash!=='#keys')return false;
['overview-page','results','case-detail','submit','test-page'].forEach(id=>$(id).hidden=true);
$('keys-page').hidden=false;
document.querySelector('header h1').textContent='Programme verbinden.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',a.hash==='#keys'));
if(token)refreshKeys();return true;
}
async function refreshKeys(){
const generation=session;
try{
const [data,profiles]=await Promise.all([api('/v1/integration-keys'),api('/v1/profiles')]);
if(generation!==session||location.hash!=='#keys')return;
$('key-list').replaceChildren();$('key-projects').replaceChildren(text('legend','Freigegebene Projekte'));
profiles.projects.forEach(p=>{const label=text('label','');const box=document.createElement('input');box.type='checkbox';box.value=p.project_id;box.name='key-project';label.append(box,document.createTextNode(p.project_id));$('key-projects').append(label);});
if(!data.keys.length)$('key-list').append(text('p','Noch keine Integrationsschlüssel vorhanden.'));
data.keys.forEach(k=>{
const row=text('article','','hypothesis-card');row.append(text('h3',k.name),text('p',`${k.projects.join(', ')} · ${new Date(k.created_at*1000).toLocaleString('de-DE')} · ${k.created_by}`));
if(k.revoked_at){row.append(text('p','Widerrufen'));$('key-list').append(row);return;}
const value=document.createElement('input');value.type='password';value.readOnly=true;value.placeholder='Schlüssel verborgen';value.setAttribute('aria-label',`API-Schlüssel für ${k.name}`);value.autocomplete='off';
const show=text('button','Anzeigen'),copy=text('button','Kopieren'),revoke=text('button','Widerrufen');
async function reveal(){const data=await api(`/v1/integration-keys/${k.id}/reveal`,{method:'POST'});if(generation!==session||location.hash!=='#keys')return null;return data.key;}
show.onclick=async()=>{show.disabled=true;try{if(value.value){value.value='';value.type='password';show.textContent='Anzeigen';}else{const key=await reveal();if(key){value.value=key;value.type='text';show.textContent='Verbergen';}}}catch(e){if(generation===session)error(e.message);}finally{show.disabled=false;}};
copy.onclick=async()=>{copy.disabled=true;try{const key=await reveal();if(key){await navigator.clipboard.writeText(key);$('key-status').textContent='Schlüssel kopiert.';}}catch(e){if(generation===session)error('Kopieren nicht möglich. Schlüssel anzeigen und manuell kopieren.');}finally{copy.disabled=false;}};
revoke.onclick=async()=>{if(!confirm(`Schlüssel für ${k.name} widerrufen?`))return;revoke.disabled=true;try{await api(`/v1/integration-keys/${k.id}/revoke`,{method:'POST'});if(generation===session)await refreshKeys();}catch(e){if(generation===session)error(e.message);}finally{revoke.disabled=false;}};
row.append(value,show,copy,revoke);$('key-list').append(row);
});
}catch(e){if(generation===session)error(`Schlüsselverwaltung: ${e.message}`);}
}
$('key-form').addEventListener('submit',async event=>{
event.preventDefault();if(keysBusy)return;const generation=session;keysBusy=true;const button=event.submitter;button.disabled=true;
try{const projects=Array.from(document.querySelectorAll('input[name="key-project"]:checked'),x=>x.value);
await api('/v1/integration-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:$('key-name').value,projects})});
if(generation!==session)return;$('key-name').value='';await refreshKeys();$('key-status').textContent='Schlüssel erstellt. Über Anzeigen oder Kopieren abrufbar.';
}catch(e){if(generation===session)error(e.message);}finally{keysBusy=false;button.disabled=false;}
});
+43
View File
@@ -0,0 +1,43 @@
'use strict';
let operationState=null, operationSending=false;
function clearOperations(){operationState=null;['operation-status','operation-projects','operation-events'].forEach(id=>$(id).replaceChildren());$('operation-audit').hidden=true;}
async function changeOperation(data,button){
if(operationSending)return;const generation=session;operationSending=true;button.disabled=true;
try{const result=await api('/v1/operations',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
if(generation!==session)return;
error(result.status==='QUEUED'?'Fortsetzung vorgemerkt. Der Worker prüft die Freigabe vor der Ausführung.':'');
await refreshOperations();
}catch(e){if(generation===session)error(`Betriebsaktion fehlgeschlagen: ${e.message}`);}
finally{operationSending=false;if(generation===session)button.disabled=false;}
}
function addRetryControl(data,root){
if(!operationState?.operator||data.revision!==data.current_revision)return;
if(data.state==='BLOCKED'&&data.reason==='ENTITY_RESOLUTION_INVALID_RESPONSES'){
root.append(text('p','Nach einer technischen Korrektur kann ein zusätzlicher Antwortversuch für die Namensauflösung freigegeben werden. Vorhandene Ergebnisse und Untersuchungsbudgets bleiben erhalten. Der Folgeaufruf kann LLM-Kosten verursachen.','hint'));
const label=text('label','Durchgeführte Fehlerbehebung (keine Schlüssel oder Passwörter)');
const reason=document.createElement('textarea');reason.maxLength=1000;reason.rows=2;label.append(reason);root.append(label);
const button=text('button','Einen Reparaturversuch freigeben');button.type='button';
button.addEventListener('click',()=>{const note=reason.value.trim();if(!note){error('Bitte die durchgeführte Fehlerbehebung beschreiben.');return;}
changeOperation({action:'repair',key:data.key,package_generation:data.package_generation,reason:note},button);});
root.append(button);return;
}
const retry=data.state==='BLOCKED'?data.reason==='RESEARCH_UNAVAILABLE':!finished.has(data.state)&&['ERROR','ATTENTION','BUSY','DEFERRED','RETRY_PENDING'].includes(data.worker.status);
if(!retry)return;
const button=text('button','Fortsetzung nach Fehlerbehebung anfordern');button.type='button';
button.addEventListener('click',()=>changeOperation({action:'retry',key:data.key,package_generation:data.package_generation},button));root.append(button);
}
async function refreshOperations(){
const generation=session;const result=await api('/v1/operations');if(generation!==session)return;
const changed=operationState?.operator!==result.operator;operationState=result;
const status=$('operation-status');status.replaceChildren();
if(result.worker){const w=result.worker;status.append(text('p',`${w.live?'Worker erreichbar':'Kein lebender Worker bestätigt'} · Letzter Zustand: ${w.status||'Unbekannt'}${w.code?' · '+w.code:''}`));
if(w.updated_at)status.append(text('p',`Letzte Aktivität: ${new Date(w.updated_at).toLocaleString('de-DE')}`,'hint'));}
else status.append(text('p',result.operator?'Workerdetails erfordern Zugriff auf alle Projekte.':'Lesender Betriebszugriff. Betreiberaktionen sind nicht freigegeben.','hint'));
if(result.pending)status.append(text('p',`${result.pending} Fortsetzungsaufträge warten auf den Worker.`));
const projectsRoot=$('operation-projects');projectsRoot.replaceChildren();
result.projects.forEach(p=>{const row=text('div','','section-head');row.append(text('p',`${p.project_id} · ${p.paused?'Neue Fälle pausiert':'Neue Fälle freigegeben'}`));
if(result.operator){const button=text('button',p.paused?'Neue Fälle wieder zulassen':'Neue Fälle pausieren');button.type='button';button.addEventListener('click',()=>changeOperation({action:p.paused?'resume':'pause',project_id:p.project_id},button));row.append(button);}projectsRoot.append(row);});
$('operation-audit').hidden=!result.operator;const log=$('operation-events');log.replaceChildren();
result.events.slice().reverse().forEach(e=>log.append(text('p',`${new Date(e.at).toLocaleString('de-DE')} · ${e.actor} · ${e.project_id}${e.case_id?' · '+e.case_id:''} · ${e.action} · ${e.status}${e.code?' · '+e.code:''}`)));
if(changed&&selectedCase&&location.hash.startsWith('#case/'))renderDetail(selectedCase);
}
@@ -0,0 +1,67 @@
'use strict';
let testData=null,answerKey=null,answerBusy=false;
function clearTests(){testData=null;answerKey=null;['test-list','test-answer','test-project'].forEach(id=>$(id).replaceChildren());}
function testRoute(){
if(location.hash!=='#tests')return false;
['overview-page','results','case-detail','submit'].forEach(id=>$(id).hidden=true);$('test-page').hidden=false;
document.querySelector('header h1').textContent='Rückfragen beantworten.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',a.hash==='#tests'));
if(token)refreshTests().catch(e=>error(e.message));return true;
}
async function refreshTests(){
const generation=session;const result=await api('/v1/test-requests');if(generation!==session)return;
testData=result;const filter=$('test-project'),selected=filter.value;
filter.replaceChildren(new Option('Alle Projekte',''));projects.forEach(p=>filter.add(new Option(p.project_id,p.project_id)));filter.value=selected;
renderTests();
}
function renderTests(){
if(!testData)return;
const project=$('test-project').value,sort=$('test-sort').value,root=$('test-list');root.replaceChildren();
if(testData.read_errors)root.append(text('p','Einige Anfragen konnten nicht gelesen werden.','error'));
const rows=testData.requests.filter(r=>!project||r.project_id===project).slice().sort((a,b)=>
(sort==='project'?a.project_id.localeCompare(b.project_id):0)||
(sort==='newest'?b.received_at.localeCompare(a.received_at):a.received_at.localeCompare(b.received_at))||a.key.localeCompare(b.key));
const wrap=text('div','','table-wrap'),table=document.createElement('table'),head=document.createElement('tr');
['Eingang','Projekt','Bug','Anfrage',''].forEach(v=>head.append(text('th',v)));const thead=document.createElement('thead');thead.append(head);table.append(thead);const body=document.createElement('tbody');
rows.forEach(row=>{const tr=document.createElement('tr');tr.append(text('td',new Date(row.received_at).toLocaleString('de-DE')),text('td',row.project_id));
const cell=document.createElement('td'),link=text('a',row.case_id,'case-link');link.href='#case/'+row.key;cell.append(link,text('small',row.title));tr.append(cell,text('td',row.request.question.slice(0,160)+(row.request.question.length>160?' …':'')));
const action=document.createElement('td'),button=text('button','Ansehen / beantworten');button.type='button';button.addEventListener('click',()=>openTest(row));action.append(button);tr.append(action);body.append(tr);});
table.append(body);wrap.append(table);root.append(wrap);if(!rows.length)root.append(text('p','Keine offenen Testanfragen für diese Auswahl.','hint'));
if(answerKey&&!testData.requests.some(r=>r.key===answerKey)){$('test-answer').replaceChildren(text('p','Diese Anfrage ist nicht mehr offen. Bitte die Liste aktualisieren.','hint'));answerKey=null;}
}
function openTest(row){
if(answerBusy)return;
answerKey=row.key;const root=$('test-answer');root.replaceChildren();const request=row.request;
root.append(text('h3',`${row.project_id} · ${row.case_id}`),text('p',request.question));
for(const [field,label] of [['environment','Angeforderte Umgebung'],['prerequisites','Voraussetzungen'],['steps','Testschritte'],['requested_observations','Benötigte Beobachtungen']])root.append(panel(label,semantic(request[field])));
root.append(text('p',`Angefragter Termin: ${new Date(request.deadline).toLocaleString('de-DE')}. Ein abgelaufener Termin ersetzt keine Antwort.`,'hint'));
if(!testData.can_answer){root.append(text('p','Zum Beantworten werden Tester- oder Betreiberrechte benötigt.','hint'));return;}
const edit=text('button','Bearbeiten');edit.type='button';root.append(edit);
edit.addEventListener('click',async()=>{edit.disabled=true;const generation=session;
try{await api('/v1/test-requests/'+row.key+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'edit',request_id:request.request_id,analysis_revision:request.analysis_revision})});
if(generation!==session||answerKey!==row.key)return;edit.remove();editTest(row,root);
}catch(e){if(generation===session)error(e.message==='API_TEST_CLAIMED'?'Diese Anfrage wird bereits in einer anderen Sitzung bearbeitet.':`Bearbeitung nicht möglich: ${e.message}`);}finally{edit.disabled=false;}});
root.scrollIntoView({behavior:'smooth',block:'start'});
}
function editTest(row,root){
const request=row.request;
root.append(text('p','Die Bearbeitung ist für deine aktuelle Sitzung reserviert.','hint'));
const form=document.createElement('form');form.className='semantic-list';
function field(label,element){const l=text('label',label);l.append(element);form.append(l);return element;}
const outcome=document.createElement('select');outcome.add(new Option('Test durchgeführt','completed'));outcome.add(new Option('Nicht durchführbar','unable_to_test'));field('Ergebnis',outcome);
const environment=field('Tatsächlich getestete Umgebung / Build',document.createElement('input'));environment.required=true;environment.maxLength=4000;
const observations=field('Beobachtungen bzw. Grund, warum der Test nicht möglich ist',document.createElement('textarea'));observations.required=true;observations.rows=7;observations.maxLength=12000;
const confirm=document.createElement('input');confirm.type='checkbox';confirm.required=true;field('Ich bestätige diese Angaben als meine Test-Rückmeldung.',confirm);
const button=text('button','Antwort bestätigen und Untersuchung fortsetzen');button.type='submit';form.append(button);root.append(form);
const release=text('button','Bearbeitung freigeben');release.type='button';form.append(release);
release.addEventListener('click',async()=>{const generation=session;release.disabled=true;
try{await api('/v1/test-requests/'+row.key+'/claim',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'release',request_id:request.request_id,analysis_revision:request.analysis_revision})});if(generation===session)openTest(row);
}catch(e){if(generation===session)error(e.message);}finally{release.disabled=false;}});
form.addEventListener('submit',async event=>{event.preventDefault();if(answerBusy)return;const generation=session;answerBusy=true;button.disabled=true;
try{await api('/v1/test-requests/'+row.key,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:request.request_id,analysis_revision:request.analysis_revision,outcome:outcome.value,environment:environment.value,observations:[observations.value],confirmed:confirm.checked})});
if(generation!==session)return;root.replaceChildren(text('p','Antwort gespeichert. Der Worker kann die Untersuchung fortsetzen.'));answerKey=null;await refreshTests();
}catch(e){if(generation===session)error(`Antwort nicht gespeichert: ${e.message}`);}finally{answerBusy=false;if(generation===session)button.disabled=false;}});
root.scrollIntoView({behavior:'smooth',block:'start'});
}
$('test-project').addEventListener('change',()=>{answerKey=null;$('test-answer').replaceChildren();renderTests();});$('test-sort').addEventListener('change',renderTests);
if(location.hash==='#tests')testRoute();
+88
View File
@@ -0,0 +1,88 @@
'use strict';
let manualData=null,manualLoading=null,manualCurrent=null;
function clearManual(){manualData=null;manualLoading=null;manualCurrent=null;$('manual-document').replaceChildren();$('manual-content').replaceChildren();$('manual-toc').replaceChildren();$('manual-download').disabled=true;}
function manualLink(target){
if(/^https?:\/\//i.test(target)){try{const url=new URL(target);if(!url.username&&!url.password)return {href:url.href,external:true};}catch{}return null;}
const match=target.match(/^(?:\.\.\/|\.\/|docs\/)*([a-z0-9-]+)\.md(?:#([a-z0-9-]+))?$/);
if(match&&manualData?.documents.some(d=>d.id===match[1]))return {href:`#manual/${match[1]}`};
return null;
}
function manualInline(parent,value){
// Render only supported Markdown with DOM nodes. Never interpret source HTML.
const pattern=/(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^\s)]+\))/g;let cursor=0;
for(const match of value.matchAll(pattern)){
parent.append(document.createTextNode(value.slice(cursor,match.index)));const part=match[0];
if(part.startsWith('`'))parent.append(text('code',part.slice(1,-1)));
else if(part.startsWith('**'))parent.append(text('strong',part.slice(2,-2)));
else{const link=part.match(/^\[([^\]]+)\]\((.+)\)$/),destination=manualLink(link[2]);
if(destination){const anchor=text('a',link[1]);anchor.href=destination.href;if(destination.external){anchor.target='_blank';anchor.rel='noopener noreferrer';}parent.append(anchor);}
else parent.append(document.createTextNode(link[1]));
}
cursor=match.index+part.length;
}
parent.append(document.createTextNode(value.slice(cursor)));
}
function renderManualMarkdown(markdown){
const fragment=document.createDocumentFragment(),lines=markdown.replace(/\r/g,'').split('\n'),headings=[];
const cells=line=>line.trim().replace(/^\||\|$/g,'').split('|').map(s=>s.trim());
let index=0;
while(index<lines.length){
const line=lines[index];if(!line.trim()){index++;continue;}
if(/^\s*```/.test(line)){
const code=[];index++;while(index<lines.length&&!/^\s*```/.test(lines[index]))code.push(lines[index++]);index++;
const pre=document.createElement('pre');pre.append(text('code',code.join('\n')));fragment.append(pre);continue;
}
const heading=line.match(/^(#{1,6})\s+(.+)$/);
if(heading){const node=document.createElement('h'+Math.min(heading[1].length+1,6));node.id='manual-heading-'+headings.length;manualInline(node,heading[2]);headings.push({id:node.id,label:node.textContent,level:heading[1].length});fragment.append(node);index++;continue;}
if(line.includes('|')&&index+1<lines.length&&/^\s*\|?\s*:?-{3,}/.test(lines[index+1])){
const wrap=text('div','','table-wrap'),table=document.createElement('table'),head=document.createElement('thead'),row=document.createElement('tr');
cells(line).forEach(value=>{const cell=document.createElement('th');manualInline(cell,value);row.append(cell);});head.append(row);table.append(head);index+=2;
const body=document.createElement('tbody');while(index<lines.length&&lines[index].includes('|')&&lines[index].trim()){
const row=document.createElement('tr');cells(lines[index++]).forEach(value=>{const cell=document.createElement('td');manualInline(cell,value);row.append(cell);});body.append(row);
}table.append(body);wrap.append(table);fragment.append(wrap);continue;
}
const item=line.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/);
if(item){const ordered=/^\s*\d+\./.test(line),list=document.createElement(ordered?'ol':'ul');
while(index<lines.length){const match=lines[index].match(ordered?/^\s*\d+\.\s+(.+)$/:/^\s*[-*]\s+(.+)$/);if(!match)break;index++;let value=match[1];
while(index<lines.length&&/^\s{2,}\S/.test(lines[index])&&!/^\s*(?:[-*]|\d+\.)\s+/.test(lines[index]))value+=' '+lines[index++].trim();
const entry=document.createElement('li');manualInline(entry,value);list.append(entry);
}fragment.append(list);continue;
}
const paragraph=[line];index++;
while(index<lines.length&&lines[index].trim()&&!/^(?:#{1,6}\s|\s*```|\s*[-*]\s|\s*\d+\.\s)/.test(lines[index]))paragraph.push(lines[index++]);
const node=document.createElement('p');manualInline(node,paragraph.join(' '));fragment.append(node);
}
return {fragment,headings};
}
function displayManual(identifier){
const previous=manualCurrent?.id;
manualCurrent=manualData.documents.find(d=>d.id===identifier);
if(!manualCurrent){$('manual-content').replaceChildren(text('p','Diese Anleitung ist nicht vorhanden.'));$('manual-toc').replaceChildren();$('manual-download').disabled=true;return;}
$('manual-document').value=manualCurrent.id;const rendered=renderManualMarkdown(manualCurrent.markdown);
$('manual-content').replaceChildren(rendered.fragment);$('manual-toc').replaceChildren();$('manual-download').disabled=false;
if(previous&&previous!==identifier)window.scrollTo(0,0);
rendered.headings.forEach(heading=>{const button=text('button',heading.label);button.type='button';button.className=heading.level===1?'manual-top-heading':'';button.onclick=()=>document.getElementById(heading.id)?.scrollIntoView({behavior:'smooth',block:'start'});$('manual-toc').append(button);});
}
function manualRoute(){
if(!/^#manual(?:\/|$)/.test(location.hash))return false;
if($('manual-page').hidden)window.scrollTo(0,0);
['overview-page','results','case-detail','submit','test-page','keys-page'].forEach(id=>$(id).hidden=true);$('manual-page').hidden=false;
document.querySelector('header h1').textContent='Benutzeranleitung.';
document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('selected',a.hash==='#manual'));
if(!token)return true;
const generation=session;
const selected=()=>location.hash.slice('#manual/'.length)||'user-guide';
if(manualData){displayManual(location.hash==='#manual'?'user-guide':selected());return true;}
if(!manualLoading){$('manual-content').replaceChildren(text('p','Anleitungen werden geladen …'));
manualLoading=api('/v1/manual').then(data=>{
if(generation!==session)return;manualData=data;$('manual-document').replaceChildren(...data.documents.map(d=>new Option(d.title,d.id)));
if(/^#manual(?:\/|$)/.test(location.hash))displayManual(location.hash==='#manual'?'user-guide':selected());
}).catch(e=>{if(generation===session){error(`Anleitung nicht verfügbar: ${e.message}`);$('manual-content').replaceChildren(text('p','Die Anleitung konnte nicht geladen werden. Bitte erneut öffnen.'));}}).finally(()=>{if(generation===session)manualLoading=null;});
}
return true;
}
$('manual-document').addEventListener('change',()=>{location.hash='#manual/'+$('manual-document').value;});
$('manual-download').addEventListener('click',()=>{
if(!manualCurrent)return;const url=URL.createObjectURL(new Blob([manualCurrent.markdown],{type:'text/markdown;charset=utf-8'})),link=document.createElement('a');
link.href=url;link.download=manualCurrent.id+'.md';document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);
});
@@ -0,0 +1,50 @@
{
"format_version": 1,
"documents": [
{
"id": "user-guide",
"title": "Verwendung und Funktionsweise",
"markdown": "# BugAnalysis verwenden\n\nBugAnalysis untersucht gemeldete Softwarefehler und übergibt das Ergebnis an die\nnächste zuständige Stelle. Das Programm erstellt selbst keinen fertigen Patch\nund spielt keine Änderungen auf einen Server ein. Der aktuelle Erfassungsadapter\nist für WoW/Vcore eingerichtet.\n\n## So arbeitet BugAnalysis\n\n1. Eine Person oder ein Erfassungsprogramm übergibt Bugbeschreibung und\n Diagnosearchive. Die Übergabe-API erstellt daraus eine Fallakte.\n2. Ein dauerhaft laufender Analyseworker übernimmt den Auftrag und lädt die\n Konfiguration des angegebenen Projekts.\n3. Der Casebuilder wertet Beobachtungen und Telemetrie aus. Er kann relevante\n Identitäten mit den konfigurierten Recherchewerkzeugen auflösen.\n4. Die LLM interpretiert den Fall und entwickelt Hypothesen. Im gemeinsamen\n Planungsmodus geschieht beides in einem Aufruf.\n5. Die Untersuchung prüft Hypothesen mit Source- und DB-Werkzeugen. Eine knappe\n Untersuchungsschleife sammelt Ergebnisse; Prüfungen und Konsolidierungen\n übernehmen diese in den strukturierten Arbeitsstand. Werkzeuge lesen Daten;\n Zugangsdaten erhält die LLM nicht.\n6. Die finale Diagnose bewertet die Befunde und kann einen Frühabschluss\n annehmen oder weitere Untersuchung verlangen. Fachliche Budgets begrenzen\n einzelne Untersuchungen. Ein bestimmter Erfolg ist nicht garantiert.\n7. Das Ergebnis enthält Diagnose und Aufträge für Codingagent und menschlichen\n Entwickler. Ein Auftrag darf ausdrücklich erklären, dass keine Arbeit für\n den jeweiligen Empfänger vorliegt. Fehlende Daten zur Umsetzung können als\n menschlicher Auftrag übergeben werden, ohne die erfolgreiche Analyse zu sperren.\n\nEin Ergebnis bleibt eine begründete Analyse, keine Garantie für einen wirksamen\nFix. Nach Umsetzung und Tests können Rückmeldungen eine neue Analyserevision\nderselben Akte auslösen. Behauptungen, Testerbeobachtungen und bestätigte Befunde\nsollten bei der Bewertung auseinandergehalten werden.\n\n## Anmeldung und Start\n\nDie Webadresse im Browser öffnen und mit Benutzername und Passwort anmelden.\nBenutzerkonten werden ausschließlich per privilegierter Konsole angelegt;\neine Selbstregistrierung gibt es nicht. Alle menschlichen Benutzer haben die\ngleichen Rechte für alle Projekte. Externe Programme benutzen eigene\nIntegrationsschlüssel mit Projektfreigaben.\n\nAPI und Analyseworker sind getrennte Dienste. Das Öffnen der Webseite startet\nkeinen Worker und sendet keine Modellanfrage. Der laufende Worker verarbeitet\nAufträge selbstständig; die Webseite ist die Übersicht und alternative Bedienung.\n\n## Übersicht und laufende Fälle\n\nDie Übersicht zeigt erreichbare Worker, den bearbeiteten Fall und dessen\nFortschrittsbalken. Der Balken folgt abgeschlossenen Phasen, nicht vergangener\nZeit. Während einer längeren Untersuchung kann er daher unverändert bleiben.\nAbgeschlossene Fälle verschwinden aus der aktiven Workeransicht.\n\nDie Diagramme zeigen gemessenen Tokenverbrauch. Korrektur- und Wiederholungsaufrufe\nbilden eine eigene Kategorie. Denktokens sind bereits in den Ausgabetokens\nenthalten und werden nicht doppelt addiert. Fehlende Messwerte werden als solche\nausgewiesen. Diese Zahlen sind keine verbindliche Cloud-Abrechnung und umfassen\nnur die erhaltenen Akten im gewählten Messumfang.\n\nÜber Fallnummern lässt sich die Detailansicht öffnen: Beobachtungen, Einordnung,\nHypothesen, Befunde, Diagnose und Aufträge. Frühere Revisionen bleiben über die\nRevisionsauswahl einsehbar. Die vollständigen Daten stehen im herunterladbaren ZIP.\n\n## Neue Aufträge und Rückgaben\n\nUnter **Auftrag übergeben** Projekt, Bug-ID, Fallname und Beobachtungen eingeben.\nFür einen neuen Vcore-Fall das wowdiag-Archiv und möglichst das dazugehörige\nwowsrc-Archiv derselben Erfassung auswählen. Die Bug-ID muss mit den Archiven\nübereinstimmen. Zusätzliche Dateien lassen sich als Anhänge hinzufügen.\n\nNach dem Absenden bestätigt die API die Annahme. Das ist noch kein Analyseerfolg.\nBei einer unterbrochenen Übergabe verwendet das Formular bei unveränderten\nEingaben denselben Idempotenzschlüssel, damit kein doppelter Auftrag entsteht.\n\nFür eine Rückgabe den Auftragstyp **Abgeschlossene Akte zurückgeben** wählen,\ndie aktuelle abgeschlossene Case-ZIP hochladen und Ergebnis sowie neue\nBeobachtungen eintragen. Beispielsweise kann ein getesteter Patch den Fehler\nnicht behoben haben. BugAnalysis ergänzt die Akte und öffnet eine neue Revision.\nBlockierte oder noch laufende Akten sind keine Abschlussrückgaben.\n\nAutomatische Erfassung erfolgt über den wowdiag-Worker und dieselbe Übergabe-API.\nExterne Programme erstellen keine internen Case-Manifeste selbst.\n\n## Testanfragen beantworten\n\nDie Seite **Testanfragen** listet offene menschliche Recherche- und Testfragen\nmit Projekt. Nach Zeitpunkt oder Projekt sortieren, eine Anfrage auswählen und\nzunächsLine truncated
},
{
"id": "linux-deployment",
"title": "Linux-Installation und Betrieb",
"markdown": "# Linux-Betrieb\n\nAPI und Analyseworker laufen als getrennte systemd-Dienste. Ein Auftragseingang\nweckt keinen neuen Prozess: Der dauerhaft laufende Worker übernimmt Inbox-Aufträge.\nDie Weboberfläche ist eine alternative Bedienung desselben Arbeitsablaufs.\nDie Installation startet keine Dienste und führt keine Modellanfrage aus.\n\n## Voraussetzungen und Verzeichnisse\n\nLinux mit systemd, Python 3.11 oder neuer inklusive venv/pip, Git und Nginx.\nDiese Programme über die Paketverwaltung des Servers installieren. Die aktuelle\nProjektvorlage ist für WoW/Vcore eingerichtet; sie ist kein allgemeiner Adapter.\n\n| Pfad | Zweck und Rechte |\n| --- | --- |\n| `/opt/viano-bug-analysis` | Root-eigener Checkout und virtuelle Python-Umgebung; Dienst darf nur lesen |\n| `/etc/viano-bug-analysis` | Root-eigene Konfiguration, Gruppe `buganalysis`, Verzeichnis 0750, Dateien 0640 |\n| `/etc/viano-bug-analysis/*.env` | Zugangsdaten, root:buganalysis 0600; systemd liest sie vor dem Benutzerwechsel |\n| `/etc/viano-bug-analysis/auth/users.sqlite3` | Root-verwaltete Nutzer, Gruppe `buganalysis` darf lesen |\n| `/var/lib/viano-bug-analysis` | Dienst-eigene Fallakten, Laufzeitdaten und Websessions |\n| `/srv/viano-bug-analysis/viano-core` | Root-eigener Source-Checkout, Dienst hat Leserechte |\n\nDie env-Dateien sind durch Betriebssystemrechte geschützte Klartextdateien, kein\nverschlüsselter Tresor. Sie gehören nicht ins Repository oder in Diagnosepakete.\nAPI und Worker erhalten unterschiedliche Dateien: Nur der Worker erhält Cloud-\nund DB-Zugangsdaten. Die Diensthärtung und EnvironmentFile-Nutzung folgen der\n[systemd-Dokumentation](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html).\n\nDie [Webverwaltung für Integrationsschlüssel](integration-keys.md) ergänzt die\nenv-basierte Maschinenanmeldung. Ihre SQLite und separate Schlüsseldatei müssen\nebenfalls gesichert werden; beide liegen standardmäßig unter der Fallablage.\n\n## Installation\n\nDen vertrauenswürdigen BugAnalysis-Checkout als root unter\n`/opt/viano-bug-analysis` ablegen. Code und Unterverzeichnisse dürfen nicht für\nandere Benutzer schreibbar sein. Dann:\n\n```sh\ncd /opt/viano-bug-analysis\npython3 deploy/install.py --dry-run\nsudo python3 deploy/install.py\n```\n\nDer Installer legt den Dienstbenutzer `buganalysis`, Konfigurationen, venv und\nUnits an. Er installiert die Python-Abhängigkeiten und lädt systemd neu.\nBestehende Zielkonfigurationen und Units werden erhalten. Updates an bereits\ninstallierten Units daher bewusst mit den versionierten Vorlagen abgleichen.\nBei Installationsfehlern bleiben vorhandene Dateien erhalten; erneutes Ausführen\nsetzt die Einrichtung fort. Der Installer verändert keine bestehende Datenbank.\n\n## Einmalige Konfiguration\n\nMit `sudoedit` die Dateien unter `/etc/viano-bug-analysis` bearbeiten:\n\n1. `project.toml`: DB-Adresse/Port, Datenbank und nur lesenden Benutzer einstellen,\n tatsächliche Snapshotkennung und vollständigen Source-Commit eintragen.\n Die ausgelieferten Null-Commit- und `CONFIGURE_ME`-Werte sind Platzhalter.\n2. Den Vcore-Checkout als root unter `/srv/viano-bug-analysis/viano-core` anlegen;\n der konfigurierte Commit muss lokal vorhanden und für `buganalysis` lesbar sein.\n Der Dienst lädt keine Commits nach. `gitconfig` vertraut ausschließlich diesem\n konkreten Root-Checkout, nicht beliebigen Repositories.\n3. `worker.env`: `GEMINI_API_KEY=...` und `VIANO_DB_PASSWORD=...` eintragen.\n Keine `export`-Anweisungen verwenden. Für Sonderzeichen gelten die\n EnvironmentFile-Quotingregeln von systemd. Werte nicht als Shellargumente übergeben.\n4. `api.env`: `VBA_INTAKE_TOKEN=...` für den Erfassungsworker setzen\n (32–256 Zeichen aus Buchstaben, Ziffern, `_` und `-`). Dies ist ein separater\n Integrationsschlüssel, kein Gemini-Key. Wird keine externe API-Integration\n benötigt, den zugehörigen `[[clients]]`-Block aus `api.toml` entfernen.\n5. `llm.toml`: gewünschtes Modell, Anbieter und Cacheeinstellungen kontrollieren.\n Investigation-, Budget- und Telemetrie-Dateien sind separat konfigurierbar.\n\nDie DB-Verbindung benötigt einen Benutzer mit ausschließlich passenden\nSELECT-Rechten auf die freigegebenen World-Tabellen. Diese Rechte legt der\nDB-Administrator an; BugAnalysis vergibt keine Grants. Der Readiness-Test belegt\nLesbarkeit, nicht die Abwesenheit zusätzlicher Schreibrechte. Für entfernte DBs\neinen geschützten Netzwerkweg verwenden: Der aktuelle DB-Provider konfiguriert\nselbst kein TLS. Zugang zu Produktionsdaten ist für die lokale Entwicklung\nweiterhin nicht erforderlich.\n\nWebnutzer anlegen; das Passwort wird verdeckt abgefragt:\n\n```sh\nsudo /opt/viano-bug-analysis/.venv/bin/python -m viano_bug_analysis.api create_user NAME --config /etc/viano-bug-analysis/api.toml --service-group buganalysis\n```\n\nNutzerverwaltung bleibt root vorbehalten. Siehe [Nutzerverwaltung](user-accounLine truncated
},
{
"id": "project-configuration",
"title": "Projekt-, Source-, DB- und Modellkonfiguration",
"markdown": "# Projekt und Recherchezugriffe konfigurieren\n\nDie Projektkonfiguration wird über Dateien verwaltet, nicht über die Webseite.\nSie legt fest, welche Werkzeuge ein Auftrag verwenden darf. Ein aufrufendes\nProgramm kann weder Repository noch DB-Zugänge oder Budgets mit einem Auftrag\nüberschreiben. Aktuell ist das Profil `wow/vcore` mit dem Importadapter `vcore`\nimplementiert; andere Projekttypen erfordern entsprechende Adapter und Profile.\n\n## Zuordnung in api.toml\n\nEin Projektelement ordnet eine feste Projekt-ID einem Profil und dessen\nKonfigurationsdateien zu. Relative Pfade beziehen sich auf die API-Konfigurationsdatei.\nDer Erfassungsworker muss genau dieselbe Projekt-/Profilzuordnung senden und\nsein Integrationsschlüssel muss für das Projekt freigegeben sein.\n\n```toml\n[[projects]]\nid = \"viano-core\"\ntype = \"wow\"\nprofile = \"vcore\"\nadapter = \"vcore\"\nplanning_mode = \"joint\"\n[projects.settings]\nproject_config = \"project.toml\"\nllm_config = \"llm.toml\"\ninvestigation_config = \"investigation.bounded.toml\"\nbudget_config = \"budget.human-tests.toml\"\n```\n\nDie Linux-Vorlagen stehen unter `deploy/linux/`; der Installer legt die\nKonfiguration unter `/etc/viano-bug-analysis` an. Er überschreibt bestehende\nDateien nicht. Die ausführliche Vcore-Projektvorlage steht unter\n`config/project.example.toml`, die Relationsregeln unter\n`config/project-relations.vcore.toml`.\n\n## Source und Datenbank in project.toml\n\n```toml\nrelations_file = \"project-relations.vcore.toml\"\n[project]\ntype = \"wow\"\nprofile = \"vcore\"\n\n[source]\npath = \"/srv/viano-bug-analysis/viano-core\"\nurl = \"https://gitea.viano-software.de/Nico/Viano-Core\"\ncommit = \"VOLLSTAENDIGEN_40_STELLIGEN_COMMIT_EINTRAGEN\"\n\n[database]\nhost = \"127.0.0.1\"\nport = 3306\ndatabase = \"cata_world\"\nuser = \"viano_reader\"\npassword_env = \"VIANO_DB_PASSWORD\"\nsnapshot_id = \"KENNUNG_DER_TATSAECHLICHEN_DATENGRUNDLAGE\"\ntables = [\"creature_template\", \"creature\"]\n```\n\nDas Beispiel zeigt nur den Aufbau und enthält absichtlich ungültige Platzhalter.\nDie beiden Tabellen sind keine vollständige Vcore-Freigabeliste. Für das echte\nProfil die vollständige Vorlage einschließlich Limits, Enrichment und\nAnalysewerkzeugregeln verwenden und passend prüfen.\n\nDer Source-Checkout muss lokal vorhanden sein und den konfigurierten Commit\nenthalten. Die URL allein klont oder aktualisiert kein Repository. Der Worker\nliest Git-Objekte dieses festen Stands und benötigt keine Schreibrechte auf den\nCheckout. Die Linux-Vorlage enthält eine gezielte Git-Vertrauenskonfiguration\nfür den root-eigenen Checkout. Neue Source-Stände bewusst bereitstellen und\nderen vollständigen Commit konfigurieren.\n\nDer DB-Administrator legt einen Benutzer mit ausschließlich passenden SELECT-\nRechten an. Zusätzlich begrenzt `tables` die zugänglichen Tabellen. Diese\nFreigabeliste ersetzt keine serverseitigen Rechte. Account- oder Charakterdaten\ngehören nicht zur standardmäßigen World-Recherche. Für entfernte DBs einen\ngeschützten Netzwerkweg nutzen; der aktuelle Provider konfiguriert selbst kein TLS.\n\nDas Passwort steht entweder in der durch `password_env` benannten\nUmgebungsvariable oder einer separaten geschützten Datei über `password_file`.\nNur eine dieser Varianten setzen. Unter Linux lädt der Worker beispielsweise\n`VIANO_DB_PASSWORD` aus seiner root-geschützten `worker.env`.\nPasswörter nicht in Fallakten, LLM-Prompts oder Versionsverwaltung ablegen.\nDie LLM bekommt Werkzeugergebnisse statt Zugangsdaten.\n\n## Mehrere Datenbanken pro Projekt\n\nEine Projektverbindung kann mehrere Datenbanken derselben MariaDB-Instanz\nansprechen. Host, Port, DB-Benutzer und Passwortbezug werden gemeinsam verwendet.\n`database` benennt die Standarddatenbank; unqualifizierte Tabellennamen beziehen\nsich ausschließlich auf diese. Zusätzliche Datenbanken werden ausdrücklich\nfreigegeben, etwa für Vcore:\n\n```toml\n# Im vorhandenen [database]-Abschnitt: database = \"cata_world\", user = \"buganalysis\"\n[database.additional_databases]\ncata_hotfixes = [\"*\"]\ncata_locale = [\"*\"]\n```\n\nDen neuen Abschnitt beispielsweise am Ende der vorhandenen Datei ergänzen.\n`[database]` nicht ein zweites Mal anlegen und die übrigen Projektregeln behalten.\nDie vollständige alternative Vorlage steht unter\n`config/project.vcore-multidb.example.toml`.\n\n`[\"*\"]` gibt ausdrücklich alle metadaten-sichtbaren Tabellen der genannten DB\nfrei. Alternativ eine nichtleere Liste konkreter Tabellen verwenden. Auch die\nStandarddatenbank unterstützt `tables = [\"*\"]`; die bisherige konkrete Liste\nbleibt genauso gültig. Ein leeres Array ist keine pauschale Freigabe.\nDie tatsächlich gewährten DB-Rechte bleiben zusätzlich maßgeblich.\nNicht aufgeführte Datenbanken sind selbst bei weitergehenden DB-Grants gesperrt.\nIn diesem Beispiel sind `cata_auth`, `cata_characters` und\n`cata_locale_reference` nichLine truncated
},
{
"id": "intake-api",
"title": "Übergabe-API und Rückgaben",
"markdown": "# Übergabe-API v1\n\nDie API erstellt interne Case-Pakete und veröffentlicht sie atomar in der Inbox.\nExterne Programme liefern Metadaten und Dateien; sie erzeugen und bearbeiten keine\ninternen Manifeste. Die API führt keine Analyse und keine LLM-Aufrufe aus. Ein\ndauerhaft laufender [Analyseworker](analysis-worker.md) übernimmt anschließend die\nAufnahme und Verarbeitung. Die [Weboberfläche](web-console.md) bietet Übersicht,\nAuftragsübergabe, Testerantworten und Ergebnisdownloads.\n\n## Lokaler Start\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m pip install -e '.[api]'\nCopy-Item config/api.example.toml config/api.local.toml\n$env:VBA_INTAKE_TOKEN = [Convert]::ToHexString([System.Security.Cryptography.RandomNumberGenerator]::GetBytes(32))\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api check --config config/api.local.toml\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml\n```\n\nDer API-Token ist ein eigener Zugangsschlüssel, kein Gemini-Key. Programme erhalten\nvorzugsweise einen Schlüssel aus der [Web-Schlüsselverwaltung](integration-keys.md).\nAlternativ bleiben Clients mit Token aus einer serverseitigen Umgebungsvariable\nunterstützt; der Beispielstart oben zeigt diese Variante. Der\nToken benötigt 32–256 Zeichen aus `A-Z a-z 0-9 _ -`. Auf älterem Windows PowerShell\nkann ein solcher Token über einen Passwortmanager erzeugt und als Variable gesetzt\nwerden. Der Beispielstart bindet nur an `127.0.0.1:8080`. Für entfernte Nutzung sind\nHTTPS und ein passend konfigurierter Reverseproxy erforderlich. Tokens nicht in\nURLs oder Git-Konfiguration ablegen. Zugriff auf das Workspace-Dateisystem bleibt\nauf den Dienst und vertrauenswürdige Betreiber beschränkt.\n\n`projects` ist die serverseitige Freigabeliste. Jeder Auftrag benennt `project_id`\nund `profile_id`. Er kann keine Repository-URLs, DB-Zugänge, Budgets oder\nKonfigurationspfade überschreiben. Optionale `projects.settings` halten die spätere\nWorker-Konfiguration bereit; die Eingangsschicht verwendet diese Dateien nicht für\nAnalysen. Aktuell ist der Importadapter `vcore` für `wow/vcore` implementiert.\nWeitere Projekte benötigen passende Adapter/Profile, keine separat gepflegten Forks.\n\n## Endpunkte\n\n| Methode/Pfad | Bedeutung |\n|---|---|\n| `GET /healthz` | Minimale öffentliche Erreichbarkeitsprüfung |\n| `GET /v1/profiles` | Für diesen Client freigegebene Projekte/Profile |\n| `GET /v1/schema` | Öffentliches JSON-Schema der Übergabemetadaten |\n| `GET /v1/overview` | Projektbezogene Worker-/Fallübersicht und Tokenstatistik |\n| `POST /v1/submissions` | Multipart-Upload, `201` bei neuer Einreihung, `200` bei Wiederholung |\n| `GET /v1/submissions/{id}` | Quittung und aktueller verwalteter Zustand |\n| `GET /v1/submissions/{id}/package` | Abgeschlossene Akte herunterladen; sonst `409` |\n\nAlle Endpunkte außer Health benötigen `Authorization: Bearer TOKEN`.\nPOST benötigt zusätzlich `Idempotency-Key: frei-gewaehlte-eindeutige-id` (1–128\nZeichen, Buchstaben/Ziffern sowie Punkt, Unterstrich, Bindestrich; erstes Zeichen\nalphanumerisch). Derselbe Client/Schlüssel mit denselben Metadaten und Dateiinhalten\nliefert dieselbe Quittung, auch nach einem Neustart. Abweichender Inhalt ergibt\n`409 API_IDEMPOTENCY_CONFLICT`. Dateinamen und Multipart-Grenzen zählen nicht als\nInhaltsänderung; Rollen und Reihenfolge der Dateien zählen. Ein zweiter neuer\nAuftrag für dasselbe Projekt und dieselbe Case-ID wird zurückgewiesen.\n\n## Neuer Auftrag\n\n`metadata` ist ein UTF-8-JSON-Textfeld, kein Datei-Upload. Dateien: genau einmal\n`wowdiag`, optional einmal `wowsrc`, optional mehrfach `attachments`. Für Vcore muss\ndie Case-ID dem Bug des Erfassungsarchivs entsprechen (`BUG-000020`). Beide\nErfassungsarchive müssen zueinander passen. Beispiel `submission.json`:\n\n```json\n{\n \"kind\": \"new_case\",\n \"project_id\": \"viano-core\",\n \"profile_id\": \"vcore\",\n \"case_id\": \"BUG-000020\",\n \"report\": {\n \"title\": \"Wolpertinger fehlen\",\n \"observations\": [\"Nach Questannahme waren im untersuchten Gebiet keine Tiere sichtbar.\"],\n \"expected_behavior\": [\"Fangbare Tiere sind im Gebiet vorhanden.\"]\n },\n \"versions\": {\n \"reported_server\": \"commit-des-gemeldeten-serverstands\",\n \"capture_server\": \"commit-des-erfassungsstands\"\n }\n}\n```\n\n```powershell\ncurl.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\n```\n\nVersionsangaben werden als unbestätigte Angaben des Einlieferers gespeichert, nicht\nals verifizierte technische Fakten. Sie ersetzen keine Prüfung des Deployments.\nZusätzliche Anhänge werden unter generierten internen Namen unverändert erhalten;\nsie werden nicht ausgeführt. OriginLine truncated
},
{
"id": "analysis-worker",
"title": "Analyseworker und Wiederanlauf",
"markdown": "# Dauerhafter Analyseworker\n\nDer Worker verbindet die Übergabe-API mit dem vorhandenen Analyseablauf. Er nimmt\nPakete aus derselben Inbox auf, wählt die serverseitige Projektkonfiguration und\nbearbeitet aktive Fälle bis zu Outbox oder Review. Er erstellt keine Patches,\nführt keine Builds aus und verändert keine Projekt-Datenbank.\n\n## Konfiguration\n\n`config/worker.example.toml` verweist auf die Konfiguration der Übergabe-API.\nAPI und Worker müssen denselben Workspace und Projektkatalog verwenden. Der\nWorker benötigt die API-Clienttokens nicht, wohl aber die in seiner LLM-Konfiguration\ngenannten Provider-Zugangsdaten. Beide Prozesse dürfen getrennt laufen.\n\nFür jedes freigegebene Projekt werden die Analysekonfigurationen in der\nAPI-Konfiguration hinterlegt:\n\n```toml\n[[projects]]\nid = \"viano-core\"\ntype = \"wow\"\nprofile = \"vcore\"\nadapter = \"vcore\"\nplanning_mode = \"joint\"\n[projects.settings]\nproject_config = \"project.local.toml\"\nllm_config = \"llm.early-review.local.toml\"\ninvestigation_config = \"investigation.bounded.toml\"\n```\n\nDie Pfade sind relativ zur API-Konfigurationsdatei. `llm_config` ist erforderlich.\nWeitere optionale Einstellungen: `analysis_config`, `budget_config`,\n`diagnosis_config`, `change_request_config`, `correction_config`. Ohne explizite\nDatei gelten deren bestehende Standardwerte. Für den aktuellen Vcore-Betrieb die\nerprobte `investigation.bounded.toml` verwenden; ältere ausführliche Promptmodi\nkönnen an der unveränderten 20.000-Zeichen-Grenze scheitern.\n\n`planning_mode` ist je Projekt `joint` (Standard) oder `separate`. Die Profilbindung\neiner Akte muss vollständig zum Katalog passen: `project.id`, `type`, `profile`.\nAlte manuelle Pakete ohne `project.id` werden nicht automatisch zugeordnet.\nAndere Projektadapter bleiben ein eigener Ausbauschritt.\n\n## Start und Prüfung\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker check --config config/worker.local.toml\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.worker run --config config/worker.local.toml\n```\n\n`check` lädt Konfiguration und prüft die Anwesenheit der LLM-Zugangsdaten. Es sendet\nkeine Modellanfrage und ist kein Erreichbarkeitstest für API, Datenbank oder\nRepository. `run` verarbeitet angenommene Aufträge und kann deshalb kostenpflichtige\nLLM-Aufrufe auslösen. Der reine Start ohne anstehende Fälle führt keine Modellaufrufe\naus. `run --once` führt genau einen Scheduler-Durchgang aus, nicht einen vollständigen\nBuglauf. Auch dieser Modus kann LLM-Aufrufe auslösen.\n\nAPI zusätzlich in einem zweiten Prozess starten:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml\n```\n\nFür diese Entwicklung wurden die zuvor nicht vorhandenen lokalen Dateien\n`config/api.local.toml` und `config/worker.local.toml` mit dem Vcore-Profil vorbereitet.\nSie sind nicht versioniert und enthalten keine Zugangsschlüssel. Vorhandene lokale\nKonfigurationen werden bei späteren Änderungen nicht automatisch überschrieben.\n\n## Scheduling und Wiederanlauf\n\n- Pro Durchgang erhält jeder berechtigte aktive Fall einen Dispatch. Danach\n darf der nächste Fall arbeiten. Gibt es Fortschritt, folgt der nächste Durchgang\n unmittelbar; nur im Leerlauf oder bei Wartefällen greift das Pollintervall.\n- Der Dienst läuft als einzelner Analyseprozess. Eine OS-Dateisperre verhindert\n einen zweiten Worker im selben Workspace. Die Fall-Lease ist mit dem manuellen\n Trialbefehl geteilt. API-Veröffentlichungen verwenden weiterhin die kurzen\n Checkpoint-/Inbox-Sperren; während einer Modellanfrage bleibt die API benutzbar.\n- Paketmanifest und Checkpoints bleiben maßgeblich. Es wird nur die neueste\n Generation pro Fall geprüft, nicht jede historische Generation erneut geöffnet.\n- Bei Neustart werden verwaltete aktive Fälle und noch nicht übernommene Uploads\n wiedergefunden. PREPARED-Veröffentlichungen der API werden vor der Verarbeitung\n abgeschlossen. Rückläufer starten die bereits eröffnete neue Analyse-Revision;\n alte Interpretation und Hypothesen werden nicht als neue Ergebnisse behandelt.\n- Bestehende LLM-Operationskennungen bleiben stabil. Der Dienst ändert keine\n Versuchskennungen, Budgets, Iterationsgrenzen oder Korrekturregeln, um einen\n blockierten Fall weiterzutreiben.\n- `RETRY_PENDING`/`DEFERRED` beachten die gespeicherte Wartezeit. Ein unerwarteter\n Konfigurations-/Vorbereitungsfehler erhält einen lokalen Cooldown; andere Fälle\n werden weiterbearbeitet. Eine ausdrücklich zurückgegebene `ERROR`-Antwort oder\n unerwartete Exception während eines Dispatch erzeugt `ATTENTION`, statt dieselbe\n Fehlantwort endlos abzuspielen oder möglicherweise erneut Modellkosten auszulösen.\n- BLOCKED bleibt BLOCKED, wenn eine menschliche Testerantwort benötigt wird,\n automatische Wiederholung untersagt ist, Modellvalidierung geschLine truncated
},
{
"id": "user-accounts",
"title": "Benutzerkonten",
"markdown": "# Benutzeranmeldung und exklusive Bearbeitung\n\nWebbenutzer melden sich mit Benutzername und Passwort an. Alle Benutzer besitzen\ndieselben Rechte für alle Projekte. Es gibt weder Administrator-/Normalbenutzer-\nKlassen noch eine Benutzerverwaltung im Web. Bestehende Bearer-Tokens bleiben\nausschließlich für externe API-Integrationen nutzbar und behalten deren bisherigen\nZugriffsgrenzen. Fehlende Token-Umgebungsvariablen deaktivieren die jeweilige\nIntegration; die Benutzeranmeldung benötigt keine Token-Zufallsgenerierung.\n\n## Konsolenverwaltung\n\nDie Befehle erfordern unter Linux effektive root-Rechte (normalerweise `sudo`),\nunter Windows eine als Administrator gestartete Konsole. Sie benötigen keinen\nGemini-Schlüssel und senden keine Modellanfrage.\n\n```bash\nsudo .venv/bin/python -m viano_bug_analysis.api create_user nico --config config/api.local.toml --service-group buganalysis\nsudo .venv/bin/python -m viano_bug_analysis.api change_pw_user nico --config config/api.local.toml\nsudo .venv/bin/python -m viano_bug_analysis.api delete_user nico --config config/api.local.toml\n```\n\nOhne Passwortargumente erfolgt eine verdeckte Abfrage. Die gewünschten expliziten\nArgumentformen funktionieren ebenfalls:\n\n```text\ncreate_user NAME PASSWORT --config DATEI\ndelete_user NAME --config DATEI\nchange_pw_user NAME ALTES_PASSWORT NEUES_PASSWORT --config DATEI\n```\n\nExplizite Passwortargumente können in Shellhistorie/Prozessargumenten auftauchen;\nfür den normalen Betrieb die verdeckte Eingabe verwenden. Benutzernamen bestehen\naus 1–64 ASCII-Buchstaben/Ziffern sowie Punkt, Unterstrich oder Bindestrich und\nbeginnen mit einem Buchstaben oder einer Ziffer. Namen sind nicht case-sensitiv.\nBeim Passwortwechsel wird das alte Passwort geprüft. Passwortwechsel und Löschung\nentwerten bestehende Sitzungen und deren Bearbeitungssperren ohne API-Neustart.\n\nWindows, in einer Administrator-PowerShell:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api create_user nico --config config/api.local.toml\n```\n\n## SQLite-Dateien und Betriebssystemrechte\n\nBenutzer liegen standardmäßig in `<workspace>/auth/users.sqlite3`. Die API öffnet\ndiese Datei ausschließlich schreibgeschützt. Gesalzene scrypt-Hashes ersetzen\nKlartextpasswörter. Sitzungen, Anmeldebegrenzung und Bearbeitungssperren liegen\nseparat in `<workspace>/runtime/web-sessions/sessions.sqlite3`; Sitzungstokens\nwerden dort nur als Hash gespeichert.\n\nProduktionskonfiguration, Pfade relativ zur Konfigurationsdatei oder absolut:\n\n```toml\n[auth]\nusers_database = \"/etc/viano-bug-analysis/users.sqlite3\"\nsession_seconds = 3600\n```\n\nDie Benutzerdatei und ihr Verzeichnis gehören unter Linux root; CLI-Befehle setzen\n0640 bzw. 0750. `--service-group buganalysis` erlaubt dem dedizierten Dienstkonto\ndas Lesen, nicht die Benutzerverwaltung. Verzeichnisvorfahren dürfen nicht durch\nunvertrauenswürdige Benutzer austauschbar sein. Der Dienst benötigt Schreibzugriff\nauf seinen Workspace und die Sitzungsdatei. Unter Windows entsprechend geschützte\nNTFS-Verzeichnisse/ACLs verwenden: Administratoren verwalten die Benutzerdatei,\ndas Dienstkonto liest sie. Die Konsolenbefehle prüfen auch dort die erhöhte\nAdministratorberechtigung. Wer Programmcode oder Authentifizierungsdateien selbst\nändern kann, liegt außerhalb der Web-Zugriffsgrenze; deshalb sind diese OS-Rechte\nBestandteil der Servereinrichtung.\n\nSQLite benötigt keinen MariaDB-Dienst. Für einen Umzug die Benutzerdatei und die\nProjekt-/Workspace-Daten sichern; die separate Sitzungsdatei kann bei gestoppter\nAPI bewusst verworfen werden, um alle Sitzungen und Sperren zu beenden.\n\n## Sitzungen und Bearbeiten\n\nDie Anmeldung erzeugt ein HttpOnly-/SameSite-Strict-Cookie; unter HTTPS zusätzlich\nSecure. Entfernte Zugriffe über HTTPS betreiben. Mutationen benötigen den an die\nSitzung gebundenen CSRF-Header. Die Oberfläche hält nur diesen CSRF-Wert im\nArbeitsspeicher; Passwörter und Sitzungstokens werden nicht im Local Storage\ngespeichert. Sitzungen gelten standardmäßig absolut eine Stunde, konfigurierbar\nvon 60 Sekunden bis 24 Stunden; regelmäßige Seitenaktualisierungen verlängern sie\nnicht endlos. Nach einem Neuladen kann eine gültige Sitzung weiterverwendet werden.\nAnmeldeversuche werden serverseitig zeitlich begrenzt.\n\nEine Testanfrage lässt sich zunächst nur ansehen. Erst **Bearbeiten** übernimmt\ndie Anfrage atomar für die aktuelle Sitzung. Eine zweite Sitzung erhält\n`API_TEST_CLAIMED` und eine verständliche Meldung. Erst dann erscheinen Eingabefelder.\nDie Serverprüfung verlangt dieselbe Sperre auch beim Absenden; das Ausblenden\nvon Feldern allein ist nicht die Zugriffskontrolle. Identische Wiederholungen\nbleiben idempotent, abweichende zweite Antworten werden zurückgewiesen.\n\n**Bearbeitung freigeben**, Abmeldung oder Sitzungsablauf machen die Anfrage für\nandere Sitzungen verfügbar. Der AblauLine truncated
},
{
"id": "integration-keys",
"title": "Integrationsschlüssel",
"markdown": "# Integrationsschlüssel\n\nAngemeldete Webnutzer finden unter **API-Schlüssel** eine eigene Verwaltungsseite.\nProgrammname und mindestens ein Projekt auswählen, dann den Schlüssel erstellen.\n**Anzeigen** ruft ihn bei Bedarf erneut ab; **Verbergen** entfernt ihn aus dem\nEingabefeld. **Kopieren** verwendet die Zwischenablage (HTTPS oder localhost).\n**Widerrufen** sperrt ihn sofort für nachfolgende API-Anfragen. Bereits laufende\nAnfragen werden dadurch nicht abgebrochen. Ein widerrufener Schlüssel wird nicht\nmehr angezeigt; für einen Ersatz einen neuen Schlüssel erstellen.\n\nAlle angemeldeten menschlichen Nutzer haben dieselben Verwaltungsrechte.\nMaschinenzugänge können weder Schlüssel verwalten noch andere Schlüssel anzeigen.\nWebmutationen einschließlich Anzeigen benötigen die vorhandene CSRF-Absicherung.\n\nDas aufrufende Programm sendet `Authorization: Bearer <Schlüssel>` an die\nÜbergabe-API. Neue Schlüssel erlauben Auftragsübergabe einschließlich Rückgaben\nund lesenden Zugriff auf die ausgewählten Projekte. Sie erlauben keine\nBetriebssteuerung oder Beantwortung menschlicher Testanfragen. Zugriff auf weitere\nProjekte erfordert einen neuen Schlüssel. Bestehende env-basierte Integrationen\nbleiben unverändert nutzbar und erscheinen nicht in der SQLite-Schlüsselliste.\n\n## Speicherung und Sicherung\n\n- SQLite unter `<workspace>/runtime/integrations/keys.sqlite3` speichert Metadaten,\n SHA-256-Prüfwerte und mit Fernet authentifiziert verschlüsselte Schlüssel.\n- Der separate Serverschlüssel entsteht beim ersten Erstellen automatisch unter\n `<workspace>/runtime/integration-secrets/master.key`, Dateimodus 0600.\n Nutzerkonten und ihre root-verwaltete SQLite-Datei werden nicht verändert.\n- Mit `VIANO_INTEGRATION_KEY_FILE` kann die API stattdessen eine separate\n Schlüsseldatei verwenden. Vorhandene Dateien müssen einen gültigen Fernet-Key\n enthalten und für den API-Dienst lesbar sein; fehlt die Datei beim ersten\n Erstellen, muss der Dienst sie im gewählten Verzeichnis anlegen dürfen.\n- SQLite und Serverschlüssel getrennt geschützt sichern. Das Laufzeitverzeichnis\n enthält damit dauerhaft notwendige Integrationsdaten und darf bei einer\n Bereinigung von Sessions nicht pauschal gelöscht werden. Unter Windows passende\n Dateisystem-ACLs verwenden; POSIX-Modusbits ersetzen dort keine ACLs.\n- Geht der Serverschlüssel verloren oder wird er unpassend ausgetauscht, können\n Schlüssel nicht mehr angezeigt werden. Das System ersetzt ihn bei bestehenden\n Einträgen nicht stillschweigend. Vorhandene Schlüssel können anhand ihrer\n Prüfwerte weiterhin authentifiziert und über die Weboberfläche widerrufen werden.\n Zur Wiederherstellung die richtige Schlüsseldatei aus der Sicherung zurückspielen.\n\nNach einem Update die API-Abhängigkeiten neu installieren (`.[api]` umfasst jetzt\n`cryptography`) und den API-Dienst neu starten. Eine Datenbankmigration per Hand\nist nicht nötig. Keine Änderung oder Neueingabe des Gemini-Schlüssels erforderlich.\n"
},
{
"id": "web-console",
"title": "Weboberfläche im Detail",
"markdown": "# Webübersicht\n\nDie Betriebsübersicht wird durch die bestehende Übergabe-API ausgeliefert:\n\n```powershell\n.\\.venv\\Scripts\\python.exe -m viano_bug_analysis.api serve --config config/api.local.toml\n```\n\nAnschließend `http://127.0.0.1:8080/` öffnen und mit **Benutzername und Passwort**\nanmelden. Benutzer werden ausschließlich über privilegierte Konsolenbefehle\nangelegt, geändert und gelöscht, siehe [Benutzerverwaltung](user-accounts.md).\nAlle Webbenutzer haben dieselben Rechte für alle Projekte. Die unten beschriebenen\nClient-/Projektberechtigungen gelten weiterhin für externe API-Integrationen.\nFür entfernte Zugriffe HTTPS verwenden. Der Analyseworker läuft unabhängig davon wie unter\n[Analyseworker](analysis-worker.md) beschrieben. Das Öffnen der Übersicht löst\nkeine Modellanfragen aus.\n\n## Benutzeranleitung\n\nDer Reiter **Benutzeranleitung** enthält die allgemeine Bedienung und\nFunktionsweise sowie Anleitungen für Linux, Projekt-/DB-/Sourcekonfiguration,\nÜbergabe-API, Analyseworker, Nutzerkonten, Integrationsschlüssel, Weboberfläche\nund Erfassungsworker. Dokumentauswahl, Inhaltsverzeichnis und interne Verweise\nerlauben Navigation ohne Server-Dateizugriff. Markdown-Downloads sind ebenfalls\nmöglich. Der Reiter benötigt eine angemeldete Sitzung; sein Aufruf startet keine\nAnalyse. Tabellen, Codebeispiele und Überschriften werden als sichere DOM-Elemente\ndargestellt, eingebettetes HTML wird nicht ausgeführt.\n\nFür Dokumentationsänderungen die ursprünglichen Markdown-Dateien unter `docs/`\nbeziehungsweise `deploy/capture-worker/README.md` bearbeiten und danach\n`python scripts/build_user_manual.py` ausführen. Die feste Auswahlliste steht im\nSkript; tatsächliche lokale Konfigurationen oder Secrets werden nicht gebündelt.\n`python scripts/build_user_manual.py --check` und die Tests erkennen veraltete\nWebfassungen. Das erzeugte `web/user-manual.json` wird mit dem Python-Paket\nausgeliefert, sodass keine lokale Kopie des Dokumentationsordners nötig ist.\n\n## Worker und Fortschritt\n\nDie Startseite listet lebende Worker und darunter den jeweils bearbeiteten Fall\nmit seinem Namen und einem Ladebalken. Der bestehende Dienst erlaubt einen Worker\nje Workspace; die Darstellung ist bereits als Liste aufgebaut. Die Einführung\neines parallelen Worker-Pools ist damit nicht verbunden.\n\nDer Balken folgt abgeschlossenen Arbeitsschritten, nicht vergangener Zeit. Seine\nsieben Anteile entsprechen intern Aufnahme, Fallaufbau, Interpretation,\nHypothesenbildung, Untersuchung, Diagnose und Codingauftrag. Die Oberfläche führt\ndiese Stufen nicht einzeln auf. Der Codingauftrag ist die letzte Stufe, auch wenn\nsein Dokument fachlich erklärt, dass kein Codingauftrag benötigt wird. Bei\ngemeinsamer Interpretation/Hypothesengenerierung können zwei Anteile gleichzeitig\nfertig werden. Eine lang laufende Untersuchung füllt den Balken nicht künstlich.\n\nDer aktive Fall verschwindet bei `OUTPUT_READY`, `HANDED_OFF` oder\n`FAILED_TO_ANALYZE` aus der Workeransicht und der Tabelle laufender Fälle. Er bleibt\nin der Verbrauchsstatistik enthalten. Wartende Fälle bleiben in der Fallübersicht,\nbelegen aber keinen arbeitenden Worker. Ein alter Dienstsnapshot ohne lebenden\nTelemetrie-Heartbeat wird nicht als aktiver Worker dargestellt.\n\n## Verbrauch\n\n- Kreisdiagramm: bekannte Eingabe- und Ausgabetokens je Phase; zusätzliche\n Korrektur-/Reparatur-/Wiederholungsaufrufe haben ein eigenes orangefarbenes Segment.\n Die Legende zeigt Fallaufbau, Fallplanung, Hypothesen, Untersuchung, Diagnose und\n Codingauftrag auch bei null gemessenen Tokens. Nullwerte erzeugen keinen\n künstlichen Tortenanteil. Gemeinsame Planungsaufrufe bleiben einmal gezählt.\n- Balkendiagramm: Eingabe und Ausgabe einschließlich Denken je Kategorie.\n- Der erste reguläre Aufruf bleibt bei seiner Phase, auch wenn sein Ergebnis später\n zurückgewiesen wurde. Seine Korrektur wird ausschließlich dem Korrektursegment\n zugeordnet. Abgeleitete, deterministisch reparierte Antwortdokumente sind keine\n zusätzlichen Anbieteraufrufe und werden nicht erneut gezählt.\n- Denktokens sind bereits Bestandteil der Ausgabetokens. Sie werden weder zum\n Kreisdiagramm noch zum Gesamtverbrauch noch einmal hinzuaddiert.\n- Neue LLM-Runden speichern ihre Verbrauchszuordnung deterministisch aus der\n Operationskennung. Das verändert keine Prompts, Modellparameter oder Budgets.\n Bereits gespeicherte Aufrufe bleiben unverändert. Bei älteren Akten wird nur\n eindeutig erkennbare Korrekturinformation übernommen; sonst erscheint der Anteil\n als „Nicht zugeordnet“.\n- Fehlende Messwerte bleiben unbekannt. Die Diagramme summieren nur bekannte Werte\n und weisen unvollständige Aufrufe aus. Bei vollständig fehlender Tokennutzung\n lässt sich deren Größe nicht als Kreisanteil darstellen.\n\nDer Messumfang umfasst **alle erhaltenen, für den Client freigegebenen Akten und\nderLine truncated
},
{
"id": "capture-worker",
"title": "Erfassungsworker v0.4",
"markdown": "# wowdiag-worker v0.4 – BugAnalysis-Übergabe\n\nErweiterung des vorgelegten v0.3-Workers. Die ursprüngliche Queue-Claim-Logik und\nDB-Struktur bleiben erhalten. Keine Änderung der aktiven `config.toml`, kein\nDienststart und keine Datenbankmigration durch diese Dateien.\n\nNach erfolgreicher wowdiag-/wowsrc-Erzeugung wird ein Versandauftrag dauerhaft\nin einer separaten lokalen SQLite-Outbox gespeichert. `diagnostic_runs.completed`\nbedeutet weiterhin **Erfassung abgeschlossen**, nicht Analyse abgeschlossen oder\nAPI-Zustellung bestätigt. Die Versandquittung ist über `--handoff-status` sichtbar.\nDie API übernimmt den Fall anschließend in ihren eigenen Analyseworker.\n\n## Einrichtung\n\n1. Bestehende `wowdiag_worker.py` sichern und die v0.4-Datei sowie\n `buganalysis_handoff.py` nebeneinander installieren. Python 3.11 und PyMySQL\n bleiben die Voraussetzungen; der HTTP-Client benötigt keine neue Bibliothek.\n2. Den Abschnitt aus `buganalysis.config.example.toml` in die vorhandene\n Worker-Konfiguration übernehmen. Tatsächliche URL und Projektzuordnung setzen.\n Der URL muss die Serverwurzel sein; HTTPS ist für entfernte Server erforderlich.\n3. Einen Integrationsschlüssel in BugAnalysis für das Projekt erzeugen und im\n Dienst-Environment als `VBA_INTAKE_TOKEN` hinterlegen. Unter Linux eine geschützte\n systemd-EnvironmentFile verwenden; den Schlüssel nicht in Skriptargumente,\n Repository oder Worker-Logs schreiben. Bestehende Unit um EnvironmentFile\n ergänzen und anschließend kontrolliert neu starten.\n4. SQLite-Outbox und Diagnosearchive bis zur bestätigten Zustellung erhalten.\n Das Outbox-Verzeichnis muss für den Worker schreibbar sein und einen lokalen\n Datenträger mit SQLite-Dateisperren verwenden. Die Datei nicht zwischen\n unabhängigen Installationen teilen.\n\nOhne aktivierten `[buganalysis]`-Abschnitt bleibt der bisherige Ablauf erhalten.\nDas Flag bedeutet ausschließlich Erfassung plus Übergabe; der Worker löst keine\nModellanfrage selbst aus. Ein laufender BugAnalysis-Worker kann nach Annahme\nallerdings sofort eine kostenpflichtige Analyse beginnen.\n\n## Wiederholungen und Status\n\n```sh\npython3 wowdiag_worker.py --config config.toml --handoff-status\npython3 wowdiag_worker.py --config config.toml --retry-handoffs\n```\n\nDiese beiden Befehle führen keine Erfassung aus. Der normale Dauerdienst sendet\nfällige Aufträge vor dem nächsten Capture; `--once` versucht sie auch danach.\nNetzwerkfehler, Serverausfälle, temporäre Ablehnungen und fehlende/ungültige\nZugangsdaten bleiben `pending`. Nach Behebung werden dieselben Inhalte mit\ndemselben Idempotenzschlüssel versandt. Auch eine verlorene Erfolgsantwort legt\ndadurch keinen zweiten Fall an. Es werden keine HTTP-Redirects verfolgt.\n\nUnveränderliche Metadaten, Ziel-URL, Archivpfade und Inhaltsprüfwerte stehen in der\nOutbox; Zugangsdaten stehen dort nicht. Archivänderung oder endgültiger HTTP-\nKonflikt wird `attention` und benötigt Betreiberprüfung. Ein Lease verhindert\ngleichzeitigen regulären Versand durch mehrere Worker-Prozesse. Ein Prozessabbruch\nlässt den Auftrag nach Ablauf des Lease erneut versuchen. Es gibt keine automatische\nNeuerzeugung von Archiven oder ein Zurücksetzen der ursprünglichen Queue-Claims.\n\nBereits vor dem Update abgeschlossene Erfassungen werden nicht automatisch\nnachträglich eingesendet. Ein erneuter neuer Auftrag für dieselbe Bug-ID wird von\nBugAnalysis bewusst abgewiesen, wenn der Fall bereits existiert. Rückgaben nach\neinem Patch erfolgen über den separaten `case_return`-Vertrag und nicht durch\nautomatisches Umdeuten neuer Captures.\n\nDer Übergabeumschlag nennt die Bug-ID und den Erfassungs-Commit. Die wirklichen\nTesterbeobachtungen, Titel und Beziehungen liest der BugAnalysis-Importer direkt\naus den unveränderten Archiven. Der Worker erfindet keine Beobachtungen und gibt\nden Erfassungsstand nicht als Stand der ursprünglichen Fehlermeldung aus.\n\n## Lokale Prüfung im BugAnalysis-Checkout\n\n`python -m pytest tests/profiles/vcore/test_capture_handoff.py` prüft den Versand gegen die echte\nlokale Intake-API mit synthetischen Archiven. Kein echter Server, keine Cloud-\nAnfrage, keine Änderung der Diagnose-Queue. Ein Server-Feldtest mit einem neuen\nBug und Dienstneustart steht nach der Konfiguration noch aus.\n"
}
]
}
+12 -5
View File
@@ -2,6 +2,8 @@ import argparse
import asyncio
import json
import signal
import os
import sys
from viano_bug_analysis.api.config import directory
from viano_bug_analysis.lifecycle.storage import Workspace
@@ -13,14 +15,18 @@ 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('command', choices=['check', 'run', 'status', 'retry', 'repair'])
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')
parser.add_argument('--reason',help='Technical correction performed; required for repair, no secrets')
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')
if args.command in {'retry','repair'} and not (args.case_id and args.package_id):
parser.error('retry/repair requires --case-id and --package-id')
if args.command=='repair':
if not args.reason or not 1<=len(args.reason.strip())<=1000:parser.error('repair requires --reason (1 to 1000 characters)')
if sys.platform=='linux' and os.geteuid()!=0:parser.error('Local repair approval requires root; alternatively use the authenticated web interface')
try:
config = WorkerConfig.load(args.config)
if args.command == 'check':
@@ -38,8 +44,9 @@ def main(argv=None):
else:
directory(config.intake.workspace)
workspace = Workspace(config.intake.workspace)
if args.command == 'retry':
AnalysisWorker(config).retry(args.case_id, args.package_id)
if args.command in {'retry','repair'}:
AnalysisWorker(config).retry(args.case_id, args.package_id,
repair_reason=args.reason if args.command=='repair' else None)
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
+1 -1
View File
@@ -9,7 +9,7 @@ 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'})
'WORKER_STORAGE_LOW', 'WORKER_CYCLE_ERROR', 'WORKER_BUSY', 'WORKER_RETRY_STATE', 'WORKER_RETRY_STALE', 'WORKER_NOT_FOUND'})
def code(value):
+59
View File
@@ -0,0 +1,59 @@
"""Durable operator intent and audit log, shared by HTTP and worker processes."""
import json
import uuid
from contextlib import contextmanager
from viano_bug_analysis.api.config import directory
from viano_bug_analysis.lifecycle.locking import FileLock
from viano_bug_analysis.lifecycle.job import timestamp
from viano_bug_analysis.lifecycle.storage import atomic_json
class Control:
def __init__(self, workspace):
self.root = directory(workspace.runtime / 'worker/control')
self.lock = workspace.runtime / 'locks/worker-control.lock'
def read(self):
path = self.root / 'state.json'
return json.loads(path.read_bytes()) if path.exists() else {'paused':{}, 'commands':[], 'events':[]}
@contextmanager
def transaction(self):
with FileLock(self.lock):
value = self.read()
yield value
atomic_json(self.root / 'state.json', value)
@staticmethod
def event(value, actor, action, project, **fields):
value['events'].append({'id':uuid.uuid4().hex, 'at':timestamp(),
'actor':actor, 'action':action, 'project_id':project, **fields})
def pause(self, actor, project, paused):
with self.transaction() as value:
value['paused'][project] = paused
self.event(value, actor, 'pause' if paused else 'resume', project, status='APPLIED')
def enqueue(self, actor, project, manifest, *, repair_reason=None):
with self.transaction() as value:
if len(value['commands']) >= 100:
from viano_bug_analysis.api.config import APIError
raise APIError('API_CONTROL_BUSY', 409)
identity = {k:manifest[k] for k in ('case_id','package_id','package_generation')}
for command in value['commands']:
if all(command[k] == v for k,v in identity.items()):
return command['id']
command = {'id':uuid.uuid4().hex,'actor':actor,'project_id':project,**identity}
if repair_reason is not None:command['repair_reason']=repair_reason
value['commands'].append(command)
self.event(value, actor, 'repair' if repair_reason is not None else 'retry', project,
command_id=command['id'], status='QUEUED', **identity)
return command['id']
def complete(self, command, code=None):
with self.transaction() as value:
value['commands'] = [c for c in value['commands'] if c['id'] != command['id']]
self.event(value, command['actor'], 'repair' if 'repair_reason' in command else 'retry', command['project_id'],
command_id=command['id'], case_id=command['case_id'], package_id=command['package_id'],
status='REJECTED' if code else 'APPLIED', code=code)
+44
View File
@@ -0,0 +1,44 @@
"""Explicit, single-attempt entity-resolution release after operator code repair."""
from types import SimpleNamespace
import hashlib
from pathlib import Path
from viano_bug_analysis.analysis.context import InterpretationLimits
from viano_bug_analysis.analysis.entity_resolution import EntityResolver,digest
from viano_bug_analysis.case.contracts import read_json
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
from viano_bug_analysis.package import PackageReader
from .config import WorkerError
def approve_entity_repair(store,case_id,package_id,*,actor,reason,expected_generation):
if not isinstance(reason,str) or not 1<=len(reason.strip())<=1000 or not isinstance(actor,str) or not actor.strip():
raise WorkerError('WORKER_RETRY_STATE')
resolver=EntityResolver(SimpleNamespace(store=store,project_research=None,limits=InterpretationLimits()))
with PackageReader(store.latest(case_id,package_id),store.limits) as reader:
m=reader.manifest
if m['package_generation']!=expected_generation:raise WorkerError('WORKER_RETRY_STALE')
cp=checkpoint_chain(reader)[0][1]
if (m['state']!='BLOCKED' or m['routing']['reason']!='ENTITY_RESOLUTION_INVALID_RESPONSES'
or cp['resume_state']!='INTERPRETING_CASE'):
raise WorkerError('WORKER_RETRY_STATE')
base=f"analysis/interpretations/{m['analysis_revision']:04d}/entity-resolution"
initial=read_json(reader,base+'/input.json','entity-resolution',4*1024**2)['payload']
context=initial['context'];settings=initial['policy']
if resolver.read(reader,context,'assessment'):raise WorkerError('WORKER_RETRY_STATE')
stage='assessment' if resolver.read(reader,context,'plan') else 'plan'
maximum=resolver.authorized_attempts(reader,context,settings,stage)
error=resolver.read(reader,context,f'error-{stage}-{maximum:04d}')
if not error:raise WorkerError('WORKER_RETRY_STATE')
call=read_json(reader,error['call_ref'],'llm-call',4*1024**2)
if (call['analysis_revision']!=m['analysis_revision'] or call['case_id']!=case_id
or call['package_id']!=package_id or call['phase']!='INTERPRETING_CASE'):
raise WorkerError('WORKER_RETRY_STATE')
details={'operation':'operator-entity-repair','actor':actor,'reason':reason.strip(),
'implementation_sha256':hashlib.sha256(Path(__file__).read_bytes()+
Path(__file__).parents[1].joinpath('analysis/entity_resolution.py').read_bytes()).hexdigest(),
'repair':{'stage':stage,'attempt':maximum+1,'rejected_call_ref':error['call_ref'],
'rejected_call_sha256':digest(call)}}
return store.advance(case_id,package_id,'INTERPRETING_CASE',
step_id=f'operator-entity-repair:{expected_generation}',details=details,
expected_generation=expected_generation,manual_retry=True)
+49 -2
View File
@@ -15,6 +15,7 @@ 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
from .control import Control
class AnalysisWorker:
"""One analysis step per eligible case/pass; case files remain authoritative."""
@@ -33,6 +34,7 @@ class AnalysisWorker:
self.stop_requested = False
self.last_case = None
self.last_inbox = None
self.control = Control(self.workspace)
def request_stop(self):
self.stop_requested = True
@@ -113,14 +115,21 @@ class AnalysisWorker:
self.runtimes[project.id] = runtime
return runtime
def retry(self, case_id, package_id):
def retry(self, case_id, package_id, *, expected_generation=None, locked=False, repair_reason=None, actor='local-cli'):
"""Release only a scheduler error; never bypass case/operation budgets."""
with FileLock(self.workspace.runtime / 'locks/analysis-service.lock'):
if not locked:
with FileLock(self.workspace.runtime / 'locks/analysis-service.lock'):
with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(case_id) + '.lock')):
return self.retry(case_id, package_id, expected_generation=expected_generation, locked=True,
repair_reason=repair_reason,actor=actor)
if locked:
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
if expected_generation is not None and manifest['package_generation'] != expected_generation:
raise WorkerError('WORKER_RETRY_STALE')
record = self._record(manifest)
project = self.projects.get(manifest['project'].get('id'))
if not project:
@@ -128,7 +137,16 @@ class AnalysisWorker:
if (record.get('analysis_revision') == manifest['analysis_revision']
and record.get('config_hash') not in {None, binding(project)}):
raise WorkerError('WORKER_CONFIG_CHANGED')
if repair_reason is not None and manifest['state']!=State.BLOCKED:
raise WorkerError('WORKER_RETRY_STATE')
if manifest['state'] == State.BLOCKED:
if repair_reason is not None:
from .repair import approve_entity_repair
latest=approve_entity_repair(self.store,case_id,package_id,actor=actor,reason=repair_reason,
expected_generation=manifest['package_generation'])
with PackageReader(latest,self.store.limits) as reader:
self._save(reader.manifest,'RELEASED')
return
# 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']):
@@ -141,7 +159,29 @@ class AnalysisWorker:
expected_generation=manifest['package_generation'])
with PackageReader(latest, self.store.limits) as reader:
manifest = reader.manifest
elif manifest['state'] in {State.OUTPUT_READY, State.HANDED_OFF, State.FAILED_TO_ANALYZE}:
raise WorkerError('WORKER_RETRY_STATE')
elif record.get('status') not in {'ERROR', 'ATTENTION', 'BUSY', 'DEFERRED', 'RETRY_PENDING'}:
raise WorkerError('WORKER_RETRY_STATE')
self._save(manifest, 'RELEASED')
if expected_generation is None:
with self.control.transaction() as value:
self.control.event(value,'local-cli','retry',project.id,status='APPLIED',
case_id=case_id,package_id=package_id)
def _commands(self):
for command in self.control.read()['commands']:
try:
with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(command['case_id']) + '.lock')):
self.retry(command['case_id'], command['package_id'],
expected_generation=command['package_generation'], locked=True,
repair_reason=command.get('repair_reason'),actor=command['actor'])
except LockBusy:
continue
except Exception as error:
self.control.complete(command, safe_code(getattr(error, 'code', 'WORKER_CASE_ERROR')))
else:
self.control.complete(command)
def _admit(self):
sources = sorted(self.workspace.inbox.glob('*.zip'))
@@ -156,9 +196,12 @@ class AnalysisWorker:
try:
with PackageReader(source, self.store.limits) as reader:
case_id = reader.manifest['case_id']
project_id = reader.manifest['project'].get('id')
except Exception:
self.queue.process(source) # normal validator/quarantine behavior
continue
if self.control.read()['paused'].get(project_id, False):
continue
with FileLock(self.workspace.runtime / 'locks' / ('trial-' + key(case_id) + '.lock')):
result = self.queue.process(source)
if result.status in {'ERROR', 'BUSY'}:
@@ -168,6 +211,7 @@ class AnalysisWorker:
async def tick(self):
self._status('RUNNING')
self._commands()
if shutil.disk_usage(self.workspace.root).free < self.config.min_free_bytes:
self._status('STORAGE_LOW', code='WORKER_STORAGE_LOW')
return 0
@@ -198,6 +242,9 @@ class AnalysisWorker:
if same_generation and not self._due(record.get('retry_not_before')):
continue
project = self.projects.get(manifest['project'].get('id'))
if (manifest['state'] in {State.NEW, State.VALIDATED, State.REOPENED}
and self.control.read()['paused'].get(manifest['project'].get('id'), False)):
continue
if not project or (project.type, project.profile) != (
manifest['project'].get('type'), manifest['project'].get('profile')):
self._pause(manifest, 'WORKER_PROJECT_UNKNOWN', status='ATTENTION')
+96 -8
View File
@@ -3,7 +3,9 @@ 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;
import {createServer} from 'node:net';
async function freePort(){const server=createServer();await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const port=server.address().port;await new Promise(resolve=>server.close(resolve));return port;}
const port=await freePort(), debugPort=await freePort();
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-'));
@@ -20,36 +22,122 @@ try {
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);}
ws.onmessage=e=>{const m=JSON.parse(e.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);if(!p)return;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);};
if(m.method==='Log.entryAdded'&&m.params.entry.level==='error'&&!(m.params.entry.url?.endsWith('/v1/auth/session')&&m.params.entry.text.includes('401')))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'"));
await evaluate("document.getElementById('username').value='preview';document.getElementById('password').value='ui-fixture-password';document.getElementById('login-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));");
await until(()=>evaluate("document.getElementById('token-count').textContent==='35.300'"));
await until(()=>evaluate('operationState?.operator === true'));
const repairControl=await evaluate(`(()=>{
const root=document.createElement('div');
addRetryControl({state:'BLOCKED',reason:'ENTITY_RESOLUTION_INVALID_RESPONSES',revision:2,current_revision:2,key:'fixture',package_generation:43},root);
const input=root.querySelector('textarea'),button=root.querySelector('button');
if(!input||!button)return false;
const original=changeOperation;let sent;
changeOperation=(data)=>{sent=data;};
try{input.value='Installed technical correction';button.click();
return button.textContent==='Einen Reparaturversuch freigeben'&&sent?.action==='repair'&&sent.package_generation===43&&sent.reason==='Installed technical correction';
}finally{changeOperation=original;}
})()`);
if(!repairControl)throw new Error('Scoped operator repair control failed');
if(await evaluate("document.cookie.includes('vba_session')"))throw new Error('Session cookie is readable by JavaScript');
await send('Page.reload');
await until(()=>evaluate("session===0&&token==='session'&&document.getElementById('token-count').textContent==='35.300'"));
await until(()=>evaluate("document.querySelector('#operation-projects button')"));
await evaluate("document.querySelector('#operation-projects button').click()");
await until(()=>evaluate("document.getElementById('operation-projects').textContent.includes('Neue Fälle pausiert')"));
if(!await evaluate("document.getElementById('operation-events').textContent.includes('preview')"))throw new Error('Operator audit missing');
await evaluate("document.querySelector('#operation-projects button').click()");
await until(()=>evaluate("document.getElementById('operation-projects').textContent.includes('Neue Fälle freigegeben')"));
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));
const categories=await evaluate("Array.from(document.querySelectorAll('#legend li')).map(e=>e.textContent)");
for(const name of ['Fallaufbau','Fallplanung','Hypothesen','Untersuchung','Diagnose','Codingauftrag','Korrekturen']) {
if(!categories.some(c=>c.includes(name)))throw new Error(`Missing phase in pie legend: ${name}`);
}
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("location.hash='#tests'");
await until(()=>evaluate("!document.getElementById('test-page').hidden&&document.querySelector('#test-list button')"));
if(!await evaluate("document.getElementById('test-list').textContent.includes('viano-core')&&document.getElementById('test-list').textContent.includes('BUG-000020')"))throw new Error('Test request project/case missing');
const sorting=await evaluate("(()=>{const original=testData.requests;const row=original[0];testData.requests=[{...row,case_id:'NEW',received_at:'2098-01-01T00:00:00Z'},{...row,project_id:'aaa',case_id:'OLD',received_at:'2000-01-01T00:00:00Z'},row];const first=()=>document.querySelector('#test-list tbody tr').textContent;document.getElementById('test-sort').value='oldest';renderTests();const oldest=first().includes('OLD');document.getElementById('test-sort').value='newest';renderTests();const newest=first().includes('NEW');document.getElementById('test-sort').value='project';renderTests();const project=first().includes('aaa');document.getElementById('test-project').value='viano-core';renderTests();const filtered=document.querySelectorAll('#test-list tbody tr').length===2;testData.requests=original;document.getElementById('test-project').value='';renderTests();return oldest&&newest&&project&&filtered;})()");
if(!sorting)throw new Error('Test request sorting/filtering failed');
await evaluate("document.getElementById('test-sort').value='project';document.getElementById('test-sort').dispatchEvent(new Event('change'));document.querySelector('#test-list button').click()");
if(await evaluate("!!document.querySelector('#test-answer textarea')"))throw new Error('Form editable without a claim');
await evaluate("Array.from(document.querySelectorAll('#test-answer button')).find(b=>b.textContent==='Bearbeiten').click()");
await until(()=>evaluate("document.querySelector('#test-answer textarea')"));
if(!await evaluate("document.getElementById('test-answer').textContent.includes('Sichtbarkeit und Build')"))throw new Error('Human test answer form missing');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Test page mobile overflow');
await writeFile(path.join(output,'test-requests-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
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');
if(await evaluate("document.getElementById('token-count').textContent")!=='35.300')throw new Error('Project aggregation differs');
await evaluate("location.hash='#results'");
await until(()=>evaluate("!document.getElementById('results').hidden&&document.querySelector('#result-rows a')"));
if(!await evaluate("document.querySelectorAll('#result-rows .package-icon').length===2&&document.getElementById('result-rows').textContent.includes('Wartet auf Testerantwort')"))throw new Error('Processed list missing blocked case or download controls');
await evaluate("document.getElementById('result-status').value='BLOCKED';document.getElementById('result-status').dispatchEvent(new Event('change'))");
if(!await evaluate("document.querySelectorAll('#result-rows .package-icon').length===1&&document.getElementById('result-rows').textContent.includes('BUG-000020')"))throw new Error('Blocked category filter failed');
await evaluate("document.getElementById('result-status').value='OUTPUT_READY';document.getElementById('result-status').dispatchEvent(new Event('change'));document.getElementById('result-sort').value='category';document.getElementById('result-sort').dispatchEvent(new Event('change'))");
if(!await evaluate("document.querySelectorAll('#result-rows .package-icon').length===1&&document.getElementById('result-rows').textContent.includes('BUG-000017')"))throw new Error('Outbox category filter failed');
await writeFile(path.join(output,'processed-cases.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.querySelector('#result-rows a').click()");
await until(()=>evaluate("document.getElementById('case-detail').textContent.includes('SQL-Vorlage mit Platzhaltern')"));
if(!await evaluate("document.getElementById('case-detail').textContent.includes('Passende Spawndaten ermitteln')"))throw new Error('Human assignment absent');
if(await evaluate('document.documentElement.scrollWidth>innerWidth'))throw new Error('Mobile detail overflow');
await writeFile(path.join(output,'case-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1200,deviceScaleFactor:1,mobile:false});await pause(200);
await writeFile(path.join(output,'case-desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("location.hash='#overview'");await pause(100);
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("location.hash='#keys'");
await until(()=>evaluate("!document.getElementById('keys-page').hidden&&document.querySelector('input[name=\"key-project\"]')"));
await evaluate("document.getElementById('key-name').value='Smoke integration';document.querySelector('input[name=\"key-project\"]').checked=true;document.querySelector('#key-form button').click()");
await until(()=>evaluate("document.getElementById('key-list').textContent.includes('Smoke integration')"));
await evaluate("Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Anzeigen').click()");
await until(()=>evaluate("document.querySelector('#key-list input').value.length>32"));
await writeFile(path.join(output,'integration-keys.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Verbergen').click()");
if(await evaluate("document.querySelector('#key-list input').value!==''"))throw new Error('Hidden key retained in input');
await evaluate("window.confirm=()=>true;Array.from(document.querySelectorAll('#key-list button')).find(b=>b.textContent==='Widerrufen').click()");
await until(()=>evaluate("document.getElementById('key-list').textContent.includes('Widerrufen')"));
await evaluate("location.hash='#manual'");
await until(()=>evaluate("!document.getElementById('manual-page').hidden&&document.getElementById('manual-content').textContent.includes('So arbeitet BugAnalysis')"));
if(!await evaluate("document.getElementById('manual-document').options.length===9&&document.querySelectorAll('#manual-toc button').length>5"))throw new Error('Manual catalog or table of contents missing');
const safe=await evaluate("(()=>{const rendered=renderManualMarkdown('<script>alert(1)</script> [bad](javascript:alert)');const root=document.createElement('div');root.append(rendered.fragment);return !root.querySelector('script,a')&&root.textContent.includes('<script>');})()");
if(!safe)throw new Error('Manual renderer interpreted unsafe markup');
await writeFile(path.join(output,'user-manual-desktop.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.getElementById('manual-document').value='linux-deployment';document.getElementById('manual-document').dispatchEvent(new Event('change'))");
await until(()=>evaluate("document.querySelector('#manual-content pre')&&document.getElementById('manual-content').textContent.includes('systemd-run')"));
await evaluate("document.getElementById('manual-document').value='project-configuration';document.getElementById('manual-document').dispatchEvent(new Event('change'))");
await until(()=>evaluate("document.getElementById('manual-content').textContent.includes('password_env')&&document.querySelector('#manual-content table')"));
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('Manual mobile overflow');
await writeFile(path.join(output,'user-manual-mobile.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));
await evaluate("document.getElementById('logout').click()");
if(!await evaluate("document.getElementById('manual-content').childElementCount===0"))throw new Error('Logout retained manual content');
if(!await evaluate("document.getElementById('key-list').childElementCount===0"))throw new Error('Logout retained keys');
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(!await evaluate("document.getElementById('case-detail').childElementCount===0&&document.getElementById('result-rows').childElementCount===0"))throw new Error('Logout retained detail data');
if(!await evaluate("document.getElementById('operation-events').childElementCount===0"))throw new Error('Logout retained audit');
if(!await evaluate("document.getElementById('test-answer').childElementCount===0&&document.getElementById('test-list').childElementCount===0"))throw new Error('Logout retained test request 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);
// Let Chromium close its database handles before removing its own profile.
if(ws?.readyState===WebSocket.OPEN)ws.send(JSON.stringify({id:999999,method:'Browser.close'}));
await pause(1500);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});
await rm(target,{recursive:true,force:true,maxRetries:15,retryDelay:300});
}
}
@@ -0,0 +1,7 @@
# BUG-000021: independent citation errors
Source: first VPS field run, 2026-10-24. This curated fixture contains one rejected interpretation and its citation-validation context, not a complete run archive.
The Furien identity in /relevant_entities/1 omits its identity evidence (N0002 or F0020). Independently, /described_behavior/0 cites B0002, a report, although that field only permits local-description facts. The original collector reported only the entity problem. The model repaired that problem and then exhausted the correction allowance on the previously undisclosed description error.
Regression: report both paths together, leave the input unchanged, retain strict final validation, and replay legacy repair requests with their frozen narrower issue list. No hypothesis or causal conclusion is validated by this fixture.
@@ -0,0 +1,156 @@
{
"behavior_gap": null,
"confirmed_technical_facts": [
{
"source_ids": [
"F0001",
"F0002",
"F0003",
"F0004",
"F0005",
"F0006",
"F0007",
"F0008",
"F0009",
"F0010",
"F0011"
],
"text": "Reproduction reproduced the reported state at position (187.7, 1782.16, 89.9472) on map 1, zone 405, area 2404 with orientation 4.70689 with diagnostic focus quest ID 28548."
},
{
"source_ids": [
"F0014",
"F0015",
"F0016"
],
"text": "Quest ID 28548 has LogTitle 'Warchief's Command: Desolace!' in quest_template with LogDescription 'Report to Furien at Tethris Aran in Desolace.'"
},
{
"source_ids": [
"F0017",
"F0018"
],
"text": "creature_queststarter query filtered for quest 28548 returned zero rows."
},
{
"source_ids": [
"F0020"
],
"text": "Lookup in creature_template for 'Furien' returned entry 35286 ('Furien') as well as related entries 35287, 35288, 35316, and 51554."
}
],
"contradictions": [],
"described_behavior": [
{
"basis": "local_description",
"source_ids": [
"B0002",
"F0015"
],
"text": "Quest 28548 instructs players to report to Furien at Tethris Aran in Desolace to turn in the quest.",
"verification": "not_independently_verified"
}
],
"expected_behavior": [],
"failure_stage": {
"source_ids": [
"F0001",
"O0004"
],
"text": "Quest turn-in and NPC interaction stage at Tethris Aran in Desolace"
},
"first_known_divergence": null,
"interpretation_summary": {
"source_ids": [
"B0002",
"F0001",
"F0015",
"O0001"
],
"text": "Reproduction and reports indicate that NPC Furien is missing at Tethris Aran in Desolace, where quest 28548 ('Warchief's Command: Desolace!') directs the player to report."
},
"investigation_direction": [
{
"source_ids": [
"F0012",
"F0020"
],
"text": "Investigate creature spawn entries in the creature table for creature_template entry 35286 ('Furien') at Tethris Aran in Desolace."
},
{
"source_ids": [
"F0014",
"F0017"
],
"text": "Examine quest ending and turn-in relations for quest 28548 to determine the configured quest ender creature."
}
],
"observed_behavior": [
{
"source_ids": [
"B0002",
"O0001",
"O0004"
],
"text": "NPC Furien is not present at Tethris Aran at the marked turn-in location for the quest."
},
{
"source_ids": [
"O0002"
],
"text": "The tester confirms during reproduction that the NPC is missing, noting a similar lack of spawn observed on an earlier Warchief's Command quest."
},
{
"source_ids": [
"O0003"
],
"text": "The player is located at the marked map coordinates, identified in-game as Tethris Aran."
}
],
"provisional_behavior_gap": {
"basis": "local_description",
"source_ids": [
"B0002",
"F0015",
"O0001"
],
"status": "provisional",
"text": "Quest text specifies reporting to Furien at Tethris Aran, but Furien is missing at that location."
},
"relevant_entities": [
{
"entity_id": "N0001",
"reason": "Diagnostic focus quest referenced in bug reports and reproduction records.",
"source_ids": [
"B0002",
"F0011",
"F0016"
]
},
{
"entity_id": "N0002",
"reason": "Quest 28548 ('Warchief's Command: Desolace!') specifically instructs the player in LogDescription and QuestDescription (F0015) to report to 'Furien' at Tethris Aran in Desolace. Bug reports B0002 and observation O0001 report NPC Furien missing at that location. Candidate K0001 (entry 35286, 'Furien') exactly matches the NPC name and target role, unlike subordinate entities (K0002 Mount, K0003/K0004 Dragonhawk, K0005 Assistant).",
"source_ids": [
"B0002",
"F0015",
"O0001"
]
}
],
"tester_assumptions": [],
"unknowns": [
{
"source_ids": [
"Q0001"
],
"text": "Expected behavior specification is missing from archived case records."
},
{
"source_ids": [
"D0006",
"F0021"
],
"text": "Exact German localization for Furien in creature_template_locale is unresolved due to truncated lookup results."
}
]
}
@@ -0,0 +1,5 @@
# BUG-000021 revision 2: no_match on a nonempty localized search
The base creature search correctly selected Furien (35286). The localized substring query returned unrelated harpy rows and was truncated. All three assessments marked it no_match, although the contract requires zero rows for no_match. The generic ENTITY_RESOLUTION_SELECTION feedback did not identify the index or rule.
Keep the independently valid selection unchanged. Explain nonempty/truncated coverage at search index 1; request only its correction. The expected repair is ambiguous, not a deterministic assertion that the other candidates match or do not match. The fixture contains one answer and the required scoped validation inputs, not a full run archive.
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more