Compare commits
19
Commits
58fc00601e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e1ade0a9 | ||
|
|
aec2bcdb21 | ||
|
|
61889740e6 | ||
|
|
4cbba4c1e4 | ||
|
|
33603db9bd | ||
|
|
c4225ebf04 | ||
|
|
a9b49bbb13 | ||
|
|
9c9d9d70d6 | ||
|
|
9408af5356 | ||
|
|
085a5c4026 | ||
|
|
b42bafb711 | ||
|
|
b8c0d7ec40 | ||
|
|
cbf112b0f8 | ||
|
|
ca4ab48fdc | ||
|
|
203e765992 | ||
|
|
9fc5c1805c | ||
|
|
ac179883c6 | ||
|
|
c478518425 | ||
|
|
cbb69a2115 |
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,2 @@
|
||||
[safe]
|
||||
directory = /srv/viano-bug-analysis/viano-core
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}. '
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
@@ -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/')&¤t?.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();
|
||||
@@ -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}}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
+7
@@ -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.
|
||||
Vendored
+156
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+3282
File diff suppressed because it is too large.
Load diff
@@ -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
Reference in new issue
Block a user