Compare commits

...
19 Commits
Author SHA1 Message Date
Christoph SchwörerandClaude Opus 5 08d90f1ccb Effortvergleich high gegen max: Effort wirkt ueber Delegation
Dieselbe TensorX-Matrix ein zweites Mal bei hoechstem Effort. Zwoelf
gueltige Laeufe, 2.116 Anforderungen, 127,6 Mio. Tokens, hochgerechnet
$10,53 aus der Preisliste vom 02.09.2026.

Befund: In den nicht-delegierenden Zellen bewegt sich der Ertrag zwischen
minus 18 und plus 44 Prozent ohne erkennbare Richtung - in der
Groessenordnung der Streuung. Der eine deutliche Ausschlag ist GLM in
custom mit plus 122 Prozent, erreicht mit 78 statt 30 Subagenten. Der
hoehere Denkaufwand schlaegt sich in mehr Zerlegung nieder, und die traegt
den Ertrag, nicht der Denkaufwand als solcher.

Qwens custom-Zelle hat sich qualitativ erholt: bei high 61 Prozent ohne
Beleg und 40 Prozent Hypothesen, bei max 3 Prozent ohne Beleg und 96
Prozent Primaerbeleg. Der Einbruch war ein Laufmerkmal, kein
Modellmerkmal - ein weiterer Beleg, dass n gleich 1 je Zelle nicht traegt.

Skill 13.2.0: analyse-anforderungen.py toleriert jetzt vier
Markdown-Fassungen der Feldvorgabe. Jedes der vier eingesetzten Modelle
formatierte sie anders, und jede Fassung wurde zunaechst mit null
Anforderungen gezaehlt, obwohl Belege und Pruefideen vollstaendig
vorlagen. Das ist ein Befund ueber den Versuchsaufbau: Die Formatvorgabe
ist fuer Menschen eindeutig, fuer maschinelle Auswertung nicht.
Regressionsprobe an sieben Laeufen unveraendert.

_matrix.ps1 nimmt zusaetzlich -Effort und -Modi fuer einzelne Zellen.
Ein Lauf fiel durch Standby des Rechners aus und wurde wiederholt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 13:41:01 +02:00
Christoph SchwörerandClaude Opus 5 e2c3a0e8f8 TensorX-Matrix: erste vollstaendig besetzte Matrix, 918 Anforderungen
Sechs Zellen - z-ai/glm-5.3-flash und qwen/qwen3.8-flash-next je solo,
builtin und custom - alle mit dem kompletten Artefaktsatz von sieben
Dateien. 47,1 Mio. Tokens in 6,4 Stunden, hochgerechnet rund $3,25.

Das Matrixskript heisst jetzt _matrix.ps1 und nimmt -Provider und
-Effort; die LM-Studio-Ladeparameter werden nur noch lokal uebergeben.
Vor dem Start bestaetigte ein Smoke-Test den TensorX-Pfad unter den
seither geaenderten Bedingungen (Denylist, Spiegel, Freigabemuster):
Anmeldung, Modellkontrolle, wirksame Effort-Variante und Dateiuebernahme.

Befund: Die Anforderungsanzahl haette in die Irre gefuehrt. Qwens
custom-Lauf liegt mit 157 Anforderungen im Mittelfeld, ist aber
qualitativ zusammengebrochen - 61 Prozent ohne jeden Beleg, 17 Prozent
mit Primaerbeleg, 40 Prozent Hypothesen, gegenueber 0 Prozent ohne Beleg
und 79 bis 98 Prozent Primaerbelegen in den uebrigen fuenf Laeufen. Er
lieferte zugleich weniger als builtin bei 37 Prozent mehr Tokens.

Der Moduseffekt ist modellabhaengig: Bei GLM steigt der Ertrag monoton
von 126 ueber 139 auf 216 bei durchgaengig hoher Belegqualitaet, bei Qwen
ist builtin das Optimum. Die Annahme, rollenspezialisierte Agenten seien
generell ueberlegen, traegt damit nicht.

Qwens custom-Lauf meldet exit_code 1 bei finish_reason stop und ohne
Timeout, nachdem alle 24 Subagenten zurueckkamen und sieben Dateien
entstanden. Er ist als gueltig mit Vorbehalt gefuehrt, die Ursache offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 16:21:31 +02:00
Christoph SchwörerandClaude Opus 5 6c5c26a2e4 Lokale Matrix liefert Ergebnisse: 119 Anforderungen aus Gemma und Qwen
Skill 13.0.0/13.1.0, Adapter 2.5.2.

Spiegel-Arbeitsverzeichnis: OpenCode laeuft nicht mehr direkt im
Codebasis-Root, sondern in einem Verzeichnis aus Junctions auf die
Top-Level-Eintraege, mit einem echten Ergebnisverzeichnis, dessen Inhalt
nach dem Lauf uebernommen wird. Damit landen relative wie absolute
Ausgabepfade am richtigen Ort, ohne dass der Prompt vom Wortlaut der
Claude-Laeufe abweichen muss. Der Spiegel liegt in _meta des Laufs; die
Codebasis bleibt unberuehrt.

Der Spiegel allein genuegte nicht. Sechs Laeufe schrieben mit korrektem
absolutem Pfad und wurden dennoch abgewiesen, weil OpenCode Ziele im
Repository gegen den worktree-relativen Pfad abgleicht - ein Befund, der
seit Skill 10.0.2 dokumentiert war und erst sichtbar wurde, als der
Spiegel vom Temp-Verzeichnis ins Projekt wanderte.

analyse-anforderungen.py erkennt Kennungen als Markdown-Ueberschrift, auch
ohne Feldnamen, sofern die Pflichtfelder folgen. Regressionsprobe an fuenf
Claude-Laeufen unveraendert.

Ergebnisse der gueltigen Laeufe: Qwen 3.5-9B liefert 107 der 119
Anforderungen, davon 75 im Modus custom mit nur drei Subagenten. Gemma
kommt auf 12. Der Modus builtin fiel bei beiden Modellen aus - drei von
drei Laeufen endeten nach einem Turn ohne einen Werkzeugaufruf. Die 13
Laeufe mit Adapter 2.3.0 bis 2.5.1 sind Artefakte der Fehlersuche und
als adapterbedingte Fehlmessungen gekennzeichnet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:49:53 +02:00
Christoph SchwörerandClaude Opus 5 28e927013b LM-Studio-Matrix mit gemma-4-e4b: zwoelf Laeufe und vier Befunde
Skill 12.0.0 bis 12.2.0 und Adapter 2.0.0 bis 2.2.0.

Werkzeugfreigabe: Die Shell-Rechte des OpenCode-Adapters sind jetzt eine
Denylist wie beim Claude-Adapter statt einer Allowlist. Ausloeser war der
erste Qwen-Lauf, dessen einzige beide Werkzeugaufrufe an einer Pipeline
scheiterten. Kontrolltest belegt beide Richtungen: Get-ChildItem | Format-Table
laeuft durch, rm wird verweigert, die Datei bleibt bestehen.

Lokaler Betrieb: Der Preflight entlaedt alle Modelle vor jedem Lauf und laedt
den Kontext auf das Modellmaximum. Gemessen waren zuvor beide Modelle
gleichzeitig geladen - 168 MiB frei von 16,3 GB. Qwen 27B passt auf dieser
Karte nicht und wurde durch qwen3.5-9b ersetzt, in Q4_K_M wie Gemma.

Messinstrument: analyse-anforderungen.py erkennt Feldnamen in Markdown-
Fettschrift und Modulpraefixe in IDs. Der erste lokale Lauf mit Artefakten
wurde sonst mit null Anforderungen gezaehlt statt mit neun. Regressionsprobe
an vier Claude-Laeufen unveraendert.

Befunde: Der Standard-Ausgabeblock, mit dem Claude sieben Artefakte erzeugt,
liefert bei gemma-4-e4b null von sechs Laeufen ein Ergebnis - das Modell loest
den Pfad relativ zum Arbeitsverzeichnis auf. Ein Lauf startete alle sieben
vorgesehenen Rollen und lieferte neun formkonforme Anforderungen. Ein anderer
erzeugte sieben richtig benannte Dateien ohne eine einzige formkonforme
Anforderung. Und eine Shell-Umleitung schrieb an der Denylist vorbei in den
eingefrorenen Snapshot - gefunden vom Vorher/Nachher-Vergleich, nicht von der
Regel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 11:02:04 +02:00
Christoph Schwörer 654339464e neues modell 2026-09-01 08:12:07 +02:00
Christoph SchwörerandClaude Opus 5 611fd0a80c Lokaler LM-Studio-Adapter fuer Gemma und Qwen (Skill 10.1.0)
Der TensorX-Wrapper wird providerneutral: opencode-tensorx-adapter.py heisst
jetzt opencode-adapter.py und waehlt ueber --provider {tensorx,lmstudio}
Gateway und Modellvorlage. Der TensorX-Pfad bleibt unveraendert; die vier
bestehenden Regressionstests laufen durch.

Neu fuer den lokalen Betrieb:
- opencode-lmstudio.json fuer google/gemma-4-e4b und qwen/qwen3.8-27b
- Preflight ueber /api/v0/models: Servererreichbarkeit, Modellverfuegbarkeit,
  tool_use-Faehigkeit, geladenes Kontextfenster (--min-context, Standard 32768)
  und genau eine geladene Instanz; --lmstudio-autoload stellt das selbst her
- local_runtime in RawResult.json (Quantisierung, Architektur, Runtime,
  lms-Version, Instanzbezeichner, Kontextfenster) fuer Kap. 4.3
- effort_applied, da der lokale Endpunkt keinen Thinking-Level annimmt

Drei Befunde aus der Inbetriebnahme, alle im Adapter abgefangen: LM Studio
laedt standardmaessig nur 8192 Kontexttokens; ein erneutes lms load erzeugt
eine zweite Instanz und macht das Routing mehrdeutig; Effort ist lokal
wirkungslos. Dazu zwei Korrekturen am gemeinsamen Pfad (Abbruchgrund nur
einmal in errors, saubere lms-Versionskennung).

Enthaelt ausserdem die bislang nicht committeten Laeufe der Iterationen 8
und 9 sowie Versuch 2 (Iterationen 1 bis 3). Der laufende Lauf unter
Iteration 10 ist bewusst nicht enthalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 20:19:33 +02:00
Christoph Schwörer b369e6115e Add OpenCode TensorX experiment adapter 2026-08-31 18:42:25 +02:00
Christoph Schwörer ca52aa4701 Add TensorX models for Versuch 2 2026-08-31 16:51:35 +02:00
Christoph Schwörer 8a22d586f1 iteration 8 2026-08-28 19:41:13 +02:00
Christoph Schwörer 37275c96d6 tensorx adapter 2026-08-28 07:38:12 +02:00
Christoph Schwörer ea1f3caff7 more runs before cline 2026-08-27 19:22:37 +02:00
Christoph SchwörerandClaude Opus 5 3d5b691bfa Iteration 3: Modell- und Modusraster erweitert, Skill 7.0.0, V2/V3 vorbereitet
Neue gueltige Zellen in Iteration 3
- claude-opus-5/solo/high: 363 Anforderungen, 99,7 % mit Primaerbeleg,
  Belege je Anforderung Median 2,0, 38,1 Mio. Tokens
- claude-fable-5/solo/high: 241 Anforderungen, 98,3 % mit Primaerbeleg,
  89,0 % PRIMAER-Anteil, vollstaendig regelkonform, 30,6 Mio. Tokens

Damit sind 6 von 12 Zellen des Rasters belegt. Zwei Befunde daraus:

Die Belegdichte folgt dem Modell, nicht dem Effort. Opus erreicht Median 2,0
auch auf high; alle 44 Sonnet-Laeufe lagen bei 1,0. max hebt Opus auf 3,0.
Die frueher dem Effort zugeschriebene Verdopplung ist damit eingegrenzt.

Die Fable-Modellverletzung ist reproduziert und abgegrenzt. Bei builtin laufen
die Subagenten auf claude-opus-5[1m] statt Fable (zweiter Fall nach Iteration 1),
bei solo dagegen sauber. Nicht das Modell ist die Ursache, sondern Fable in
Kombination mit Delegation.

Fehlmessungen, vollstaendig protokolliert
- vier 429-Abbrueche (Session-Kontingent) aus dem Parallelblock 19:59;
  drei davon mit Teilbestand, einer ohne Ergebnis
- opus-5/builtin/high zum dritten Mal gescheitert: 790,7 Mio. Tokens ueber drei
  Anlaeufe ohne Artefakt. Zelle mit dieser Prompt-Version nicht messbar.

Skill 7.0.0 (MAJOR)
- Isolationsmechanismus modusabhaengig: --safe-mode schaltet MCP-Server und
  Custom-Agenten ab und ist mit V2/V3 unvereinbar. Smoke-Test verifiziert:
  mit Flag spawned=0, ohne Flag spawned=2. Ersatz fuer custom/MCP:
  --strict-mcp-config plus --disallowedTools Skill WebSearch WebFetch SlashCommand.
- 6.1.0: Pflichtpruefung leeres Ergebnisverzeichnis = Fehlmessung unabhaengig von
  is_error; CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0; Protokollfeld Gueltigkeit
- extract-subagenten.py: Start-Quittung wird nicht mehr als Ertragsmass ausgewiesen

Versuch 2 und 3 vorbereitet
- Prompt-Kette V1 -> V2 (02-A, angepasst an Agentendateien) -> V3 (02-B, MCP)
- V2: acht Rollen inkl. nicht delegierendem ISO-29148-Orchestrator
- V3: elf Rollen, fuenf Werkzeugserver, neue Belegklasse LAUFZEIT

Ablaufprotokoll um Phase 6 und 7 sowie die Vorbereitung von V2/V3 ergaenzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 08:03:00 +02:00
Christoph Schwörer affde3a45f gpt skill ergünzt 2026-08-26 16:54:32 +02:00
Christoph Schwörer 844b4e5569 Lots of runs 2026-08-26 16:37:24 +02:00
Christoph SchwörerandClaude Opus 5 f349d189c7 Codebasis-Snapshot: DB-Schema als SQL-Dump ergaenzt
SSMS_DB_SCHEMA.sql (3.266.626 B, 76.793 Zeilen) - Schema-Dump der Datenbank
CentronVOED2: 1.558 Tabellen, 182 Views, 63 Prozeduren, 30 Funktionen,
134 Fremdschluessel.

Aenderung der Versuchsbedingung: Der Prompt fordert Datenbankschemata in
Schritt 2 (Artefakterhebung) ausdruecklich als Quelle. Laeufe mit und ohne
diese Datei untersuchen einen anderen Gegenstand und sind nicht poolbar.

Zeitpunkt im Arbeitsverzeichnis: 2026-08-26 10:28:08.
- Laeufe ohne die Datei: Tag 2, 02_Lauf_..._084301-d6f9 sowie 094249/094250
  (3983, f631, c69e); 4840 lief zum Zeitpunkt des Hinzufuegens noch.
- Laeufe mit der Datei: die fuenf 102932-Laeufe, abgelegt unter Tag 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:34:44 +02:00
Christoph Schwörer 7df384f6d2 Tag 1 abgeschlossen 2026-08-26 08:19:33 +02:00
Christoph Schwörer f045b99a25 Codebasis als Dateien ins Arbeitsrepo statt als Gitlink
QuellCode/CentronERP war nur als Gitlink (Submodul-Referenz auf 79c1142)
getrackt, ohne .gitmodules und ohne erreichbares Remote. Der
Untersuchungsgegenstand der Versuchsreihe war damit nicht reproduzierbar
gesichert: Ein Klon haette ein leeres Verzeichnis erhalten, und die Belege
der 3.287 Anforderungen waeren nicht ueberpruefbar gewesen.

Umstellung:
- Historie nach c:\DEV\CentronERP_git_snapshot_79c1142 ausgelagert
  (vollstaendig lesbar, enthaelt 79c1142 und Vorgaenger 89ccfd6)
- Gitlink aus dem Index entfernt
- Dateiinhalt aufgenommen: 24.557 Dateien, rund 333 MB

Die verschachtelte .gitignore der Codebasis gilt weiter, Build-Artefakte
bleiben ausgeschlossen. Details in Versuche/Versuch_01/_Codebasis-Nachweis.md
2026-08-26 07:43:51 +02:00
Christoph Schwörer 18edae75b6 V01 Erste Runs 2026-08-26 07:34:29 +02:00
ChristophSchwoerer 0ec86aed8c lit.bib fixed 2026-08-25 10:39:25 +02:00
26939 changed files with 6896275 additions and 15 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""Wertet die erzeugten Anforderungen eines Laufs aus und schreibt einen Protokollabschnitt.
Aufruf: python analyse-anforderungen.py <laufverzeichnis> [--print]
Erzeugt `<laufverzeichnis>/_meta/anforderungen.md` (der fertige Abschnitt) und
`<laufverzeichnis>/_meta/anforderungen.json` (maschinenlesbar).
Geparst wird das im Prompt vorgegebene Blockformat (`ID:`, `Typ:`, `Belege:`, `Status:` ...).
"""
import io, json, os, re, sys, collections
EBENEN = ('StRS', 'SyRS', 'SwRS')
# Anforderungen, fuer die der Prompt mindestens einen PRIMAER-Beleg verlangt
RISIKO = re.compile(r'sicherheit|abrechnung|fakturier|berechtigung|recht|zugriff|authentifiz|'
r'passwort|rolle|lizenz|steuer|zahlung|mahn', re.I)
# Feldnamen in Markdown-Fettschrift, z. B. "**ID:** StRS-01" statt "ID: StRS-01".
# Modelle formatieren die Vorgabe des Prompts haeufig als Markdown aus; ohne
# Normalisierung bleibt eine vollstaendig korrekt gefuellte Datei unerkannt.
# Beobachtet am 01.09.2026 im Lauf Iteration 7/.../v12.1.0-001c: vier Dateien mit
# regelkonformen Bloecken wurden als "keine Anforderungen" gezaehlt.
FETTES_FELD = re.compile(r'(?m)^[ \t]*\*\*([^*:\n]{1,40}):\*\*[ \t]*')
# Ueberschriftenmarker vor einem Feldnamen, z. B. "### ID: StRS-1".
# Beobachtet am 01.09.2026 im Lauf Iteration 15/.../v13.0.0-312f: sechs Dateien
# mit vollstaendig ausgefuellten, regelkonformen Bloecken wurden mit 0 gezaehlt,
# weil die ID-Zeile als Markdown-Ueberschrift gesetzt war.
UEBERSCHRIFT_FELD = re.compile(r'(?m)^[ \t]*#{1,6}[ \t]+(?=(?:\*\*)?[A-Za-zÄÖÜäöüß ]{1,40}:)')
# Kennung als blosse Ueberschrift, ohne Feldnamen: "### StRS-001".
# Nur dann als ID gewertet, wenn die Pflichtfelder unmittelbar folgen - sonst
# wuerde jede Zwischenueberschrift zur Anforderung. Beobachtet am 01.09.2026 im
# Lauf Iteration 15/.../v13.0.0-0b06 (qwen, solo): sieben Dateien, alle Felder
# ausser der Kennung regelkonform beschriftet.
UEBERSCHRIFT_KENNUNG = re.compile(
r'(?m)^[ \t]*#{1,6}[ \t]*((?:StRS|SyRS|SwRS)[A-Za-z0-9_.\-]*)[ \t]*$'
r'(?=(?:[ \t]*\n)*(?:[ \t]*(?:\*\*)?(?:Titel|Ebene)[:\*]))',
re.IGNORECASE,
)
# Felder als eingerueckte Listenpunkte, z. B. " - ID: StRS-001" unterhalb eines
# Bullets. Beobachtet am 04.09.2026 im Lauf Iteration 9/.../v13.0.0-d345: sieben
# Dateien, 323 kB, alle Felder korrekt gefuellt - vom Parser mit 0 gezaehlt.
# Bewusst auf die Feldnamen des Prompts beschraenkt: Ein allgemeines Entfernen
# der Einrueckung wuerde auch "Begruendung:" innerhalb der Belegliste treffen.
FELDNAMEN = (
'ID|Titel|Ebene|Typ|Qualitätsmerkmal|Qualitaetsmerkmal|Akteur|Vorbedingung|'
'Fakt|Aussage|Ergebnis|Belege|Prüfidee|Pruefidee|Tracelinks|Konsolidierung|'
'Übernahmewürdigkeit|Uebernahmewuerdigkeit|Status'
)
EINGERUECKTES_FELD = re.compile(r'(?m)^[ \t]+(?:[-*+][ \t]+)?(?=(?:\*\*)?(?:%s)[:\*])' % FELDNAMEN)
def normalisiere(text):
"""Markdown-Auszeichnung der Feldnamen auf die Klartextform zuruecknehmen.
Modelle setzen die Feldvorgabe des Prompts haeufig als Markdown - fett
(``**ID:**``), als Ueberschrift (``### ID:``), ganz ohne Feldnamen
(``### StRS-001``) oder als eingerueckte Liste (`` - ID: StRS-001``).
Inhaltlich ist der Block dann regelkonform; ohne Normalisierung bleibt er
unerkannt.
"""
text = UEBERSCHRIFT_KENNUNG.sub(lambda m: 'ID: ' + m.group(1), text)
text = UEBERSCHRIFT_FELD.sub('', text)
text = EINGERUECKTES_FELD.sub('', text)
return FETTES_FELD.sub(lambda m: m.group(1) + ': ', text)
def bloecke(pfad):
"""Zerlegt eine Anforderungsdatei in Bloecke ab jeder ID:-Zeile."""
if not os.path.exists(pfad):
return
text = normalisiere(io.open(pfad, encoding='utf-8').read())
teile = re.split(r'(?m)^ID:', text)
for t in teile[1:]:
yield 'ID:' + t
def feld(block, name):
m = re.search(r'(?m)^%s:[ \t]*(.*)$' % re.escape(name), block)
return m.group(1).strip() if m else ''
def ebene_von(block, aid, datei_ebene):
"""Ebene einer Anforderung bestimmen.
Die Ebene wird NICHT aus dem Dateinamen abgeleitet: Agenten legen Bloecke
regelmaessig in der Datei einer anderen Ebene ab (beobachtet im Lauf
Iteration 2/.../094250_v4.2.1-c69e: 12 StRS- und 5 SyRS-Bloecke standen in SwRS.md).
Massgeblich ist das Feld `Ebene:` des Blocks, hilfsweise das ID-Praefix,
erst zuletzt die Datei.
"""
f = feld(block, 'Ebene')
for eb in EBENEN:
if f.strip().upper().startswith(eb.upper()):
return eb, False
# IDs tragen haeufig ein Modulpraefix ("M003-StRS-01"). Die Ebene steht dann
# nicht am Anfang, sondern als Bestandteil der ID; ein reiner Praefixtest
# meldet sonst eine Fremdablage, die es nicht gibt.
kennung = aid.strip().upper()
for eb in EBENEN:
if re.search(r'(?:^|[^A-Z])%s' % eb.upper(), kennung):
return eb, eb.upper() != datei_ebene.upper()
return datei_ebene, False
def analysiere(lauf):
erg = os.path.join(lauf, 'Ergebnisse')
anf = []
for eb_datei in EBENEN:
for b in bloecke(os.path.join(erg, eb_datei + '.md')):
aid = feld(b, 'ID')
if not aid:
continue
eb, _ = ebene_von(b, aid, eb_datei)
fremd = not aid.strip().upper().startswith(eb_datei.upper())
belege = re.findall(r'\[(PRIM\w*R|SEKUND\w*R|KONTEXT)\]', b)
norm = []
for x in belege:
norm.append('PRIMÄR' if x.startswith('PRIM')
else 'SEKUNDÄR' if x.startswith('SEKUND') else 'KONTEXT')
status = feld(b, 'Status')
anf.append(dict(
id=aid, ebene=eb, datei_ebene=eb_datei, fremdabgelegt=fremd,
titel=feld(b, 'Titel'), typ=feld(b, 'Typ') or '(ohne)',
belege=norm, status=status,
hypothese=('HYPOTHESE' in status.upper()) or ('[HYPOTHESE]' in b),
workaround='workaround' in status.lower(),
tracelinks=feld(b, 'Tracelinks'),
konsolidierung=feld(b, 'Konsolidierung'),
pruefidee=feld(b, 'Prüfidee') or feld(b, 'Pruefidee'),
qm=feld(b, 'Qualitätsmerkmal'),
uebernahme=feld(b, 'Übernahmewürdigkeit') or feld(b, 'Uebernahmewuerdigkeit'),
))
return anf
def de(n):
return '{:,}'.format(int(n)).replace(',', '.')
def pct(a, b):
if not b:
return '–'
return ('%.1f' % (100.0 * a / b)).replace('.', ',') + ' %'
def abschnitt(anf):
n = len(anf)
if not n:
return '## Gefundene Anforderungen\n\nKeine Anforderungen im vorgegebenen Format gefunden.\n'
je_ebene = collections.Counter(a['ebene'] for a in anf)
typen = collections.Counter(a['typ'] for a in anf)
bel = collections.Counter()
for a in anf:
bel.update(a['belege'])
bel_ges = sum(bel.values())
ohne_beleg = [a for a in anf if not a['belege']]
hyp = [a for a in anf if a['hypothese']]
work = [a for a in anf if a['workaround']]
ohne_trace = [a for a in anf if not a['tracelinks'] or a['tracelinks'].lower() in ('-', '–', 'keine')]
ohne_pruef = [a for a in anf if not a['pruefidee']]
kons = [a for a in anf if a['konsolidierung'] and a['konsolidierung'].lower() != 'nein']
mit_qm = [a for a in anf if a['qm']]
# Uebernahmewuerdigkeit: erst ab Prompt-Fassung 2026-08-26; aeltere Laeufe kennen das Feld nicht
uebern = collections.Counter()
for a in anf:
v = a['uebernahme'].lower()
if not v:
continue
for schl in ('übernehmen', 'workaround', 'sonderfall', 'veraltet'):
if schl in v:
uebern[schl] += 1
break
else:
uebern['(sonstige Angabe)'] += 1
mit_uebern = sum(uebern.values())
risiko = [a for a in anf if RISIKO.search(a['typ'] + ' ' + a['titel'])]
risiko_ungedeckt = [a for a in risiko if 'PRIMÄR' not in a['belege'] and not a['hypothese']]
zahlen = sorted(len(a['belege']) for a in anf)
median = zahlen[n // 2] if n % 2 else (zahlen[n // 2 - 1] + zahlen[n // 2]) / 2.0
z = ['## Gefundene Anforderungen', '',
'Maschinell aus `Ergebnisse\\StRS.md`, `SyRS.md` und `SwRS.md` ausgewertet '
'(Blockformat des Prompts). Erzeugt von `analyse-anforderungen.py`.', '',
'Die Kenngrößen decken die **maschinell prüfbare** Hälfte des Evaluationsrahmens aus '
'Kapitel 4.3 ab: Belegqualität und Übernahmewürdigkeit gehören zur *Statement-Qualität*, '
'Verteilung und Konsolidierungskandidaten zur *Set-Qualität*, Tracelinks und '
'Belegklassifikation zur *Traceability-Qualität*. Die Expertenbewertung nach '
'Likert-Skala tritt daneben und wird hier nicht ersetzt.', '',
'### Verteilung über die Ebenen', '',
'| Ebene | Anzahl | Anteil |', '|---|---:|---:|']
for eb in EBENEN:
z.append('| %s | %d | %s |' % (eb, je_ebene.get(eb, 0), pct(je_ebene.get(eb, 0), n)))
z += ['| **Gesamt** | **%d** | 100 %% |' % n, '']
fremd = [a for a in anf if a.get('fremdabgelegt')]
if fremd:
nach = collections.Counter(
'%s-Block in `%s.md`' % (a['ebene'], a['datei_ebene']) for a in fremd)
z.append('')
z.append('> **Auffälligkeit – Ebene weicht von der Ablagedatei ab.** %d von %d '
'Anforderungen stehen in der Datei einer anderen Ebene: %s. Die Ebene wurde '
'aus dem Feld `Ebene:` beziehungsweise dem ID-Präfix bestimmt, nicht aus dem '
'Dateinamen. Für die Set-Qualität ist das relevant: Die Dreiteilung StRS / '
'SyRS / SwRS ist dann nicht mehr an der Dateistruktur ablesbar.'
% (len(fremd), n,
', '.join('%d × %s' % (v, k) for k, v in sorted(nach.items()))))
z.append('')
z += ['### Anforderungstypen', '', '| Typ | Anzahl | Anteil |', '|---|---:|---:|']
for t, c in typen.most_common(10):
z.append('| %s | %d | %s |' % (t, c, pct(c, n)))
if len(typen) > 10:
rest = sum(c for _, c in typen.most_common()[10:])
z.append('| (%d weitere) | %d | %s |' % (len(typen) - 10, rest, pct(rest, n)))
z.append('')
z += ['### Belegqualität', '', '| Messgröße | Wert |', '|---|---:|',
'| Belege gesamt | %s |' % de(bel_ges),
'| davon `PRIMÄR` | %s (%s) |' % (de(bel['PRIMÄR']), pct(bel['PRIMÄR'], bel_ges)),
'| davon `SEKUNDÄR` | %s (%s) |' % (de(bel['SEKUNDÄR']), pct(bel['SEKUNDÄR'], bel_ges)),
'| davon `KONTEXT` | %s (%s) |' % (de(bel['KONTEXT']), pct(bel['KONTEXT'], bel_ges)),
'| Belege je Anforderung (Median) | %s |' % str(median).replace('.', ','),
'| Anforderungen mit mindestens einem `PRIMÄR`-Beleg | %d (%s) |'
% (sum(1 for a in anf if 'PRIMÄR' in a['belege']),
pct(sum(1 for a in anf if 'PRIMÄR' in a['belege']), n)),
'']
z += ['### Übernahmewürdigkeit', '']
if not mit_uebern:
z += ['**nicht erhoben** – das Feld `Übernahmewürdigkeit` wurde erst mit der '
'Prompt-Fassung vom 2026-08-26 eingeführt und liegt für diesen Lauf nicht vor. '
'Hinweise auf Workarounds stecken ersatzweise im Feld `Status`.', '']
else:
z += ['| Einstufung | Anzahl | Anteil |', '|---|---:|---:|']
for k in ('übernehmen', 'workaround', 'sonderfall', 'veraltet', '(sonstige Angabe)'):
if uebern.get(k):
z.append('| %s | %d | %s |' % (k, uebern[k], pct(uebern[k], n)))
if n - mit_uebern:
z.append('| **ohne Angabe** | %d | %s |' % (n - mit_uebern, pct(n - mit_uebern, n)))
z.append('')
z += ['### Status', '', '| Kategorie | Anzahl | Anteil |', '|---|---:|---:|',
'| belegt | %d | %s |' % (n - len(hyp), pct(n - len(hyp), n)),
'| als `HYPOTHESE` gekennzeichnet | %d | %s |' % (len(hyp), pct(len(hyp), n)),
'| als Workaround vermerkt | %d | %s |' % (len(work), pct(len(work), n)),
'| Konsolidierungskandidaten | %d | %s |' % (len(kons), pct(len(kons), n)),
'| mit ISO-25010-Qualitätsmerkmal | %d | %s |' % (len(mit_qm), pct(len(mit_qm), n)),
'']
z += ['### Regelkonformität (Prüfung gegen die Vorgaben des Prompts)', '',
'| Vorgabe | Ergebnis |', '|---|---|']
z.append('| **Belegpflicht** – jede Anforderung mindestens ein Artefaktbeleg | %s |'
% ('**erfüllt** (0 Anforderungen ohne Beleg)' if not ohne_beleg
else '**verletzt** – %d ohne Beleg: %s' % (len(ohne_beleg),
', '.join(a['id'] for a in ohne_beleg[:8]) + (' …' if len(ohne_beleg) > 8 else ''))))
z.append('| **Risikobasierte Priorisierung** – Sicherheit, Abrechnung, Berechtigungen brauchen '
'einen `PRIMÄR`-Beleg oder die Kennzeichnung `[HYPOTHESE]` | %s |'
% ('**erfüllt** (%d risikorelevante Anforderungen, alle gedeckt)' % len(risiko)
if not risiko_ungedeckt
else '**verletzt** – %d von %d ungedeckt: %s' % (len(risiko_ungedeckt), len(risiko),
', '.join(a['id'] for a in risiko_ungedeckt[:8]) + (' …' if len(risiko_ungedeckt) > 8 else ''))))
z.append('| **Verifizierbarkeit** – jede Anforderung mit Prüfidee oder Akzeptanzkriterium | %s |'
% ('**erfüllt**' if not ohne_pruef
else '**verletzt** – %d ohne Prüfidee' % len(ohne_pruef)))
z.append('| **Übernahmewürdigkeit** – Einstufung für die Migrationsperspektive | %s |'
% ('*nicht erhoben* (Feld erst ab Prompt-Fassung 2026-08-26)' if not mit_uebern
else ('**erfüllt** (alle %d Anforderungen eingestuft)' % n if mit_uebern == n
else '**verletzt** – %d von %d ohne Angabe' % (n - mit_uebern, n))))
z.append('| **Traceability** – Verknüpfung zwischen den Ebenen | %d von %d mit Tracelinks (%s) |'
% (n - len(ohne_trace), n, pct(n - len(ohne_trace), n)))
z.append('')
return '\n'.join(z)
def main():
lauf = sys.argv[1]
anf = analysiere(lauf)
meta = os.path.join(lauf, '_meta')
os.makedirs(meta, exist_ok=True)
io.open(os.path.join(meta, 'anforderungen.json'), 'w', encoding='utf-8').write(
json.dumps(anf, indent=1, ensure_ascii=False))
text = abschnitt(anf)
io.open(os.path.join(meta, 'anforderungen.md'), 'w', encoding='utf-8').write(text + '\n')
if '--print' in sys.argv:
print(text)
else:
print('%s: %d Anforderungen ausgewertet -> _meta/anforderungen.md'
% (os.path.basename(os.path.normpath(lauf))[8:], len(anf)))
if __name__ == '__main__':
main()
@@ -0,0 +1,20 @@
{
"type": "object",
"properties": {
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": { "type": "string", "minLength": 1 },
"content": { "type": "string" }
},
"required": ["path", "content"],
"additionalProperties": false
}
},
"summary": { "type": "string" }
},
"required": ["files", "summary"],
"additionalProperties": false
}
@@ -0,0 +1,125 @@
"""Extrahiert Subagenten-Aufrufe (Prompt, Typ, Ergebnislaenge) aus dem persistierten
Session-Transkript eines Headless-Laufs und legt sie im Laufverzeichnis unter _meta ab.
Aufruf: python subagenten.py <laufverzeichnis> [<transkript-wurzel>]
Die Session-ID wird aus <laufverzeichnis>/RawResult.json gelesen.
"""
import io, json, os, sys, glob
lauf = sys.argv[1]
wurzel = sys.argv[2] if len(sys.argv) > 2 else os.path.expandvars(r'%USERPROFILE%\.claude\projects')
roh = json.load(io.open(os.path.join(lauf, 'RawResult.json'), encoding='utf-8-sig'))
sid = roh.get('session_id')
stats = roh.get('subagent_stats') or {}
gesamt = stats.get('spawned', 0)
verschachtelt = stats.get('spawned_by_subagents', 0)
# Im Haupttranskript stehen nur die direkt vom Hauptagenten gestarteten Subagenten.
# Von Subagenten gestartete liegen in deren eigenen Transkripten.
erwartet = gesamt - verschachtelt
treffer = glob.glob(os.path.join(wurzel, '*', sid + '.jsonl'))
if not treffer:
print('KEIN TRANSKRIPT gefunden fuer Session', sid)
sys.exit(1)
pfad = treffer[0]
aufrufe, ergebnisse = [], {}
for zeile in io.open(pfad, encoding='utf-8'):
try:
o = json.loads(zeile)
except Exception:
continue
cont = (o.get('message') or {}).get('content')
if not isinstance(cont, list):
continue
for c in cont:
if not isinstance(c, dict):
continue
if c.get('type') == 'tool_use' and c.get('name') in ('Task', 'Agent'):
ein = c.get('input') or {}
aufrufe.append({
'id': c.get('id'),
'werkzeug': c.get('name'),
'subagent_type': ein.get('subagent_type'),
'description': ein.get('description'),
'run_in_background': ein.get('run_in_background'),
'model': ein.get('model'),
'prompt': ein.get('prompt') or '',
})
elif c.get('type') == 'tool_result':
inhalt = c.get('content')
if isinstance(inhalt, list):
inhalt = ' '.join(str(x.get('text', '')) for x in inhalt if isinstance(x, dict))
ergebnisse[c.get('tool_use_id')] = str(inhalt or '')
# Ein Aufruf, der am Nebenlaeufigkeitslimit scheitert, erscheint im Transkript wie ein
# regulaerer Subagenten-Start, ist aber keiner: Er liefert nur die Absage zurueck und
# zaehlt nicht in `subagent_stats.spawned`. Ohne diese Trennung meldet der Abgleich eine
# Abweichung, die es nicht gibt (Lauf Iteration 3/.../125032_v4.4.0-fb24: 21 gefundene
# Aufrufe gegenueber 13 erwarteten - die Differenz waren 8 Absagen).
ABSAGE = 'Concurrent subagent limit reached'
for a in aufrufe:
txt = ergebnisse.get(a['id']) or ''
a['ergebnis_zeichen'] = len(txt)
a['abgewiesen'] = ABSAGE in txt
# Ein im Hintergrund gestarteter Subagent liefert sofort eine Start-Quittung
# ('Async agent launched successfully'), nicht seine Befunde. Ihre Laenge ist
# KEIN Ertragsmass - sie ist fuer alle Hintergrund-Subagenten praktisch gleich.
a['nur_startquittung'] = 'Async agent launched successfully' in txt
abgewiesen = [a for a in aufrufe if a['abgewiesen']]
echte = [a for a in aufrufe if not a['abgewiesen']]
meta = os.path.join(lauf, '_meta')
os.makedirs(meta, exist_ok=True)
io.open(os.path.join(meta, 'subagenten.json'), 'w', encoding='utf-8').write(
json.dumps(aufrufe, indent=1, ensure_ascii=False))
md = ['# Subagenten-Aufrufe', '',
'Session `%s`, Transkript `%s`.' % (sid, os.path.basename(pfad)),
'',
'`subagent_stats`: **%s** Subagenten gesamt, davon **%s** von Subagenten gestartet '
'(max_depth %s). Direkt vom Hauptagenten erwartet: **%s**. Im Transkript gefunden: '
'**%s** echte Starts%s.'
% (gesamt, verschachtelt, stats.get('max_depth'), erwartet, len(echte),
' und **%d** am Nebenlaeufigkeitslimit abgewiesene Aufrufe' % len(abgewiesen)
if abgewiesen else ''), '']
if verschachtelt:
md += ['> Die %s von Subagenten gestarteten Aufrufe stehen in deren eigenen Transkripten und'
' sind hier **nicht** enthalten.' % verschachtelt, '']
if abgewiesen:
md += ['> **%d Aufrufe wurden am Nebenlaeufigkeitslimit abgewiesen** '
'(`subagent_stats.refused.concurrency_limit` = %s) und sind unten **nicht** '
'aufgefuehrt. Sie erscheinen im Transkript wie regulaere Starts, liefern aber nur '
'die Absage zurueck und zaehlen nicht in `spawned`. Fuer die Auswertung der '
'selbstgewaehlten Zerlegung sind sie dennoch aufschlussreich: Der Hauptagent wollte '
'staerker parallelisieren, als das Werkzeug zuliess.'
% (len(abgewiesen), stats.get('refused', {}).get('concurrency_limit', '?')), '']
if erwartet != len(echte):
md += ['> **Abweichung** zwischen erwarteter (%s) und gefundener Anzahl echter Starts (%s).'
% (erwartet, len(echte)),
'> Ursache pruefen, bevor die Prompts ausgewertet werden.', '']
for i, a in enumerate(echte, 1):
md += ['## %d. %s' % (i, a['description'] or '(ohne Beschreibung)'),
'',
'- **Werkzeug:** `%s` **Typ:** `%s` **Hintergrund:** %s'
% (a['werkzeug'], a['subagent_type'], a['run_in_background']),
'- **Prompt-Zeichen:** %d **Rueckgabe:** %s'
% (len(a['prompt']),
'Start-Quittung (Befunde kommen per Benachrichtigung, nicht als '
'Werkzeugergebnis - Laenge ist kein Ertragsmass)'
if a.get('nur_startquittung') else '%s Zeichen' % a['ergebnis_zeichen']),
'', '### Prompt', '', '```', a['prompt'].rstrip(), '```', '']
io.open(os.path.join(meta, 'subagenten.md'), 'w', encoding='utf-8').write('\n'.join(md))
print('Subagenten gefunden: %d echte%s (direkt erwartet: %s, gesamt: %s, davon verschachtelt: %s)'
% (len(echte), (', %d abgewiesen' % len(abgewiesen)) if abgewiesen else '',
erwartet, gesamt, verschachtelt))
for a in echte:
print(' - %-42s %-16s Prompt %6d Z. %s'
% ((a['description'] or '')[:42], a['subagent_type'], len(a['prompt']),
'Hintergrund (Start-Quittung)' if a.get('nur_startquittung')
else 'Ergebnis %s Z.' % a['ergebnis_zeichen']))
print('geschrieben:', os.path.join(meta, 'subagenten.md'))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Verdichtet die RawResult.json unterhalb eines Verzeichnisses zu einer Tabelle.
Gedacht fuer die Sichtung einer Matrix: Welche Zelle hat gemessen, welche ist
eine Fehlmessung, und woran lag es. Schreibt nichts - reine Auswertung.
python lauf-uebersicht.py "<Verzeichnis>" [--details]
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def schreibziele(lauf: Path) -> list[str]:
"""Wohin der Lauf zu schreiben versuchte, samt Ergebnis.
Der haeufigste Grund fuer ein leeres Ergebnisverzeichnis ist ein
Schreibversuch an den falschen Ort - der ist ohne diese Aufstellung nicht
zu sehen, weil ``written_files`` dann schlicht leer bleibt.
"""
session = lauf / "_meta" / "opencode-session.json"
if not session.is_file():
return []
try:
daten = json.loads(session.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
ziele = []
for nachricht in daten.get("messages", []):
for teil in nachricht.get("parts", []):
if teil.get("type") != "tool" or teil.get("tool") not in ("write", "edit"):
continue
zustand = teil.get("state", {})
ziele.append(
f"{teil.get('tool')} {zustand.get('status')} -> "
f"{zustand.get('input', {}).get('filePath')}"
)
return ziele
def main() -> int:
parser = argparse.ArgumentParser(description="Uebersicht ueber Versuchslaeufe")
parser.add_argument("verzeichnis")
parser.add_argument("--details", action="store_true", help="Schreibziele zeigen")
args = parser.parse_args()
wurzel = Path(args.verzeichnis)
zeilen = []
for datei in sorted(wurzel.rglob("RawResult.json")):
try:
d = json.loads(datei.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
lauf = datei.parent
zeilen.append(
{
"lauf": lauf.name,
"modus": d.get("mode", "?"),
"exit": d.get("exit_code"),
"status": d.get("subtype", "?"),
"min": round(d.get("duration_ms", 0) / 60000, 1),
"turns": d.get("num_turns", 0),
"tools": d.get("tool_call_count", 0),
"sub": d.get("subagent_stats", {}).get("spawned", 0),
"dateien": len(d.get("written_files") or []),
# Eine 0 aus einem laufenden Subagenten ist kein Messwert.
"tokens": (
d["usage"]["total_tokens"]
if d.get("usage_captured", True)
else None
),
"pfad": lauf,
}
)
if not zeilen:
print("Keine RawResult.json gefunden.")
return 1
kopf = f"{'Lauf':44} {'Modus':8} {'St':3} {'Min':>6} {'Turn':>5} {'Tool':>5} {'Sub':>4} {'Dat':>4} {'Tokens':>10}"
print(kopf)
print("-" * len(kopf))
for z in zeilen:
tok = "n. erf." if z["tokens"] is None else f"{z['tokens']:,}"
print(
f"{z['lauf'][:44]:44} {z['modus']:8} {str(z['exit']):3} {z['min']:6.1f} "
f"{z['turns']:5} {z['tools']:5} {z['sub']:4} {z['dateien']:4} {tok:>10}"
)
if args.details:
for ziel in schreibziele(z["pfad"]):
print(f" {ziel}")
gueltig = [z for z in zeilen if z["dateien"] > 0]
print(
f"\n{len(zeilen)} Laeufe, davon {len(gueltig)} mit Ergebnisdateien, "
f"{len(zeilen) - len(gueltig)} Fehlmessungen."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Materialize Codex result files and normalize its JSONL measurements."""
from __future__ import annotations
import argparse
import json
from collections import Counter
from datetime import datetime
from pathlib import Path, PurePosixPath
from typing import Any
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("run_directory", type=Path)
parser.add_argument("--model", required=True)
parser.add_argument("--effort", required=True)
return parser.parse_args()
def read_jsonl(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
events: list[dict[str, Any]] = []
malformed: list[str] = []
for number, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
if not line.strip():
continue
try:
value = json.loads(line)
except json.JSONDecodeError as exc:
malformed.append(f"line {number}: {exc}")
continue
if isinstance(value, dict):
events.append(value)
else:
malformed.append(f"line {number}: JSON value is not an object")
return events, malformed
def safe_result_path(results_dir: Path, raw_path: str) -> Path:
relative = PurePosixPath(raw_path.replace("\\", "/"))
if relative.is_absolute() or not relative.parts or ".." in relative.parts:
raise ValueError(f"unsafe result path: {raw_path!r}")
if any(part in ("", ".") or ":" in part for part in relative.parts):
raise ValueError(f"invalid result path: {raw_path!r}")
target = results_dir.joinpath(*relative.parts).resolve()
root = results_dir.resolve()
if root != target and root not in target.parents:
raise ValueError(f"result path escapes Ergebnisse: {raw_path!r}")
return target
def parse_iso(path: Path) -> datetime | None:
if not path.exists():
return None
value = path.read_text(encoding="utf-8-sig").strip()
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def main() -> int:
args = parse_args()
run_dir = args.run_directory.resolve()
meta_dir = run_dir / "_meta"
results_dir = run_dir / "Ergebnisse"
events, malformed = read_jsonl(run_dir / "RawEvents.jsonl")
envelope = json.loads((meta_dir / "final_response.json").read_text(encoding="utf-8-sig"))
if not isinstance(envelope, dict) or not isinstance(envelope.get("files"), list):
raise ValueError("final_response.json does not match the Codex output envelope")
results_dir.mkdir(parents=True, exist_ok=True)
seen: set[str] = set()
materialized: list[str] = []
for entry in envelope["files"]:
if not isinstance(entry, dict):
raise ValueError("file entry is not an object")
raw_path = entry.get("path")
content = entry.get("content")
if not isinstance(raw_path, str) or not isinstance(content, str):
raise ValueError("file entry requires string path and content")
target = safe_result_path(results_dir, raw_path)
key = str(target).casefold()
if key in seen:
raise ValueError(f"duplicate result path: {raw_path!r}")
seen.add(key)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8", newline="\n")
materialized.append(str(target.relative_to(results_dir)).replace("\\", "/"))
usage = Counter()
item_types = Counter()
errors: list[Any] = list(malformed)
thread_id = None
turns = 0
for event in events:
event_type = event.get("type")
if event_type == "thread.started":
thread_id = event.get("thread_id")
elif event_type == "turn.completed":
turns += 1
turn_usage = event.get("usage") or {}
for field in ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"):
value = turn_usage.get(field, 0)
if isinstance(value, int):
usage[field] += value
elif event_type in ("turn.failed", "error"):
errors.append(event)
if event_type == "item.completed":
item = event.get("item") or {}
if isinstance(item.get("type"), str):
item_types[item["type"]] += 1
start = parse_iso(meta_dir / "startzeit.txt")
end = parse_iso(meta_dir / "endzeit.txt")
duration_ms = round((end - start).total_seconds() * 1000) if start and end else None
exit_code = None
exit_code_path = meta_dir / "exitcode.txt"
if exit_code_path.exists():
try:
exit_code = int(exit_code_path.read_text(encoding="utf-8-sig").strip())
except ValueError:
errors.append("invalid exitcode.txt")
if exit_code not in (None, 0):
errors.append({"exit_code": exit_code})
normalized = {
"adapter": "codex-cli",
"is_error": bool(errors),
"subtype": "success" if not errors else "error",
"session_id": thread_id,
"duration_ms": duration_ms,
"duration_api_ms": None,
"num_turns": turns,
"requested_model": args.model,
"actual_models": None,
"model_control": "not_verifiable_from_codex_exec_jsonl",
"effort": args.effort,
"usage": {
"input_tokens": usage["input_tokens"],
"cached_input_tokens": usage["cached_input_tokens"],
"output_tokens": usage["output_tokens"],
"reasoning_output_tokens": usage["reasoning_output_tokens"],
"total_tokens": usage["input_tokens"] + usage["output_tokens"],
"semantics": "cached_input_tokens is a subset of input_tokens and is not added again",
},
"item_counts": dict(sorted(item_types.items())),
"permission_denials": None,
"subagent_stats": {"spawned": 0, "source": "multi-agent disabled by configuration"},
"materialized_files": materialized,
"result": envelope.get("summary", ""),
"errors": errors,
}
(run_dir / "RawResult.json").write_text(
json.dumps(normalized, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
)
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lmstudio": {
"npm": "@ai-sdk/openai-compatible",
"name": "LM Studio (lokal)",
"options": {
"baseURL": "http://localhost:1234/v1",
"apiKey": "lm-studio"
},
"models": {
"google/gemma-4-e4b": {
"name": "Gemma 4 E4B (lokal)",
"limit": {
"context": 131072,
"output": 32768
}
},
"qwen/qwen3.5-9b": {
"name": "Qwen 3.5 9B (lokal)",
"limit": {
"context": 262144,
"output": 32768
}
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"tensorx": {
"npm": "@ai-sdk/openai-compatible",
"name": "TensorX",
"options": {
"baseURL": "https://api.tensorx.ai/v1"
},
"models": {
"qwen/qwen3.8-flash-next": {
"name": "Qwen 3.8 Flash Next",
"limit": {
"context": 262144,
"output": 131072
},
"variants": {
"low": {
"thinking": {
"type": "enabled",
"level": "low"
}
},
"medium": {
"thinking": {
"type": "enabled",
"level": "medium"
}
},
"high": {
"thinking": {
"type": "enabled",
"level": "high"
}
},
"xhigh": {
"thinking": {
"type": "enabled",
"level": "xhigh"
}
},
"max": {
"thinking": {
"type": "enabled",
"level": "xhigh"
}
}
}
},
"z-ai/glm-5.2": {
"name": "GLM 5.2",
"limit": {
"context": 1048576,
"output": 131072
},
"variants": {
"low": { "thinking": { "type": "enabled", "level": "low" } },
"medium": { "thinking": { "type": "enabled", "level": "medium" } },
"high": { "thinking": { "type": "enabled", "level": "high" } },
"xhigh": { "thinking": { "type": "enabled", "level": "xhigh" } },
"max": { "thinking": { "type": "enabled", "level": "xhigh" } }
}
},
"z-ai/glm-5.3-flash": {
"name": "GLM 5.3 Flash",
"variants": {
"low": { "thinking": { "type": "enabled", "level": "low" } },
"medium": { "thinking": { "type": "enabled", "level": "medium" } },
"high": { "thinking": { "type": "enabled", "level": "high" } },
"xhigh": { "thinking": { "type": "enabled", "level": "xhigh" } },
"max": { "thinking": { "type": "enabled", "level": "xhigh" } }
}
},
"moonshotai/kimi-k3": {
"name": "Kimi K3",
"limit": {
"context": 1048576,
"output": 131072
}
}
}
}
}
}
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Create and verify an isolated Codex analysis workspace."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
def fail(message: str) -> None:
raise SystemExit(message)
def resolved(path: str) -> Path:
return Path(path).resolve()
def native_path(path: Path) -> str:
value = str(path)
if os.name == "nt" and not value.startswith("\\\\?\\"):
return "\\\\?\\" + value
return value
def allowed_workspace(workspace: Path, meta: Path) -> bool:
temporary_root = Path(tempfile.gettempdir()).resolve()
return (
(workspace.parent == meta and workspace.name == "workspace")
or (workspace.parent == temporary_root and workspace.name.startswith("codex-experiment-"))
)
def git_output(cwd: Path, *args: str) -> bytes:
completed = subprocess.run(
["git", "-C", str(cwd), *args],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return completed.stdout
def source_files(source: Path) -> tuple[list[Path], str | None]:
try:
top = resolved(git_output(source, "rev-parse", "--show-toplevel").decode().strip())
relative_source = source.relative_to(top)
raw = git_output(
top,
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
relative_source.as_posix(),
)
paths = []
for entry in raw.split(b"\0"):
if not entry:
continue
candidate = resolved(top / os.fsdecode(entry))
if candidate.is_file() and candidate.is_relative_to(source):
paths.append(candidate)
return sorted(set(paths), key=lambda item: item.as_posix().casefold()), str(top)
except (subprocess.CalledProcessError, ValueError):
paths = [item for item in source.rglob("*") if item.is_file() and ".git" not in item.parts]
return sorted(paths, key=lambda item: item.as_posix().casefold()), None
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with open(native_path(path), "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest().upper()
def manifest(workspace: Path) -> dict[str, dict[str, int | str]]:
result: dict[str, dict[str, int | str]] = {}
workspace_native = native_path(workspace)
files: list[tuple[str, Path]] = []
for directory, _, names in os.walk(workspace_native):
for name in names:
full_path = Path(directory) / name
relative = os.path.relpath(str(full_path), workspace_native).replace("\\", "/")
files.append((relative, full_path))
for relative, path in sorted(files, key=lambda item: item[0].casefold()):
result[relative] = {"size": os.stat(str(path)).st_size, "sha256": sha256(path)}
return result
def write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def create(source_arg: str, workspace_arg: str, meta_arg: str) -> None:
source = resolved(source_arg)
workspace = resolved(workspace_arg)
meta = resolved(meta_arg)
if not source.is_dir():
fail(f"Source directory does not exist: {source}")
if not allowed_workspace(workspace, meta):
fail("Workspace must be _meta/workspace or a codex-experiment-* directory in system temp")
if workspace.exists():
before_manifest = meta / "workspace-manifest-before.json"
if workspace.parent == meta and workspace.name == "workspace" and not before_manifest.exists():
shutil.rmtree(native_path(workspace))
else:
fail(f"Workspace already exists: {workspace}")
files, git_root = source_files(source)
workspace.mkdir(parents=True)
for path in files:
relative = path.relative_to(source)
target = workspace / relative
os.makedirs(native_path(target.parent), exist_ok=True)
try:
shutil.copy2(native_path(path), native_path(target), follow_symlinks=False)
except OSError as error:
fail(f"Failed to copy {path} -> {target}: {error}")
before = manifest(workspace)
write_json(meta / "workspace-manifest-before.json", before)
write_json(
meta / "workspace-source.json",
{
"source": str(source),
"workspace": str(workspace),
"git_root": git_root,
"file_count": len(before),
"total_bytes": sum(int(item["size"]) for item in before.values()),
},
)
print(f"Created isolated workspace with {len(before)} files: {workspace}")
def verify(workspace_arg: str, meta_arg: str) -> None:
workspace = resolved(workspace_arg)
meta = resolved(meta_arg)
before_path = meta / "workspace-manifest-before.json"
if not workspace.is_dir() or not before_path.is_file():
fail("Workspace or before-manifest is missing")
before = json.loads(before_path.read_text(encoding="utf-8"))
after = manifest(workspace)
added = sorted(set(after) - set(before), key=str.casefold)
removed = sorted(set(before) - set(after), key=str.casefold)
modified = sorted(
(path for path in set(before) & set(after) if before[path] != after[path]),
key=str.casefold,
)
write_json(meta / "workspace-manifest-after.json", after)
result = {
"unchanged": not (added or removed or modified),
"added": added,
"removed": removed,
"modified": modified,
"file_count_before": len(before),
"file_count_after": len(after),
}
write_json(meta / "workspace-integrity.json", result)
print(json.dumps(result, ensure_ascii=False))
if not result["unchanged"]:
raise SystemExit(3)
def cleanup(workspace_arg: str, meta_arg: str) -> None:
workspace = resolved(workspace_arg)
meta = resolved(meta_arg)
if not allowed_workspace(workspace, meta):
fail("Refusing to remove a directory outside the approved experiment workspace locations")
if workspace.exists():
shutil.rmtree(native_path(workspace))
print(f"Removed temporary workspace: {workspace}")
def main() -> None:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
create_parser = subparsers.add_parser("create")
create_parser.add_argument("source")
create_parser.add_argument("workspace")
create_parser.add_argument("meta")
verify_parser = subparsers.add_parser("verify")
verify_parser.add_argument("workspace")
verify_parser.add_argument("meta")
cleanup_parser = subparsers.add_parser("cleanup")
cleanup_parser.add_argument("workspace")
cleanup_parser.add_argument("meta")
args = parser.parse_args()
if args.command == "create":
create(args.source, args.workspace, args.meta)
elif args.command == "verify":
verify(args.workspace, args.meta)
else:
cleanup(args.workspace, args.meta)
if __name__ == "__main__":
main()
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Erzeugt das Messprotokoll-Geruest eines Laufs aus seinen Rohdaten.
Fuellt ausschliesslich Felder, die aus `RawResult.json`, `_meta` und dem
Dateibestand **belegbar** sind. Alles Uebrige bleibt als Platzhalter stehen und
ist von Hand zu ergaenzen - insbesondere der Abschnitt *Anmerkungen*, der die
Deutung enthaelt und nicht generierbar ist.
Nicht ermittelbare Groessen werden als `nicht erfasst` ausgewiesen, niemals
geschaetzt (Randbedingung des Skills).
python protokoll-geruest.py "<Laufverzeichnis>" [--ueberschreiben]
"""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
from pathlib import Path
EBENEN_DATEIEN = ("StRS.md", "SyRS.md", "SwRS.md")
def sha256(pfad: Path) -> str:
return hashlib.sha256(pfad.read_bytes()).hexdigest().upper()
def lies(pfad: Path, standard: str = "nicht erfasst") -> str:
try:
return pfad.read_text(encoding="utf-8").strip() or standard
except OSError:
return standard
def zahl(n) -> str:
"""Tausenderpunkte nach deutscher Schreibweise."""
try:
return f"{int(n):,}".replace(",", ".")
except (TypeError, ValueError):
return "nicht erfasst"
def dauer(ms: int) -> str:
s = ms // 1000
return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"
def git_kopf(repo: Path) -> str:
try:
r = subprocess.run(
["git", "-C", str(repo), "rev-parse", "HEAD"],
capture_output=True, text=True, check=False,
)
return r.stdout.strip() or "nicht erfasst"
except OSError:
return "nicht erfasst"
def main() -> int:
parser = argparse.ArgumentParser(description="Protokollgeruest erzeugen")
parser.add_argument("lauf")
parser.add_argument("--repo", default="C:/DEV/MasterArbeit")
parser.add_argument("--ueberschreiben", action="store_true")
args = parser.parse_args()
lauf = Path(args.lauf).resolve()
ziel = lauf / "Protokoll.md"
if ziel.exists() and not args.ueberschreiben:
print(f"{ziel} existiert bereits - mit --ueberschreiben erzwingen.")
return 1
roh = json.loads((lauf / "RawResult.json").read_text(encoding="utf-8"))
meta = lauf / "_meta"
u = roh.get("usage", {})
lr = roh.get("local_runtime") or {}
erfasst = roh.get("usage_captured", True)
def tok(feld: str) -> str:
return zahl(u.get(feld, 0)) if erfasst else "nicht erfasst"
# Ebene und Iteration aus der Ablagestruktur ableiten
teile = lauf.parts
iteration = next((p for p in teile if p.startswith("Iteration")), "?")
zelle = "/".join(teile[teile.index(iteration):-1]) if iteration in teile else "?"
# Die Prompt-Datei liegt im Versuchsordner, also oberhalb der
# Bedingungsebenen; deren Anzahl schwankt mit der Modell-ID (ein- oder
# zweiteilig). Deshalb aufwaerts suchen statt eine feste Tiefe annehmen.
prompt_datei = None
for eltern in lauf.parents[:8]:
treffer = sorted(eltern.glob("*_Prompt.md"))
if treffer:
prompt_datei = treffer[-1]
break
anforderungen = lies(meta / "anforderungen.md", "*(nicht ausgewertet)*")
dateien = sorted(p.name for p in (lauf / "Ergebnisse").glob("*") if p.is_file())
vorher, nachher = lies(meta / "before.txt", ""), lies(meta / "after.txt", "")
text = f"""# Messprotokoll – {zelle}
> **GERUEST** – maschinell aus den Rohdaten erzeugt. Die Abschnitte *Anmerkungen*
> und *Gueltigkeit* sind von Hand zu pruefen und zu ergaenzen.
## Lauf
- **Prompt-Datei:** `{prompt_datei.name if prompt_datei else 'nicht erfasst'}`
- **SHA-256 (Prompt):** `{sha256(prompt_datei) if prompt_datei else 'nicht erfasst'}`
- **Startzeit:** {lies(meta / 'startzeit.txt')}
- **Endzeit:** {lies(meta / 'endzeit.txt')}
- **Dauer gesamt:** {dauer(roh.get('duration_ms', 0))} (API: nicht erfasst – OpenCode liefert keine separate API-Zeit)
- **Root-Verzeichnis:** `{args.repo}/QuellCode/CentronERP`
- **Codebasis-Commit:** `{git_kopf(Path(args.repo))}` (vor dem Lauf dirty: {'nein' if not vorher else 'ja – ' + vorher.replace(chr(10), '; ')})
- **Snapshot-Zustand:** bereinigt von KI-Konfigurationen: ja; Remote entkoppelt: ja
## Werkzeugkonfiguration
- **Skill-Version:** {lauf.name.split('_v')[-1].split('-')[0] if '_v' in lauf.name else 'nicht erfasst'}
- **Werkzeugadapter:** OpenCode, `opencode-adapter.py --provider {roh.get('provider')}` (Adapter-Version {roh.get('adapter_version')})
- **CLI-Version:** OpenCode {roh.get('opencode_version', 'nicht erfasst')}
- **Modell (angefordert):** `{roh.get('model_requested')}`
- **Modell (tatsaechlich):** `{roh.get('model')}`
- **Kontrolle Modell:** {'bestanden' if roh.get('model') == roh.get('model_requested') else '**verletzt**'}
- **Effort:** `{roh.get('effort')}` angefordert, **{'wirksam' if roh.get('effort_applied') else 'nicht wirksam'}** (`effort_applied: {str(roh.get('effort_applied')).lower()}`)
- **Ablage:** `{zelle}/`
- **Agentenmodus:** `{roh.get('mode')}`
- **Kontextfenster:** {zahl(roh.get('context_window', 0))} Tokens geladen (Modellmaximum {zahl(lr.get('max_context_length', 0))})
- **Sampling-Parameter:** nicht steuerbar
- **Lokaler Modellbetrieb:** Runtime `{lr.get('compatibility_type', '?')}`, `lms` {lr.get('lms_version', '?')}, Architektur `{lr.get('arch', '?')}`, **Quantisierung `{lr.get('quantization', '?')}`**, {lr.get('parallel_slots', '?')} Slots, GPU-Offload `{lr.get('gpu_offload', '?')}`, alleiniges Modell: {str(lr.get('alleiniges_modell', '?')).lower()}
- **Abbruchsicherungen:** `--stall-timeout 0`, `--max-runtime` siehe Matrixskript
- **Subagenten:** `spawned` = {roh.get('subagent_stats', {}).get('spawned', 0)}, `completed` = {roh.get('subagent_stats', {}).get('completed', 0)}, `failed` = {roh.get('subagent_stats', {}).get('failed', 0)}
- **Rollen:** {json.dumps(roh.get('subagent_stats', {}).get('by_type', {}), ensure_ascii=False)}
## Validierungsstichprobe
- **Stand:** entfaellt
## Verbrauch
| Messgroesse | Wert |
|---|---|
| Input-Tokens | {tok('prompt_tokens')} |
| Output-Tokens | {tok('completion_tokens')} |
| Reasoning-Tokens | {tok('reasoning_tokens')} |
| Cache-Write-/Cache-Read-Tokens | nicht erfasst – der lokale Server liefert keine |
| Agent-Turns | {roh.get('num_turns', 0)} |
**Tokens gesamt: {tok('total_tokens')}.** Kosten `0` – lokaler Betrieb ({roh.get('cost_source', '')}).
{'' if erfasst else chr(10) + '**Achtung:** ' + roh.get('usage_note', '') + chr(10)}
## Gefundene Anforderungen
{anforderungen}
## Ergebnis
- **Status:** `is_error: {str(roh.get('is_error')).lower()}`, `subtype: {roh.get('subtype')}`, `exit_code: {roh.get('exit_code')}`, `timed_out: {str(roh.get('timed_out')).lower()}`
- **Session-ID:** `{roh.get('session_id')}`
- **Werkzeugaufrufe:** {roh.get('tool_call_count', 0)} – {json.dumps(roh.get('tool_call_types', {}), ensure_ascii=False)}
- **Kontrolle Agentenmodus:** `subagent_stats.spawned` = {roh.get('subagent_stats', {}).get('spawned', 0)}{' – korrekt fuer solo' if roh.get('mode') == 'solo' else ''}
- **Gueltigkeit:** *(pruefen)* {'**Fehlmessung** – Ergebnisverzeichnis leer.' if not dateien else 'Ergebnisdateien vorhanden.'}
- **Erzeugte Dateien:** {', '.join(dateien) if dateien else 'keine'}
- **Root unveraendert:** {'ja' if vorher == nachher else '**nein – pruefen**'}
- **Fehlermeldungen:** {json.dumps(roh.get('errors', []), ensure_ascii=False)}
## Anmerkungen/Auffaelligkeiten
*(von Hand zu ergaenzen)*
"""
ziel.write_text(text, encoding="utf-8")
print(f"{ziel}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,305 @@
# OpenCode-Adapter
Ein Wrapper, zwei Provider. `opencode-adapter.py` startet OpenCode headless und normalisiert den
Lauf nach `RawResult.json`. Welcher Provider gilt, entscheidet `--provider`:
| `--provider` | Modell-IDs | Betrieb | Vorlage |
|---|---|---|---|
| `tensorx` (Standard) | `z-ai/*`, `qwen/qwen3.8-flash-next`, `moonshotai/*` | Remote-Gateway `https://api.tensorx.ai/v1` | `opencode-tensorx.json` |
| `lmstudio` | `google/gemma-4-e4b`, `qwen/qwen3.8-27b` | lokaler LM-Studio-Server `http://localhost:1234/v1` | `opencode-lmstudio.json` |
`qwen/qwen3.8-flash-next` (TensorX) und `qwen/qwen3.8-27b` (lokal) teilen sich das Präfix `qwen/`.
Das Präfix allein bestimmt den Adapter deshalb **nicht** – maßgeblich ist die vollständige
Modell-ID gemäß dieser Tabelle.
Der Abschnitt **LM Studio** unten beschreibt alles, was nur für den lokalen Betrieb gilt.
Alle übrigen Abschnitte gelten für beide Provider.
## Voraussetzungen und Authentifizierung
1. OpenCode installieren und die Version protokollieren:
```powershell
npm install -g opencode-ai
opencode --version
```
2. Nur für `--provider tensorx`: TensorX einmal im OpenCode-Credential-Store anmelden
(`--provider lmstudio` braucht keine Anmeldung – der lokale Server prüft keinen Key):
```powershell
opencode auth login
opencode auth list
```
Die statische Datei `opencode-tensorx.json` enthält Provider, Basis-URL, Modelle und
Effort-Varianten, aber keinen API-Key. Der Adapter liest weder Cline-Dateien noch einen
Cline-Credential-Store. OpenCode löst das Credential selbst auf. Keys niemals in
Laufartefakte, Prompts oder die Konfigurationsvorlage schreiben.
## Modell- und Effort-Mapping
Der Adapter ergänzt intern das Providerpräfix (`tensorx/` bzw. `lmstudio/`). Aus
`qwen/qwen3.8-flash-next` wird daher für OpenCode
`tensorx/qwen/qwen3.8-flash-next`; im Messprotokoll bleibt die ursprüngliche TensorX-ID.
Die Stufen `low`, `medium`, `high`, `xhigh` und `max` werden als OpenCode-Varianten aus der
Providervorlage übergeben. Für Qwen und GLM über TensorX enthält die Variante
`thinking: {type: enabled, level: ...}`; `max` wird auf `xhigh` abgebildet, falls der Provider
keine eigene `max`-Stufe kennt. Nur in der Vorlage vorhandene Varianten werden per
`--variant` gesetzt.
**Bei `lmstudio` ist Effort nicht steuerbar.** Der OpenAI-kompatible Endpunkt von LM Studio
nimmt keinen Thinking-Level entgegen; `opencode-lmstudio.json` deklariert deshalb bewusst keine
Varianten. Der Adapter protokolliert das im `Adapter.log` und setzt `effort_applied: false` in
`RawResult.json`. Der übergebene `--effort`-Wert bleibt als angeforderte Bedingung erhalten, ist
im Protokoll aber unter „Sampling-Parameter" als **nicht steuerbar** auszuweisen – niemals so
darzustellen, als hätte er gewirkt. Reasoning-Tokens liefern die Modelle trotzdem, sofern sie
von sich aus mit `reasoning_content` antworten.
## Isolierte Laufkonfiguration
Für jeden Lauf schreibt der Adapter `_meta/opencode-config.json` und setzt nur für den
Kindprozess `OPENCODE_CONFIG` auf diese Datei. Der Aufruf verwendet `opencode run --pure`,
damit keine interaktive Oberfläche benötigt wird. Der Prompt wird über stdin übergeben.
Die Berechtigungen beginnen mit `deny` und erlauben gezielt:
- Lesen, Suchen, Auflisten sowie eine Read-only-Shell-Allowlist;
- Schreiben und externe Verzeichnisse ausschließlich für das angegebene Ergebnisverzeichnis;
- im Modus `solo` keine Tasks;
- im Modus `builtin` nur OpenCodes `general`- und `explore`-Subagenten;
- im Modus `custom` nur Rollen aus der mit `--agents` übergebenen JSON-Datei.
Für das Ergebnisziel erzeugt der Wrapper kanonische sowie zum aktiven OpenCode-Root und zur
Git-Worktree-Wurzel relative Allow-Patterns. Das ist unter Windows notwendig, wenn Root und
Laufverzeichnis im selben Git-Worktree liegen, das Laufverzeichnis aber außerhalb des
Root-Unterordners liegt.
Die Shell-Rechte sind seit Skill 12.0.0 eine **Denylist**, spiegelbildlich zum
Claude-Code-Adapter: Erlaubt ist alles, gesperrt sind ausdrücklich die schreibenden und
bauenden Kommandos – `rm`, `rmdir`, `mv`, `cp`, `dd`, `truncate`, `chmod`, `chown`, `ln`,
`tee`, `sed -i`, die schreibenden `git`-Kommandos einschließlich `fetch`, `pull` und `remote`,
`dotnet`, `msbuild`, `npm install`, `nuget` sowie `Remove-Item`, `Move-Item`, `Copy-Item`,
`New-Item`, `Set-Content`, `Add-Content`, `Clear-Content`, `Out-File`, `Set-ItemProperty` und
`Rename-Item`.
**Die Reihenfolge ist bedeutsam.** OpenCode wertet die Regeln der Reihe nach aus; die *zuletzt
passende* Regel gewinnt – nicht die spezifischste, und `deny` gewinnt nicht automatisch. Das
Catch-all `"*": "allow"` muss deshalb **zuerst** stehen, die Sperren danach. Python-Dicts
erhalten ihre Einfügereihenfolge, `json.dump` schreibt sie unverändert.
Die frühere Allowlist konnte Kommandos nur als Präfix treffen und scheiterte deshalb an
Pipelines: `Get-ChildItem ... | Format-Table ...` blieb gesperrt, obwohl `Get-ChildItem`
erlaubt war. Zugleich war die Werkzeugfreiheit nicht mit der des Claude-Adapters vergleichbar.
Wie beim Claude-Adapter gilt: Mustervergleich auf Kommandozeilen ist **nicht lückenlos**. Die
belastbare Read-only-Garantie bleibt der Vorher/Nachher-Vergleich per `git status`; die
Denylist senkt das Risiko, sie ersetzt die Verifikation nicht.
Webzugriff, Skills, Rückfragen und Weiterdelegation durch Subagenten bleiben gesperrt. Bei
`custom` übersetzt der Adapter `description` und `prompt` jeder Rolle in eine explizite
OpenCode-Subagentenkonfiguration mit demselben Modell wie der Hauptagent.
## LM Studio (nur `--provider lmstudio`)
Lokale Läufe sind eine eigene Versuchsbedingung: kein Netzzugriff, keine Providerkosten,
gewichtsbezogene Reproduzierbarkeitsangaben (Quantisierung, Runtime, Kontextfenster) und ein
Kontextfenster, das der Server beim Laden festlegt.
### Vorbereitung
```powershell
lms server start # OpenAI-kompatibler Endpunkt auf Port 1234
lms ls # heruntergeladene Modelle
lms get qwen/qwen3.8-27b # fehlendes Modell holen (mehrere GB)
lms ps # geladene Instanzen samt Kontextfenster
```
### Preflight des Adapters
Vor dem Start von OpenCode prüft der Adapter über `GET /api/v0/models` und bricht mit
Exitcode `2` und einer Handlungsanweisung ab, wenn eine Bedingung verletzt ist:
| Prüfung | Abbruchgrund |
|---|---|
| Server erreichbar | `lms server start` fehlt |
| Modell vorhanden | nicht heruntergeladen → `lms get <ID>` |
| `capabilities` enthält `tool_use` | ohne Tool-Calling ist kein Analyselauf möglich |
| `max_context_length` ≥ `--min-context` | Modell kann die Bedingung nicht erfüllen |
| Zustand `loaded` und `loaded_context_length` ≥ `--min-context` | zu kleines Fenster schneidet die Codebasis **stillschweigend** ab |
| genau **eine** geladene Instanz | mehrere Instanzen (`modell`, `modell:2`) beantworten dieselbe `model`-Angabe; das Routing wäre nicht reproduzierbar |
`--min-context` ist standardmäßig `32768`. Ein bewusst kleinerer Wert ist zulässig, gehört aber
als abweichende Versuchsbedingung ins Protokoll.
`--lmstudio-autoload` stellt den Sollzustand selbst her: Es entlädt **alle** Instanzen des
Modells und lädt genau eine mit `--min-context` neu. Ohne das Flag meldet der Preflight nur den
exakten `lms`-Befehl. Die Standardgröße von LM Studio (häufig 8192) reicht für eine
Codebasisanalyse nicht.
Das geladene Fenster wird zusätzlich als `limit.context` in die Laufkonfiguration geschrieben,
damit OpenCode nicht mehr Kontext sendet, als der Server vorhält.
### Speicherhygiene und Ladeparameter
Der lokale Durchsatz haengt fast vollstaendig davon ab, ob Gewichte und KV-Cache in den VRAM
passen. Gemessen am 01.09.2026 auf einer RTX 5080 Laptop GPU (16.303 MiB):
| Konfiguration | VRAM belegt | Generierung |
|---|---:|---:|
| `gemma-4-e4b`, 32k, parallel 1 | 5.162 MiB | 48,3 tok/s |
| `gemma-4-e4b`, 32k, parallel 4 | 5.222 MiB | 47,7 tok/s |
| `gemma-4-e4b`, **131k**, parallel 4 | 6.854 MiB | 46,8 tok/s |
| `qwen3.8-27b`, 32k, `--gpu max` | 15.696 MiB (308 frei) | Abbruch nach 10 min |
| beide Modelle gleichzeitig geladen | 15.836 MiB (168 frei) | 0,028 Mio. Tokens/h |
Daraus die drei Regeln, die der Preflight seit Adapter 2.2.0 durchsetzt:
1. **Genau ein Modell ist geladen.** `--lmstudio-autoload` entlaedt `--all` und laedt nur das
angeforderte Modell; ein fremdes geladenes Modell fuehrt sonst zum Abbruch. Ein nebenher
geladenes Modell belegt VRAM, das dem Lauf fehlt, und veraendert dessen Durchsatz um
Groessenordnungen.
2. **Der Kontext wird auf das Modellmaximum geladen** (`--lmstudio-context max`, Standard).
Freier VRAM ist in Kontext besser investiert als ungenutzt: vierfaches Fenster fuer 1,7 GB
und 1,5 tok/s. `--min-context` bleibt die Gueltigkeitsschwelle, nicht die Ladegroesse.
3. **`--lmstudio-gpu max`** erzwingt die vollstaendige Auslagerung, **`--lmstudio-parallel 4`**
ist messbar kostenneutral und hilft, wenn Subagenten nebenlaeufig anfragen.
**Modelle jenseits der VRAM-Grenze sind nicht messbar.** `qwen3.8-27b` belegt mit 17,74 GB
Gewichten mehr, als die Karte hat; der Rest laeuft auf der CPU. Auch eine kleinere
Quantisierung loest das nicht, weil der KV-Cache eines 27B-Modells bei brauchbarem Kontext
mehrere GB zusaetzlich fordert. Fuer eine 16-GB-Karte ist die 9B-Klasse die groesste, die mit
vollem Fenster hineinpasst - dann aber in hoher Quantisierung, um den Speicher zu nutzen.
### Zusätzliche Laufartefakte und Messfelder
| Datei | Inhalt |
|---|---|
| `_meta/lmstudio-modelle.json` | Rohantwort von `/api/v0/models` zum Zeitpunkt des Preflights |
`RawResult.json` enthält bei lokalen Läufen zusätzlich:
| Messgröße | Feld |
|---|---|
| Kontextfenster des Laufs | `context_window` (= `local_runtime.loaded_context_length`) |
| Quantisierungsstufe | `local_runtime.quantization` |
| Runtime und Architektur | `local_runtime.compatibility_type`, `local_runtime.arch` |
| Runtime-Version | `local_runtime.lms_version` |
| Instanzbezeichner | `local_runtime.instance_id` |
| Endpunkt | `local_runtime.base_url` |
| Kosten | `cost` ist `0`; `cost_source` weist „nicht erfasst (lokaler Betrieb)" aus |
| Effortwirkung | `effort_applied` ist `false` |
Damit sind die von Kapitel 4.3 geforderten Angaben für lokalen Betrieb – Runtime samt Version
und Quantisierungsstufe – vollständig erfasst.
### Laufzeitverhalten
Kleine lokale Modelle beenden eine Aufgabe nicht zuverlässig von selbst; im Smoke-Test lief
`google/gemma-4-e4b` über 50 Schritte weiter, ohne die geforderte Datei zu schreiben. Der
Stall-Timeout greift dabei **nicht**, weil laufend Text erzeugt wird. Für lokale Läufe deshalb
immer ein absolutes `--max-runtime` setzen und einen Abbruch als solchen protokollieren, statt
ihn als Ergebnis zu werten.
## Aufruf
```powershell
$skillDir = "<Verzeichnis des Skills>"
$lauf = "<absoluter Pfad zum Laufverzeichnis>"
$root = "<Root-Verzeichnis der Codebasis>"
$provider = "<tensorx|lmstudio>"
$modell = "<Modell-ID>"
$effort = "<low|medium|high|xhigh|max>"
$modus = "<solo|builtin|custom>"
$agents = "<Agenten-JSON; nur bei custom>"
python "$skillDir\opencode-adapter.py" `
--prompt "$lauf\_meta\combined_prompt.md" `
--root $root `
--output "$lauf\Ergebnisse" `
--provider $provider `
--model $modell `
--effort $effort `
--mode $modus `
--agents $agents `
--stall-timeout 600 `
--max-runtime 0 `
--result-dir $lauf `
--title "run-experiment $modell $modus $effort"
```
Für `--provider lmstudio` kommen hinzu:
```powershell
--min-context 32768 ` # Mindestgröße des geladenen Kontextfensters
--lmstudio-autoload ` # Modell notfalls selbst neu laden
--max-runtime 3600 # absolutes Limit; lokale Modelle terminieren nicht zuverlässig
```
`--base-url` (Standard `http://localhost:1234`) und `--lms` (Pfad zur CLI) sind nur nötig, wenn
Port oder Installationsort abweichen.
`--agents` bei `solo` und `builtin` weglassen. `--stall-timeout 600` beendet den gesamten
OpenCode-Prozessbaum, wenn zehn Minuten lang weder stdout noch stderr Aktivität zeigen.
`--stall-timeout 0` deaktiviert diese Sicherung.
**In den Modi `builtin` und `custom` ist `--stall-timeout 0` zwingend.** OpenCode sendet
**keine Ereignisse, solange ein Subagent arbeitet** – der Ereignisstrom schweigt für dessen
gesamte Laufzeit. Jeder positive Stall-Timeout bricht den Lauf deshalb ab, sobald der erste
Subagent startet, und erzeugt eine Fehlmessung, die wie ein Hänger aussieht. Der Adapter lehnt
diese Kombination seit Version 1.3.0 mit einer Fehlermeldung ab, statt sie stillschweigend zu
korrigieren. Die Laufzeit wird in diesen Modi ausschließlich über `--max-runtime` begrenzt. `--max-runtime 0` setzt kein absolutes
Laufzeitlimit. `Ctrl+C` beendet ebenfalls den Prozessbaum und persistiert soweit möglich das
Teilergebnis. Ein leeres `Ergebnisse`-Verzeichnis macht den Lauf standardmäßig zu einem Fehler.
Nur ein bewusst textueller Smoke-Test darf diese Prüfung mit `--allow-empty-output` abschalten.
## Laufartefakte und Messfelder
| Datei | Inhalt |
|---|---|
| `OpenCodeEvents.jsonl` | unveränderter, inkrementell geschriebener JSON-Ereignisstrom |
| `OpenCode.log` | OpenCode-stderr, inkrementell geschrieben |
| `Adapter.log` | Start, Lebenszyklus, Abbruchgrund und Abschluss des Wrappers |
| `_meta/opencode-config.json` | tatsächlich verwendete, keyfreie Laufkonfiguration |
| `_meta/opencode-session.json` | exportierte Session, soweit eine Session-ID vorliegt |
| `RawResult.json` | normalisierte Metriken für das gemeinsame Messprotokoll |
Aus `RawResult.json` verwenden:
| Messgröße | Feld |
|---|---|
| Erfolg/Abbruch | `is_error`, `subtype`, `timed_out`, `interrupted`, `exit_code`, `errors` |
| Modellkontrolle | `provider`, `model`, `model_requested` |
| Zeit | `duration_ms`, `start_time`, `end_time` |
| Tokens | `usage.prompt_tokens`, `completion_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `total_tokens` |
| Turns und Ende | `num_turns`, `finish_reason` |
| Tools | `tool_call_count`, `tool_call_types`, `tool_calls` |
| Subagenten | `subagent_stats`, `subagent_details` |
| Ergebnisdateien | `written_files` |
| Abschlusstext | `result` |
| Reproduzierbarkeit | `adapter_version`, `opencode_version`, `config_path`, `session_id`, `adapter` |
| Effortwirkung | `effort`, `effort_applied` |
| Lokaler Betrieb | `local_runtime`, `context_window`, `cost_source` (nur `lmstudio`) |
`usage.total_tokens` übernimmt nach Möglichkeit OpenCodes Gesamtwert. Fehlt dieser, addiert
der Adapter Input, Output, Reasoning, Cache-Read und Cache-Write aus den gelieferten
OpenCode-Feldern. Kosten werden nur protokolliert, wenn OpenCode sie liefert; nicht schätzen.
Bei `lmstudio` entstehen keine Providerkosten – `cost` ist definitionsgemäß `0`, nicht
„unbekannt". Cache-Read und Cache-Write liefert der lokale Server nicht; sie sind im Protokoll
als `nicht erfasst` auszuweisen.
## Pflichtprüfung
1. `RawResult.json` existiert und `is_error` ist `false`.
2. `timed_out` und `interrupted` sind `false`; `exit_code` ist `0`.
3. `OpenCode.log` und `errors` enthalten keinen Provider- oder Permissionfehler.
4. Für Analyseversuche ist `Ergebnisse/` nicht leer und `tool_call_count` größer null.
5. `model_requested` entspricht der angeforderten Modell-ID; `provider` entspricht `--provider`.
6. Bei `custom` sind die erwarteten Rollen in `subagent_stats.by_type` nachweisbar.
7. Bei `lmstudio` zusätzlich: `local_runtime.loaded_context_length` ≥ `--min-context`,
`local_runtime.quantization` und `local_runtime.lms_version` sind gefüllt, und
`effort_applied` ist `false` (im Protokoll als nicht steuerbar vermerken).
Ein absichtlich textueller Smoke-Test darf von den Prüfungen 4 und 6 abweichen, muss aber
Antwort, Exitcode, Modell, Sessionexport und Tokenmetriken bestätigen.
@@ -0,0 +1,244 @@
import importlib.util
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
ADAPTER_PATH = Path(__file__).with_name("glm-kimi-adapter.py")
SPEC = importlib.util.spec_from_file_location("glm_kimi_adapter", ADAPTER_PATH)
ADAPTER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ADAPTER)
def response(message, finish_reason="tool_calls", tokens=10):
return {
"model": "test/model",
"usage": {
"prompt_tokens": tokens,
"completion_tokens": 1,
"total_tokens": tokens + 1,
"prompt_tokens_details": {"cached_tokens": 0},
"completion_tokens_details": {"reasoning_tokens": 0},
},
"choices": [{"finish_reason": finish_reason, "message": message}],
}
def subagent_result(agent_id):
return {
"result": f"Ergebnis {agent_id}",
"usage": {
"prompt_tokens": 2,
"completion_tokens": 1,
"total_tokens": 3,
"cached_tokens": 0,
"reasoning_tokens": 0,
},
"turns": 1,
"tool_calls": 0,
"errors": [],
"status": "completed",
}
class AdapterTests(unittest.TestCase):
def setUp(self):
self.provider = {
"base_url": "https://example.invalid/v1",
"__id": "test",
}
self.liveness = ADAPTER.LivenessMonitor(interval_seconds=0)
def test_timeout_zero_is_forwarded_as_no_requests_timeout(self):
captured = {}
class FakeResponse:
status_code = 200
text = ""
@staticmethod
def json():
return {"ok": True}
def fake_post(*args, **kwargs):
captured["timeout"] = kwargs["timeout"]
return FakeResponse()
with patch.object(ADAPTER.requests, "post", side_effect=fake_post):
result = ADAPTER.post_chat_completion(
"https://example.invalid", {}, {}, 0, self.liveness,
"test", "Testaufruf",
)
self.assertEqual({"ok": True}, result)
self.assertIsNone(captured["timeout"])
def test_requested_tensorx_models_have_explicit_effort_mapping(self):
self.assertEqual("thinking", ADAPTER.MODEL_EFFORT_TYPE["qwen"])
self.assertEqual("thinking", ADAPTER.MODEL_EFFORT_TYPE["z-ai"])
def test_custom_mode_exposes_spawn_subagent(self):
captured = {}
def fake_post(url, headers, body, timeout, liveness,
activity_id, activity_label):
captured["tools"] = body["tools"]
return response(
{"role": "assistant", "content": "Fertig."},
finish_reason="stop",
)
with tempfile.TemporaryDirectory() as temp_dir, \
patch.object(ADAPTER, "post_chat_completion", side_effect=fake_post):
result = ADAPTER.run_agent_loop(
provider=self.provider,
model="qwen/qwen3.8-flash-next",
system_prompt="System",
user_prompt="Aufgabe",
api_key="key",
effort="high",
root=temp_dir,
output_dir=temp_dir,
max_turns=0,
temperature=1.0,
timeout=0,
liveness=self.liveness,
mode="custom",
)
tool_names = {
tool["function"]["name"] for tool in captured["tools"]
}
self.assertIn("spawn_subagent", tool_names)
self.assertFalse(result["is_error"])
def test_liveness_monitor_reports_active_main_and_subagent_operations(self):
lines = []
monitor = ADAPTER.LivenessMonitor(interval_seconds=0.02)
with patch.object(ADAPTER, "log_stderr", side_effect=lines.append):
monitor.set("main", "Hauptagent wartet auf Subagenten")
monitor.set("subagent-1", "Subagent 1 wartet auf API-Antwort")
monitor.start()
time.sleep(0.06)
monitor.stop()
output = "\n".join(lines)
self.assertIn("LIFESIGN: Prozess lebt", output)
self.assertIn("Hauptagent wartet auf Subagenten", output)
self.assertIn("Subagent 1 wartet auf API-Antwort", output)
self.assertIn("keinen serverseitigen Fortschritt", output)
def test_subagents_from_one_turn_run_in_parallel_without_count_limit(self):
first_message = {
"role": "assistant",
"content": "Ich delegiere.",
"tool_calls": [
{
"id": f"spawn-{index}",
"type": "function",
"function": {
"name": "spawn_subagent",
"arguments": (
'{"description":"Aufgabe %d",'
'"subagent_type":"explore"}' % index
),
},
}
for index in range(12)
],
}
responses = [
response(first_message),
response(
{"role": "assistant", "content": "Fertig."},
finish_reason="stop",
),
]
starts = []
def fake_post(*args, **kwargs):
return responses.pop(0)
def fake_subagent(*args, **kwargs):
agent_id = args[10]
starts.append(time.monotonic())
time.sleep(0.15)
return subagent_result(agent_id)
with tempfile.TemporaryDirectory() as temp_dir:
started = time.monotonic()
with patch.object(ADAPTER, "post_chat_completion", side_effect=fake_post), \
patch.object(ADAPTER, "run_subagent", side_effect=fake_subagent):
result = ADAPTER.run_agent_loop(
provider=self.provider,
model="test/model",
system_prompt="System",
user_prompt="Aufgabe",
api_key="key",
effort="high",
root=temp_dir,
output_dir=temp_dir,
max_turns=0,
temperature=1.0,
timeout=0,
liveness=self.liveness,
mode="builtin",
subagent_max_turns=0,
)
elapsed = time.monotonic() - started
self.assertEqual(12, result["subagent_stats"]["spawned"])
self.assertEqual(12, result["subagent_stats"]["completed"])
self.assertEqual(0, result["subagent_stats"]["failed"])
self.assertLess(max(starts) - min(starts), 0.12)
self.assertLess(elapsed, 0.6)
def test_api_error_is_not_masked_by_earlier_content(self):
first_message = {
"role": "assistant",
"content": "Zwischenstand",
"tool_calls": [{
"id": "list-1",
"type": "function",
"function": {
"name": "list_directory",
"arguments": '{"path":""}',
},
}],
}
calls = [response(first_message), RuntimeError("Transportfehler")]
def fake_post(*args, **kwargs):
item = calls.pop(0)
if isinstance(item, Exception):
raise item
return item
with tempfile.TemporaryDirectory() as temp_dir, \
patch.object(ADAPTER, "post_chat_completion", side_effect=fake_post):
result = ADAPTER.run_agent_loop(
provider=self.provider,
model="test/model",
system_prompt="System",
user_prompt="Aufgabe",
api_key="key",
effort="high",
root=temp_dir,
output_dir=temp_dir,
max_turns=0,
temperature=1.0,
timeout=0,
liveness=self.liveness,
mode="solo",
)
self.assertEqual("Zwischenstand", result["result"])
self.assertTrue(result["is_error"])
self.assertEqual("error", result["subtype"])
self.assertIn("Transportfehler", result["errors"][0])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,447 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
ADAPTER_PATH = Path(__file__).with_name("opencode-adapter.py")
SPEC = importlib.util.spec_from_file_location("opencode_adapter", ADAPTER_PATH)
ADAPTER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ADAPTER)
class OpenCodeAdapterTests(unittest.TestCase):
def test_model_reference_keeps_upstream_slashes(self):
self.assertEqual(
(
"tensorx/qwen/qwen3.8-flash-next",
"qwen/qwen3.8-flash-next",
),
ADAPTER.normalize_model("qwen/qwen3.8-flash-next"),
)
self.assertEqual(
(
"tensorx/qwen/qwen3.8-flash-next",
"qwen/qwen3.8-flash-next",
),
ADAPTER.normalize_model("tensorx/qwen/qwen3.8-flash-next"),
)
def test_custom_mode_translates_agents_and_restricts_delegation(self):
base = {
"provider": {
"tensorx": {
"models": {"qwen/qwen3.8-flash-next": {"name": "Qwen"}}
}
}
}
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
root = temp / "root"
output = root / "run" / "Ergebnisse"
root.mkdir()
output.mkdir(parents=True)
agents = temp / "agents.json"
agents.write_text(
json.dumps(
{
"reviewer": {
"description": "Prueft Fakten",
"prompt": "Pruefe nur die zugewiesenen Fakten.",
}
}
),
encoding="utf-8",
)
config = ADAPTER.build_run_config(
base,
"tensorx/qwen/qwen3.8-flash-next",
"qwen/qwen3.8-flash-next",
"custom",
root,
output,
agents,
)
self.assertEqual("allow", config["permission"]["task"]["reviewer"])
self.assertEqual("deny", config["permission"]["task"]["*"])
self.assertEqual("subagent", config["agent"]["reviewer"]["mode"])
self.assertEqual(
"tensorx/qwen/qwen3.8-flash-next",
config["agent"]["reviewer"]["model"],
)
self.assertEqual("deny", config["agent"]["reviewer"]["permission"]["task"])
self.assertIn(
ADAPTER.normalized_path(output) + "/**",
config["permission"]["edit"],
)
self.assertIn(
"run/Ergebnisse/**",
config["permission"]["edit"],
)
def test_output_permissions_include_parent_relative_worktree_path(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
root = temp / "QuellCode" / "Product"
output = temp / "Versuche" / "Lauf" / "Ergebnisse"
root.mkdir(parents=True)
output.mkdir(parents=True)
patterns = ADAPTER.output_permission_patterns(root, output, temp)
self.assertIn("Versuche/Lauf/Ergebnisse/**", patterns)
self.assertIn("../../Versuche/Lauf/Ergebnisse", patterns)
self.assertIn("../../Versuche/Lauf/Ergebnisse/**", patterns)
self.assertIn(ADAPTER.normalized_path(output) + "/**", patterns)
def test_normalize_result_uses_exported_metrics_and_tool_calls(self):
session = {
"info": {
"id": "ses_test",
"version": "1.18.25",
"model": {
"id": "qwen/qwen3.8-flash-next",
"providerID": "tensorx",
},
"tokens": {
"input": 100,
"output": 10,
"reasoning": 5,
"cache": {"read": 20, "write": 0},
},
"cost": 0.1,
},
"messages": [
{
"info": {"role": "assistant", "finish": "stop"},
"parts": [
{
"type": "tool",
"tool": "task",
"state": {
"status": "completed",
"input": {
"subagent_type": "reviewer",
"description": "Pruefen",
},
},
},
{"type": "text", "text": "Fertig"},
],
}
],
}
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir)
(output / "StRS.md").write_text("Inhalt", encoding="utf-8")
result = ADAPTER.normalize_result(
session=session,
events=[],
model_ref="tensorx/qwen/qwen3.8-flash-next",
mode="custom",
effort="low",
exit_code=0,
timed_out=False,
interrupted=False,
duration_s=1.5,
output_dir=output,
errors=[],
)
self.assertFalse(result["is_error"])
self.assertEqual(135, result["usage"]["total_tokens"])
self.assertEqual(1, result["tool_call_count"])
self.assertEqual(1, result["subagent_stats"]["spawned"])
self.assertEqual({"reviewer": 1}, result["subagent_stats"]["by_type"])
self.assertEqual("Fertig", result["result"])
self.assertEqual(1, len(result["written_files"]))
class ReadonlyShellTests(unittest.TestCase):
"""Denylist: alles erlaubt ausser schreibenden und bauenden Kommandos."""
def test_catch_all_allow_comes_first(self):
# OpenCode wertet der Reihe nach aus, die letzte passende Regel gewinnt.
# Stuende das Catch-all hinten, waere jede Sperre wirkungslos.
perms = ADAPTER.readonly_shell_permissions()
self.assertEqual("allow", perms["*"])
self.assertEqual("*", next(iter(perms)))
def test_writing_and_building_commands_are_denied(self):
perms = ADAPTER.readonly_shell_permissions()
for muster in ("rm *", "mv *", "sed -i*", "git commit*", "git push*",
"dotnet *", "msbuild *", "npm install*",
"Remove-Item *", "Set-Content *", "Out-File *"):
self.assertEqual("deny", perms[muster], muster)
def test_reading_commands_need_no_rule(self):
# Der Kern der Umstellung: Lesekommandos - auch als Pipeline - sind
# erlaubt, ohne einzeln aufgefuehrt zu sein. Genau daran scheiterte
# 'Get-ChildItem ... | Format-Table ...' unter der Allowlist.
perms = ADAPTER.readonly_shell_permissions()
for kommando in ("ls -la", "cat foo.cs", "rg muster",
"Get-ChildItem -Recurse | Format-Table Name",
"find . -name *.cs"):
self.assertNotIn(kommando, perms)
self.assertEqual("allow", perms["*"])
def test_no_reading_command_is_denied(self):
verboten_praefixe = ("ls", "cat", "rg", "grep", "find", "head", "tail",
"Get-ChildItem", "Get-Content", "Select-String",
"git status", "git log", "dir", "type")
for muster, aktion in ADAPTER.readonly_shell_permissions().items():
if aktion != "deny":
continue
for praefix in verboten_praefixe:
self.assertFalse(
muster.lower().startswith(praefix.lower()),
f"Lesekommando gesperrt: {muster}",
)
class SpiegelTests(unittest.TestCase):
"""Der Spiegel loest relative Ausgabepfade, ohne die Quelle zu beruehren."""
def test_relativer_schreibpfad_landet_im_laufverzeichnis(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
quelle = temp / "codebasis"
(quelle / "src").mkdir(parents=True)
(quelle / "src" / "a.cs").write_text("Quellcode", encoding="utf-8")
(quelle / "README.md").write_text("Liesmich", encoding="utf-8")
ausgabe = temp / "lauf" / "Ergebnisse"
ausgabe.mkdir(parents=True)
spiegel = temp / "spiegel"
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
try:
self.assertTrue((spiegel / "src" / "a.cs").is_file())
self.assertTrue((spiegel / "README.md").is_file())
# `Ergebnisse` ist im Spiegel ein echtes Verzeichnis - ein
# relativer Schreibpfad landet dort und wird danach uebernommen.
(spiegel / "Ergebnisse" / "StRS.md").write_text("X", encoding="utf-8")
self.assertFalse((ausgabe / "StRS.md").is_file())
anzahl = ADAPTER.uebernimm_ergebnisse(spiegel, ausgabe, lambda _: None)
self.assertEqual(1, anzahl)
self.assertTrue((ausgabe / "StRS.md").is_file())
self.assertEqual("X", (ausgabe / "StRS.md").read_text(encoding="utf-8"))
finally:
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
# Der Abbau darf die Quelle nicht durch die Junction hindurch loeschen.
self.assertFalse(spiegel.exists())
self.assertTrue((quelle / "src" / "a.cs").is_file())
self.assertEqual("Quellcode", (quelle / "src" / "a.cs").read_text(encoding="utf-8"))
self.assertTrue((ausgabe / "StRS.md").is_file())
def test_freigabe_enthaelt_arbeitsverzeichnis_relative_form(self):
"""resolve() folgt der Junction - die relative Form darf nicht verloren gehen."""
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
quelle = temp / "codebasis"
(quelle / "src").mkdir(parents=True)
ausgabe = temp / "lauf" / "Ergebnisse"
ausgabe.mkdir(parents=True)
spiegel = temp / "spiegel"
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
try:
config = ADAPTER.build_run_config(
{}, "lmstudio/x", "x", "solo", spiegel, ausgabe, None,
provider="lmstudio",
zusatz_ausgaben=[spiegel / ausgabe.name],
)
finally:
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
erlaubt = [k for k, v in config["permission"]["edit"].items() if v == "allow"]
self.assertIn("Ergebnisse", erlaubt)
self.assertIn("Ergebnisse/**", erlaubt)
def test_quelle_bleibt_ohne_neue_eintraege(self):
with tempfile.TemporaryDirectory() as temp_dir:
temp = Path(temp_dir)
quelle = temp / "codebasis"
(quelle / "src").mkdir(parents=True)
vorher = sorted(p.name for p in quelle.iterdir())
ausgabe = temp / "lauf" / "Ergebnisse"
ausgabe.mkdir(parents=True)
spiegel = temp / "spiegel"
ADAPTER.erstelle_spiegel(quelle, spiegel, ausgabe, lambda _: None)
(spiegel / "Ergebnisse" / "datei.md").write_text("X", encoding="utf-8")
ADAPTER.uebernimm_ergebnisse(spiegel, ausgabe, lambda _: None)
ADAPTER.entferne_spiegel(spiegel, lambda _: None)
self.assertEqual(vorher, sorted(p.name for p in quelle.iterdir()))
class UsageCapturedTests(unittest.TestCase):
"""Eine Null aus einem laufenden Subagenten ist kein Messwert."""
def _result(self, session, output):
return ADAPTER.normalize_result(
session=session, events=[], model_ref="lmstudio/google/gemma-4-e4b",
mode="builtin", effort="high", exit_code=1, timed_out=True,
interrupted=False, duration_s=3600.0, output_dir=output, errors=[],
provider="lmstudio", effort_applied=False,
)
def test_running_subagent_with_zero_tokens_is_not_captured(self):
session = {
"info": {"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 0, "output": 0, "reasoning": 0,
"cache": {"read": 0, "write": 0}}},
"messages": [{
"info": {"role": "assistant"},
"parts": [{"type": "tool", "tool": "task",
"state": {"status": "running",
"input": {"subagent_type": "explore"}}}],
}],
}
with tempfile.TemporaryDirectory() as temp_dir:
result = self._result(session, Path(temp_dir))
self.assertFalse(result["usage_captured"])
self.assertIn("nicht erfasst", result["usage_note"])
self.assertIn("1 Subagent(en)", result["usage_note"])
def test_real_token_counts_are_marked_captured(self):
session = {
"info": {"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 100, "output": 10, "reasoning": 0,
"cache": {"read": 0, "write": 0}}},
"messages": [{"info": {"role": "assistant"},
"parts": [{"type": "text", "text": "ok"}]}],
}
with tempfile.TemporaryDirectory() as temp_dir:
result = self._result(session, Path(temp_dir))
self.assertTrue(result["usage_captured"])
self.assertNotIn("usage_note", result)
class OpenCodeLmStudioTests(unittest.TestCase):
def test_model_reference_uses_lmstudio_provider(self):
self.assertEqual(
("lmstudio/google/gemma-4-e4b", "google/gemma-4-e4b"),
ADAPTER.normalize_model("google/gemma-4-e4b", "lmstudio"),
)
self.assertEqual(
("lmstudio/qwen/qwen3.5-9b", "qwen/qwen3.5-9b"),
ADAPTER.normalize_model("lmstudio/qwen/qwen3.5-9b", "lmstudio"),
)
def test_template_declares_both_local_models_without_key(self):
template = json.loads(
(ADAPTER_PATH.parent / "opencode-lmstudio.json").read_text(
encoding="utf-8-sig"
)
)
provider = template["provider"]["lmstudio"]
self.assertEqual(
"http://localhost:1234/v1", provider["options"]["baseURL"]
)
self.assertEqual(
{"google/gemma-4-e4b", "qwen/qwen3.5-9b"},
set(provider["models"]),
)
# Lokale Server pruefen den Key nicht; er darf nur ein Platzhalter sein.
self.assertEqual("lm-studio", provider["options"]["apiKey"])
def test_build_run_config_pins_loaded_context_window(self):
base = json.loads(
(ADAPTER_PATH.parent / "opencode-lmstudio.json").read_text(
encoding="utf-8-sig"
)
)
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) / "root"
output = root / "run" / "Ergebnisse"
output.mkdir(parents=True)
config = ADAPTER.build_run_config(
base,
"lmstudio/google/gemma-4-e4b",
"google/gemma-4-e4b",
"solo",
root,
output,
None,
provider="lmstudio",
context_limit=32768,
)
model = config["provider"]["lmstudio"]["models"]["google/gemma-4-e4b"]
self.assertEqual(32768, model["limit"]["context"])
self.assertEqual("deny", config["permission"]["task"])
self.assertEqual("deny", config["permission"]["webfetch"])
def test_normalize_result_reports_local_runtime_and_zero_cost(self):
runtime = {
"provider": "lmstudio",
"quantization": "Q4_K_M",
"arch": "gemma4",
"compatibility_type": "gguf",
"loaded_context_length": 32768,
"max_context_length": 131072,
}
session = {
"info": {
"id": "ses_local",
"model": {"id": "google/gemma-4-e4b"},
"tokens": {"input": 10, "output": 4, "reasoning": 2,
"cache": {"read": 0, "write": 0}},
"cost": 0,
},
"messages": [
{
"info": {"role": "assistant", "finish": "stop"},
"parts": [{"type": "text", "text": "Fertig"}],
}
],
}
with tempfile.TemporaryDirectory() as temp_dir:
output = Path(temp_dir)
(output / "StRS.md").write_text("Inhalt", encoding="utf-8")
result = ADAPTER.normalize_result(
session=session,
events=[],
model_ref="lmstudio/google/gemma-4-e4b",
mode="solo",
effort="high",
exit_code=0,
timed_out=False,
interrupted=False,
duration_s=2.0,
output_dir=output,
errors=[],
provider="lmstudio",
effort_applied=False,
local_runtime=runtime,
)
self.assertEqual("lmstudio", result["provider"])
self.assertEqual("opencode-lmstudio", result["adapter"])
self.assertFalse(result["effort_applied"])
self.assertEqual(32768, result["context_window"])
self.assertEqual("Q4_K_M", result["local_runtime"]["quantization"])
self.assertEqual(0, result["cost"])
self.assertIn("lokaler Betrieb", result["cost_source"])
self.assertEqual(16, result["usage"]["total_tokens"])
def test_tensorx_result_keeps_remote_shape(self):
session = {"info": {"model": {"id": "qwen/qwen3.8-flash-next"},
"tokens": {"input": 1, "output": 1}}, "messages": []}
result = ADAPTER.normalize_result(
session=session, events=[], model_ref="tensorx/qwen/qwen3.8-flash-next",
mode="solo", effort="low", exit_code=0, timed_out=False,
interrupted=False, duration_s=1.0, output_dir=Path("."), errors=[],
)
self.assertEqual("tensorx", result["provider"])
self.assertEqual("opencode-tensorx", result["adapter"])
self.assertTrue(result["effort_applied"])
self.assertNotIn("local_runtime", result)
self.assertNotIn("context_window", result)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -1 +1,5 @@
# Spiegel-Arbeitsverzeichnisse der OpenCode-Laeufe (Junctions, laufzeitlokal)
_meta/spiegel/
**/_meta/spiegel/
@@ -31,6 +31,8 @@ Der Versuchsaufbau folgt einer schrittweise aufbauenden Vergleichslogik. Versuch
/ Versuch 1 (Baseline, Prompt-only): Reine Prompt-Steuerung ohne Agentendateien und ohne externe Tools. Die Hypothese lautet, dass eine formal strukturierte Anforderungsmenge bereits ohne Spezialisierung erreichbar ist, allerdings mit begrenzter Discovery-Breite und ohne dynamische Code- oder Datenbeobachtung. / Versuch 1 (Baseline, Prompt-only): Reine Prompt-Steuerung ohne Agentendateien und ohne externe Tools. Die Hypothese lautet, dass eine formal strukturierte Anforderungsmenge bereits ohne Spezialisierung erreichbar ist, allerdings mit begrenzter Discovery-Breite und ohne dynamische Code- oder Datenbeobachtung.
/ Versuch 1b (Baseline mit werkzeugeigenen Agenten): Wie Versuch 1, jedoch mit den Subagenten, die das eingesetzte Werkzeug von sich aus mitbringt. Diese Subagenten sind keine Agentendateien im Sinne von Versuch 2, sondern eine Funktion der CLI. Der Versuch trennt damit den Effekt werkzeugeigener Delegation vom Effekt rollenspezialisierter Agentendateien. Die Hypothese lautet, dass die Delegation die Discovery-Breite erhöht, zugleich aber die Streuung zwischen Läufen deutlich vergrößert, weil das Werkzeug die Zerlegung der Analyse selbst wählt.
/ Versuch 2 (Spezialisierung über Agenten): Wie Versuch 1, ergänzt um rollenspezialisierte Agentendateien für Stakeholder-Analyse, System-Requirements, Software-Requirements und einen ISO-29148-Orchestrator. Die Hypothese lautet, dass Spezialisierung die Strukturierungstiefe und die Normkonformität erhöht, ohne die Discovery-Breite signifikant zu verschlechtern. / Versuch 2 (Spezialisierung über Agenten): Wie Versuch 1, ergänzt um rollenspezialisierte Agentendateien für Stakeholder-Analyse, System-Requirements, Software-Requirements und einen ISO-29148-Orchestrator. Die Hypothese lautet, dass Spezialisierung die Strukturierungstiefe und die Normkonformität erhöht, ohne die Discovery-Breite signifikant zu verschlechtern.
/ Versuch 3 (Toolzugriff über MCP-Server): Wie Versuch 2, ergänzt um strukturierten Tool-Zugriff über MCP. Vorgesehen sind drei Server für Symbol-Navigation auf Code-Ebene, für Datenbank-Inspektion auf Schema- und Datensatzebene sowie optional für GUI-Beobachtung. Die Hypothese lautet, dass strukturierter Tool-Zugriff die Discovery-Breite vergrößert und zuvor undokumentierte Use Cases sichtbar macht, allerdings zu Lasten erhöhter Steuerungskomplexität. / Versuch 3 (Toolzugriff über MCP-Server): Wie Versuch 2, ergänzt um strukturierten Tool-Zugriff über MCP. Vorgesehen sind drei Server für Symbol-Navigation auf Code-Ebene, für Datenbank-Inspektion auf Schema- und Datensatzebene sowie optional für GUI-Beobachtung. Die Hypothese lautet, dass strukturierter Tool-Zugriff die Discovery-Breite vergrößert und zuvor undokumentierte Use Cases sichtbar macht, allerdings zu Lasten erhöhter Steuerungskomplexität.
@@ -53,9 +55,19 @@ Der Versuchsaufbau folgt einer schrittweise aufbauenden Vergleichslogik. Versuch
- Formal strukturierte Spezifikation erreichbar - Formal strukturierte Spezifikation erreichbar
- Discovery-Breite begrenzt - Discovery-Breite begrenzt
], ],
[V2 Agenten], [V1b Werkzeug-Agenten],
[ [
- Wie V1 - Wie V1
- werkzeugeigene Subagenten zugelassen
- keine Agentendateien
],
[
- Höhere Discovery-Breite
- Deutlich größere Streuung zwischen Läufen
],
[V2 Agenten],
[
- Wie V1b
- rollenspezialisierte Agentendateien - rollenspezialisierte Agentendateien
], ],
[ [
@@ -172,4 +184,6 @@ Jede Prompt-Version wird im Versuchsordner mit Zeitstempel und kurzer Notiz zum
caption: [Zustandsgraph des Versuchsablaufs. Die Versuche bauen schrittweise aufeinander auf, V4 ist optional. Der finale Prompt eines Versuchs wird zu Beginn des Folgeversuchs an die neu hinzukommende Werkzeugkomponente angepasst und dient dann als Startprompt. Innerhalb jedes Versuchs verfeinert ein Iterations-Loop den Prompt: Lauf → Begutachtung durch den Autor → bei „nein" Prompt-Anpassung, bei „ja" Übergang zum nächsten Versuch.], caption: [Zustandsgraph des Versuchsablaufs. Die Versuche bauen schrittweise aufeinander auf, V4 ist optional. Der finale Prompt eines Versuchs wird zu Beginn des Folgeversuchs an die neu hinzukommende Werkzeugkomponente angepasst und dient dann als Startprompt. Innerhalb jedes Versuchs verfeinert ein Iterations-Loop den Prompt: Lauf → Begutachtung durch den Autor → bei „nein" Prompt-Anpassung, bei „ja" Übergang zum nächsten Versuch.],
) <abb_versuchsablauf> ) <abb_versuchsablauf>
Abweichend von dieser Aufteilung wurde ein Teil des Modellvergleichs bereits innerhalb von Versuch 1 durchgeführt. Nachdem sich in den Wiederholungsläufen eine unerwartet große Streuung zeigte, wurde derselbe Prompt unter sonst identischer Bedingung zusätzlich mit zwei weiteren Modellen ausgeführt, um zu prüfen, ob diese Streuung modellabhängig ist. Diese Läufe liegen deshalb im Versuchsordner von Versuch 1 und nicht in Versuch 4. Sie ersetzen den dort geplanten LLM-Querschnitt nicht, da sie nur die Baseline-Konfiguration abdecken und nicht die in Versuch 1 bis 3 wirksamste Konfiguration. Ihr Zweck ist die Absicherung der Varianzaussage, nicht der Werkzeugvergleich.
Konstanten und Variablen sind in jedem Versuch klar dokumentiert. In Versuch 1 bis 3 umfassen die Konstanten Codebasis, Iterations-Vorgehen, Modellfamilie, Validierungsstichprobe und Bewertungskriterien. Variabel sind die Werkzeugkonfiguration und der Startprompt jedes Versuchs. Versuch 1 startet mit einem zu Versuchsbeginn neu formulierten Prompt. Versuch 2 und Versuch 3 übernehmen jeweils den finalen Prompt des vorhergehenden Versuchs und passen ihn zu Beginn an die neu hinzukommende Werkzeugkomponente an (Agentendateien in Versuch 2, MCP-Server in Versuch 3). Erst dieser angepasste Prompt bildet den Ausgangspunkt für den Iterations-Loop des Folgeversuchs. In Versuch 4 wird die Logik umgekehrt. Die Werkzeugkonfiguration ist die Konstante, das Modell die Variable. Damit lassen sich Unterschiede in Versuch 1 bis 3 ursächlich der Werkzeugvariation und in Versuch 4 ursächlich der Modellwahl zuordnen. Konstanten und Variablen sind in jedem Versuch klar dokumentiert. In Versuch 1 bis 3 umfassen die Konstanten Codebasis, Iterations-Vorgehen, Modellfamilie, Validierungsstichprobe und Bewertungskriterien. Variabel sind die Werkzeugkonfiguration und der Startprompt jedes Versuchs. Versuch 1 startet mit einem zu Versuchsbeginn neu formulierten Prompt. Versuch 2 und Versuch 3 übernehmen jeweils den finalen Prompt des vorhergehenden Versuchs und passen ihn zu Beginn an die neu hinzukommende Werkzeugkomponente an (Agentendateien in Versuch 2, MCP-Server in Versuch 3). Erst dieser angepasste Prompt bildet den Ausgangspunkt für den Iterations-Loop des Folgeversuchs. In Versuch 4 wird die Logik umgekehrt. Die Werkzeugkonfiguration ist die Konstante, das Modell die Variable. Damit lassen sich Unterschiede in Versuch 1 bis 3 ursächlich der Werkzeugvariation und in Versuch 4 ursächlich der Modellwahl zuordnen.
+14
View File
@@ -0,0 +1,14 @@
**/.git
**/.idea
**/app.config
**/bin
**/obj
./.editorconfig
./azure
./deployment
./docker
./docs
./scripts
./src/centron/
./tests
bin
+64
View File
@@ -0,0 +1,64 @@
# Remove the line below if you want to inherit .editorconfig settings from higher directories
root = true
# C# and XAML files
[*.{cs,xaml}]
charset = utf-8-bom
# C# files
[*.cs]
#### Naming styles ####
# Naming rules
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with___prefix.severity = warning
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with___prefix.symbols = private_or_internal_field
dotnet_naming_rule.private_or_internal_field_should_be_camel_case_with___prefix.style = camel_case_with___prefix
# Symbol specifications
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal
dotnet_naming_symbols.interface.required_modifiers =
dotnet_naming_symbols.private_or_internal_field.applicable_kinds = field
dotnet_naming_symbols.private_or_internal_field.applicable_accessibilities = internal, private
dotnet_naming_symbols.private_or_internal_field.required_modifiers =
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal
dotnet_naming_symbols.types.required_modifiers =
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascal_case.required_prefix =
dotnet_naming_style.pascal_case.required_suffix =
dotnet_naming_style.pascal_case.word_separator =
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.required_suffix =
dotnet_naming_style.begins_with_i.word_separator =
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.camel_case_with___prefix.required_prefix = _
dotnet_naming_style.camel_case_with___prefix.required_suffix =
dotnet_naming_style.camel_case_with___prefix.word_separator =
dotnet_naming_style.camel_case_with___prefix.capitalization = camel_case
+63
View File
@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto eol=lf
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain
@@ -0,0 +1,149 @@
name: Sign artifacts
description: >
Signs files via azure/artifact-signing-action with up to three attempts.
The Microsoft timestamp server (timestamp.acs.microsoft.com) fails
intermittently; re-signing already-signed files is safe because signtool
replaces existing signatures, so failed batches can simply be retried.
inputs:
azure-tenant-id:
description: Azure tenant id used for authentication.
required: true
azure-client-id:
description: Azure client id used for authentication.
required: true
azure-client-secret:
description: Azure client secret used for authentication.
required: true
files:
description: Newline-separated list of files to sign.
required: false
default: ''
files-folder:
description: Folder containing the files to sign.
required: false
default: ''
files-folder-filter:
description: Comma-separated file extensions to sign within files-folder.
required: false
default: ''
files-folder-recurse:
description: Whether to search files-folder recursively.
required: false
default: 'false'
endpoint:
description: Artifact Signing endpoint.
required: false
default: https://weu.codesigning.azure.net/
signing-account-name:
description: Artifact Signing account name.
required: false
default: CentronCodesigning
certificate-profile-name:
description: Certificate profile name.
required: false
default: centroncert
runs:
using: composite
steps:
- name: Sign (attempt 1)
id: attempt1
continue-on-error: true
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
azure-tenant-id: ${{ inputs.azure-tenant-id }}
azure-client-id: ${{ inputs.azure-client-id }}
azure-client-secret: ${{ inputs.azure-client-secret }}
endpoint: ${{ inputs.endpoint }}
signing-account-name: ${{ inputs.signing-account-name }}
certificate-profile-name: ${{ inputs.certificate-profile-name }}
files: ${{ inputs.files }}
files-folder: ${{ inputs.files-folder }}
files-folder-filter: ${{ inputs.files-folder-filter }}
files-folder-recurse: ${{ inputs.files-folder-recurse }}
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
exclude-environment-credential: false
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: true
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Wait before retry (attempt 2)
if: steps.attempt1.outcome == 'failure'
shell: pwsh
run: |
Write-Host 'Signing failed, retrying in 30 seconds...'
Start-Sleep -Seconds 30
- name: Sign (attempt 2)
id: attempt2
if: steps.attempt1.outcome == 'failure'
continue-on-error: true
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
azure-tenant-id: ${{ inputs.azure-tenant-id }}
azure-client-id: ${{ inputs.azure-client-id }}
azure-client-secret: ${{ inputs.azure-client-secret }}
endpoint: ${{ inputs.endpoint }}
signing-account-name: ${{ inputs.signing-account-name }}
certificate-profile-name: ${{ inputs.certificate-profile-name }}
files: ${{ inputs.files }}
files-folder: ${{ inputs.files-folder }}
files-folder-filter: ${{ inputs.files-folder-filter }}
files-folder-recurse: ${{ inputs.files-folder-recurse }}
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
exclude-environment-credential: false
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: true
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Wait before retry (attempt 3)
if: steps.attempt1.outcome == 'failure' && steps.attempt2.outcome == 'failure'
shell: pwsh
run: |
Write-Host 'Signing failed again, retrying in 90 seconds...'
Start-Sleep -Seconds 90
- name: Sign (attempt 3)
if: steps.attempt1.outcome == 'failure' && steps.attempt2.outcome == 'failure'
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
azure-tenant-id: ${{ inputs.azure-tenant-id }}
azure-client-id: ${{ inputs.azure-client-id }}
azure-client-secret: ${{ inputs.azure-client-secret }}
endpoint: ${{ inputs.endpoint }}
signing-account-name: ${{ inputs.signing-account-name }}
certificate-profile-name: ${{ inputs.certificate-profile-name }}
files: ${{ inputs.files }}
files-folder: ${{ inputs.files-folder }}
files-folder-filter: ${{ inputs.files-folder-filter }}
files-folder-recurse: ${{ inputs.files-folder-recurse }}
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
exclude-environment-credential: false
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: true
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
+431
View File
@@ -0,0 +1,431 @@
name: Build, sign, and publish
on:
workflow_dispatch:
pull_request:
branches:
- main
- 'release/**'
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
- 'release/**'
permissions:
contents: read
# Supersede in-flight runs of this workflow for the same pull request. Pushes to main and
# release branches are excluded so every commit there still gets a full, recorded result.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build and sign
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) }}
runs-on:
group: self-hosted
labels: [self-hosted, Windows, X64]
timeout-minutes: 180
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
main_version: ${{ steps.version.outputs.main_version }}
env:
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
CENTRON_BUILD_RUNNING_IN_AZURE_PIPELINE: true
CENTRON_BUILD_IS_DEV_BUILD: false
steps:
- name: Show runner information
shell: pwsh
run: |
Write-Host "Runner: $env:RUNNER_NAME"
Write-Host "Computer: $env:COMPUTERNAME"
Write-Host "PowerShell: $($PSVersionTable.PSVersion)"
- name: Check out repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Normalize Git repository format
shell: pwsh
run: |
$workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE)
$safeDirectories = @(git config --global --get-all safe.directory)
$safeDirectoriesExitCode = $LASTEXITCODE
if ($safeDirectoriesExitCode -notin 0, 1) {
throw 'Could not determine the configured Git safe directories.'
}
if ($safeDirectories -notcontains $workspace) {
git config --global --add safe.directory $workspace
if ($LASTEXITCODE -ne 0) {
throw "Could not register the GitHub workspace as a safe directory: $workspace"
}
}
$repositoryFormatVersion = git config --local --get core.repositoryFormatVersion
if ($LASTEXITCODE -ne 0) {
throw 'Could not determine the Git repository format version.'
}
if ($repositoryFormatVersion -eq '1') {
$extensions = @(git config --local --name-only --get-regexp '^extensions\.')
$extensionsExitCode = $LASTEXITCODE
if ($extensionsExitCode -notin 0, 1) {
throw 'Could not determine the active Git repository extensions.'
}
if ($extensions.Count -gt 0) {
throw "Cannot normalize the Git repository while extensions are active: $($extensions -join ', ')"
}
git config --local core.repositoryFormatVersion 0
if ($LASTEXITCODE -ne 0) {
throw 'Could not normalize the Git repository format for Nerdbank.GitVersioning.'
}
}
- name: Verify build environment
shell: pwsh
run: |
$sdkVersion = dotnet --version
if ($LASTEXITCODE -ne 0) {
throw "The .NET SDK could not be resolved using global.json."
}
if ($sdkVersion -notmatch '^10\.0\.') {
throw "Expected a .NET 10 SDK, but resolved '$sdkVersion'."
}
if ((git rev-parse --is-shallow-repository) -ne 'false') {
throw 'Repository was checked out without its full history.'
}
Write-Host "Resolved .NET SDK: $sdkVersion"
dotnet --info
- name: Set version and create NuGet packages
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- create-nuget-packages
- name: Determine build version
id: version
shell: pwsh
run: |
$versionText = [string](
& ".\dotnettools\nbgv.exe" get-version -v Version
)
if ($LASTEXITCODE -ne 0) {
throw "Nerdbank.GitVersioning failed with exit code $LASTEXITCODE."
}
$version = [Version]$versionText
$buildVersion = $version.ToString()
$mainVersion = $version.ToString(3)
Write-Host "Build version: $buildVersion"
Write-Host "Main version: $mainVersion"
"version=$buildVersion" >> $env:GITHUB_OUTPUT
"main_version=$mainVersion" >> $env:GITHUB_OUTPUT
$metadataDirectory = Join-Path $env:RUNNER_TEMP 'centron-build-metadata'
New-Item -Path $metadataDirectory -ItemType Directory -Force | Out-Null
Set-Content `
-LiteralPath (Join-Path $metadataDirectory 'version.txt') `
-Value $buildVersion `
-Encoding utf8NoBOM
- name: Prepare Artifact Signing dependencies
shell: powershell
run: |
if ((Get-PackageProvider -Name NuGet -ErrorAction Ignore) -eq $null) {
Install-PackageProvider -Name NuGet -Force -Scope CurrentUser
}
if ((Get-InstalledModule -Name PowerShellGet -MinimumVersion 2.2.1 -ErrorAction Ignore) -eq $null) {
Install-Module -Name PowerShellGet -MinimumVersion 2.2.1 -Scope CurrentUser -Force -AllowClobber
}
- name: Build Web Service
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- build-web-service-only
- name: Sign Web Service
uses: ./.github/actions/sign-artifacts
with:
azure-tenant-id: ${{ vars.ARTIFACT_SIGNING_TENANT_ID }}
azure-client-id: ${{ vars.ARTIFACT_SIGNING_CLIENT_ID }}
azure-client-secret: ${{ secrets.ARTIFACT_SIGNING_CLIENT_SECRET }}
files: |
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\Centron.Host.WindowsService.exe
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\Centron.Host.WindowsService.dll
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\ConnectionManager\c-entron Connection Manager.exe
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\ConnectionManager\c-entron Connection Manager.dll
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\Centron.Interfaces.dll
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\Centron.WebServices.Core.dll
${{ github.workspace }}\src\webservice\Centron.Host.WindowsService\bin\Release\net10.0-windows\win-x64\publish\Centron.Core.dll
- name: Create Web Service installer
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- set-dependencies-web-service build-web-service-installer-only
- name: Sign Web Service installer
uses: ./.github/actions/sign-artifacts
with:
azure-tenant-id: ${{ vars.ARTIFACT_SIGNING_TENANT_ID }}
azure-client-id: ${{ vars.ARTIFACT_SIGNING_CLIENT_ID }}
azure-client-secret: ${{ secrets.ARTIFACT_SIGNING_CLIENT_SECRET }}
files-folder: ${{ github.workspace }}\deployment\centron\WebServiceSetupProject\bin\Release
files-folder-filter: exe,dll,msi
files-folder-recurse: true
- name: Package Web Service installer
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- set-zip-directory-web-service
- name: Build c-entron.NET
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- build-centron-net-only
- name: Sign c-entron.NET
uses: ./.github/actions/sign-artifacts
with:
azure-tenant-id: ${{ vars.ARTIFACT_SIGNING_TENANT_ID }}
azure-client-id: ${{ vars.ARTIFACT_SIGNING_CLIENT_ID }}
azure-client-secret: ${{ secrets.ARTIFACT_SIGNING_CLIENT_SECRET }}
files: |
${{ github.workspace }}\src\centron\Centron.WPF.UI\bin\Release\net10.0-windows\win-x64\publish\c-entron 2.0.exe
${{ github.workspace }}\src\centron\Centron.WPF.UI\bin\Release\net10.0-windows\win-x64\publish\c-entron 2.0.dll
- name: Create c-entron.NET installer
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- set-dependencies-centron-net build-centron-net-installer-only
- name: Sign c-entron.NET installer
uses: ./.github/actions/sign-artifacts
with:
azure-tenant-id: ${{ vars.ARTIFACT_SIGNING_TENANT_ID }}
azure-client-id: ${{ vars.ARTIFACT_SIGNING_CLIENT_ID }}
azure-client-secret: ${{ secrets.ARTIFACT_SIGNING_CLIENT_SECRET }}
files-folder: ${{ github.workspace }}\deployment\centron\CentronSetupProject\bin\Release
files-folder-filter: exe,dll,msi
files-folder-recurse: true
- name: Package c-entron.NET installer
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- set-zip-directory-centron-net
- name: Build Nexus
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Scripts\Scripts.csproj" `
-- build-nexus
- name: Sign Nexus
uses: ./.github/actions/sign-artifacts
with:
azure-tenant-id: ${{ vars.ARTIFACT_SIGNING_TENANT_ID }}
azure-client-id: ${{ vars.ARTIFACT_SIGNING_CLIENT_ID }}
azure-client-secret: ${{ secrets.ARTIFACT_SIGNING_CLIENT_SECRET }}
files: |
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\CentronNexus.Host.exe
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\CentronNexus.Host.dll
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\CentronNexus.dll
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\CentronNexus.OutlookAddIn.dll
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\Centron.Core.dll
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\Centron.WebServices.Core.dll
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\Centron.Interfaces.dll
${{ github.workspace }}\src\nexus\CentronNexus.Host\bin\Release\net10.0\win-x64\publish\Centron.Office.Client.dll
- name: Prepare Nexus installer
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Scripts\Scripts.csproj" `
-- zip-nexus-directory
dotnet tool update --global wix --version 5.0.2 --allow-downgrade
if ($LASTEXITCODE -ne 0) {
dotnet tool install --global wix --version 5.0.2
}
wix extension add -g WixToolset.UI.wixext/5.0.2
dotnet run `
--project ".\scripts\Scripts\Scripts.csproj" `
-- build-nexus-installer
- name: Sign Nexus installer
uses: ./.github/actions/sign-artifacts
with:
azure-tenant-id: ${{ vars.ARTIFACT_SIGNING_TENANT_ID }}
azure-client-id: ${{ vars.ARTIFACT_SIGNING_CLIENT_ID }}
azure-client-secret: ${{ secrets.ARTIFACT_SIGNING_CLIENT_SECRET }}
files: ${{ github.workspace }}\deployment\WixSharpInstaller\bin\Release\net10.0-windows\c-entron Nexus.msi
- name: Verify signed installers
shell: pwsh
run: |
$files = @(
'.\deployment\centron\WebServiceSetupProject\bin\Release\c-entron Web-Service Installer.msi'
'.\deployment\centron\CentronSetupProject\bin\Release\c-entron.NET Installer.msi'
'.\deployment\WixSharpInstaller\bin\Release\net10.0-windows\c-entron Nexus.msi'
)
foreach ($file in $files) {
if (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
throw "Signed installer not found: $file"
}
$signature = Get-AuthenticodeSignature -LiteralPath $file
if ($signature.Status -ne 'Valid') {
throw "Invalid signature for '$file': $($signature.StatusMessage)"
}
Write-Host "Valid signature: $file"
}
- name: Package Nexus installer
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Scripts\Scripts.csproj" `
-- zip-nexus-singleFile
- name: Upload build artifacts
id: build-artifact
uses: actions/upload-artifact@v7
with:
name: centron-build
path: artifacts/
if-no-files-found: error
retention-days: 14
- name: Upload build version metadata
uses: actions/upload-artifact@v7
with:
name: centron-build-version-${{ steps.version.outputs.version }}
path: ${{ runner.temp }}/centron-build-metadata/version.txt
if-no-files-found: error
retention-days: 14
- name: Add artifact download link
shell: pwsh
run: |
"### Signed build artifacts" >> $env:GITHUB_STEP_SUMMARY
"Build version: ${{ steps.version.outputs.version }}" >> $env:GITHUB_STEP_SUMMARY
"[Download centron-build](${{ steps.build-artifact.outputs.artifact-url }})" >> $env:GITHUB_STEP_SUMMARY
upload-centron-net:
name: Upload c-entron.NET
needs: build
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') }}
permissions:
actions: read
contents: read
id-token: write
uses: NEXOWARE-Systems/ci-cd-reusable-workflows/.github/workflows/upload-software-build.yml@main
with:
artifact_name: centron-build
source_path: c-entron.NET Installer.zip
destination_folder: c-entron.NET
destination_file: c-entron.NET Installer.zip
version: ${{ needs.build.outputs.version }}
main_version: ${{ needs.build.outputs.main_version }}
environment_name: SoftwareBuilds
azure_client_id: ${{ vars.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ vars.AZURE_TENANT_ID }}
upload-web-service:
name: Upload c-entron Web Service
needs: build
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') }}
permissions:
actions: read
contents: read
id-token: write
uses: NEXOWARE-Systems/ci-cd-reusable-workflows/.github/workflows/upload-software-build.yml@main
with:
artifact_name: centron-build
source_path: c-entron Web-Service Installer.zip
destination_folder: c-entron Web-Service
destination_file: c-entron Web-Service Installer.zip
version: ${{ needs.build.outputs.version }}
main_version: ${{ needs.build.outputs.main_version }}
environment_name: SoftwareBuilds
azure_client_id: ${{ vars.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ vars.AZURE_TENANT_ID }}
upload-nexus:
name: Upload c-entron Nexus
needs: build
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') }}
permissions:
actions: read
contents: read
id-token: write
uses: NEXOWARE-Systems/ci-cd-reusable-workflows/.github/workflows/upload-software-build.yml@main
with:
artifact_name: centron-build
source_path: c-entron Nexus Installer.zip
destination_folder: c-entron Nexus
destination_file: c-entron Nexus Installer.zip
version: ${{ needs.build.outputs.version }}
main_version: ${{ needs.build.outputs.main_version }}
environment_name: SoftwareBuilds
azure_client_id: ${{ vars.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ vars.AZURE_TENANT_ID }}
@@ -0,0 +1,117 @@
name: Clean up closed PR artifacts
on:
pull_request_target:
types:
- closed
permissions:
actions: write
pull-requests: read
jobs:
delete-artifacts:
name: Delete PR artifacts
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Delete artifacts from PR workflow runs
uses: actions/github-script@v9
with:
script: |
const { owner, repo } = context.repo;
const pullRequest = context.payload.pull_request;
const workflowRuns = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner,
repo,
event: 'pull_request',
branch: pullRequest.head.ref,
per_page: 100
}
);
const pullRequestRuns = [];
const associatedPullRequestsBySha = new Map();
for (const run of workflowRuns) {
if (!associatedPullRequestsBySha.has(run.head_sha)) {
const associatedPullRequests = await github.paginate(
github.rest.repos.listPullRequestsAssociatedWithCommit,
{
owner,
repo,
commit_sha: run.head_sha,
per_page: 100
}
);
associatedPullRequestsBySha.set(run.head_sha, associatedPullRequests);
}
const belongsToPullRequest = associatedPullRequestsBySha
.get(run.head_sha)
.some(pr => pr.number === pullRequest.number);
if (belongsToPullRequest) {
pullRequestRuns.push(run);
}
}
let deletedArtifacts = 0;
let deletedBytes = 0;
for (const run of pullRequestRuns) {
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{
owner,
repo,
run_id: run.id,
per_page: 100
}
);
for (const artifact of artifacts) {
if (artifact.expired) {
core.info(`Skipping expired artifact ${artifact.name} (${artifact.id}).`);
continue;
}
try {
await github.rest.actions.deleteArtifact({
owner,
repo,
artifact_id: artifact.id
});
deletedArtifacts += 1;
deletedBytes += artifact.size_in_bytes;
core.info(`Deleted artifact ${artifact.name} (${artifact.id}).`);
} catch (error) {
if (error.status === 404) {
core.info(`Artifact ${artifact.name} (${artifact.id}) was already deleted.`);
continue;
}
throw error;
}
}
}
const deletedMiB = (deletedBytes / 1024 / 1024).toFixed(1);
core.info(
`Deleted ${deletedArtifacts} artifact(s) (${deletedMiB} MiB) ` +
`from ${pullRequestRuns.length} workflow run(s) for PR #${pullRequest.number}.`
);
await core.summary
.addHeading('PR artifact cleanup')
.addRaw(`Pull request: #${pullRequest.number}`, true)
.addRaw(`Workflow runs inspected: ${pullRequestRuns.length}`, true)
.addRaw(`Artifacts deleted: ${deletedArtifacts}`, true)
.addRaw(`Storage released: ${deletedMiB} MiB`, true)
.write();
@@ -0,0 +1,269 @@
name: Regression tests
on:
workflow_dispatch:
pull_request:
branches:
- main
- 'release/**'
push:
branches:
- main
- 'release/**'
permissions:
contents: read
# Supersede in-flight runs of this workflow for the same pull request. Pushes to main and
# release branches are excluded so every commit there still gets a full, recorded result.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
regression-tests:
name: End-to-end regression tests
runs-on: ubuntu-24.04
timeout-minutes: 180
env:
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DevExpress_License: ${{ secrets.DEVEXPRESS_LICENSE }}
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
DB_USERNAME: sa
DB_PASSWORD: SA!password
DB_PORT: 1433
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
fetch-depth: 0
# Verifier.OverrideExpectedFiles = true rewrites the expected files instead of comparing
# against them, so a test that keeps the flag silently verifies nothing. It is only meant
# to be set temporarily while regenerating snapshots and must never be committed.
- name: Check for committed OverrideExpectedFiles
shell: pwsh
run: |
$violations = foreach ($file in Get-ChildItem -Path ./tests -Recurse -Filter *.cs) {
$segments = $file.FullName -split '[\\/]'
if ($segments -contains 'bin' -or $segments -contains 'obj') { continue }
$text = Get-Content -LiteralPath $file.FullName -Raw
if ([string]::IsNullOrEmpty($text)) { continue }
# Blank out block comments, keeping newlines so reported line numbers stay correct.
$text = [regex]::Replace($text, '(?s)/\*.*?\*/', { param($m) $m.Value -replace '[^\r\n]', '' })
$lineNumber = 0
foreach ($line in $text -split '\r?\n') {
$lineNumber++
# The assignment must start the line, optionally behind a dotted receiver such as
# this.Verifier. - anything else in front (other code, // or /// ) means it is not a
# statement. Deliberately not stripping // comments: that would also cut a real
# assignment that follows a string containing '//', turning a false positive into a
# false negative.
if ($line -match '^\s*(?:[A-Za-z_][A-Za-z0-9_.]*\.)?OverrideExpectedFiles\s*=\s*true') {
[pscustomobject]@{ Path = $file.FullName; Line = $lineNumber }
}
}
}
if ($violations) {
foreach ($violation in $violations) {
Write-Host "::error file=$($violation.Path),line=$($violation.Line)::Remove 'OverrideExpectedFiles = true' before committing - the test compares nothing while it is set."
}
throw "Found $(@($violations).Count) committed 'OverrideExpectedFiles = true' assignment(s). Regenerate the expected files locally, then remove the flag."
}
Write-Host 'No committed OverrideExpectedFiles assignments found.'
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Verify test environment
shell: pwsh
run: |
$sdkVersion = dotnet --version
if ($LASTEXITCODE -ne 0 -or $sdkVersion -notmatch '^10\.0\.') {
throw "Expected a .NET 10 SDK, but resolved '$sdkVersion'."
}
docker version
if ($LASTEXITCODE -ne 0) {
throw 'Docker is unavailable on the self-hosted runner.'
}
- name: Sign in to Azure Container Registry
shell: pwsh
run: |
if ([string]::IsNullOrWhiteSpace($env:ACR_USERNAME) -or
[string]::IsNullOrWhiteSpace($env:ACR_PASSWORD)) {
throw 'ACR_USERNAME and ACR_PASSWORD secrets are required on GitHub-hosted runners.'
}
$env:ACR_PASSWORD | docker login centron.azurecr.io --username $env:ACR_USERNAME --password-stdin
if ($LASTEXITCODE -ne 0) {
throw 'Azure Container Registry login failed.'
}
# Superseded runs are now cancelled mid-test. "Stop regression database" below uses
# if: always() and therefore still runs on cancellation, but a hard runner failure can
# leave a container behind. Drop anything older than the job timeout so leftovers cannot
# pile up on the self-hosted runner. Younger containers may belong to a concurrent run of
# another pull request and are left alone.
- name: Remove stale regression containers
continue-on-error: true
shell: pwsh
run: |
$cutoff = (Get-Date).ToUniversalTime().AddHours(-4)
# The docker name filter is a regex over a substring, so an unanchored pattern would
# also match something like backup-centron-regression-db. '^/?' anchors it and works
# whether the daemon matches the bare name or the internal '/name'.
foreach ($id in @(docker ps --all --quiet --filter 'name=^/?centron-regression-')) {
if ([string]::IsNullOrWhiteSpace($id)) { continue }
$parts = (docker inspect --format '{{.Name}}|{{.Created}}' $id) -split '\|', 2
if ($parts.Count -ne 2) { continue }
$name = $parts[0].TrimStart('/')
$created = $parts[1]
# Second guard: this force-removes containers, so never act on a name that does not
# actually carry the prefix, whatever the daemon's filter semantics happen to be.
if (-not $name.StartsWith('centron-regression-')) { continue }
$parsed = [datetime]::MinValue
$isParsed = [datetime]::TryParse(
$created,
[cultureinfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AdjustToUniversal,
[ref] $parsed)
if ($isParsed -and $parsed -lt $cutoff) {
Write-Host "Removing stale regression container $name created at $created."
docker rm --force $id | Out-Null
}
}
$global:LASTEXITCODE = 0
- name: Start regression database
shell: pwsh
run: |
$containerName = "centron-regression-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT".ToLowerInvariant()
"DB_CONTAINER_NAME=$containerName" >> $env:GITHUB_ENV
docker pull centron.azurecr.io/centron_db/regression_tests:latest
if ($LASTEXITCODE -ne 0) {
throw 'Could not pull the regression database image.'
}
docker run --detach `
--name $containerName `
--env "MSSQL_SA_PASSWORD=$env:DB_PASSWORD" `
--env ACCEPT_EULA=Y `
--env MSSQL_PID=Standard `
--publish "${env:DB_PORT}:1433" `
centron.azurecr.io/centron_db/regression_tests:latest | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Could not start the regression database container.'
}
- name: Wait for regression database
shell: pwsh
run: |
for ($attempt = 1; $attempt -le 120; $attempt++) {
$client = [Net.Sockets.TcpClient]::new()
try {
$connected = $client.ConnectAsync('localhost', [int]$env:DB_PORT).Wait(1000)
if ($connected -and $client.Connected) {
Write-Host 'Regression database is reachable.'
exit 0
}
}
catch {
# Database is still starting.
}
finally {
$client.Dispose()
}
Start-Sleep -Seconds 2
}
throw 'Regression database did not become reachable within four minutes.'
- name: Build regression tests
shell: pwsh
run: |
dotnet build `
"./tests/Centron.Tests.EndToEnd/Centron.Tests.EndToEnd.csproj" `
--configuration Release `
--framework net10.0 `
-nodeReuse:false
- name: Run regression tests
shell: pwsh
env:
CENTRON_TESTS_DATABASE_SERVER: localhost,1433
CENTRON_TESTS_DATABASE_USERNAME: sa
CENTRON_TESTS_DATABASE_PASSWORD: SA!password
DATABASE_BACKUP_PATH: /var/opt/mssql/backup/DatabaseBackup.bak
run: |
dotnet test `
"./tests/Centron.Tests.EndToEnd/Centron.Tests.EndToEnd.csproj" `
--configuration Release `
--framework net10.0 `
--no-build `
--logger "trx;LogFileName=TestResults.trx" `
--results-directory "./artifacts/EndToEndTests" `
-nodeReuse:false
- name: Capture database logs
if: always()
continue-on-error: true
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path './artifacts/EndToEndTests' | Out-Null
docker logs $env:DB_CONTAINER_NAME *>&1 |
Set-Content -LiteralPath './artifacts/EndToEndTests/database.log'
- name: Stop regression database
if: always()
continue-on-error: true
shell: pwsh
run: |
if (-not [string]::IsNullOrWhiteSpace($env:DB_CONTAINER_NAME)) {
docker rm --force $env:DB_CONTAINER_NAME 2>$null
}
$global:LASTEXITCODE = 0
- name: Upload regression results
if: always()
uses: actions/upload-artifact@v7
with:
name: regression-test-results
path: artifacts/EndToEndTests/
if-no-files-found: warn
retention-days: 14
+105
View File
@@ -0,0 +1,105 @@
name: Unit tests
on:
workflow_dispatch:
pull_request:
branches:
- main
- 'release/**'
push:
branches:
- main
- 'release/**'
permissions:
contents: read
# Supersede in-flight runs of this workflow for the same pull request. Pushes to main and
# release branches are excluded so every commit there still gets a full, recorded result.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
name: ${{ matrix.display_name }}
runs-on: windows-2025
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- name: backend-bl
display_name: Backend BL tests
project: .\tests\backend\Centron.Tests.BL\Centron.Tests.BL.csproj
results_directory: artifacts/tests/backend/bl
- name: backend-dao
display_name: Backend DAO tests
project: .\tests\backend\Centron.Tests.DAO\Centron.Tests.DAO.csproj
results_directory: artifacts/tests/backend/dao
- name: shared-core
display_name: Shared Core tests
project: .\tests\shared\Centron.Tests.Core\Centron.Tests.Core.csproj
results_directory: artifacts/tests/shared/core
- name: nexus
display_name: Nexus tests
project: .\tests\CentronNexusTests\CentronNexusTests.csproj
results_directory: artifacts/tests/nexus
env:
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
CENTRON_BUILD_IS_DEV_BUILD: true
DevExpress_License: ${{ secrets.DEVEXPRESS_LICENSE }}
steps:
- name: Check out repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 10.0.x
- name: Verify .NET SDK
shell: pwsh
run: |
$sdkVersion = dotnet --version
if ($LASTEXITCODE -ne 0 -or $sdkVersion -notmatch '^10\.0\.') {
throw "Expected a .NET 10 SDK, but resolved '$sdkVersion'."
}
Write-Host "Resolved .NET SDK: $sdkVersion"
- name: Set build version
shell: pwsh
run: |
dotnet run `
--project ".\scripts\Centron.Scripts\Centron.Scripts.csproj" `
-- setup-versioning
- name: Run tests
shell: pwsh
run: |
dotnet test "${{ matrix.project }}" `
--configuration Release `
--framework net10.0 `
--logger "trx;LogFileName=TestResults.trx" `
--results-directory "${{ matrix.results_directory }}" `
-nodeReuse:false
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.name }}
path: ${{ matrix.results_directory }}/TestResults.trx
if-no-files-found: warn
retention-days: 14
+404
View File
@@ -0,0 +1,404 @@
# Created by https://www.gitignore.io/api/visualstudio
# Edit at https://www.gitignore.io/?templates=visualstudio
### VisualStudio ###
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.lscache
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# End of https://www.gitignore.io/api/visualstudio
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/.idea
# c-entron Custom Ignores
!src/backend/Centron.Entities/Entities/Import/Log
/deployment/centron/CentronSetupProject/Files
/deployment/centron/WebServiceSetupProject/Files
/deployment/riverbird/RiverbirdWebserviceSetup/Files
/dotnettools
*actual.txt
*AnalyticsUserId.txt
!/nugets/*.nupkg
/.claude/settings.local.json
kind-pike
/.claude/worktrees
BlazorServer/wwwroot/js/index.bundle.js
BlazorServer/NpmJS/src/*.js
BlazorServer/NpmJS/src/*.js.map
BlazorServer/NpmJS/package-lock.json
src/CentronNexus/ProjectManagement/NpmJS/src/*.js
src/CentronNexus/ProjectManagement/NpmJS/src/*.js.map
src/CentronNexus/ProjectManagement/NpmJS/package-lock.json
src/CentronNexus/wwwroot/js/*.bundle.js
src/CentronNexus/Shared/Scripts/src/*.js
src/CentronNexus/Shared/Scripts/src/*.js.map
src/CentronNexus/Shared/Scripts/package-lock.json
/deployment/WixSharpInstaller/Files
/src/CentronNexus/.config/dotnet-tools.json
/src/CentronNexus/Microsoft.CodeAnalysis.Razor.Compiler/Microsoft.NET.Sdk.Razor.SourceGenerators.RazorSourceGenerator/
/.windows-mcp
+26
View File
@@ -0,0 +1,26 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/src/centron/Centron.WPF.UI/bin/Debug/net8.0-windows/c-entron 2.0.dll",
"args": [],
"cwd": "${workspaceFolder}/src/centron/Centron.WPF.UI",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/centron/Centron.WPF.UI/Centron.WPF.UI.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/centron/Centron.WPF.UI/Centron.WPF.UI.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/src/centron/Centron.WPF.UI/Centron.WPF.UI.csproj"
],
"problemMatcher": "$msCompile"
}
]
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\src\backend\Centron.Interfaces\Centron.Interfaces.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,131 @@
using Centron.Api.docuFORM.Helper;
using Centron.Api.docuFORM.Models;
using Centron.Api.docuFORM.Models.Swagger;
using Centron.Api.docuFORM.Models.Swagger.Responses;
using Centron.Core;
using Centron.Core.Extensions;
using Centron.Interfaces.BL;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM
{
public class DocuFormRestApiClient : IDocuFormApiClient, IDisposable
{
#region Fields
private readonly HttpClient _httpClient;
private bool _disposed = false;
#endregion
#region Ctor
public DocuFormRestApiClient(string serverAddress)
{
try
{
var trimmedAddress = serverAddress.TrimEnd('/');
var handler = new SocketsHttpHandler() { MaxConnectionsPerServer = 20 };
this._httpClient = new HttpClient(handler) { BaseAddress = new Uri(trimmedAddress) };
this._httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
catch(Exception ex)
{
throw new Exception($"Fehler beim Initalisieren des HTTP-Clients für folgende Adresse \"{serverAddress}\"!{Environment.NewLine}{ex.Message}");
}
}
#endregion
#region Methods
public async Task<string> RequestAuthorization(AuthCodeRequest authCodeRequest)
{
Guard.NotNull(authCodeRequest, nameof(authCodeRequest));
var requestUrl = DocuFormRequestHelper.CreateAuthorizationRequestURI(authCodeRequest);
var response = await this._httpClient.GetAsync(requestUrl);
var contentString = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Fehler während der Authorisierung bei docuFORM: {contentString}");
return contentString;
}
public async Task<AuthTokenResponse> RequestToken(AuthTokenRequest tokenRequest)
{
Guard.NotNull(tokenRequest, nameof(tokenRequest));
var paremeters = DocuFormRequestHelper.RequestToParameters(tokenRequest);
var request = new HttpRequestMessage(HttpMethod.Post, DocuFormRestApiConstants._endpointAuthToken)
{
Content = new FormUrlEncodedContent(paremeters)
};
var response = await this._httpClient.SendAsync(request);
var responseContentString = await response.ReadContentAndThrowIfError();
var authToken = JsonSerializer.Deserialize<AuthTokenResponse>(responseContentString);
if(authToken == null)
throw new HttpRequestException($"No auth token was received!");
return authToken;
}
public async Task<Result<DevicesResponse>> GetAllDevices(string token)
{
var requestUri = DocuFormRestApiConstants._endpointDevices;
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var response = await this._httpClient.SendRequestWithToken(request, token);
return await response.CheckResponseAndDeserializeContent<DevicesResponse>();
}
public async Task<Result<DeviceCountersResponse>> GetDeviceCounters(string token, int deviceId, DateTime? requestedDate = null)
{
var requestUri = DocuFormRestApiConstants._endpointDevices + $"/{deviceId}/counters";
if (requestedDate.HasValue)
{
var utcDate = requestedDate.Value.ToUniversalTime();
requestUri += $"?date={utcDate.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)}";
}
var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
var response = await this._httpClient.SendRequestWithToken(request, token);
return await response.CheckResponseAndDeserializeContent<DeviceCountersResponse>();
}
#endregion
#region IDisposable Support
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_httpClient?.Dispose();
_disposed = true;
}
}
#endregion
}
}
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Centron.Api.docuFORM
{
internal static class DocuFormRestApiConstants
{
internal static readonly string _redirectURI = "http://127.0.0.1"; // Don't ad trailing /
internal static readonly string _endpointAuthToken = "/auth/v2/token";
internal static readonly string _endpointAuthCode = "/auth/v2/authorize";
internal static readonly string _endpointDevices = "/dfmserver/v2/devices";
}
}
@@ -0,0 +1,70 @@
using Centron.Api.docuFORM.Models;
using System;
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using System.Text;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Helper
{
public static class DocuFormRequestHelper
{
public static string CreateAuthorizationRequestURI(AuthCodeRequest authCodeRequest, string? baseUriToAdd = null)
{
var parameters = RequestToParameterString(authCodeRequest);
var endPointAndParameters = DocuFormRestApiConstants._endpointAuthCode + "?" + parameters;
var baseUri = baseUriToAdd != null
? new Uri(baseUriToAdd)
: null;
return baseUri != null
? new Uri(baseUri, endPointAndParameters).ToString()
: endPointAndParameters;
}
public static string RequestToParameterString<T>(T request)
{
var parameters = RequestToParameters(request);
var parameterString = string.Join("&", parameters.Select(f => $"{WebUtility.UrlEncode(f.Key)}={WebUtility.UrlEncode(f.Value)}"));
return parameterString;
}
public static Dictionary<string, string> RequestToParameters<T>(T request, bool removeParameterWithoutValue = true)
{
var dict = new Dictionary<string, string>();
var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var prop in props)
{
var attr = prop.GetCustomAttribute<JsonPropertyNameAttribute>();
var key = attr?.Name ?? prop.Name;
var value = prop.GetValue(request)?.ToString();
if(removeParameterWithoutValue && string.IsNullOrWhiteSpace(value))
continue;
dict[key] = value ?? string.Empty;
}
return dict;
}
public static string CreateRedirectURI(int? portNumber = null)
{
var uriBuilder = new UriBuilder(DocuFormRestApiConstants._redirectURI);
uriBuilder.Port = portNumber.HasValue && portNumber.Value > 0
? portNumber.Value
: -1;
var uri = uriBuilder.ToString();
while(uri.EndsWith("/"))
uri = uri.Substring(0, uri.Length - 1);
return uri;
}
}
}
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Text;
namespace Centron.Api.docuFORM.Helper
{
public static class HttpClientExtensions
{
public static async Task<HttpResponseMessage> SendRequestWithToken(this HttpClient httpClient, HttpRequestMessage httpRequest, string token)
{
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
if (httpRequest.Headers.Accept.Count == 0)
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return await httpClient.SendAsync(httpRequest).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,50 @@
using Centron.Api.docuFORM.Models.Swagger.Responses;
using Centron.Interfaces.BL;
using System;
using System.Collections.Generic;
using System.Net;
using System.Text;
using System.Text.Json;
namespace Centron.Api.docuFORM.Helper
{
public static class HttpResponseMessageExtensions
{
public static async Task<string> ReadContentAndThrowIfError(this HttpResponseMessage? response)
{
if(response == null)
throw new HttpRequestException ($"No http response received!");
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
throw new HttpRequestException ($"An error occured during the http request: {content}");
return content;
}
public static async Task<Result<T>> CheckResponseAndDeserializeContent<T>(this HttpResponseMessage? response)
{
try
{
if(response == null)
throw new HttpRequestException ($"No http response received!");
var content = await ReadContentAndThrowIfError(response);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var obj = JsonSerializer.Deserialize<T>(content, options);
if(obj != null)
return Result<T>.AsSuccess(obj);
return Result<T>.AsError("Unable to deserialize object!");
}
catch (Exception ex)
{
return Result<T>.FromException(ex);
}
}
}
}
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace Centron.Api.docuFORM.Helper
{
public static class OAuthHelper
{
// Can be used to generate a "state" or the codeVerifier for the "code challenge"
public static string GenerateRandomBase64String(int byteLength = 32)
{
byte[] randomBytes = new byte[byteLength];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(randomBytes);
}
// Base64 URL-safe encoding
string state = Convert.ToBase64String(randomBytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
return state;
}
public static string GenerateCodeChallenge(string codeVerifier)
{
using (var sha256 = SHA256.Create())
{
byte[] hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(codeVerifier));
string codeChallenge = Convert.ToBase64String(hash)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
return codeChallenge;
}
}
}
}
@@ -0,0 +1,21 @@
using Centron.Api.docuFORM.Models;
using Centron.Api.docuFORM.Models.Swagger;
using Centron.Api.docuFORM.Models.Swagger.Responses;
using Centron.Interfaces.BL;
using System;
using System.Collections.Generic;
using System.Text;
namespace Centron.Api.docuFORM
{
public interface IDocuFormApiClient
{
public Task<string> RequestAuthorization(AuthCodeRequest authCodeRequest);
public Task<AuthTokenResponse> RequestToken(AuthTokenRequest tokenRequest);
public Task<Result<DevicesResponse>> GetAllDevices(string token);
public Task<Result<DeviceCountersResponse>> GetDeviceCounters(string token, int deviceId, DateTime? requestedDate);
}
}
@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models
{
public class AuthCodeRequest
{
[JsonPropertyName("client_id")]
public string ClientId { get; set; } = string.Empty;
[JsonPropertyName("response_type")]
public string ResponseType { get; set; } = "code";
[JsonPropertyName("scope")]
public string Scope { get; set; } = string.Empty;
[JsonPropertyName("redirect_uri")]
public string RedirectUri { get; set; } = "http://127.0.0.1";
[JsonPropertyName("authMode")]
public string AuthMode { get; set; } = string.Empty;
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
[JsonPropertyName("code_challenge_method")]
public string CodeChallengeMethod { get; set; } = "S256";
[JsonPropertyName("code_challenge")]
public string CodeChallenge { get; set; } = string.Empty;
[JsonPropertyName("infotext")]
public string InfoText { get; set; } = string.Empty;
[JsonPropertyName("username")]
public string UserName { get; set; } = string.Empty;
}
}
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class AuthTokenRequest
{
[JsonPropertyName("grant_type")]
public string GrantType { get; set; } = string.Empty;
[JsonPropertyName("client_id")]
public string ClientId { get; set; } = string.Empty;
[JsonPropertyName("client_secret")]
public string? ClientSecret { get; set; }
[JsonPropertyName("username")]
public string? Username { get; set; }
[JsonPropertyName("password")]
public string? Password { get; set; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("code")]
public string? Code { get; set; }
[JsonPropertyName("code_verifier")]
public string? CodeVerifier { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("redirect_uri")]
public string? RedirectUri { get; set; }
[JsonPropertyName("client_deviceid")]
public string? ClientDeviceId { get; set; }
}
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class AuthTokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("expires_in")]
public long ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
[JsonPropertyName("server_api_url")]
public string? ServerApiUrl { get; set; }
[JsonPropertyName("client_api_url")]
public string? ClientApiUrl { get; set; }
[JsonPropertyName("user_id")]
public int UserId { get; set; }
}
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class Customer : CustomerCreate
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("dealerUuid")]
public string? DealerUuid { get; set; }
[JsonPropertyName("created")]
public DateTime? Created { get; set; }
[JsonPropertyName("createdBy")]
public string? CreatedBy { get; set; }
[JsonPropertyName("restApiClientUuid")]
public string? RestApiClientUuid { get; set; }
[JsonPropertyName("lastLogin")]
public DateTime? LastLogin { get; set; }
[JsonPropertyName("productVersion")]
public string? ProductVersion { get; set; }
}
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class CustomerCreate : CustomerUpdate
{
[JsonPropertyName("dealerId")]
public int DealerId { get; set; }
/// <summary>Customer name. Shadows <see cref="UserProperties.Name"/> (the user login name).</summary>
[JsonPropertyName("name")]
public new string Name { get; set; } = string.Empty;
}
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class CustomerDeployment
{
[JsonPropertyName("mcsCollectionUuid")]
public string? McsCollectionUuid { get; set; }
[JsonPropertyName("mcsPassword")]
public string? McsPassword { get; set; }
[JsonPropertyName("mcsOauthHost")]
public string? McsOauthHost { get; set; }
}
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class CustomerRights
{
[JsonPropertyName("dashboard")]
public bool? Dashboard { get; set; }
[JsonPropertyName("reports")]
public bool? Reports { get; set; }
[JsonPropertyName("eshop")]
public bool? Eshop { get; set; }
[JsonPropertyName("customerThresholds")]
public bool? CustomerThresholds { get; set; }
[JsonPropertyName("customerAlerts")]
public bool? CustomerAlerts { get; set; }
[JsonPropertyName("deletePrinters")]
public bool? DeletePrinters { get; set; }
[JsonPropertyName("contractManagement")]
public bool? ContractManagement { get; set; }
[JsonPropertyName("deployment")]
public bool? Deployment { get; set; }
}
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class CustomerUpdate : UserProperties
{
[JsonPropertyName("password")]
public string? Password { get; set; }
[JsonPropertyName("submitPassword")]
public string? SubmitPassword { get; set; }
[JsonPropertyName("f2pControl")]
public string? F2pControl { get; set; }
[JsonPropertyName("rights")]
public CustomerRights? Rights { get; set; }
[JsonPropertyName("deployment")]
public CustomerDeployment? Deployment { get; set; }
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class Dealer : UserProperties
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("created")]
public DateTime? Created { get; set; }
[JsonPropertyName("createdBy")]
public string? CreatedBy { get; set; }
[JsonPropertyName("restApiClientUuid")]
public string? RestApiClientUuid { get; set; }
[JsonPropertyName("lastLogin")]
public DateTime? LastLogin { get; set; }
}
@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class Device : DeviceCreate
{
// DeviceProperties (server-populated read fields)
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("macAddress")]
public string? MacAddress { get; set; }
[JsonPropertyName("serialNumber")]
public string? SerialNumber { get; set; }
[JsonPropertyName("ipAddress")]
public string? IpAddress { get; set; }
[JsonPropertyName("hostAddressDate")]
public DateTime? HostAddressDate { get; set; }
[JsonPropertyName("contractId")]
public int? ContractId { get; set; }
[JsonPropertyName("createdOn")]
public DeviceCreatedOn? CreatedOn { get; set; }
[JsonPropertyName("firmware")]
public string? Firmware { get; set; }
[JsonPropertyName("mibFile")]
public DeviceMibFile? MibFile { get; set; }
[JsonPropertyName("modelName")]
public string? ModelName { get; set; }
[JsonPropertyName("vendorName")]
public string? VendorName { get; set; }
[JsonPropertyName("vendorId")]
public int? VendorId { get; set; }
[JsonPropertyName("systemName")]
public string? SystemName { get; set; }
[JsonPropertyName("pageCount")]
public int? PageCount { get; set; }
[JsonPropertyName("properties")]
public List<string>? Properties { get; set; }
[JsonPropertyName("outputDimensions")]
public DeviceFeatureOutputDimensions? OutputDimensions { get; set; }
[JsonPropertyName("technicalSpecs")]
public DeviceFeatureTecSpecs? TechnicalSpecs { get; set; }
[JsonPropertyName("numOutputTrays")]
public int? NumOutputTrays { get; set; }
[JsonPropertyName("numPaperTrays")]
public int? NumPaperTrays { get; set; }
[JsonPropertyName("resolution")]
public string? Resolution { get; set; }
[JsonPropertyName("paperTrays")]
public List<DevicePaperTray>? PaperTrays { get; set; }
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceCiData
{
[JsonPropertyName("articleNumber")]
public string? ArticleNumber { get; set; }
[JsonPropertyName("ciSerial")]
public string? CiSerial { get; set; }
[JsonPropertyName("exceptionFleet")]
public bool ExceptionFleet { get; set; }
[JsonPropertyName("mpsClientId")]
public string? MpsClientId { get; set; }
[JsonPropertyName("npsServerId")]
public string? NpsServerId { get; set; }
[JsonPropertyName("sla")]
public string? Sla { get; set; }
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceCounters
{
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("timestamp")]
public DateTime? Timestamp { get; set; }
[JsonPropertyName("standardCounters")]
public Dictionary<string, int>? StandardCounters { get; set; }
[JsonPropertyName("extendedCounters")]
public Dictionary<string, int>? ExtendedCounters { get; set; }
}
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceCreate : DeviceUpdate
{
[JsonPropertyName("dealerId")]
public int DealerId { get; set; }
[JsonPropertyName("customerId")]
public int CustomerId { get; set; }
[JsonPropertyName("hostAddress")]
public string HostAddress { get; set; } = string.Empty;
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceCreatedOn
{
[JsonPropertyName("client")]
public DateTime? Client { get; set; }
[JsonPropertyName("server")]
public DateTime? Server { get; set; }
}
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceEvent
{
[JsonPropertyName("id")]
public int? Id { get; set; }
[JsonPropertyName("eventId")]
public string? EventId { get; set; }
[JsonPropertyName("time")]
public DateTime? Time { get; set; }
[JsonPropertyName("notified")]
public bool? Notified { get; set; }
[JsonPropertyName("clearDate")]
public DateTime? ClearDate { get; set; }
[JsonPropertyName("contractId")]
public int? ContractId { get; set; }
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("key")]
public string? Key { get; set; }
[JsonPropertyName("pageCount")]
public int? PageCount { get; set; }
[JsonPropertyName("severity")]
public string? Severity { get; set; }
[JsonPropertyName("source")]
public string? Source { get; set; }
}
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceEvents
{
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("events")]
public List<DeviceEvent>? Events { get; set; }
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; set; }
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
/// <summary>Media feed dimensions of the device.</summary>
public class DeviceFeatureOutputDimensions
{
[JsonPropertyName("maxMediaFeedDimension")]
public long? MaxMediaFeedDimension { get; set; }
[JsonPropertyName("maxMediaXFeedDimension")]
public long? MaxMediaXFeedDimension { get; set; }
[JsonPropertyName("northMargin")]
public long? NorthMargin { get; set; }
}
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
/// <summary>Technical specifications of the device.</summary>
public class DeviceFeatureTecSpecs
{
[JsonPropertyName("copiesByDriver")]
public int? CopiesByDriver { get; set; }
[JsonPropertyName("externalPSGeneration")]
public int? ExternalPsGeneration { get; set; }
[JsonPropertyName("feedResolution")]
public int? FeedResolution { get; set; }
[JsonPropertyName("paperTrayFormatIds")]
public string? PaperTrayFormatIds { get; set; }
[JsonPropertyName("securePinPrinting")]
public string? SecurePinPrinting { get; set; }
[JsonPropertyName("supportedPDL")]
public string? SupportedPdl { get; set; }
[JsonPropertyName("xFeedResolution")]
public int? XFeedResolution { get; set; }
}
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceFeatures
{
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("properties")]
public List<string>? Properties { get; set; }
[JsonPropertyName("outputDimensions")]
public DeviceFeatureOutputDimensions? OutputDimensions { get; set; }
[JsonPropertyName("technicalSpecs")]
public DeviceFeatureTecSpecs? TechnicalSpecs { get; set; }
[JsonPropertyName("outputTrays")]
public int? OutputTrays { get; set; }
[JsonPropertyName("paperTrays")]
public int? PaperTrays { get; set; }
[JsonPropertyName("resolution")]
public string? Resolution { get; set; }
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceItem
{
[JsonPropertyName("supplyIndex")]
public int SupplyIndex { get; set; }
[JsonPropertyName("installationIncluded")]
public bool? InstallationIncluded { get; set; }
[JsonPropertyName("isRecommended")]
public bool? IsRecommended { get; set; }
[JsonPropertyName("isAttached")]
public bool? IsAttached { get; set; }
[JsonPropertyName("isOriginal")]
public bool? IsOriginal { get; set; }
[JsonPropertyName("item")]
public Item? Item { get; set; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceItemCreate : ItemCreateProperties
{
[JsonPropertyName("supplyIndex")]
public int SupplyIndex { get; set; }
[JsonPropertyName("installationIncluded")]
public bool? InstallationIncluded { get; set; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceMibFile
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceNotifications
{
[JsonPropertyName("alertName")]
public string? AlertName { get; set; }
[JsonPropertyName("thresholdName")]
public string? ThresholdName { get; set; }
[JsonPropertyName("thresholdAction")]
public int? ThresholdAction { get; set; }
/// <summary>Note: the API uses "tresholdReceiver" (typo) in the write schema but "thresholdReceiver" in read.</summary>
[JsonPropertyName("thresholdReceiver")]
public string? ThresholdReceiver { get; set; }
[JsonPropertyName("thresholdSubject")]
public string? ThresholdSubject { get; set; }
}
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DevicePaperTray
{
[JsonPropertyName("index")]
public int Index { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("formatId")]
public int? FormatId { get; set; }
[JsonPropertyName("formatName")]
public string? FormatName { get; set; }
[JsonPropertyName("mediaDimX")]
public int? MediaDimX { get; set; }
[JsonPropertyName("mediaDimY")]
public int? MediaDimY { get; set; }
[JsonPropertyName("mediaLevelPcnt")]
public int? MediaLevelPcnt { get; set; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DevicePaperTrays
{
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("paperTrays")]
public List<DevicePaperTray>? PaperTrays { get; set; }
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
/// <summary>SNMP configuration as returned in Device read responses.</summary>
public class DeviceSnmp
{
[JsonPropertyName("community")]
public string? Community { get; set; }
[JsonPropertyName("contextName")]
public string? ContextName { get; set; }
[JsonPropertyName("portNumber")]
public int? PortNumber { get; set; }
[JsonPropertyName("securityName")]
public string? SecurityName { get; set; }
[JsonPropertyName("version")]
public int? Version { get; set; }
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSnmpData
{
[JsonPropertyName("contact")]
public string? Contact { get; set; }
[JsonPropertyName("location")]
public string? Location { get; set; }
[JsonPropertyName("overwritable")]
public bool Overwritable { get; set; }
[JsonPropertyName("queriedOn")]
public DateTime? QueriedOn { get; set; }
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSummary
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("lastUpdate")]
public DateTime? LastUpdate { get; set; }
[JsonPropertyName("warning")]
public bool Warning { get; set; }
[JsonPropertyName("groups")]
public List<DeviceSummaryGroup>? Groups { get; set; }
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSummaryCounterElement
{
/// <summary>Type discriminator — value is 2 for counter elements.</summary>
[JsonPropertyName("type")]
public int? Type { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("value")]
public string? Value { get; set; }
}
@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSummaryGroup
{
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Each element is either a <see cref="DeviceSummarySupplyElement"/> (type=1) or a
/// <see cref="DeviceSummaryCounterElement"/> (type=2). Deserialized as generic objects;
/// check the "type" field to cast accordingly.
/// </summary>
[JsonPropertyName("elements")]
public List<object>? Elements { get; set; }
}
@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSummarySupplyElement
{
/// <summary>Type discriminator — value is 1 for supply elements.</summary>
[JsonPropertyName("type")]
public int? Type { get; set; }
[JsonPropertyName("hex")]
public string? Hex { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("subtitle")]
public string? Subtitle { get; set; }
[JsonPropertyName("warning")]
public bool? Warning { get; set; }
[JsonPropertyName("value")]
public string? Value { get; set; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSupplies
{
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("supplies")]
public List<DeviceSupply>? Supplies { get; set; }
}
@@ -0,0 +1,60 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceSupply
{
[JsonPropertyName("attachedItemId")]
public int? AttachedItemId { get; set; }
[JsonPropertyName("capacity")]
public int? Capacity { get; set; }
[JsonPropertyName("class")]
public int? Class { get; set; }
[JsonPropertyName("colorant")]
public string? Colorant { get; set; }
[JsonPropertyName("index")]
public int Index { get; set; }
[JsonPropertyName("level")]
public int? Level { get; set; }
[JsonPropertyName("levelCorrectionMode")]
public string? LevelCorrectionMode { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("orderState")]
public string? OrderState { get; set; }
[JsonPropertyName("originalPartNo")]
public string? OriginalPartNo { get; set; }
[JsonPropertyName("percent")]
public int? Percent { get; set; }
[JsonPropertyName("remainingPages")]
public int? RemainingPages { get; set; }
[JsonPropertyName("remainingDays")]
public int? RemainingDays { get; set; }
[JsonPropertyName("thresholdReached")]
public bool? ThresholdReached { get; set; }
[JsonPropertyName("timestamp")]
public DateTime? Timestamp { get; set; }
[JsonPropertyName("type")]
public int? Type { get; set; }
[JsonPropertyName("unit")]
public int? Unit { get; set; }
[JsonPropertyName("value")]
public string? Value { get; set; }
}
@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceUpdate
{
[JsonPropertyName("isActive")]
public bool? IsActive { get; set; }
[JsonPropertyName("isManaged")]
public bool? IsManaged { get; set; }
[JsonPropertyName("isLicensed")]
public bool? IsLicensed { get; set; }
[JsonPropertyName("assetNumber")]
public string? AssetNumber { get; set; }
[JsonPropertyName("cardReaderAddress")]
public string? CardReaderAddress { get; set; }
[JsonPropertyName("ciid")]
public string? Ciid { get; set; }
[JsonPropertyName("ciData")]
public DeviceCiData? CiData { get; set; }
[JsonPropertyName("costCenter")]
public string? CostCenter { get; set; }
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("follow2Print")]
public bool? Follow2Print { get; set; }
[JsonPropertyName("information")]
public List<string>? Information { get; set; }
[JsonPropertyName("inventoryNumber")]
public string? InventoryNumber { get; set; }
[JsonPropertyName("itemDeliveryAddress")]
public string? ItemDeliveryAddress { get; set; }
[JsonPropertyName("itemDeliveryNote")]
public string? ItemDeliveryNote { get; set; }
[JsonPropertyName("itemDeliveryInformation")]
public List<string>? ItemDeliveryInformation { get; set; }
[JsonPropertyName("locationDescription")]
public string? LocationDescription { get; set; }
[JsonPropertyName("locationLevels")]
public List<string>? LocationLevels { get; set; }
[JsonPropertyName("notifications")]
public DeviceNotifications? Notifications { get; set; }
[JsonPropertyName("officePrint")]
public bool? OfficePrint { get; set; }
[JsonPropertyName("productionProcess")]
public bool? ProductionProcess { get; set; }
[JsonPropertyName("securePrint")]
public bool? SecurePrint { get; set; }
[JsonPropertyName("snmp")]
public DeviceSnmp? Snmp { get; set; }
[JsonPropertyName("snmpData")]
public DeviceSnmpData? SnmpData { get; set; }
[JsonPropertyName("warranty")]
public DeviceWarranty? Warranty { get; set; }
}
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class DeviceWarranty
{
[JsonPropertyName("start")]
public DateTime? Start { get; set; }
[JsonPropertyName("months")]
public int? Months { get; set; }
[JsonPropertyName("clicks")]
public int? Clicks { get; set; }
}
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class Item : ItemCreateProperties
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("colorant")]
public string? Colorant { get; set; }
[JsonPropertyName("supplyType")]
public int? SupplyType { get; set; }
[JsonPropertyName("isColorMarker")]
public bool? IsColorMarker { get; set; }
}
@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class ItemCreateProperties
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("manufacturer")]
public string? Manufacturer { get; set; }
[JsonPropertyName("partNo")]
public string PartNo { get; set; } = string.Empty;
[JsonPropertyName("articleNo")]
public string ArticleNo { get; set; } = string.Empty;
[JsonPropertyName("originalPartNo")]
public string OriginalPartNo { get; set; } = string.Empty;
[JsonPropertyName("isKit")]
public bool? IsKit { get; set; }
[JsonPropertyName("pages")]
public int? Pages { get; set; }
[JsonPropertyName("percentage")]
public double? Percentage { get; set; }
[JsonPropertyName("price")]
public double? Price { get; set; }
[JsonPropertyName("currency")]
public string? Currency { get; set; }
[JsonPropertyName("unit")]
public string? Unit { get; set; }
}
@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class JobCreate
{
[JsonPropertyName("ipAddress")]
public string IpAddress { get; set; } = string.Empty;
[JsonPropertyName("cardId")]
public string CardId { get; set; } = string.Empty;
/// <summary>1=SmartCard, 2=PIN, 3=Fingerprint, 4=Username.</summary>
[JsonPropertyName("cardType")]
public int CardType { get; set; }
[JsonPropertyName("jobName")]
public string JobName { get; set; } = string.Empty;
[JsonPropertyName("billingCode")]
public int? BillingCode { get; set; }
/// <summary>20=Copies, 21=Scan, 22=Fax.</summary>
[JsonPropertyName("dataType")]
public int DataType { get; set; }
[JsonPropertyName("stapled")]
public bool? Stapled { get; set; }
[JsonPropertyName("punched")]
public bool? Punched { get; set; }
[JsonPropertyName("paperInfos")]
public List<JobCreatePaperInfo>? PaperInfos { get; set; }
}
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class JobCreatePaperInfo
{
[JsonPropertyName("paperTray")]
public int? PaperTray { get; set; }
[JsonPropertyName("windowsPaperSize")]
public int? WindowsPaperSize { get; set; }
[JsonPropertyName("paperWidth")]
public int? PaperWidth { get; set; }
[JsonPropertyName("paperHeight")]
public int? PaperHeight { get; set; }
[JsonPropertyName("pagesSimplexBw")]
public int PagesSimplexBw { get; set; }
[JsonPropertyName("pagesDuplexBw")]
public int PagesDuplexBw { get; set; }
[JsonPropertyName("pagesSimplexColor")]
public int PagesSimplexColor { get; set; }
[JsonPropertyName("pagesDuplexColor")]
public int PagesDuplexColor { get; set; }
}
@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class JobRead
{
[JsonPropertyName("access")]
public bool Access { get; set; }
[JsonPropertyName("balance")]
public double? Balance { get; set; }
[JsonPropertyName("billing")]
public bool? Billing { get; set; }
[JsonPropertyName("billingId")]
public int? BillingId { get; set; }
[JsonPropertyName("budget")]
public double? Budget { get; set; }
[JsonPropertyName("clickTimeout")]
public int? ClickTimeout { get; set; }
[JsonPropertyName("currency")]
public string? Currency { get; set; }
[JsonPropertyName("email")]
public string? Email { get; set; }
[JsonPropertyName("fullName")]
public string? FullName { get; set; }
[JsonPropertyName("maxTotalCount")]
public int? MaxTotalCount { get; set; }
[JsonPropertyName("maxColorCount")]
public int? MaxColorCount { get; set; }
[JsonPropertyName("private")]
public bool? Private { get; set; }
[JsonPropertyName("senseTimeout")]
public int? SenseTimeout { get; set; }
[JsonPropertyName("userId")]
public int? UserId { get; set; }
[JsonPropertyName("userName")]
public string? UserName { get; set; }
}
@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class MpsClient
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("dealerId")]
public int DealerId { get; set; }
[JsonPropertyName("clientApiUrl")]
public string? ClientApiUrl { get; set; }
[JsonPropertyName("lastTransfer")]
public DateTime? LastTransfer { get; set; }
[JsonPropertyName("lastUpdate")]
public DateTime? LastUpdate { get; set; }
[JsonPropertyName("lastUpdateQuery")]
public DateTime? LastUpdateQuery { get; set; }
}
@@ -0,0 +1,67 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class Order
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("orderNumber")]
public string? OrderNumber { get; set; }
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("supplyIndex")]
public int? SupplyIndex { get; set; }
[JsonPropertyName("quantity")]
public int? Quantity { get; set; }
[JsonPropertyName("purchaseRequisitionNumber")]
public string? PurchaseRequisitionNumber { get; set; }
[JsonPropertyName("installationIncluded")]
public bool? InstallationIncluded { get; set; }
[JsonPropertyName("note")]
public string? Note { get; set; }
[JsonPropertyName("orderDate")]
public DateTime? OrderDate { get; set; }
[JsonPropertyName("orderedBy")]
public string? OrderedBy { get; set; }
[JsonPropertyName("deliveryDate")]
public DateTime? DeliveryDate { get; set; }
[JsonPropertyName("address")]
public string? Address { get; set; }
[JsonPropertyName("deliveryNotes")]
public List<string>? DeliveryNotes { get; set; }
[JsonPropertyName("supplierInfoUrl")]
public string? SupplierInfoUrl { get; set; }
[JsonPropertyName("item")]
public Item? Item { get; set; }
/// <summary>Current order state: notAvailable, proposed, placed, confirmed, shipped, delivered, completed.</summary>
[JsonPropertyName("state")]
public string? State { get; set; }
[JsonPropertyName("requestDate")]
public DateTime? RequestDate { get; set; }
[JsonPropertyName("requestedBy")]
public string? RequestedBy { get; set; }
[JsonPropertyName("mountDate")]
public DateTime? MountDate { get; set; }
[JsonPropertyName("shipmentInfo")]
public OrderShipmentInfo? ShipmentInfo { get; set; }
}
@@ -0,0 +1,64 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class OrderCreate
{
// Required fields
[JsonPropertyName("deviceId")]
public int DeviceId { get; set; }
[JsonPropertyName("supplyIndex")]
public int SupplyIndex { get; set; }
[JsonPropertyName("quantity")]
public int Quantity { get; set; }
/// <summary>Required. Allowed values: placed, confirmed, shipped, delivered.</summary>
[JsonPropertyName("state")]
public string State { get; set; } = string.Empty;
// Item identification (at least one required)
[JsonPropertyName("itemId")]
public int? ItemId { get; set; }
[JsonPropertyName("articleNo")]
public string? ArticleNo { get; set; }
[JsonPropertyName("partNo")]
public string? PartNo { get; set; }
// Order details
[JsonPropertyName("orderNumber")]
public string? OrderNumber { get; set; }
[JsonPropertyName("purchaseRequisitionNumber")]
public string? PurchaseRequisitionNumber { get; set; }
[JsonPropertyName("installationIncluded")]
public bool? InstallationIncluded { get; set; }
[JsonPropertyName("note")]
public string? Note { get; set; }
[JsonPropertyName("orderDate")]
public DateTime? OrderDate { get; set; }
[JsonPropertyName("orderedBy")]
public string? OrderedBy { get; set; }
[JsonPropertyName("deliveryDate")]
public DateTime? DeliveryDate { get; set; }
[JsonPropertyName("address")]
public string? Address { get; set; }
[JsonPropertyName("deliveryNotes")]
public List<string>? DeliveryNotes { get; set; }
[JsonPropertyName("supplierInfoUrl")]
public string? SupplierInfoUrl { get; set; }
[JsonPropertyName("shipmentInfo")]
public OrderShipmentInfo? ShipmentInfo { get; set; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class OrderShipmentInfo
{
[JsonPropertyName("text")]
public string? Text { get; set; }
[JsonPropertyName("url")]
public string? Url { get; set; }
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class OrderUpdate
{
/// <summary>Allowed values: notAvailable, confirmed, shipped, delivered.</summary>
[JsonPropertyName("state")]
public string? State { get; set; }
[JsonPropertyName("deliveryDate")]
public DateTime? DeliveryDate { get; set; }
[JsonPropertyName("shipmentInfo")]
public OrderShipmentInfo? ShipmentInfo { get; set; }
}
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class ResourceGroups
{
[JsonPropertyName("contract")]
public Dictionary<string, string>? Contract { get; set; }
[JsonPropertyName("delivery")]
public Dictionary<string, string>? Delivery { get; set; }
[JsonPropertyName("device")]
public Dictionary<string, string>? Device { get; set; }
[JsonPropertyName("location")]
public Dictionary<string, string>? Location { get; set; }
[JsonPropertyName("system")]
public Dictionary<string, string>? System { get; set; }
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger;
public class Resources
{
[JsonPropertyName("language")]
public string? Language { get; set; }
[JsonPropertyName("groups")]
public ResourceGroups? Groups { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class ClientsResponse
{
[JsonPropertyName("clients")]
public List<MpsClient>? Clients { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class CustomerResponse
{
[JsonPropertyName("customer")]
public Customer? Customer { get; set; }
}
@@ -0,0 +1,13 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class CustomersResponse
{
[JsonPropertyName("customers")]
public List<Customer>? Customers { get; set; }
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DealerResponse
{
[JsonPropertyName("dealer")]
public Dealer? Dealer { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DealersResponse
{
[JsonPropertyName("dealers")]
public List<Dealer>? Dealers { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceCountersResponse
{
[JsonPropertyName("deviceCounters")]
public DeviceCounters? DeviceCounters { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceEventsResponse
{
[JsonPropertyName("deviceEvents")]
public DeviceEvents? DeviceEvents { get; set; }
}
@@ -0,0 +1,11 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceFeaturesResponse
{
/// <summary>Note: the API response uses the capitalized key "DeviceFeatures".</summary>
[JsonPropertyName("DeviceFeatures")]
public DeviceFeatures? DeviceFeatures { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceItemResponse
{
[JsonPropertyName("deviceItem")]
public DeviceItem? DeviceItem { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceItemsResponse
{
[JsonPropertyName("deviceItems")]
public List<DeviceItem>? DeviceItems { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DevicePaperTraysResponse
{
[JsonPropertyName("devicePaperTrays")]
public DevicePaperTrays? DevicePaperTrays { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceResponse
{
[JsonPropertyName("device")]
public Device? Device { get; set; }
}
@@ -0,0 +1,10 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DeviceSuppliesResponse
{
[JsonPropertyName("deviceSupplies")]
public DeviceSupplies? DeviceSupplies { get; set; }
}
@@ -0,0 +1,16 @@
using Centron.Api.docuFORM.Models.Swagger;
using System.Text.Json.Serialization;
namespace Centron.Api.docuFORM.Models.Swagger.Responses;
public class DevicesResponse
{
[JsonPropertyName("devices")]
public List<Device>? Devices { get; set; }
[JsonPropertyName("lastHostAddressChangeOn")]
public string? LastHostAddressChangeOn { get; set; }
[JsonPropertyName("nextPageToken")]
public string? NextPageToken { get; set; }
}

Some files were not shown because too many files have changed in this diff Show More