feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
# Agentenkommunikation — Erfassung, Ansicht, Auswertung
|
||||
|
||||
Ziel: Die **gesamte** Kommunikation zwischen Agenten wird erfasst, ist im WhatsApp-Stil
|
||||
paarweise nachlesbar und lässt sich von einem Agenten automatisiert auswerten — um zu
|
||||
finden, wo die Zusammenarbeit klemmt.
|
||||
|
||||
Abgegrenzt davon: Rocket.Chat (siehe [RocketChat-Nextcloud-Konzept](RocketChat-Nextcloud-Konzept.md))
|
||||
trägt **ausschließlich** das, was ein Mensch wissen soll. Interne Absprachen der Agenten
|
||||
gehen dort nie hin.
|
||||
|
||||
Aufbauend auf [Audit-Konzept](Audit-Konzept.md) (A3) und [Taskboard-Konzept](Taskboard-Konzept.md) (A1).
|
||||
|
||||
---
|
||||
|
||||
## 1 — Beschlüsse (August 2026)
|
||||
|
||||
| # | Beschluss |
|
||||
|---|---|
|
||||
| 1 | **Korrelation**: `ParentRunId` + `RootRunId` in Audit-Log und Receipts. Eine Delegationskette wird damit zu einer Abfrage. |
|
||||
| 2 | **Nachrichtenspeicher**: eigene Tabelle `AgentMessages`, die alle Wege gleich behandelt und beide Richtungen festhält. |
|
||||
| 3 | **Rocket.Chat bleibt außen vor** — kein Spiegeln der Agentenkommunikation dorthin. Ein Gruppenchat mit allem drin wäre unlesbar. |
|
||||
| 4 | **Ansicht**: Paar auswählen (Agent A / Agent B), Verlauf im Chat-Stil scrollen. |
|
||||
| 5 | **Auswertung**: ein Analyse-Tool, das ein dafür vorgesehener Agent bekommt. |
|
||||
|
||||
---
|
||||
|
||||
## 2 — AgentComm behalten oder durch das Taskboard ersetzen?
|
||||
|
||||
Das war die offene Frage. Der Befund zuerst, die Empfehlung danach.
|
||||
|
||||
### 2.1 Was heute passiert
|
||||
|
||||
`AgentComm.send_message` ist ein **synchroner Aufruf**: A ruft, `SendMessageAsync` startet
|
||||
`ChatAsync(B)`, wartet auf den vollständigen Lauf von B und gibt dessen Schlussnachricht
|
||||
als Tool-Ergebnis an A zurück. Drei Eigenschaften folgen daraus:
|
||||
|
||||
- **Es gibt keine Tiefenbegrenzung** (B8). A→B→A→B… läuft, bis ein Timeout greift.
|
||||
- **Es kann echt verklemmen.** Seit B2 serialisiert ein Gate je Agent alle Läufe. A hält
|
||||
sein Gate, während es auf B wartet. Ruft B nun `send_message(A)`, wartet B auf As Gate —
|
||||
das A hält, während es auf B wartet. Das löst nur der Timeout auf. Der Selbstaufruf
|
||||
A→A wurde damals abgefangen, der Zweierzyklus nicht.
|
||||
- **Der Fehler ist teuer**: Jeder Hop ist ein vollständiger, bezahlter Lauf.
|
||||
|
||||
### 2.2 Was ein Task nicht kann
|
||||
|
||||
Trotzdem ist „einfach alles über Tasks" nicht ohne Verlust. Zwei Dinge kann der
|
||||
asynchrone Weg strukturell nicht:
|
||||
|
||||
- **Antwort im selben Lauf.** Bei `send_message` kommt die Antwort als Tool-Ergebnis
|
||||
zurück, und A arbeitet damit sofort weiter. Über einen Task endet As Lauf; die Antwort
|
||||
kommt später als neuer Weckvorgang, und A muss seinen Gedankengang neu aufnehmen. Für
|
||||
eine Rückfrage sind das **zwei Läufe statt einem** — der asynchrone Weg ist hier also
|
||||
nicht nur langsamer, sondern *teurer*.
|
||||
- **Antwortzeit.** Der Scanner tickt im Minutentakt. Eine Rückfrage „hast du die Datei
|
||||
schon abgelegt?" braucht damit im Mittel eine halbe Minute plus den Lauf des anderen.
|
||||
|
||||
### 2.3 Empfehlung: nach Zweck trennen, nicht beides parallel führen
|
||||
|
||||
Der Fehler in der jetzigen Lage ist nicht, dass es zwei Mechanismen gibt — es ist, dass
|
||||
**beide dasselbe können**. Ein Agent kann Arbeit sowohl per Task delegieren als auch per
|
||||
`send_message` „mal eben" abschieben, und der zweite Weg ist der gefährliche.
|
||||
|
||||
Vorschlag:
|
||||
|
||||
> **Delegation gehört ausschließlich ins Taskboard.** `AgentComm` verliert diese Rolle
|
||||
> vollständig — kein „mach du mal", kein Auftrag, kein Arbeitspaket.
|
||||
>
|
||||
> **Die kurze Rückfrage bleibt**, aber als eigenes, eng gefasstes Werkzeug: `ask_agent`.
|
||||
|
||||
`ask_agent` mit harten Grenzen:
|
||||
|
||||
| Grenze | Begründung |
|
||||
|---|---|
|
||||
| **Tiefe 1** — wer gerade eine Rückfrage *beantwortet*, darf selbst keine stellen | Beendet die Rekursion an der Wurzel. Prüfbar, sobald `ParentRunId` steht (Punkt 1) — die Synergie ist der Grund, warum das jetzt fast umsonst zu haben ist |
|
||||
| **Zyklusprüfung** — Ziel darf nicht im aktuellen Aufrufpfad liegen | Schließt den Deadlock aus 2.1 aus, statt auf den Timeout zu hoffen |
|
||||
| **Kurzer Timeout + kleines Schrittbudget** (z. B. 60 s, 5 Schritte) | Eine Rückfrage, die fünf Schritte braucht, war keine Rückfrage, sondern ein Auftrag |
|
||||
| **Eigene Beschreibung im Prompt**: „für kurze Fragen an einen Kollegen, nicht um Arbeit abzugeben" | Der häufigste Missbrauch ist der falsche Griff, nicht die böse Absicht |
|
||||
|
||||
Damit gibt es weiterhin zwei Wege, aber sie überschneiden sich nicht mehr: Der eine ist
|
||||
ein Auftrag (dauerhaft, nachvollziehbar, mit Abnahme), der andere eine Frage
|
||||
(flüchtig, sofort, begrenzt).
|
||||
|
||||
**Die Gegenposition, fairerweise:** Man kann `AgentComm` auch ersatzlos streichen und die
|
||||
Rückfrage über einen Task mit hoher Priorität abbilden. Das wäre die konsequentere
|
||||
Umsetzung des „alles ist ein Task"-Prinzips und spart ein Tool. Der Preis sind die zwei
|
||||
Läufe je Rückfrage und die Minute Wartezeit. **Meine Empfehlung ist die Trennung**, weil
|
||||
Rückfragen im Mehr-Agenten-Betrieb häufig sind und der Aufpreis sich dann summiert — aber
|
||||
das ist eine Abwägung, keine technische Notwendigkeit.
|
||||
|
||||
`AgentSpawn` geht in beiden Varianten im Taskboard auf (`assignee: @new:<agent>` ist genau
|
||||
das) und wird zurückgebaut.
|
||||
|
||||
---
|
||||
|
||||
## 3 — Korrelation: `ParentRunId` und `RootRunId`
|
||||
|
||||
Heute erzeugt jeder Lauf eine frische `runId`; der Lauf des Empfängers weiß nichts vom
|
||||
Lauf des Absenders. Eine Kette A→B→C ist deshalb nur über Zeitstempel zu erraten.
|
||||
|
||||
Zwei Felder auf `AuditEntry` und `RunReceipt`:
|
||||
|
||||
- **`ParentRunId`** — der Lauf, aus dem dieser hervorging. `null` bei einem Lauf, den ein
|
||||
Mensch oder der Scanner auslöst.
|
||||
- **`RootRunId`** — die Wurzel der Kette. Das ist faktisch die **Vorgangs-Id**: Alles, was
|
||||
aus einer Anweisung entstand, trägt denselben Wert.
|
||||
|
||||
Regeln:
|
||||
|
||||
- **Die Engine stempelt.** Wie beim Audit gilt: Herkunft wird nie vom Agenten behauptet.
|
||||
- Ein Lauf ohne Vorgänger ist seine eigene Wurzel (`RootRunId = RunId`).
|
||||
- Weitergereicht wird über die Aufrufstellen, an denen ein Lauf einen anderen auslöst:
|
||||
`ask_agent`, Task-Dispatch, Staging-Folgetask.
|
||||
- **Migration**: Bestandszeilen bekommen `RootRunId = RunId` und `ParentRunId = NULL`.
|
||||
Das ist nicht rückwirkend korrekt, aber ehrlich — alte Ketten bleiben unbekannt, statt
|
||||
falsch zusammengesetzt zu werden.
|
||||
|
||||
Der Nutzen reicht über die Analyse hinaus: Was eine Delegationskette insgesamt gekostet
|
||||
hat, ist danach ein `SUM` über `RunReceipts` gruppiert nach `RootRunId`. Damit fällt ein
|
||||
Teil von C7 nebenbei ab.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Der Nachrichtenspeicher
|
||||
|
||||
### 4.1 Tabelle
|
||||
|
||||
Neue Tabelle in der Instanz-DB, neben `AuditLog` und `RunReceipts`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS AgentMessages (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
PairKey TEXT NOT NULL, -- sortiertes Paar: "agentA|agentB"
|
||||
FromAgentId TEXT NOT NULL,
|
||||
ToAgentId TEXT NOT NULL,
|
||||
Channel TEXT NOT NULL, -- ask | task | task_comment | task_result
|
||||
Direction TEXT NOT NULL, -- request | response
|
||||
Content TEXT NOT NULL, -- vollständig, nach Scrubbing
|
||||
RunId TEXT NOT NULL,
|
||||
ParentRunId TEXT,
|
||||
RootRunId TEXT NOT NULL,
|
||||
TaskId TEXT,
|
||||
Status TEXT NOT NULL, -- delivered | failed | timeout | denied
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IX_AgentMessages_Pair ON AgentMessages(PairKey, OccurredAt);
|
||||
CREATE INDEX IX_AgentMessages_Root ON AgentMessages(RootRunId, OccurredAt);
|
||||
```
|
||||
|
||||
Dazu ein FTS5-Index auf `Content` — dieselbe Technik wie beim geplanten Historien-Umzug
|
||||
(K6), damit die Suche in der Ansicht und im Analyse-Tool nicht über `LIKE` läuft.
|
||||
|
||||
### 4.2 Die Entscheidungen dahinter
|
||||
|
||||
**`PairKey` als sortiertes Paar.** Die geforderte Ansicht („A und B auswählen, scrollen")
|
||||
wird damit zu `WHERE PairKey = ? ORDER BY OccurredAt` — eine Abfrage auf einem Index,
|
||||
unabhängig davon, wer gerade wen anspricht.
|
||||
|
||||
**Beide Richtungen als eigene Zeilen.** Eine Rückfrage erzeugt zwei Zeilen (`request`
|
||||
A→B, `response` B→A) mit derselben `RootRunId`. Nur so entsteht ein Verlauf, der sich wie
|
||||
ein Chat liest. Das Audit-Log kann das nicht leisten: Es speichert nur `Arguments`, die
|
||||
Antwort landet dort nirgends.
|
||||
|
||||
**Inhalt ungekappt.** Das Audit kappt bei 4.000 Zeichen — richtig, denn es dient der
|
||||
Nachvollziehbarkeit. Für die Auswertung braucht es den vollen Text. Gekappt wird erst
|
||||
dort, wo Text in einen LLM-Kontext zurückfließt (Abschnitt 6).
|
||||
|
||||
**Alle Wege in einer Tabelle.** `ask_agent`, Task-Delegation, `task_comment` und das
|
||||
Ergebnis eines Tasks landen im selben Format. Sonst müsste die Auswertung drei Quellen
|
||||
zusammensuchen — und genau daran scheitert sie heute.
|
||||
|
||||
**Fan-out statt Sammelzeile.** Eine Nachricht an mehrere Empfänger wird zu mehreren
|
||||
Zeilen. Etwas redundant, dafür bleibt jede Zeile paarweise auswertbar.
|
||||
|
||||
### 4.3 Wer schreibt
|
||||
|
||||
Ein `IAgentMessageLog` im Core, aufgerufen an genau den Stellen, an denen eine Nachricht
|
||||
eine Agentengrenze überschreitet:
|
||||
|
||||
| Aufrufstelle | Zeilen |
|
||||
|---|---|
|
||||
| `AgentEngine` — `ask_agent` | `request` beim Absenden, `response` beim Rückgabewert (auch bei Fehler/Timeout, mit passendem `Status`) |
|
||||
| Taskboard — `task_create` mit fremdem Assignee | `request` |
|
||||
| Taskboard — `task_comment` | `request` bzw. `response`, je nach Richtung |
|
||||
| Taskboard — Task abgeschlossen/geblockt | `response` mit Ergebnis oder Blocker-Grund |
|
||||
|
||||
Drei bis vier Stellen, alle im Core. Kein Tool schreibt selbst — sonst könnte ein Agent
|
||||
seine eigene Kommunikationsakte färben.
|
||||
|
||||
**Fehlschläge werden mitgeschrieben.** Eine nicht zugestellte Nachricht ist für die
|
||||
Analyse wertvoller als eine erfolgreiche.
|
||||
|
||||
### 4.4 Was hier nicht hineingehört
|
||||
|
||||
- **Mensch↔Agent-Chat.** Das ist die Chat-Historie, ein anderer Gegenstand mit anderem
|
||||
Umzugsplan (K6). *Ausnahme mit gutem Preis-Leistungs-Verhältnis:* Tasks mit
|
||||
`assignee: @human` durchlaufen dieselben Aufrufstellen — man kann sie als Paar
|
||||
(Agent, `@human`) mitschreiben und bekommt die Ansicht dafür geschenkt. Vorschlag: ja,
|
||||
aber als Nachzügler, nicht als Teil der ersten Fassung.
|
||||
- **Tool-Aufrufe.** Die stehen im Audit-Log und gehören nicht in einen Gesprächsverlauf.
|
||||
- **Rocket.Chat-Nachrichten.** Anderer Gegenstand, andere Vertrauensgrenze.
|
||||
|
||||
### 4.5 Zwei Pflichten
|
||||
|
||||
- **Output-Scrubbing vor dem Schreiben.** Nachrichteninhalte enthalten Tool-Ergebnisse.
|
||||
Ohne Maskierung bekannter Geheimnisse wird dieser Speicher zur zweiten Fundstelle für
|
||||
Zugangsdaten — und über den MySQL-Spiegel (A6) verlässt er sogar die Maschine. Der
|
||||
Roadmap-Punkt „Output-Scrubbing" ist damit **Voraussetzung**, nicht Beiwerk.
|
||||
- **Aufbewahrung.** Die Tabelle wächst unbegrenzt. Ein Instanz-Wert
|
||||
(`agentMessageRetentionDays`, 0 = unbegrenzt) plus ein Aufräum-Task gehören von Anfang
|
||||
an dazu, nicht erst, wenn die DB groß ist.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Die Ansicht
|
||||
|
||||
Neuer Reiter im Hauptfenster: **Agentenkommunikation**.
|
||||
|
||||
**Bedienung**: zwei Auswahlfelder (Agent A, Agent B) — dazu „alle" für einen Agenten, um
|
||||
zu sehen, mit wem er überhaupt spricht. Zeitraum, Kanalfilter, Freitextsuche.
|
||||
|
||||
**Darstellung**: Chat-Stil, A rechts, B links, Zeitstempel, Tagestrenner. Jede Blase
|
||||
trägt eine kleine Kennzeichnung des Kanals (`Rückfrage` / `Auftrag` / `Kommentar` /
|
||||
`Ergebnis`) und, wo vorhanden, die anklickbare Task-Id.
|
||||
|
||||
**Technisch**: über den vorhandenen **WebView2**-Unterbau, wie ihn `frm_chat` schon nutzt
|
||||
(inkl. Virtual-Host-Mapping auf einen lokalen Ordner). Der Verlauf wird als HTML
|
||||
gerendert. Handgezeichnete Sprechblasen in WinForms wären ein Vielfaches an Aufwand für
|
||||
ein schlechteres Ergebnis.
|
||||
|
||||
**Paging**: die jüngsten ~200 Nachrichten, „ältere laden" nach oben. Ein Paar mit 50.000
|
||||
Zeilen darf die Oberfläche nicht am Start blockieren.
|
||||
|
||||
**Der eigentliche Mehrwert** liegt über dem flachen Verlauf: Ein Klick auf eine Nachricht
|
||||
zeigt die ganze Kette zu ihrer `RootRunId` — also den Vorgang von der auslösenden
|
||||
Anweisung bis zum letzten Beitrag, über alle beteiligten Agenten hinweg, mit den Kosten
|
||||
aus den Receipts. Das ist die Ansicht, die die Frage „warum hat das drei Stunden und
|
||||
vier Dollar gekostet" tatsächlich beantwortet.
|
||||
|
||||
---
|
||||
|
||||
## 6 — Automatisierte Auswertung
|
||||
|
||||
Ein Tool `AgentCommAnalysis`, das **bewusst nur einem dafür vorgesehenen Agenten**
|
||||
zugewiesen wird (die Zuweisung je Agent gibt es ohnehin).
|
||||
|
||||
| Aktion | Zweck |
|
||||
|---|---|
|
||||
| `list_pairs` | Wer spricht mit wem, wie oft, seit wann — der Einstieg |
|
||||
| `stats` | Kennzahlen je Paar/Kanal/Zeitraum (siehe unten) |
|
||||
| `read_conversation` | Verlauf eines Paares, gekappt und seitenweise |
|
||||
| `chain` | Ein kompletter Vorgang über `RootRunId`, inkl. Kosten |
|
||||
| `search` | Volltext über FTS5 |
|
||||
|
||||
**Fragen, die das beantworten soll** — sie sind der Grund für den Schnitt des Schemas:
|
||||
|
||||
- Wie viele Delegationen führen zu einem Ergebnis, und wie viele versanden?
|
||||
- Wie tief werden Ketten, und ab welcher Tiefe steigt die Fehlerquote?
|
||||
- Welche Paare stellen sich wiederholt dieselbe Rückfrage? (Ein Hinweis auf unklare
|
||||
Zuständigkeit oder einen fehlenden Skill — genau die Art Problem, die man sucht.)
|
||||
- Was kostet ein Vorgang von der Anweisung bis zum Ergebnis?
|
||||
- Wo häufen sich `failed`/`timeout`?
|
||||
|
||||
**Drei Sicherungen**, weil ein Agent hier fremde Kommunikation liest:
|
||||
|
||||
1. Ergebnisse werden als `<untrusted_content>` gerahmt. Der Inhalt stammt aus anderen
|
||||
Läufen und kann Anweisungen enthalten — auch ohne böse Absicht.
|
||||
2. Standardmäßig liefert das Tool **Kennzahlen**, Rohtext nur auf ausdrückliche Anfrage
|
||||
und mit harter Obergrenze. Ein unbedachtes „lies mir alles vor" ist sonst ein
|
||||
Kontext-Überlauf mit Rechnung.
|
||||
3. Das Tool ist **lesend**. Es gibt keine Schreibaktion.
|
||||
|
||||
---
|
||||
|
||||
## 7 — Verhältnis zu Rocket.Chat
|
||||
|
||||
Klargestellt, weil es die vorherige Überlegung ablöst:
|
||||
|
||||
- Agentenkommunikation wird **nicht** nach Rocket.Chat gespiegelt. Ein Raum, in dem jede
|
||||
interne Absprache mitläuft, ist nach einer Woche unlesbar und verdeckt genau das, was
|
||||
man sehen soll.
|
||||
- Nach Rocket.Chat geht nur, was ein Mensch wissen soll oder muss — über den
|
||||
`ChannelRouter` bzw. eine bewusste Handlung des Agenten.
|
||||
- Wer den internen Verlauf sehen will, nimmt die Ansicht aus Abschnitt 5. Die ist dafür
|
||||
gebaut; ein Gruppenchat ist es nicht.
|
||||
|
||||
---
|
||||
|
||||
## 8 — Schnitt
|
||||
|
||||
| Phase | Inhalt | Abhängigkeit |
|
||||
|---|---|---|
|
||||
| **1** | `ParentRunId` + `RootRunId` in `AuditLog`/`RunReceipts`, Migration, Stempelung in der Engine | — |
|
||||
| **2** | `AgentMessages` + FTS5 + `IAgentMessageLog`, Schreiben an den Aufrufstellen | 1 |
|
||||
| **3** | `ask_agent` (Tiefe 1, Zyklusprüfung), Rückbau von `AgentComm`/`AgentSpawn` | 1 — die Tiefe kommt aus der Kette |
|
||||
| **4** | Ansicht (WebView2-Reiter) | 2 |
|
||||
| **5** | `AgentCommAnalysis`-Tool | 2 |
|
||||
| **—** | Output-Scrubbing | **vor** 2 |
|
||||
|
||||
Phase 1 zuerst, weil Phase 3 die Kette braucht und Phase 2 die Felder mitschreibt. Das
|
||||
Scrubbing muss vor Phase 2 stehen — sonst legen wir einen Speicher an, der erst
|
||||
nachträglich bereinigt werden müsste.
|
||||
|
||||
Zur Einstufung: Phasen 1, 2, 4 und 5 sind gut spezifizierbar. Phase 3 fasst das
|
||||
Agent-Gate an, an dem schon einmal ein Deadlock lauerte (B2/B8) — dafür gehören die Tests
|
||||
aus der [Teststrategie](Teststrategie.md) mit dazu, insbesondere der dort vorgesehene,
|
||||
bis heute fehlende Fall **A13** (`A→B→A` wird begrenzt statt zu verklemmen).
|
||||
|
||||
---
|
||||
|
||||
## 9 — Offene Punkte
|
||||
|
||||
1. **`ask_agent` behalten oder ersatzlos streichen?** Meine Empfehlung steht in 2.3
|
||||
(behalten, eng gefasst) — die Gegenposition ist dort ebenfalls notiert.
|
||||
2. **`@human`-Tasks mitschreiben?** Gibt die Paar-Ansicht auch für Mensch↔Agent, fast
|
||||
ohne Zusatzaufwand. Vorschlag: ja, aber nach Phase 4.
|
||||
3. **Aufbewahrungsdauer** — Vorgabewert? (Vorschlag: unbegrenzt, bis der Spiegel aus A6
|
||||
steht; dann 180 Tage lokal.)
|
||||
4. **Wer bekommt `AgentCommAnalysis`?** Ein eigener Analyse-Agent oder ein bestehender?
|
||||
@@ -0,0 +1,91 @@
|
||||
# Audit-Log & Receipts — Nachvollziehbarkeit
|
||||
|
||||
Setzt A3 aus der [Roadmap](Roadmap.md) um (F-A2). Zwei zusammengehörige Dinge:
|
||||
|
||||
- **Audit-Log** — ein Eintrag je Tool-Aufruf: wer, wann, welches Tool, mit welchem
|
||||
Ausgang.
|
||||
- **Receipts** — ein Abschluss-Beleg je Lauf: Ergebnis, Schritte, Tokens, Kosten,
|
||||
verknüpft mit dem Task.
|
||||
|
||||
A3 ist das Fundament für A2 (jede Staging-Entscheidung wird als Datensatz verankert)
|
||||
und für C7 („Kosten pro Ergebnis", fällt aus den Receipts ab). Die Tool-Fehlerquote aus
|
||||
der Leistungsanalyse liest sich direkt aus dem Log.
|
||||
|
||||
## Warum eigene Tabellen, nicht der State-Store
|
||||
|
||||
Dieselbe Überlegung wie bei Gedächtnis und Taskboard: `IStateStore` ist Schlüssel-Wert.
|
||||
Ein Log, das man nach Lauf, Task oder Tool filtern und dessen Fehlerquote man auswerten
|
||||
will, braucht typisierte Spalten. Zwei Tabellen auf dem vorhandenen
|
||||
[`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs): `AuditLog` und
|
||||
`RunReceipts`.
|
||||
|
||||
## Provenienz — von der Engine gestempelt, nie vom Agenten behauptet
|
||||
|
||||
Die entscheidende Regel (aus dem OpenAlice-Provenance-Konzept):
|
||||
|
||||
- **Herkunft stempelt die Engine.** `AgentId`, `Model` und `Source` kommen aus dem
|
||||
Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Ein Tool kann seine
|
||||
Herkunft nicht fälschen, weil es sie gar nicht schreibt.
|
||||
- **Einträge sind unveränderlich.** Das Repository hat kein Update und kein Delete —
|
||||
eine Korrektur ist ein neuer Eintrag. Das ist die eigentliche Zusage, keine fehlende
|
||||
Funktion.
|
||||
- **Unbekanntes wird als unbekannt markiert, nicht geraten.** Fehlt die Quelle, steht
|
||||
`unknown`, nicht ein plausibel geratener Kanal.
|
||||
- **Worker-Typ und Session sind getrennt.** `Model` (das ausführende Modell) und
|
||||
`Source` (die verantwortliche Session: `webview`, `telegram`, `task`, `agentcomm`,
|
||||
`job`, `direct`) sind verschiedene Begriffe und stehen in eigenen Spalten.
|
||||
|
||||
## Audit-Log
|
||||
|
||||
Gestempelt an genau einer Stelle: `AgentEngine.ExecuteToolCallAsync` — dort, wo jeder
|
||||
Tool-Aufruf durchläuft. Je Aufruf ein Eintrag mit Ausgang:
|
||||
|
||||
| Status | Wann |
|
||||
|---|---|
|
||||
| `Ok` | Tool lief und lieferte ein Ergebnis |
|
||||
| `Error` | Tool meldete einen Fehler oder warf |
|
||||
| `Denied` | das `PermissionGate` hat abgelehnt |
|
||||
| `NotFound` | Tool dem Agenten nicht zugewiesen/unbekannt |
|
||||
|
||||
Ein Abbruch (Cancellation) wird **nicht** protokolliert — der Aufruf kam nicht zum
|
||||
Abschluss. Die Argumente werden roh, aber gekappt abgelegt (4 000 Zeichen); die
|
||||
Ausgangsnotiz kurz (500).
|
||||
|
||||
**Best effort:** Ein Fehler beim Schreiben des Audits darf den Lauf nie scheitern
|
||||
lassen — dieselbe Linie wie bei der Verbrauchserfassung. Der Eintrag wird geschrieben,
|
||||
nachdem die eigentliche Arbeit getan ist.
|
||||
|
||||
## Receipts
|
||||
|
||||
Je Lauf ein Beleg, geschrieben beim Abschluss von `RunAsync`/`ChatAsync` (neben der
|
||||
vorhandenen `RunUsage`-Erfassung). Er trägt Status, Schritte, Prompt-/Completion-/
|
||||
Cached-Tokens, geschätzte Kosten (aus dem `ModelPricingCatalog`, mit
|
||||
`CostIsKnown`-Flag) und einen kurzen Ergebnis-Verweis.
|
||||
|
||||
**Verknüpfung `RunUsage` ↔ Task:** Der Receipt trägt die `TaskId`, wenn der Lauf aus dem
|
||||
Taskboard kam — der `EngineTaskDispatcher` reicht sie (samt `source: task`) durch. Damit
|
||||
ist „Kosten pro Ergebnis" (C7) ein Abfallprodukt: `ListReceiptsForTaskAsync` liefert
|
||||
alle Belege zu einem Task.
|
||||
|
||||
## RunId — die Klammer
|
||||
|
||||
Jeder Lauf bekommt zu Beginn eine `RunId` (GUID). Alle Audit-Einträge **und** der
|
||||
Receipt eines Laufs tragen sie. So lässt sich ein Lauf lückenlos rekonstruieren:
|
||||
`ListForRunAsync(runId)` gibt die Aufrufe in Reihenfolge, `GetReceiptForRunAsync(runId)`
|
||||
den Abschluss.
|
||||
|
||||
## Verdrahtung
|
||||
|
||||
`IAuditRepository` ist optional (wie Gedächtnis und Taskboard): ohne Repo läuft die
|
||||
Engine unverändert. In `Program.cs` wird ein `SqliteAuditRepository` auf der Instanz-DB
|
||||
erzeugt und der Engine übergeben.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Output-Scrubbing** — die `Arguments` können Secrets enthalten. Das zentrale
|
||||
Maskieren bekannter Secret-Werte (eigener beschlossener Roadmap-Punkt) greift, sobald
|
||||
es steht; der Andockpunkt (`ExecuteToolCallAsync`) ist derselbe.
|
||||
- **Review-Oberfläche** — die Anzeige/Durchsicht des Logs und der Receipts gehört zu A2
|
||||
(Staging-Review im Hauptfenster); die Abfragemethoden dafür stehen bereit.
|
||||
- **Export** — ein JSONL-Export des Logs wäre für externe Auswertung nützlich (später,
|
||||
passt zum A6-Spiegel).
|
||||
@@ -0,0 +1,186 @@
|
||||
# Avalonia-Portierung — Leitfaden
|
||||
|
||||
Für alle, die weitere Ansichten von WinForms nach Avalonia übertragen.
|
||||
Stand: 2026-08-07.
|
||||
|
||||
---
|
||||
|
||||
## 1. Auftrag
|
||||
|
||||
Drei Bereiche des Hauptfensters sind noch Platzhalter. In dieser Reihenfolge portieren —
|
||||
sie steigen im Umfang, und jede baut auf dem Muster der vorigen auf:
|
||||
|
||||
| # | Bereich | WinForms-Vorlage | Daten aus |
|
||||
|---|---|---|---|
|
||||
| 1 | **Info** | `frm_main.Designer.cs`, Suchwort `tabPage_Info` | `AppHost.AppVersion`, `AppHost.BuildSummary`, `host.Instance` |
|
||||
| 2 | **Sicherung** | `UI/BackupPanel.cs` + `UI/BackupPanel.Designer.cs` | `host.InstancePath`, `host.Settings`, `Core.Backup.BackupService` |
|
||||
| 3 | **Aufgaben** (Jobs/Services/Verlauf) | `frm_main.cs`, Abschnitt `WORKER TAB` ab Zeile 932 | `host.Instance.Agents`, `App.Services.JobHistoryService` |
|
||||
|
||||
**Nicht anfassen:** Chat und Einstellungen. Beide sind Entwurfsarbeit, nicht Übersetzung,
|
||||
und werden gesondert gemacht.
|
||||
|
||||
Die WinForms-Dateien liegen noch im Repository, sind aber **nicht mehr Teil des Builds**
|
||||
(siehe Kommentar in `ClawdDotNet.slnx`). Sie sind Vorlage zum Lesen — nicht zum Kompilieren,
|
||||
nicht zum Reparieren.
|
||||
|
||||
---
|
||||
|
||||
## 2. Drei Regeln, die nicht verletzt werden dürfen
|
||||
|
||||
### 2.1 Der Schichtschnitt
|
||||
|
||||
```
|
||||
src/ClawdDotNet.App ← Fachlogik. KEIN Verweis auf Avalonia. Niemals.
|
||||
src/ClawdDotNet.Desktop ← Oberfläche. Darf App und Core verwenden.
|
||||
```
|
||||
|
||||
`ClawdDotNet.App` muss ohne Fenster laufen — darauf setzt der geplante systemd-Dienst auf.
|
||||
Sobald dort ein `using Avalonia…` steht, ist der Schnitt kaputt und fällt erst Wochen
|
||||
später auf.
|
||||
|
||||
**Faustregel:** Alles, was Dateien liest, rechnet oder mit der Engine spricht, gehört nach
|
||||
`App`. Alles, was etwas anzeigt, nach `Desktop`.
|
||||
|
||||
### 2.2 Fäden
|
||||
|
||||
Ereignisse aus `AgentEngine`, `TaskScanner`, `OpenRouterStatusService` und
|
||||
`BackupScheduler` kommen auf **Hintergrundfäden**. Eine `ObservableCollection` von dort aus
|
||||
zu ändern wirft entweder oder beschädigt still die Anzeige.
|
||||
|
||||
```csharp
|
||||
// Aus einem Ereignis der Fachschicht heraus:
|
||||
Dispatcher.UIThread.Post(() => Lines.Add(neu));
|
||||
|
||||
// Wenn ein Rückgabewert gebraucht wird:
|
||||
await Dispatcher.UIThread.InvokeAsync(() => …);
|
||||
```
|
||||
|
||||
Ein `DispatcherTimer` läuft dagegen bereits auf dem Oberflächenfaden — dort ist kein
|
||||
Wechsel nötig (siehe `LogPageViewModel`).
|
||||
|
||||
### 2.3 Avalonia **12**, nicht 11
|
||||
|
||||
Praktisch alle Anleitungen im Netz sind für Avalonia 11 und lassen sich hier nicht
|
||||
übernehmen. Bekannte Unterschiede:
|
||||
|
||||
- `BindingPlugins` ist nicht mehr öffentlich. Das übliche
|
||||
`DisableAvaloniaDataAnnotationValidation()` aus den 11er-Vorlagen **entfällt ersatzlos** —
|
||||
nicht nachbauen.
|
||||
- `ShutdownMode` voll qualifizieren: `Avalonia.Controls.ShutdownMode`.
|
||||
|
||||
Diese Fehler brechen den Build. Das ist gut — sie fallen sofort auf.
|
||||
|
||||
---
|
||||
|
||||
## 3. Das Muster
|
||||
|
||||
Der Logs-Bereich ist als vollständiges Beispiel gebaut. Drei Dateien, drei Aufgaben:
|
||||
|
||||
**`src/ClawdDotNet.App/Services/LogTail.cs`** — die Fachlogik. Liest Dateien, kennt keine
|
||||
Oberfläche, wäre ohne Fenster lauffähig.
|
||||
|
||||
**`src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs`** — das Ansichtsmodell. Erbt von
|
||||
`PageViewModel`, hält Zustand und Befehle. Kennt keine Steuerelemente.
|
||||
|
||||
**`src/ClawdDotNet.Desktop/Views/LogPageView.axaml`** — die Ansicht. Nur Aufbau und
|
||||
Bindungen.
|
||||
|
||||
### Ein neuer Bereich in vier Schritten
|
||||
|
||||
**1.** Ansichtsmodell anlegen, von `PageViewModel` erbend:
|
||||
|
||||
```csharp
|
||||
public sealed partial class InfoPageViewModel : PageViewModel
|
||||
{
|
||||
public InfoPageViewModel(AppHost? host) : base("Info") { … }
|
||||
}
|
||||
```
|
||||
|
||||
`AppHost?` ist **nullbar** — der Entwurfsmodus des Editors erzeugt das Ansichtsmodell ohne
|
||||
laufenden Aufbau. Bei `null` einfach nichts starten und Beispielwerte zeigen.
|
||||
|
||||
**2.** Ansicht anlegen: `Views/InfoPageView.axaml` + `.axaml.cs`. Der Name muss der
|
||||
Konvention folgen — `ViewLocator` sucht `…ViewModels.FooViewModel` → `…Views.FooView`.
|
||||
Passt der Name nicht, steht der gesuchte Typ im Fenster statt der Ansicht.
|
||||
|
||||
**3.** In `MainWindowViewModel` den Platzhalter ersetzen:
|
||||
|
||||
```csharp
|
||||
new PlaceholderPageViewModel("Info", "…") // vorher
|
||||
new InfoPageViewModel(host) // nachher
|
||||
```
|
||||
|
||||
**4.** `x:DataType` in der AXAML setzen. Ohne das greifen die kompilierten Bindungen nicht
|
||||
und Tippfehler in Bindungspfaden fallen erst zur Laufzeit auf.
|
||||
|
||||
### Werkzeugkasten
|
||||
|
||||
- Zustand: `[ObservableProperty] private string _text = "";` → erzeugt `Text` samt
|
||||
Benachrichtigung.
|
||||
- Befehle: `[RelayCommand] private void Speichern() { … }` → bindbar als
|
||||
`SpeichernCommand`.
|
||||
- Formatierung gehört in `Styles/Shell.axaml`, nicht an einzelne Steuerelemente.
|
||||
Vorhandene Klassen: `heading`, `caption`, `toolbar`, `card`, `statusbar`.
|
||||
- Ordner öffnen: `Core.Storage.SystemShell.OpenFolder(pfad)`. **Kein** `explorer.exe`.
|
||||
- Dateinamen erzeugen: `Core.Storage.PortableFileName.Sanitize(name)`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Prüfliste für die leisen Fehler
|
||||
|
||||
Diese Klasse bricht weder den Build noch die Tests. Vor jeder Abgabe durchgehen:
|
||||
|
||||
- [ ] **Fenster-Schließen behandelt?** Wartet der Code auf eine Antwort aus einem Fenster
|
||||
(`TaskCompletionSource`), muss `window.Closed` als Abbruch gelten. Sonst hängt der
|
||||
Ablauf lautlos für immer.
|
||||
- [ ] **Sammlungen nur vom Oberflächenfaden geändert?** Siehe 2.2.
|
||||
- [ ] **Wächst etwas unbegrenzt?** Listen, die im Betrieb volllaufen, brauchen eine
|
||||
Obergrenze (`LogPageViewModel.MaxLines = 2000` als Vorbild).
|
||||
- [ ] **Timer beendet?** `DispatcherTimer` in einem Ansichtsmodell läuft weiter, auch wenn
|
||||
der Bereich nicht sichtbar ist. Bei teuren Abfragen anhalten.
|
||||
- [ ] **Farben aus dem Thema?** Keine festen Farbwerte — die Anwendung läuft hell und
|
||||
dunkel. `{DynamicResource …}` verwenden.
|
||||
- [ ] **Keine relativen Pfade.** `./Backups` und Ähnliches hängt vom Arbeitsverzeichnis ab
|
||||
und zeigt unter Linux ins Leere. `AppPaths.DataDirectory` verwenden.
|
||||
- [ ] **Kein `MessageBox`, kein `System.Windows.Forms`, kein `System.Drawing`.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Abnahme
|
||||
|
||||
```bash
|
||||
dotnet build ClawdDotNet.slnx
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj
|
||||
```
|
||||
|
||||
Beide müssen fehlerfrei sein — 567 Tests, keine neuen Fehlschläge.
|
||||
|
||||
**Und dann tatsächlich starten.** Die Oberfläche hat keine Testabdeckung; die Fehler aus
|
||||
Abschnitt 4 fallen ausschließlich beim Laufen auf.
|
||||
|
||||
```bash
|
||||
dotnet run --project src/ClawdDotNet.Desktop
|
||||
```
|
||||
|
||||
Hinweis: Ein Starttest hinterlässt unter Windows einen Prozess, der die `.exe` sperrt und
|
||||
den nächsten Build mit `MSB3021` scheitern lässt. Aufräumen mit:
|
||||
|
||||
```bash
|
||||
powershell -Command "Get-Process ClawdDotNet -EA SilentlyContinue | Stop-Process -Force"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Wenn etwas unklar ist
|
||||
|
||||
Lieber nachfragen als raten. Zwei Dinge sind besonders leicht falsch zu machen:
|
||||
|
||||
- **Was gehört in welche Schicht?** Im Zweifel nach `App` — von dort kann die Oberfläche
|
||||
es holen, umgekehrt nicht.
|
||||
- **Wie kommen Daten aus der Engine in die Ansicht?** `AppHost` gibt `Engine`, `Scanner`,
|
||||
`Staging`, `Status` und `Usage` heraus; alle sind **nullbar**, wenn kein
|
||||
OpenRouter-Schlüssel hinterlegt ist. Diesen Fall mitdenken — die Anwendung läuft dann
|
||||
bewusst ohne Agenten.
|
||||
@@ -523,6 +523,11 @@ Siehe K3.
|
||||
|
||||
## 6. Vorgeschlagene Reihenfolge
|
||||
|
||||
> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte
|
||||
> werden dort weitergeführt; dieser Abschnitt bleibt als Stand der Bestandsaufnahme
|
||||
> eingefroren. F-A1/S4, F-A2, F-A5, T6, T7 sowie B6–B8 sind in den Roadmap-Vorhaben
|
||||
> A1–A4 aufgegangen.
|
||||
|
||||
**Sofort — es blockiert oder gefährdet den Betrieb**
|
||||
1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben
|
||||
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
|
||||
@@ -535,9 +540,9 @@ Siehe K3.
|
||||
6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`)
|
||||
7. ~~T2 Tool-Ergebnisse kappen (= B5)~~ ✅ umgesetzt
|
||||
8. ~~T3 Günstiges Compaction-Modell~~ ✅ umgesetzt
|
||||
9. ~~B4 Kostenerfassung korrigieren~~ ✅ teilweise: Prompt/Completion werden jetzt
|
||||
getrennt erfasst statt 50/50 geschätzt. Offen bleibt die veraltete, hartcodierte
|
||||
Preistabelle (`ModelPricing`) — Preise sollten vom `/models`-Endpoint kommen.
|
||||
9. ~~B4 Kostenerfassung korrigieren~~ ✅ vollständig: Prompt/Completion getrennt
|
||||
erfasst, Preise kommen live vom `/models`-Endpunkt (`ModelPricingCatalog`),
|
||||
Modelle ohne Preisdaten werden sichtbar gemeldet statt still mit 0 gerechnet.
|
||||
10. ~~B12 Retry/Backoff~~ ✅ umgesetzt
|
||||
11. T4 Proaktiv statt reaktiv kompaktieren
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# Deploymentcenter-Anbindung — Durchsicht
|
||||
|
||||
> **Nachtrag 2026-08-08 — die Anbindung ist umgestellt, Server und SDK stehen auf 2.1.**
|
||||
> Abschnitt 4 und 5 sind abgearbeitet; wie es jetzt aussieht, steht in
|
||||
> [Deploymentcenter-Integration](Deploymentcenter-Integration.md).
|
||||
>
|
||||
> Mit **SDK 2.1 erledigt** (waren Befunde aus Abschnitt 3 bzw. aus der Durchsicht der
|
||||
> 2.0-Anbindung):
|
||||
>
|
||||
> - `HttpClient` ohne Zeitgrenze → intern 15 s. Unsere Umgehung (eigener Client mit
|
||||
> 8 s) ist zurückgebaut.
|
||||
> - HTTP 429/5xx entzogen die Lizenz, ohne den Zwischenspeicher zu befragen → jeder
|
||||
> Nicht-Erfolg führt jetzt in denselben Offline-Zweig, `IsTransient` macht den
|
||||
> Unterschied sichtbar. Unsere Behelfsprüfung auf `unknown_error` ist entfernt.
|
||||
> - `cache_ttl_hours` wurde ignoriert, die Gnadenfrist war faktisch unbegrenzt.
|
||||
> - `app_version` fest `"1.0.0"` → kommt jetzt aus `ReleaseInfo.Version`.
|
||||
> - `BuildInfo.targets` war nicht einbindbar (CS0433/CS0103) → erzeugt die Klasse im
|
||||
> eigenen Namensraum, ist eingebunden.
|
||||
> - `UpdateClient`: API-Zweig las snake_case in ein camelCase-Modell → eigenes Modell
|
||||
> `ApiReleaseInfo`, `is_critical` von der obersten Ebene.
|
||||
> - `DeactivateAsync` schickte den Shared Key zusätzlich als `X-Watchdog-Key`.
|
||||
> - Kein `CancellationToken` in der Lizenz-API.
|
||||
>
|
||||
> **Weiterhin offen** — betrifft das Deploymentcenter, nicht ClawdDotNet:
|
||||
>
|
||||
> - **2.1 (keine Signaturprüfung)** — unverändert. `LicenseInfo.PublicKeyBase64` ist
|
||||
> gestrichen, damit nichts Totes stehenbleibt und niemand Schutz vermutet, wo keiner
|
||||
> ist. Kommt die Signatur, kommt das Feld mit ihr zurück.
|
||||
> - **2.2 (v1-Ersatzhash)** und **2.3 (Klartext-Rückfall)** — unverändert, beides im
|
||||
> SDK zu beheben.
|
||||
> - **3 (HW-ID bei jedem Aufruf neu)** — clientseitig umgangen: einmal berechnet und
|
||||
> behalten.
|
||||
> - **`parent_source` ist nur eine `source`, kein Paar** — damit schließen sich „ein
|
||||
> Monitor je Instanz" und instanzweise Alarmunterdrückung gegenseitig aus.
|
||||
|
||||
Stand: 2026-08-06. Geprüft: `J:\Softwareprojekte\Deploymentcenter` (Client, Server,
|
||||
Schema, beide Integrationsleitfäden) gegen den
|
||||
[HW-ID-v2-Vorschlag](Lizenz-HardwareId-v2-Implementierungsvorschlag.md) und die
|
||||
[Linux-Analyse](Linux-Portierung-Analyse.md).
|
||||
|
||||
**Ergebnis vorweg: Die Lizenz blockiert den Linux-Umzug nicht mehr.** Alles, was
|
||||
an Hardware-ID v2 plattformrelevant war, ist da und richtig. Was hier steht, sind
|
||||
Punkte aus derselben Durchsicht — drei davon würden beim Ausrollen wehtun.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was erledigt ist
|
||||
|
||||
| Punkt aus dem Vorschlag | Umsetzung |
|
||||
|---|---|
|
||||
| Format `2:<plattform>:<hex>` | [HardwareId.cs:125](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs) |
|
||||
| **Kein `MachineName` im Hash** | `ComputeV2Hash`, `:138` — der wichtigste Punkt, sauber umgesetzt |
|
||||
| Quellenkette Windows/Linux | `:42–107`, inklusive `dmi-uuid` |
|
||||
| `IsPlausibleMachineId` (Länge, `uninitialized`, nur Nullen) | `:161` |
|
||||
| MAC-Filter über locally-administered-Bit | `:224` |
|
||||
| `/sys/class/net/<name>/device`-Prüfung | `:228` |
|
||||
| Erweiterte Stoppwortliste | `:23` — inkl. `br-`, `virbr`, `cni`, `cali` |
|
||||
| `machine.key` mit `0600` | `:290`, `SetUnixPermissions` mit `#if NET8_0_OR_GREATER` |
|
||||
| Vorgabe per Umgebungsvariable | `LicenseConfig.HardwareIdOverride`, beide Namen |
|
||||
| XDG-Auflösungskette, nie leerer Pfad | [LicenseConfig.cs:28](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs), mit `ValidateNonEmpty` |
|
||||
| Mehrfachziel `netstandard2.0;net8.0` | csproj, BouncyCastle nur im netstandard-Zweig |
|
||||
| `LLS2`-Hülle, AES-GCM, HKDF | [StateStore.cs:169](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) — Schlüssel aus HW-ID abgeleitet, bindet den Cache also echt an die Maschine |
|
||||
| `ILicensePrompt` + Konsolenfassung | vorhanden — genau das, was der kopflose Host braucht |
|
||||
| Servermigration v1→v2 | [LicenseService.php:108](../../Deploymentcenter/src/Modules/License/LicenseService.php), mit Prüfprotokolleintrag `hwid_migrated` |
|
||||
| Schema `hwid_version`/`hwid_source`/`platform` | `sql/migrations/v2_hardware_id.sql`, rückwärtskompatibel |
|
||||
| Verwaltungsansicht zeigt Quelle/Plattform | `public/index.php:1227` |
|
||||
|
||||
`OperatingSystemHelpers` nutzt jetzt `RuntimeInformation`. Der Client hat auf dem
|
||||
Linux-Pfad keine Windows-Laufzeitabhängigkeit — `ProtectedData` wird nur unter
|
||||
`IsWindows()` aufgerufen.
|
||||
|
||||
**Für die Portierung heißt das:** Punkt 4 aus der Entscheidungsliste der
|
||||
Linux-Analyse („Erlaubt LicenseLabrador den Wechsel der Hardware-ID?") ist
|
||||
beantwortet. Der Aufwandsblock „Lizenz" schrumpft von 3–5 PT auf **2–3 PT** —
|
||||
das ist jetzt reine Anschlussarbeit in ClawdDotNet, keine Konzeptarbeit mehr.
|
||||
|
||||
---
|
||||
|
||||
## 2. Drei Befunde, die vor dem Ausrollen geklärt sein sollten
|
||||
|
||||
### 2.1 Es wird nichts signiert — die Lizenzprüfung ist eine Vertrauensfrage an DNS
|
||||
|
||||
[LicenseClient.cs:62](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs):
|
||||
|
||||
```csharp
|
||||
string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
|
||||
if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// → gültig
|
||||
}
|
||||
```
|
||||
|
||||
Das ist die vollständige Prüfung. Es gibt im neuen Client **kein `Signature.cs`,
|
||||
keinen hinterlegten öffentlichen Schlüssel, keine Hüllenprüfung** — die Dateien
|
||||
`Signature.cs`, `LicenseResult.cs` und `LicenseState.cs` aus dem alten
|
||||
LicenseLabrador-SDK sind beim Umzug nicht mitgekommen.
|
||||
|
||||
Folge: Wer die HTTP-Anfrage umlenken kann, hat eine gültige Lizenz. Ein Eintrag
|
||||
in `/etc/hosts`, ein Proxy, ein eigener DNS — die Antwort `{"status":"valid"}`
|
||||
genügt. Auf einem Linux-Server, den der Betreiber ohnehin vollständig
|
||||
kontrolliert, ist das kein Kunststück.
|
||||
|
||||
Serverseitig sieht es passend dazu aus. `public/index.php:45`:
|
||||
|
||||
```php
|
||||
'signature' => 'ED25519_SIG_' . base64_encode(hash('sha256', $lic['license_key'] . 'DC_OFFLINE_SECRET', true))
|
||||
```
|
||||
|
||||
Das ist ein SHA-256 über den Lizenzschlüssel plus eine fest verdrahtete
|
||||
Zeichenkette — keine Signatur, sondern ein Wert, der jeder erzeugen kann, der den
|
||||
Quelltext kennt. Und `public/index.php:1993` im JavaScript:
|
||||
|
||||
```javascript
|
||||
"ED25519_SIG_" + btoa(key + hwId).substring(0, 32)
|
||||
```
|
||||
|
||||
Base64 der Eingabe, abgeschnitten. Auch kein Hash.
|
||||
|
||||
Das ist erkennbar ein Platzhalter — nur trägt er einen Namen, der nach fertigem
|
||||
Verfahren klingt, und darauf verlässt sich [LicenseGate](Services/LicenseGate.cs)
|
||||
mit seiner harten Startsperre. **Es ist keine Portierungsfrage** (unter Windows
|
||||
gilt heute dasselbe) und auch kein Grund, den Linux-Umzug aufzuhalten — aber es
|
||||
sollte eine bewusste Entscheidung sein und nicht in dem Glauben untergehen, die
|
||||
Signaturprüfung sei bereits da.
|
||||
|
||||
Wenn das Verfahren zurückkommen soll: Ed25519 über die kanonisch serialisierte
|
||||
Antwort, öffentlicher Schlüssel im Client einkompiliert, `nonce` aus der Anfrage
|
||||
in der signierten Nutzlast gegenprüfen (gegen Wiedereinspielung). Der alte
|
||||
`Signer.php` und `Signature.cs` sind im LicenseLabrador-Repo noch vorhanden und
|
||||
lassen sich als Vorlage nehmen.
|
||||
|
||||
### 2.2 Der v1-Ersatzhash trifft die alten Aktivierungen nicht
|
||||
|
||||
Der Migrationsweg ist auf beiden Seiten korrekt gebaut — er wird nur nie
|
||||
auslösen, weil der Client eine andere v1-ID berechnet als die, die in der
|
||||
Datenbank steht.
|
||||
|
||||
Alt ([LicenseLabrador/HardwareId.cs:20](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)):
|
||||
|
||||
```csharp
|
||||
rawBuilder.Append(machineId); // MachineGuid, sonst MAC
|
||||
rawBuilder.Append(Environment.MachineName); // direkt angehängt, kein Trenner
|
||||
→ sha256(machineGuid + machineName)
|
||||
```
|
||||
|
||||
Neu ([HardwareId.cs:149](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs)):
|
||||
|
||||
```csharp
|
||||
string raw = $"{Environment.MachineName}:{firstMac}";
|
||||
→ sha256(machineName + ":" + mac)
|
||||
```
|
||||
|
||||
Andere Reihenfolge, anderer Trenner, und **MAC statt MachineGuid**. Auf jedem
|
||||
Windows-Rechner, auf dem `MachineGuid` lesbar war — also praktisch allen —
|
||||
stimmen die Hashes nicht überein. Der Server sucht die Altaktivierung, findet
|
||||
nichts und legt eine neue an: **genau der Platzverbrauch, den die Migration
|
||||
verhindern sollte.** Bei `max_activations = 2` ist danach ein Platz für den
|
||||
Linux-Server weniger da.
|
||||
|
||||
Auch `GetFirstPhysicalMacLegacy` (`:254`) weicht ab: keine Stoppwortfilterung,
|
||||
keine Sortierung, erste Schnittstelle in Aufzählungsreihenfolge. Die alte
|
||||
Fassung nahm die alphabetisch erste *gefilterte* MAC.
|
||||
|
||||
Zu tun: `GetLegacyHardwareId()` muss den v1-Algorithmus zeichengenau
|
||||
nachbilden — inklusive der alten Stichwortliste (`virtual`, `veth`, `docker`,
|
||||
`hyper-v`, `wsl`, `mullvad`, `wireguard`, `tap`, `tun`, `vpn`, `bluetooth`,
|
||||
`vmware`, `box`, `pseudo`, `loopback`, `npcap`, `pcap`), `OrderBy(…, Ordinal)`
|
||||
und `FirstOrDefault()`. Der Code steht im LicenseLabrador-Repo noch da und kann
|
||||
weitgehend übernommen werden.
|
||||
|
||||
Am besten mit einem Test absichern, der einen bekannten Eingabewert gegen den
|
||||
erwarteten v1-Hash prüft — sonst fällt eine Abweichung erst auf, wenn die
|
||||
Aktivierungsplätze schon verbraucht sind.
|
||||
|
||||
### 2.3 Der Klartext-Rückfall ist noch da, nur woanders
|
||||
|
||||
[StateStore.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) —
|
||||
„Legacy Migration Check":
|
||||
|
||||
```csharp
|
||||
string legacyJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
|
||||
if (legacyData != null)
|
||||
{
|
||||
legacyData.SchemaVersion = 2;
|
||||
Save(productSlug, hardwareId, legacyData);
|
||||
return legacyData;
|
||||
}
|
||||
```
|
||||
|
||||
Der LLS2-Zweig darüber ist genau richtig — Entschlüsselung fehlgeschlagen heißt
|
||||
Cache-Fehltreffer, kein Klartext. Der Zweig darunter hebt das wieder auf: Jede
|
||||
Datei ohne `LLS2`-Kennung wird als JSON gelesen und, wenn sie sich deserialisieren
|
||||
lässt, **übernommen und anschließend verschlüsselt neu geschrieben**.
|
||||
|
||||
Durchgespielt: Eine von Hand angelegte `state.dat` mit
|
||||
|
||||
```json
|
||||
{"SchemaVersion":2,"Status":"valid","ExpiresAt":99999999999,"MaxSeenTime":0}
|
||||
```
|
||||
|
||||
wird angenommen. In `ValidateAsync` greift bei fehlender Verbindung der
|
||||
Cache-Zweig (`:110`): `Status == "valid"` ✓, `now < MaxSeenTime` ✗, `now >
|
||||
ExpiresAt` ✗ → **`IsValid = true`**. Die Bindung an die Hardware, die
|
||||
`DeriveKey(hardwareId, …)` sonst herstellt, ist auf diesem Weg umgangen; die
|
||||
Datei ist zwischen Maschinen übertragbar.
|
||||
|
||||
Der Zweig hilft dabei nicht einmal beim eigentlichen Zweck. Die alte
|
||||
`LocalCacheData` hieß `last_envelope`, `max_seen_time`, `endpoints`,
|
||||
`last_license_key`; die neue `SchemaVersion`, `Status`, `ExpiresAt`, … Kein
|
||||
gemeinsames Feld, und `JsonSerializer` ist ohne
|
||||
`PropertyNameCaseInsensitive`/`JsonPropertyName` bei den Namen streng. Eine echte
|
||||
v1-Datei ergibt also ein Objekt mit lauter Vorgabewerten (`Status = "invalid"`)
|
||||
und ist als Cache wertlos.
|
||||
|
||||
**Empfehlung: den Zweig ersatzlos streichen.** Er kostet Sicherheit und leistet
|
||||
nichts. Alte Cachedateien sollen verworfen werden — eine einmalige
|
||||
Online-Prüfung ist der ganze Preis.
|
||||
|
||||
Nebenbei: `Checksum = hwInfo.HardwareId` ([LicenseClient.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs))
|
||||
ist keine Prüfsumme, sondern eine Kopie der HW-ID. Das Feld ist damit ohne
|
||||
Funktion — entweder mit einem HMAC über die übrigen Felder füllen oder entfernen,
|
||||
damit niemand später Schutz vermutet, wo keiner ist.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kleinere Punkte
|
||||
|
||||
| Fundstelle | Sache |
|
||||
|---|---|
|
||||
| [LicenseClient.cs:26](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs) | Eigener `HttpClient` je Instanz, nie freigegeben, **ohne Zeitgrenze** (Vorgabe 100 s). Der alte `LicenseConfig.HttpTimeout` war 6 s. In `LicenseGate.RunStartupCheck` bedeutet das bis zu 100 s Standbild beim Start, wenn der Server nicht antwortet. |
|
||||
| `:107` | `catch (Exception ex)` um den gesamten Block: Auch ein Fehler beim Auswerten einer *erfolgreichen* Antwort landet im Offline-Zweig. Ein defekter Server gilt dann als „offline". |
|
||||
| `:47` | `app_version = "1.0.0"` fest verdrahtet. ClawdDotNet hat `BuildInfo.Build` — sollte Parameter sein, sonst steht in der Verwaltungsansicht bei jeder Instanz dasselbe. |
|
||||
| `:32`, `:172` | `HardwareId.GetHardwareId()` bei jedem Aufruf neu: liest unter Linux Dateien und zählt Netzwerkschnittstellen auf. Einmal berechnen und halten. |
|
||||
| `HardwareId.cs:205` | MAC-Auswahl überspringt Schnittstellen, die nicht `Up` oder `Unknown` sind. Ein Kabel, das beim Start nicht steckt, ändert damit die Hardware-ID. Für die Ausweichlösung sollte der Betriebszustand keine Rolle spielen — sonst ist sie genau in dem Moment instabil, in dem sie gebraucht wird. |
|
||||
| `HardwareId.cs:231` | `/sys/class/net/<name>/device` ist ein Symlink. `Directory.Exists`/`File.Exists` folgen ihm — funktioniert, ist aber Zufall und sollte kommentiert sein. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Watchdog: die Anbindung passt noch nicht
|
||||
|
||||
Kein Linux-Thema, fällt aber in dieselbe Umbauarbeit.
|
||||
|
||||
[WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) sendet an:
|
||||
|
||||
| ClawdDotNet | Deploymentcenter |
|
||||
|---|---|
|
||||
| `POST /api/heartbeat` | `POST /api/watchdog/v1/ping` (nimmt auch `/heartbeat`) |
|
||||
| `POST /api/event` | `POST /api/watchdog/v1/event` |
|
||||
| `POST /api/register` | **existiert nicht** |
|
||||
|
||||
Die Pfade sind also alle um `/watchdog/v1` zu ergänzen. Der Kopfzeilenname passt:
|
||||
`public/api/watchdog/v1/index.php:30` akzeptiert `X-Watchdog-Key`,
|
||||
`Authorization` und `X-Agent-Token`.
|
||||
|
||||
Der Selbstregistrierungsweg aus [Program.cs:334](Program.cs:334) — mit dem
|
||||
Master-Token einen eigenen Agent-Token holen und in der Instanzkonfiguration
|
||||
zwischenspeichern — hat serverseitig kein Gegenstück mehr. Zu klären: Tokens
|
||||
künftig von Hand in der Verwaltung anlegen und in die Instanzkonfiguration
|
||||
eintragen, oder `/register` im Deploymentcenter nachziehen. Für den ersten Weg
|
||||
spricht, dass er den Master-Token gar nicht erst auf die Instanzen verteilt.
|
||||
|
||||
Die Feldnamen des Ping-Rumpfs (`source`, `instance`, `type`, `status`, `message`,
|
||||
`interval`, `group`, `os`) sind gegen
|
||||
[InstanceHealthProvider](src/ClawdDotNet.Core/Watchdog/InstanceHealthProvider.cs)
|
||||
abzugleichen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Was in ClawdDotNet zu tun ist
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| [ClawdDotNet.csproj](ClawdDotNet.csproj) | Projektverweis von `..\LicenseLabrador\client-dotnet\…` auf `..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\…` umhängen. Langfristig als Submodul unter `external/` — der Kommentar dazu steht schon im csproj. |
|
||||
| [Services/LicenseGate.cs](Services/LicenseGate.cs) | Neu gegen `LicenseValidationResult` schreiben. `LicenseState` gibt es nicht mehr, `Status` ist jetzt eine Zeichenkette — `DescribeProblem` (`:125`) muss auf `revoked`/`expired`/`activation_limit`/`not_found` umgestellt werden. `MessageBox` durch `ILicensePrompt` ersetzen; die Konsolenfassung bringt der Client mit. |
|
||||
| [Services/LicenseInfo.cs](Services/LicenseInfo.cs) | `PublicKeyBase64` hat ohne Signaturprüfung keine Funktion mehr — entweder mit 2.1 zurückholen oder streichen, damit nichts Totes stehenbleibt. |
|
||||
| [Program.cs:112](Program.cs:112) | Lizenzprüfung so verlagern, dass sie ohne Fenster auskommt (kopfloser Host). |
|
||||
| Host (neu) | `--license-status`, `--license-set-key`, `--license-deactivate` — der Client bringt alles Nötige mit. |
|
||||
| [WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) | Pfade auf `/api/watchdog/v1/…`; Registrierungsweg klären (Abschnitt 4). |
|
||||
| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Abgelöst durch [Deploymentcenter-Integration](Deploymentcenter-Integration.md). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Antwort auf die Ausgangsfrage
|
||||
|
||||
**Ja — Avalonia und Linux sind damit machbar.** Die einzige Frage, die ich als
|
||||
möglicher Blocker außerhalb unserer Hand markiert hatte, ist geklärt: Der Client
|
||||
läuft auf beiden Plattformen, zielt auf `net8.0` (von net10.0 problemlos
|
||||
verwendbar), löst seinen Ablageort auch ohne `HOME` auf, und die HW-ID ist
|
||||
container- und umbenennungsfest.
|
||||
|
||||
Der Lizenzblock in der Aufwandsschätzung fällt von 3–5 PT auf **2–3 PT**. Die
|
||||
Gesamtspanne bleibt bei **50–80 PT**, weil die Lizenz nie der große Posten war —
|
||||
das sind PropertyGrid und Chat-Ansicht.
|
||||
|
||||
Zwei Dinge sollten aber vor dem Ausrollen erledigt sein, unabhängig von Linux:
|
||||
|
||||
- **2.2 (v1-Ersatzhash)** — klein, aber wenn es beim Ausrollen falsch ist, sind
|
||||
Aktivierungsplätze verbraucht und man bekommt sie nur einzeln über die
|
||||
Verwaltung zurück. Das ist der Punkt mit dem schlechtesten Verhältnis von
|
||||
Aufwand zu Schaden.
|
||||
- **2.3 (Klartext-Rückfall)** — eine Zeile weniger Code, dafür wieder das
|
||||
Verhalten, das der `LLS2`-Umbau eigentlich herstellen sollte.
|
||||
|
||||
**2.1 (keine Signaturprüfung)** ist eine eigene Entscheidung mit eigenem Umfang
|
||||
und hält den Umzug nicht auf. Sie sollte nur getroffen und nicht übersehen
|
||||
werden — der Name `ED25519_SIG_` im Serverquelltext legt sonst nahe, dass die
|
||||
Sache erledigt sei.
|
||||
@@ -0,0 +1,338 @@
|
||||
# Deploymentcenter-Integration
|
||||
|
||||
Stand: 2026-08-08, Deploymentcenter **2.1**. Ersetzt den früheren
|
||||
`Integrationsplan-WatchDog-LicenseLabrador.md`.
|
||||
|
||||
ClawdDotNet spricht das [Deploymentcenter](../../Deploymentcenter/docs/README.md) als
|
||||
**eine** Gegenstelle an. Vorher waren es zwei Fremdprojekte mit je eigenem Server,
|
||||
eigenem Schlüssel und eigener Anleitung:
|
||||
|
||||
| Vorher | Jetzt |
|
||||
|---|---|
|
||||
| WatchDog (`watchdog.mhdf.de`, `X-Watchdog-Key`) | Deploymentcenter-Modul Watchdog, `Authorization: Bearer` |
|
||||
| LicenseLabrador (`license.mhdf.de`, Ed25519-Public-Key) | Deploymentcenter-Modul Lizenz |
|
||||
| — | Update-Prüfung |
|
||||
| — | Fehler-Stream (ungefangene Ausnahmen) |
|
||||
| — | Bugtracker |
|
||||
|
||||
Eine Adresse, ein Token. Beides steht in den Anwendungseinstellungen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was der Betreiber einzutragen hat
|
||||
|
||||
| Ort | Wert |
|
||||
|---|---|
|
||||
| Einstellungen → Deploymentcenter → **Server-URL** | `https://dc.mhdf.de` (Vorgabe) |
|
||||
| Einstellungen → Deploymentcenter → **Token** | Master-Token mit `watchdog:ping` + `bugtracker:report` |
|
||||
| Einstellungen → Lizenz → **Lizenzschlüssel** | Der Schlüssel für das Projekt `clawddotnet` |
|
||||
| Worker-Tab → Dienst **Instanz-Watchdog** | einschalten, greift beim nächsten Start der Instanz |
|
||||
|
||||
Das Token entsteht im WebUI unter **Token-Verwaltung → Master-Token erstellen**. Es
|
||||
wird verschlüsselt (DPAPI) in `Settings.json` abgelegt.
|
||||
|
||||
> **Bis die Avalonia-Einstellungsansicht steht**, gibt es für diese Felder noch keine
|
||||
> Oberfläche — die Seite „Einstellungen" ist ein Platzhalter. Die Werte kommen
|
||||
> vorläufig von Hand in `Settings.json` (Ort steht beim Start im Protokoll:
|
||||
> `%APPDATA%\ClawdDotNet\Settings.json`, unter Linux `$XDG_CONFIG_HOME`) bzw. in die
|
||||
> `instance.json` der Instanz. Das Token wird beim ersten Speichern durch die Anwendung
|
||||
> verschlüsselt; im Klartext eingetragen funktioniert es ebenfalls, weil der
|
||||
> `SecretProtector` beide Richtungen verträgt.
|
||||
|
||||
**Serverseitig ist eine Sache Pflicht**, sonst ist die Überwachung wertlos: der
|
||||
Evaluator-Cron. Ohne ihn ändert sich ein Monitor-Zustand nur beim Eintreffen eines
|
||||
Heartbeats — eine abgestürzte Instanz bliebe dauerhaft grün.
|
||||
|
||||
```bash
|
||||
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Watchdog — ein Monitor je Instanz
|
||||
|
||||
Das war die Vorgabe und ist jetzt sauber abgedeckt: Der Server führt Monitore über das
|
||||
Paar `source` + `instance` (`UNIQUE KEY uq_monitor (source, instance)` in
|
||||
`sql/schema.sql`). Alle Instanzen melden unter `source = "clawddotnet"` und tragen ihre
|
||||
eigene `instance`. Fällt eine von dreien aus, fällt genau deren Monitor — und nur der
|
||||
schlägt Alarm.
|
||||
|
||||
`instance` ist standardmäßig die `InstanceId` (stabil, aber im Dashboard nichtssagend).
|
||||
In den Instanz-Einstellungen lässt sich stattdessen ein Name eintragen
|
||||
([`WatchdogConfig.Instance`](../src/ClawdDotNet.Core/Config/WatchdogConfig.cs)); ein
|
||||
späterer Wechsel legt allerdings einen neuen Monitor an.
|
||||
|
||||
**Eine Registrierung vorab gibt es nicht mehr.** Der Monitor entsteht beim ersten
|
||||
Heartbeat von selbst (`INSERT … ON DUPLICATE KEY UPDATE`). Der frühere Weg über
|
||||
`POST /api/register` hatte im Deploymentcenter nie ein Gegenstück — die alte Anbindung
|
||||
lief in dieser Form also gegen einen Endpunkt, den es nicht gibt.
|
||||
|
||||
### Was der Heartbeat trägt
|
||||
|
||||
```
|
||||
POST /api/watchdog/v1/ping
|
||||
Authorization: Bearer <Instanz-Token>
|
||||
```
|
||||
|
||||
| Feld | Inhalt |
|
||||
|---|---|
|
||||
| `source` / `instance` | `clawddotnet` / InstanceId bzw. eingestellter Name |
|
||||
| `status` | `ok`, `warning`, `error` — beim Beenden `stopped` |
|
||||
| `interval` | 60 s (Vorgabe). Daraus leitet der Evaluator ab: 2× → `warning`, 4× → `down` |
|
||||
| `message` | Instanzname + Kurzbegründung |
|
||||
| `os` | Betriebssystem + .NET-Version |
|
||||
| `version` | Produktversion (2.1). Landet in `watchdog_monitors.app_version` — bei mehreren Instanzen der Unterschied zwischen „läuft" und „läuft noch auf der alten Fassung" |
|
||||
| `checks` | `agents`, `scheduler`, `budget` — siehe unten |
|
||||
| `metrics` | `agentCount`, `runningChats`, `todayCostUsd`, `todayTokens` |
|
||||
|
||||
### `checks` — der eigentliche Gewinn
|
||||
|
||||
Ein Heartbeat beweist nur, dass ein Faden läuft. Deshalb geht der selbst ermittelte
|
||||
Zustand je Teilbereich mit; schlägt eine Prüfung fehl, stuft der Server einen als `ok`
|
||||
gemeldeten Beat auf `warning` herab und nennt in der Antwort die betroffene.
|
||||
|
||||
| Prüfung | Fehlschlag bedeutet |
|
||||
|---|---|
|
||||
| `agents` | Kein OpenRouter-Key — die Instanz läuft, arbeitet aber nichts ab |
|
||||
| `scheduler` | Die Taktschleife des Aufgaben-Scanners ist ausgestiegen |
|
||||
| `budget` | Tagesgrenze für Kosten oder Token erreicht |
|
||||
|
||||
„Scanner noch nicht gestartet" gilt **nicht** als Fehlschlag: Er läuft erst nach der
|
||||
Startabgleichung an, der erste Heartbeat geht sofort raus. Sonst gäbe es bei jedem
|
||||
Start ein `warning_raised` und kurz darauf ein `recovered` — zwei Einträge im
|
||||
Ereignisprotokoll für einen Normalvorgang.
|
||||
|
||||
### Metriken sind nur Zahlen
|
||||
|
||||
Der Server legt numerische Werte mit Zeitstempel ab (14 Tage) und vergleicht den
|
||||
aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische Werte
|
||||
verwirft er dabei stillschweigend — `instanceId`, `instanceName` und `buildVersion`
|
||||
standen früher in den Metriken und waren dort wirkungslos. Beschreibendes steht jetzt
|
||||
in `message` und `os`.
|
||||
|
||||
### Angekündigtes Ende
|
||||
|
||||
Beim Herunterfahren geht ein Heartbeat mit `status: "stopped"` raus, danach das Ereignis
|
||||
`stopped_graceful`. Der Evaluator lässt einen so gemeldeten Monitor in Ruhe. Ohne das
|
||||
erzeugte jedes geplante Beenden wenige Minuten später einen Fehlalarm.
|
||||
|
||||
Nebenbei korrigiert: Die alte Anbindung schickte die Ereignisarten `start` und `stop` —
|
||||
beide stehen nicht auf der Liste des Servers und landeten stillschweigend als `started`.
|
||||
Jetzt sind es `started` und `stopped_graceful`.
|
||||
|
||||
### Token je Instanz
|
||||
|
||||
Beim ersten Start tauscht die Instanz das anwendungsweite Token über
|
||||
`POST /api/tokens/v1/provision` gegen ein eigenes, auf `watchdog:ping` und
|
||||
`bugtracker:report` beschränktes Sub-Token und legt es verschlüsselt in der
|
||||
Instanzkonfiguration ab. Danach liegt auf der Instanz nicht mehr das Master-Token, und
|
||||
ein einzelner Zugang lässt sich widerrufen, ohne die anderen mitzunehmen.
|
||||
|
||||
Das ist derselbe Zweck, den die frühere Selbstregistrierung hatte. Scheitert es (etwa
|
||||
weil das hinterlegte Token selbst ein Sub-Token ist und keine weiteren ausstellen darf),
|
||||
wird mit dem hinterlegten Token gemeldet — Monitoring, das nur bei perfekter Rechtelage
|
||||
läuft, ist genau dann still, wenn man es braucht.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lizenz
|
||||
|
||||
Startprüfung in [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs). Die
|
||||
Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung einen mit
|
||||
AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an (`LLS2`,
|
||||
seit 2.1 Schema 3) und trägt damit über Ausfälle hinweg.
|
||||
|
||||
### Urteil und Fehlversuch sind zwei verschiedene Dinge
|
||||
|
||||
Das ist der Kern der 2.1-Anpassung. `LicenseValidationResult.IsTransient` unterscheidet:
|
||||
|
||||
| | Statuswerte | Folge |
|
||||
|---|---|---|
|
||||
| **Urteil des Servers** | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Anwendung startet nicht bzw. beendet sich |
|
||||
| **Kein Urteil erhalten** | `server_unavailable`, `cache_expired` | Warnung, Betrieb läuft weiter |
|
||||
|
||||
Nur das Urteil sperrt. Ein Serverausfall darf nicht jede Installation gleichzeitig
|
||||
aussperren — und eine Drosselung (`429`) oder ein `500` sind Aussagen über den Server,
|
||||
nicht über die Lizenz. Das gilt an beiden Stellen gleich: Startprüfung und laufende
|
||||
Nachprüfung fragen dasselbe Merkmal ab.
|
||||
|
||||
> **Bewusst in Kauf genommen:** Ein Rechner, der die Gegenstelle nie erreicht, läuft
|
||||
> damit auf Dauer mit Warnung weiter — auch nach Ablauf der Gnadenfrist
|
||||
> (`cache_expired` ist laut SDK-Vertrag vorübergehend). Wer das anders will, prüft in
|
||||
> [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs) zusätzlich auf
|
||||
> `cache_expired` und behandelt es als Urteil. Es sollte eine Entscheidung sein, nicht
|
||||
> ein Nebeneffekt.
|
||||
|
||||
### Offline-Gnadenfrist ist echt begrenzt
|
||||
|
||||
Seit 2.1 wertet der Client `cache_ttl_hours` des Projekts aus (Vorgabe 168 h). Vorher
|
||||
galt faktisch das Ablaufdatum der Lizenz — bei einer Lizenz bis 2040 also unbegrenzt.
|
||||
Der verbleibende Rest steht in `CacheExpiresAt` und wird beim Start angezeigt, wenn die
|
||||
Prüfung aus dem Zwischenspeicher kam.
|
||||
|
||||
`state.dat` steigt auf Schema 3; Schema 2 wird weiter gelesen. Ein Rückschritt auf ein
|
||||
älteres SDK verwirft den Zwischenspeicher — dann ist einmal eine Online-Prüfung nötig.
|
||||
|
||||
### Kein Public-Key mehr
|
||||
|
||||
Die frühere Fassung führte einen Ed25519-Public-Key als „Vertrauensanker". Im
|
||||
Deploymentcenter gibt es dazu keine Gegenseite — der Client liest ausschließlich das Feld
|
||||
`status`. Ein Schlüssel, der nichts prüft, ist schlimmer als keiner: Er lässt Schutz
|
||||
vermuten, wo keiner ist. Details in
|
||||
[Deploymentcenter-Anbindung-Review](Deploymentcenter-Anbindung-Review.md), Abschnitt 2.1.
|
||||
|
||||
### Der Projekt-Slug ist `clawddotnet`
|
||||
|
||||
[`LicenseInfo.ProductSlug`](../src/ClawdDotNet.App/Services/LicenseInfo.cs) gilt für
|
||||
**alle** Module — Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf
|
||||
dieselbe Tabelle `dc_projects` zu.
|
||||
|
||||
Bis zur Umstellung stand hier `clawd`, der Name aus dem LicenseLabrador-Backend. Das
|
||||
ist eine Falle mit langer Zündschnur: Der Server beantwortet ein unbekanntes Projekt mit
|
||||
demselben `not_found` wie einen unbekannten Schlüssel — der Unterschied steht
|
||||
ausschließlich in `message` (`"Project not found"` gegen `"Invalid license key"`). Wer
|
||||
den Text nicht durchreicht, sucht den Fehler beim Lizenzschlüssel, während das Projekt
|
||||
gar nicht existiert. Der Torwächter gibt die Serverantwort deshalb mit aus und schreibt
|
||||
sie ins Protokoll.
|
||||
|
||||
### Deaktivieren läuft über das WebUI
|
||||
|
||||
`POST /api/license/v1/deactivate` verlangt den `shared_key` des Servers. Der gehört nicht
|
||||
in eine ausgelieferte Anwendung, deshalb ist der Weg die Hardware-Liste im WebUI
|
||||
(Schaltfläche „Freigeben").
|
||||
|
||||
### Laufende Nachprüfung
|
||||
|
||||
[`LicenseWatch`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) prüft alle zwölf
|
||||
Stunden nach — dieselbe Unterscheidung wie oben. Ohne das wirkt ein Widerruf erst beim
|
||||
nächsten Start, bei einem wochenlang laufenden Dienst also praktisch nie.
|
||||
|
||||
---
|
||||
|
||||
## 4. Version und Updates
|
||||
|
||||
### Eine Stelle für die Version
|
||||
|
||||
`<Version>` in [Directory.Build.props](../Directory.Build.props) ist die Wahrheit.
|
||||
`Deploymentcenter.BuildInfo.targets` (seit 2.1 einbindbar) erzeugt daraus zur
|
||||
Übersetzungszeit `ClawdDotNet.App.ReleaseInfo` mit `Version`, `GitCommit`,
|
||||
`GitCommitShort`, `BuildDateUtc`, `Channel` und `Summary`.
|
||||
|
||||
Der Wert geht an vier Stellen nach draußen, die vorher alle geraten haben:
|
||||
|
||||
| Stelle | Vorher |
|
||||
|---|---|
|
||||
| Aktivierungsliste (`app_version`) | fest `"1.0.0"` im SDK — jede Installation gleich |
|
||||
| Heartbeat (`version`) | gab es nicht |
|
||||
| Fehlermeldungen (`build`) | — |
|
||||
| Versionsvergleich der Update-Prüfung | `0.0.<BuildInfo.Build>`, behelfsweise |
|
||||
|
||||
Die Klasse heißt bewusst `ReleaseInfo`, nicht `BuildInfo`: Diesen Namen trägt in
|
||||
`ClawdDotNet.Core` schon ein von Hand geführter Zähler mit Änderungstext. Zwei
|
||||
gleichnamige Klassen mit verschiedener Bedeutung wären eine Falle. Umgestellt über
|
||||
`DeploymentcenterBuildInfoClass` in der csproj.
|
||||
|
||||
### Prüfung
|
||||
|
||||
Einmalig beim Start gegen `GET /api/updateservice/v1/check`, über
|
||||
`Deploymentcenter.Client.UpdateClient`. Läuft nebenher und blockiert nichts; liegt eine
|
||||
neuere Version vor, erscheint ein Hinweis mit Changelog. Ob und wann aktualisiert wird,
|
||||
entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
|
||||
erneuern, ist genau dann im Weg, wenn man sie braucht.
|
||||
|
||||
Seit 2.1 liefern beide Wege vollständige Daten: die statische `latest.json` in camelCase,
|
||||
die API in snake_case, jeweils über ein eigenes Modell (`VersionInfo` bzw.
|
||||
`ApiReleaseInfo`). Vorher kam über den API-Zweig außer der Versionsnummer nichts an — und
|
||||
der ist genau der Rückfall, wenn die `latest.json` fehlt. Die Download-Adresse wird
|
||||
mitgeführt (`UpdateAvailability.DownloadUrl`), damit der `update-agent` später ohne
|
||||
weitere Änderung anschließen kann.
|
||||
|
||||
Der `update-agent` ist noch **nicht** eingebunden, und solange kein Release über
|
||||
`pack-and-deploy` veröffentlicht wird, hat die Prüfung nichts zu finden.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fehler-Stream
|
||||
|
||||
Ungefangene Ausnahmen gehen an `POST /api/errors/v1/report`. Verdrahtet in
|
||||
[`App.axaml.cs`](../src/ClawdDotNet.Desktop/App.axaml.cs) an drei Stellen:
|
||||
`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException` und
|
||||
`Dispatcher.UIThread.UnhandledException`.
|
||||
|
||||
Erst nach dem Aufbau verdrahtet, nicht in `Main`: Vorher gibt es weder Einstellungen
|
||||
noch Token. Die Kehrseite ist bewusst in Kauf genommen — ein Absturz *während* des
|
||||
Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.
|
||||
|
||||
**Eigene Drosselung** in
|
||||
[`ErrorReporter`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs): Derselbe
|
||||
Fehler (Typ + oberste Stelle im Stacktrace) geht höchstens einmal alle fünf Minuten
|
||||
raus. Der Server drosselt auch, aber erst, nachdem die Anfragen über die Leitung waren.
|
||||
Die Fehlermeldung selbst gehört nicht zum Kennzeichen — sie enthält oft wechselnde
|
||||
Werte, und dann wäre jeder Aufruf ein neuer Fehler.
|
||||
|
||||
Bekannte, harmlose Fehler lassen sich serverseitig unter **Bugtracker → Ignore-Regeln**
|
||||
stummschalten. Sie werden weiter gezählt; der Zähler ist der Zweck: Dass ein bekannter
|
||||
Fehler auftritt, ist normal — dass er plötzlich hundertmal so oft auftritt, bedeutet,
|
||||
dass sich etwas geändert hat.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bugtracker
|
||||
|
||||
[`BugtrackerClient`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) für
|
||||
bewusst formulierte Einträge (Fehler, Wunsch, Idee) mit Titel und Beschreibung, gegen
|
||||
`POST /api/bugtracker/v1/report`. Der Absender wird serverseitig aus dem Token
|
||||
abgeleitet und lässt sich nicht frei wählen.
|
||||
|
||||
Der Client ist da und über `AppHost.Deploymentcenter.Bugtracker` erreichbar; **eine
|
||||
Oberfläche dafür fehlt noch** („Fehler melden"-Schaltfläche). Ein Agenten-Tool wäre der
|
||||
nächste sinnvolle Schritt — Agenten könnten dann selbst Wünsche und Fehler eintragen,
|
||||
und der Agenten-Workflow des Deploymentcenters (Claim/Lease über
|
||||
`manage?action=next`) würde sie abarbeiten.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wo was liegt
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| [`Deploymentcenter/DeploymentcenterApi.cs`](../src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs) | Gemeinsamer Unterbau: Bearer-Header, HTTPS-Pflicht, Umschlag auspacken, `DeploymentcenterException` mit stabilem `Code` |
|
||||
| [`Deploymentcenter/Watchdog/`](../src/ClawdDotNet.Core/Deploymentcenter/Watchdog) | Heartbeat-Client, Zustandsermittlung, Takt-Dienst |
|
||||
| [`Deploymentcenter/ErrorReporter.cs`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs) | Fehler-Stream mit Drosselung |
|
||||
| [`Deploymentcenter/BugtrackerClient.cs`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) | Bugtracker-Einträge |
|
||||
| [`Deploymentcenter/TokenProvisioner.cs`](../src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs) | Sub-Token je Instanz |
|
||||
| [`Services/DeploymentcenterService.cs`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs) | Verdrahtung: Token beschaffen, Heartbeat starten, Update prüfen |
|
||||
| [`Services/LicenseGate.cs`](../src/ClawdDotNet.App/Services/LicenseGate.cs) | Startprüfung |
|
||||
| [`Services/LicenseWatch.cs`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) | Laufende Nachprüfung |
|
||||
|
||||
Die Lizenz läuft bewusst **nicht** über `DeploymentcenterApi`: Sie hat ein eigenes
|
||||
Antwortformat (kein `status`/`error`-Umschlag — `status` trägt dort den Lizenzzustand),
|
||||
einen eigenen Zwischenspeicher und muss vor allem anderen laufen.
|
||||
|
||||
Watchdog, Fehler-Stream und Bugtracker deckt das SDK `Deploymentcenter.Client` nicht ab;
|
||||
dafür ist der eigene Unterbau da. Hardware-ID v2, Lizenz-Zwischenspeicher und
|
||||
Update-Prüfung kommen aus dem SDK — die nachzubauen wäre Verdopplung.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
[`tests/ClawdDotNet.Core.Tests/Deploymentcenter/`](../tests/ClawdDotNet.Core.Tests/Deploymentcenter):
|
||||
Bearer-Header, Fehlerumschlag → Ausnahme mit Code (auch bei HTTP 200), HTTPS-Pflicht mit
|
||||
Localhost-Ausnahme, Heartbeat-Pfad und -Rumpf, zwei Instanzen → zwei Monitore, Checks
|
||||
und Metriken, Antwort-Auswertung, Drosselung des Fehler-Streams, Sub-Token-Bezug.
|
||||
|
||||
---
|
||||
|
||||
## 9. Offen
|
||||
|
||||
1. **Oberfläche für den Bugtracker** — Client vorhanden, Schaltfläche fehlt.
|
||||
2. **Agenten-Tool für den Bugtracker** — würde den Agenten-Workflow des
|
||||
Deploymentcenters nutzbar machen.
|
||||
3. **Release-Strecke** — `pack-and-deploy` aufrufen und `<Version>` dabei mitgeben,
|
||||
danach `update-agent` einbinden. Die Versionsnummer selbst ist mit 2.1 erledigt.
|
||||
4. **SDK als Git-Submodul** unter `external/` statt Cross-Repo-Pfad.
|
||||
5. **Hierarchie** (`parent_source`): Läuft die Instanz auf einem Host, der selbst als
|
||||
Monitor geführt wird, sollte sie ihn als übergeordnete Entität eingetragen bekommen —
|
||||
sonst erzeugt ein Hostausfall eine Meldung je Instanz. Das ist im WebUI zu pflegen,
|
||||
nicht im Client (siehe aber Anmerkung 2 in den Rückmeldungen).
|
||||
@@ -278,10 +278,14 @@ Rohzahlen.
|
||||
|
||||
# Vorgeschlagene Reihenfolge
|
||||
|
||||
> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte
|
||||
> laufen dort als C1–C8 weiter; S4 + K2 sind in den Vorhaben A2/A3
|
||||
> (Staging-Freigabe, Audit-Log) aufgegangen.
|
||||
|
||||
| # | Was | Warum zuerst |
|
||||
|---|---|---|
|
||||
| 1 | Atomares Schreiben | Datenverlust ist bereits eingetreten |
|
||||
| 2 | Backup + Restore mit Test | Schützt alles Folgende |
|
||||
| 1 | ~~Atomares Schreiben~~ ✅ | umgesetzt (`File.Replace`-Muster) |
|
||||
| 2 | ~~Backup + Restore mit Test~~ ✅ | umgesetzt inkl. Oberfläche im Settings-Tab |
|
||||
| 3 | Marktkalender | Spart sofort Kosten, verbessert Datenlage |
|
||||
| 4 | `Indicators` | Qualität hoch, Tokens runter |
|
||||
| 5 | Ergebnisregister (Stufe 2) | Grundlage jeder Bewertung |
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
# Linux-Portierung — Analyse
|
||||
|
||||
Stand: 2026-08-06. Reine Bestandsaufnahme und Aufwandsschätzung, **kein** Umbau.
|
||||
|
||||
Frage: Was ist nötig, damit ClawdDotNet unter Linux läuft, und was kostet das?
|
||||
|
||||
---
|
||||
|
||||
## 0. Kurzfassung
|
||||
|
||||
Die gute Nachricht zuerst: **Der Kern ist bereits portabel.** Alle 16 Bibliotheks-
|
||||
und beide Testprojekte zielen auf `net10.0` (nicht `net10.0-windows`), es gibt im
|
||||
gesamten Repository **kein einziges `DllImport`, keinen Registry-Zugriff und keine
|
||||
`System.Drawing`-Nutzung** in `src/`. Windows steckt an genau drei Stellen im Kern:
|
||||
DPAPI-Verschlüsselung, Groß-/Kleinschreibung bei Pfadvergleichen und die
|
||||
Zeitzonen-IDs.
|
||||
|
||||
Die schlechte Nachricht: Die gesamte Bedienoberfläche — rund **8.900 Zeilen** in
|
||||
`frm_*.cs`, `UI/`, `Models/` und `Services/` — hängt an Windows Forms, an WebView2
|
||||
und, am unangenehmsten, an vier `PropertyGrid`-Instanzen, die praktisch die
|
||||
komplette Einstellungsverwaltung ausmachen. Dafür gibt es in Avalonia keine
|
||||
Eins-zu-eins-Entsprechung.
|
||||
|
||||
**Empfehlung: den Umzug in zwei Schnitte teilen.** Ein kopfloser Host (ohne GUI)
|
||||
auf Linux ist in etwa **12–18 Personentagen** erreichbar und liefert den
|
||||
eigentlichen Nutzen — Agenten laufen auf einem Server, nicht auf einem
|
||||
Windows-Desktop. Die Avalonia-Oberfläche ist ein davon unabhängiges Vorhaben
|
||||
von **32–52 Personentagen**, das man danach in Ruhe angehen kann.
|
||||
|
||||
Gesamt für „alles auf Linux, mit GUI": **50–80 Personentage.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Bestandsaufnahme
|
||||
|
||||
### 1.1 Was bereits portabel ist
|
||||
|
||||
| Bereich | Zeilen | Zielframework | Windows-Abhängigkeit |
|
||||
|---|---:|---|---|
|
||||
| `src/ClawdDotNet.Core` | 8.959 | `net10.0` | nur DPAPI (1 Datei) |
|
||||
| 15 Tool-Projekte | 6.415 | `net10.0` | nur `.exe`-Pfade im SocialMediaManager |
|
||||
| `tests/` (348 Tests, 39 Dateien) | 6.888 | `net10.0` | 3 Testfälle mit `C:\`-Pfaden |
|
||||
|
||||
Alle NuGet-Pakete laufen unter Linux: `Microsoft.Data.Sqlite` (bringt
|
||||
`e_sqlite3` nativ für linux-x64/arm64 mit), `MySqlConnector`, `Npgsql`,
|
||||
`Microsoft.Data.SqlClient`, `MongoDB.Driver`, `MailKit`, `FluentFTP`,
|
||||
`Telegram.Bot`, `WTelegramClient`, `SharpCompress`, `Snappier`,
|
||||
`Microsoft.Extensions.Logging`. Kein Paket muss ersetzt werden — mit zwei
|
||||
Ausnahmen (siehe 2.1 und 2.3).
|
||||
|
||||
Auch die Dinge, bei denen man Ärger erwarten würde, sind sauber gelöst:
|
||||
|
||||
- [AtomicFile.cs:167](src/ClawdDotNet.Core/Storage/AtomicFile.cs:167) — `Commit`
|
||||
prüft `File.Exists` und weicht auf `File.Move` aus. `File.Replace` verlangt
|
||||
unter Unix ebenfalls eine vorhandene Zieldatei; der Fall ist also schon
|
||||
abgedeckt. Die Wiederholschleife ist unter Linux überflüssig, aber harmlos.
|
||||
- [TaskFrontmatter.cs:27](src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs:27) —
|
||||
normalisiert `\r\n` und `\r` vor dem Zerlegen. Task-Dateien von einem
|
||||
Windows-Rechner werden unter Linux korrekt gelesen.
|
||||
- Textdateien werden durchgängig als **UTF-8 ohne BOM** geschrieben
|
||||
(`AtomicFile`, `FileLogWriter`, `AgentEditorTool`). Kein `Encoding.Default`,
|
||||
keine Codepage-Fallen.
|
||||
- Zeitstempel gehen als `DateTime.UtcNow` in die Datenbank und werden mit
|
||||
`DateTimeStyles.RoundtripKind` gelesen.
|
||||
|
||||
### 1.2 Was am Windows-Teil hängt
|
||||
|
||||
| Bereich | Zeilen | davon Designer |
|
||||
|---|---:|---:|
|
||||
| `frm_*.cs` (6 Formulare + Dialoge) | 4.946 | 1.865 |
|
||||
| `UI/` (BackupPanel, WebViewBridge, EmbeddedUiManager) | 1.087 | 428 |
|
||||
| `Models/` (PropertyGrid-ViewModels) | 1.257 | — |
|
||||
| `Services/` (4 Dienste, an WinForms-Timer gekoppelt) | 1.501 | — |
|
||||
| `Properties/` | 123 | — |
|
||||
| **Summe** | **8.914** | **2.293** |
|
||||
|
||||
Dazu drei `.resx`-Dateien à ~272 KB (eingebettete Symbole/Bilder) und eine
|
||||
`frm_main.en.resx` für die englische Lokalisierung über den
|
||||
WinForms-Resx-Mechanismus.
|
||||
|
||||
Steuerelement-Inventar aus den Designer-Dateien: 24 `Label`, 19
|
||||
`ToolStripButton`, 12 `TabPage`, 12 `Button`, 9 `TextBox`, 6 `DataGridView`, 6
|
||||
`ToolStrip`, 5 `ComboBox`, **4 `PropertyGrid`**, 4 `TableLayoutPanel`, 4
|
||||
`FlowLayoutPanel`, 3 `TabControl`, 3 `SplitContainer`, 1 `RichTextBox`, 1
|
||||
`ListView`, 1 `NotifyIcon`, 1 `DateTimePicker`, 1 `NumericUpDown`.
|
||||
|
||||
Tabs in `frm_main`: Chat, Logs, Settings (mit Unter-Tabs App-Settings,
|
||||
Instance-Settings), Agent Settings, Jobs/Services (mit Unter-Tabs Jobs,
|
||||
Services, Job History), Info, Backup.
|
||||
|
||||
---
|
||||
|
||||
## 2. Die harten Brocken
|
||||
|
||||
### 2.1 WebView2 → kein Linux (Chat- und Übersichts-Ansicht)
|
||||
|
||||
`Microsoft.Web.WebView2` ist die einzige Windows-only-Paketabhängigkeit des
|
||||
Hauptprojekts und trägt die zwei sichtbarsten Ansichten:
|
||||
[frm_main.cs:235](frm_main.cs:235) und [frm_chat.cs:44](frm_chat.cs:44) laden
|
||||
`overview.html` bzw. `chat.html` aus `EmbeddedUI/` über
|
||||
`SetVirtualHostNameToFolderMapping` unter `https://ui.clwd.internal/`. Die
|
||||
Kommunikation läuft über [WebViewBridge.cs](UI/WebViewBridge.cs) —
|
||||
`WebMessageReceived` in die eine, `ExecuteScriptAsync` in die andere Richtung.
|
||||
|
||||
Drei Wege, jeder mit einem eigenen Preis:
|
||||
|
||||
| Variante | Was passiert | Aufwand | Risiko |
|
||||
|---|---|---:|---|
|
||||
| **A — Avalonia.WebView** | HTML/JS bleiben. Unter Linux rendert WebKitGTK, unter Windows weiterhin WebView2. Die Bridge wird auf die Abstraktion der Bibliothek umgeschrieben. | 4–6 PT | Bibliothek ist deutlich weniger reif als WebView2; WebKitGTK-Abhängigkeit muss auf dem Zielserver vorhanden sein; Verhalten unterscheidet sich je Plattform. |
|
||||
| **B — nativ neu in Avalonia** | Chat als echte Avalonia-Ansicht mit `ItemsControl` und einem Markdown-Renderer. `EmbeddedUI/` entfällt. | 8–12 PT | Kein Fremdrisiko, aber Neuentwicklung. Am Ende deutlich wartbarer als HTML-in-Container. |
|
||||
| **C — lokaler HTTP-Server + Systembrowser** | Die App liefert `EmbeddedUI/` über `http://localhost:port` aus, der Nutzer öffnet den Browser. | 3–4 PT | Bricht die Ein-Fenster-Anmutung. Passt aber ausgezeichnet zum kopflosen Betrieb — dort **ist** der Browser die Oberfläche. |
|
||||
|
||||
**Empfehlung:** C für den kopflosen Host (fällt dort ohnehin an), B für die
|
||||
Desktop-Oberfläche. Variante A koppelt uns an eine Bibliothek, die weniger stabil
|
||||
ist als alles andere im Projekt.
|
||||
|
||||
### 2.2 PropertyGrid → es gibt keinen Ersatz von der Stange
|
||||
|
||||
Vier `PropertyGrid`-Instanzen in [frm_main.Designer.cs](frm_main.Designer.cs)
|
||||
bilden App-Settings, Instance-Settings, Agent-Settings und Tool-Settings ab. Sie
|
||||
werden vollständig durch Attribute gesteuert — **246 `[Category]`,
|
||||
`[DisplayName]`, `[Description]`-Angaben** verteilt auf vier Dateien:
|
||||
|
||||
- [Models/ToolSettingsViewModels.cs](Models/ToolSettingsViewModels.cs) — 108
|
||||
- [Models/AgentSettingsViewModel.cs](Models/AgentSettingsViewModel.cs) — 54
|
||||
- [Models/AppSettings.cs](Models/AppSettings.cs) — 51
|
||||
- [Models/InstanceSettingsViewModel.cs](Models/InstanceSettingsViewModel.cs) — 33
|
||||
|
||||
Dazu kommen `[TypeConverter(typeof(ExpandableObjectConverter))]` für
|
||||
verschachtelte Objekte, `[PasswordPropertyText(true)]` für Geheimnisse und ein
|
||||
eigener [ModelTypeConverter](Models/ModelTypeConverter.cs), der das
|
||||
Modell-Auswahlfeld dynamisch aus der OpenRouter-Modellliste füllt.
|
||||
|
||||
Avalonia hat kein `PropertyGrid`. Zwei Möglichkeiten:
|
||||
|
||||
1. **`Avalonia.PropertyGrid`** (Community, MIT). Versteht `Category`,
|
||||
`DisplayName`, `Description`, `Browsable`, `ReadOnly` und
|
||||
`ExpandableObjectConverter`. Die ViewModels und ihre Attribute könnten
|
||||
weitgehend unverändert bleiben — das spart am meisten. Zu prüfen ist, ob der
|
||||
dynamische `ModelTypeConverter` mit `GetStandardValues` unterstützt wird; das
|
||||
ist der Punkt, an dem so etwas erfahrungsgemäß hakt. **Aufwand 6–8 PT**, plus
|
||||
dauerhafte Abhängigkeit an ein Ein-Personen-Projekt.
|
||||
2. **Von Hand gebaute Einstellungsformulare.** Mehr Arbeit, aber wir bekommen
|
||||
eine Oberfläche, die man Nutzern zumuten kann — das `PropertyGrid` ist
|
||||
ehrlicherweise eine Entwickleransicht. Passwörter, Verzeichnisauswahl,
|
||||
Validierung und die Modell-Auswahl werden dabei richtig statt behelfsmäßig.
|
||||
**Aufwand 10–14 PT.**
|
||||
|
||||
Das ist der größte Einzelposten der GUI-Portierung. Die Entscheidung kann und
|
||||
sollte man verschieben, bis das Grundgerüst steht.
|
||||
|
||||
### 2.3 DPAPI → Geheimnisse liegen unter Linux im Klartext
|
||||
|
||||
[SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42):
|
||||
|
||||
```csharp
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return plainText;
|
||||
```
|
||||
|
||||
Unter Linux verschlüsselt `Protect` **stillschweigend nicht**. OpenRouter-Key,
|
||||
Datenbank-Verbindungszeichenfolgen mit Passwort, Mail-Zugangsdaten und das
|
||||
Telegram-2FA-Passwort lägen im Klartext in `InstanceConfig.json` und
|
||||
`AgentSettings.json` — genau der Zustand, den S7 behoben hat. Auf einem Server,
|
||||
der per SSH erreichbar ist und gesichert wird, ist das schlechter als auf einem
|
||||
Einzelplatz-Windows.
|
||||
|
||||
Dasselbe gilt für den Lizenz-Zustandsspeicher:
|
||||
`LicenseLabrador/client-dotnet/.../StateStore.cs:92` schützt seine Datei ebenfalls
|
||||
nur unter Windows per DPAPI.
|
||||
|
||||
Zu klären ist also ein plattformübergreifendes Verfahren. Realistisch:
|
||||
|
||||
- **AES-GCM mit Schlüssel aus einer Datei mit `0600`** neben der Konfiguration
|
||||
(Linux) bzw. weiterhin DPAPI (Windows). Einfach, wirkt gegen versehentliche
|
||||
Weitergabe und Backups, nicht gegen einen Angreifer mit demselben Benutzer —
|
||||
dieselbe Schutzstufe wie DPAPI heute.
|
||||
- Optional zusätzlich `libsecret`/Schlüsselbund, wenn eine Desktop-Sitzung da
|
||||
ist. Auf einem Server gibt es die nicht, also braucht es den Dateiweg ohnehin.
|
||||
|
||||
Nebenwirkung, die man einplanen muss: **Konfigurationen sind nicht mehr zwischen
|
||||
Betriebssystemen austauschbar.** Ein `enc:v1:`-Wert von Windows ist unter Linux
|
||||
nicht lesbar und umgekehrt. `Unprotect` wirft dann korrekterweise eine
|
||||
`SecretProtectionException` ([SecretProtector.cs:81](src/ClawdDotNet.Core/Security/SecretProtector.cs:81)) —
|
||||
für den Umzug einer Instanz braucht es einen Migrationsweg (Präfix `enc:v2:`,
|
||||
Werte neu eintragen oder ein Export/Import-Kommando).
|
||||
|
||||
**Aufwand 3–5 PT** inklusive Tests und Migration.
|
||||
|
||||
### 2.4 Zeitzonen → das ist die stillste Fehlerquelle
|
||||
|
||||
[TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161):
|
||||
|
||||
```csharp
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
|
||||
catch { return TimeZoneInfo.Utc; }
|
||||
```
|
||||
|
||||
Und [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32)
|
||||
schreibt `TimeZoneInfo.Local.Id` in die Task-Frontmatter. Auf dem
|
||||
Entwicklungsrechner ergibt das `"W. Europe Standard Time"`, unter Linux
|
||||
`"Europe/Berlin"`.
|
||||
|
||||
Task-Dateien sind Markdown im `SharedWorkspace` und wandern zwischen Rechnern.
|
||||
Trifft eine Windows-ID auf ein System ohne die Umsetzungsdaten, greift das
|
||||
`catch` — und der Task läuft ab sofort nach **UTC statt Ortszeit**, also im
|
||||
Sommer zwei Stunden zu früh. Ohne Fehlermeldung, ohne Logeintrag. Ein Task, der
|
||||
um 08:00 die Marktübersicht holen soll, läuft um 06:00.
|
||||
|
||||
.NET 6+ kann Windows-IDs unter Linux über ICU auflösen, aber nur wenn ICU
|
||||
vorhanden ist. In einem schlanken Container (Alpine ohne `icu-libs`, distroless)
|
||||
oder bei `InvariantGlobalization=true` ist es das nicht — dann schlägt jede
|
||||
Auflösung fehl und alles fällt auf UTC.
|
||||
|
||||
Was zu tun ist:
|
||||
|
||||
- Beim Schreiben auf **IANA normalisieren**
|
||||
(`TimeZoneInfo.TryConvertWindowsIdToIanaId`), beim Lesen beide Formen
|
||||
akzeptieren.
|
||||
- Das `catch` **nicht mehr still schlucken** — eine unbekannte Zeitzone muss
|
||||
protokolliert werden, besser noch den Task als fehlerhaft markieren.
|
||||
- Das Zielsystem muss `tzdata` haben. Für Container explizit installieren.
|
||||
|
||||
Verwandt: **82 Vorkommen von `DateTime.Now`/`UtcNow`**. Die meisten sind
|
||||
unkritisch, zwei fallen auf:
|
||||
[TaskboardService.cs:80](src/ClawdDotNet.Core/Tasks/TaskboardService.cs:80)
|
||||
schreibt `DateTime.Now`-Zeitstempel in Task-Dateien, und
|
||||
[LiveLogViewerService.cs:98](Services/LiveLogViewerService.cs:98) sucht die
|
||||
Logdatei des Tages über `DateTime.Now`. Server laufen üblicherweise mit `TZ=UTC`
|
||||
— dort wechselt die Logdatei dann um 02:00 Ortszeit statt um Mitternacht, und
|
||||
Task-Zeitstempel bekommen eine andere Bedeutung als bisher. Kein Fehler, aber
|
||||
eine Verhaltensänderung, die man kennen sollte.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 2.5 Groß-/Kleinschreibung bei Pfaden → sicherheitsrelevant
|
||||
|
||||
Linux-Dateisysteme unterscheiden Groß- und Kleinschreibung, Windows nicht. An
|
||||
vier Stellen wird das Gegenteil angenommen — und drei davon bewachen eine
|
||||
Sandbox-Grenze:
|
||||
|
||||
- [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72) —
|
||||
`normalizedCandidate.StartsWith(normalizedRoot, OrdinalIgnoreCase)`. Das ist
|
||||
die Prüfung, die Agenten daran hindert, aus ihrem Arbeitsverzeichnis
|
||||
auszubrechen.
|
||||
- [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174) —
|
||||
Abgleich gegen die Liste geschützter Pfade.
|
||||
- [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155) — dieselbe
|
||||
Einschließungsprüfung.
|
||||
- [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384).
|
||||
|
||||
Unter Linux sind `/home/x/Workspace` und `/home/x/workspace` **zwei
|
||||
verschiedene Verzeichnisse**. Der Vergleich mit `OrdinalIgnoreCase` würde einen
|
||||
Pfad im zweiten als „innerhalb" des ersten durchwinken. Genauso liefe die
|
||||
Sperrliste in `FileRWTool` ins Leere, sobald jemand die Schreibweise ändert.
|
||||
|
||||
Nötig ist ein Vergleichsverfahren, das die Plattform berücksichtigt — ein
|
||||
`PathComparer`, der unter Windows `OrdinalIgnoreCase` und unter Unix `Ordinal`
|
||||
verwendet, konsequent an allen vier Stellen.
|
||||
|
||||
Ebenfalls betroffen, aber harmlos:
|
||||
[AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) schlüsselt
|
||||
seine Sperren mit `fullPath.ToLowerInvariant()`. Unter Linux teilen sich damit
|
||||
zwei verschiedene Dateien eine Sperre — das serialisiert zu viel, gefährdet aber
|
||||
nichts.
|
||||
|
||||
**Aufwand 2–3 PT**, davon der größere Teil Tests.
|
||||
|
||||
### 2.6 Prozessaufrufe und `.exe`-Annahmen
|
||||
|
||||
- **`Process.Start("explorer.exe", …)`** — 4 Stellen
|
||||
([frm_main.cs:1552](frm_main.cs:1552), [frm_main.cs:1557](frm_main.cs:1557),
|
||||
[frm_main.cs:1562](frm_main.cs:1562), [BackupPanel.cs:338](UI/BackupPanel.cs:338)).
|
||||
Ersatz: `Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })`
|
||||
bzw. `xdg-open`. Die Variante `explorer.exe /select,"…"` hat unter Linux kein
|
||||
Gegenstück — dort öffnet man nur den Ordner.
|
||||
- **`Microsoft.VisualBasic.Interaction.InputBox`** — 3 Stellen
|
||||
([Program.cs:280](Program.cs:280), [Program.cs:291](Program.cs:291),
|
||||
[frm_main.cs:658](frm_main.cs:658)), zwei davon für den interaktiven
|
||||
Telegram-Login (Code und 2FA-Passwort). Braucht einen eigenen Dialog. Für den
|
||||
kopflosen Betrieb ohnehin problematisch: **ein Login, der ein Eingabefenster
|
||||
öffnet, blockiert einen Dienst.** Dort muss der Telegram-Login anders gelöst
|
||||
werden (vorab per CLI, oder über die Weboberfläche).
|
||||
- **`yt-dlp.exe` / `ffmpeg.exe`** —
|
||||
[SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782)
|
||||
und `:825`. Die PATH-Suche davor funktioniert unter Linux bereits; nur die
|
||||
Ausweichpfade sind fest auf `.exe` verdrahtet und laufen dort ins Leere.
|
||||
Kleine Änderung, aber sie fällt sonst erst zur Laufzeit auf.
|
||||
|
||||
**Aufwand zusammen 1–2 PT.**
|
||||
|
||||
### 2.7 WinForms-Timer in der Dienstschicht
|
||||
|
||||
`Services/` ist logisch kein UI-Code, hängt aber an
|
||||
`System.Windows.Forms.Timer`:
|
||||
|
||||
- [BackupScheduler.cs:41](Services/BackupScheduler.cs:41)
|
||||
- [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38) — schreibt
|
||||
zusätzlich direkt in eine `RichTextBox`
|
||||
- [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43)
|
||||
- [frm_main.License.cs:41](frm_main.License.cs:41)
|
||||
|
||||
Der Backup-Zeitplan und die Lizenzprüfung gehören in den kopflosen Host und
|
||||
müssen dafür auf `System.Threading.PeriodicTimer` umgestellt werden. Der
|
||||
Log-Betrachter ist echte Oberfläche und wird ohnehin neu gebaut.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 2.8 Lizenzierung — erledigt (Stand 2026-08-06)
|
||||
|
||||
> **Nachtrag.** LicenseLabrador und WatchDog sind im **Deploymentcenter**
|
||||
> zusammengefasst, Hardware-ID v2 ist dort umgesetzt. Der Client
|
||||
> (`Deploymentcenter.Client`, `netstandard2.0;net8.0`) läuft auf beiden
|
||||
> Plattformen, die HW-ID ist container- und umbenennungsfest, der Ablageort
|
||||
> löst sich auch ohne `HOME` auf, und der Zustandsspeicher ist mit AES-GCM
|
||||
> plattformübergreifend verschlüsselt.
|
||||
>
|
||||
> **Damit ist die einzige potenziell blockierende Frage dieser Analyse
|
||||
> beantwortet.** Details und offene Punkte der Anbindung:
|
||||
> [Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md).
|
||||
|
||||
Es bleibt reine Anschlussarbeit in ClawdDotNet: Projektverweis umhängen,
|
||||
[LicenseGate](Services/LicenseGate.cs) gegen die neue Ergebnisklasse schreiben
|
||||
(`LicenseState` ist entfallen, `Status` ist jetzt eine Zeichenkette), `MessageBox`
|
||||
durch das mitgelieferte `ILicensePrompt` ersetzen und die Lizenzprüfung aus
|
||||
[Program.cs:112](Program.cs:112) fensterfrei machen.
|
||||
|
||||
**Aufwand 2–3 PT** (vorher 3–5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Kleinere Punkte, die trotzdem beißen
|
||||
|
||||
### 3.1 Kultur- und Zahlenformatierung
|
||||
|
||||
Nur 16 Stellen im gesamten Projekt nennen eine Kultur explizit. Das heißt
|
||||
umgekehrt: fast alles formatiert mit `CurrentCulture`. Auf dem
|
||||
Entwicklungsrechner ist das `de-DE`, auf einem Server mit unbesetztem `LANG`
|
||||
ist es `InvariantCulture`. Aus `1,25` wird `1.25`.
|
||||
|
||||
Wo das folgenlos bleibt:
|
||||
- **JSON** — `System.Text.Json` schreibt Zahlen immer invariant. Alle
|
||||
Konfigurationen, Zustandsdateien und API-Aufrufe sind sicher.
|
||||
- **SQLite** — Werte gehen typisiert über Parameter, nicht als Text.
|
||||
|
||||
Wo hinzuschauen ist:
|
||||
- Zeichenkettenverkettung in Logeinträgen und Prompts (`$"{cost:F4}"`). Wenn
|
||||
eine Zahl mit deutschem Dezimalkomma in einen Prompt gerät, muss das Modell
|
||||
raten.
|
||||
- Anzeigewerte in der Oberfläche — dort ist Ortsformat gewünscht, aber es sollte
|
||||
bewusst gesetzt sein, nicht zufällig.
|
||||
|
||||
**Empfehlung:** einmal alle Formatierungen durchgehen und trennen — invariant
|
||||
für alles Maschinenlesbare, `CurrentCulture` nur für die Anzeige. Am besten mit
|
||||
einem Analyzer (`CA1305`, `CA1304`, `CA1310`) als Warnung im Build, damit es so
|
||||
bleibt.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 3.2 Globalisierungsmodus festlegen
|
||||
|
||||
`InvariantGlobalization=true` macht das Publikat kleiner und ICU überflüssig —
|
||||
kostet aber `TimeZoneInfo.FindSystemTimeZoneById` (siehe 2.4), kulturabhängige
|
||||
Vergleiche und korrektes `ToLower()` für Umlaute. Für dieses Projekt mit
|
||||
zeitzonenabhängiger Planung ist das **keine Option**; die Entscheidung sollte im
|
||||
Projekt dokumentiert und ICU/tzdata als Voraussetzung festgehalten werden.
|
||||
|
||||
Nebenbemerkung: `COLLATE NOCASE` in
|
||||
[SqliteMemoryRepository.cs:143](src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs:143)
|
||||
und [SqliteTaskRepository.cs:105](src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs:105)
|
||||
ist ASCII-beschränkt — `Ä` und `ä` gelten SQLite als verschieden. Das ist heute
|
||||
schon so und ändert sich beim Umzug nicht, ist also kein Portierungsthema,
|
||||
sondern eine bestehende Eigenheit.
|
||||
|
||||
### 3.3 Zeilenenden
|
||||
|
||||
1.230 Stellen verwenden `Environment.NewLine` oder `\r\n`. Für Logdateien ist
|
||||
das egal. Bei **Task-Dateien** und Agenten-erzeugten Dateien im geteilten
|
||||
Arbeitsverzeichnis führt es zu Rauschen: Eine Datei, die unter Windows
|
||||
geschrieben und unter Linux angefasst wird, ändert komplett ihre Zeilenenden.
|
||||
Wenn der Arbeitsbereich unter Git liegt oder synchronisiert wird, sieht jede
|
||||
Änderung wie eine Vollumschreibung aus. Der Parser kommt damit klar (siehe 1.1)
|
||||
— es ist eine Frage der Ordnung, kein Fehler. Empfehlung: für Task- und
|
||||
Konfigurationsdateien fest `\n` schreiben.
|
||||
|
||||
### 3.4 Dateinamen
|
||||
|
||||
`Path.GetInvalidFileNameChars()` liefert unter Windows 41 Zeichen, unter Linux
|
||||
genau zwei (`\0` und `/`). [FileLogWriter.cs:95](src/ClawdDotNet.Core/Logging/FileLogWriter.cs:95)
|
||||
säubert Modulnamen damit — unter Linux entstehen also Dateinamen, die auf
|
||||
Windows nicht mehr lesbar sind. Betrifft Sicherungen, die zwischen Systemen
|
||||
wandern. Ebenso die Windows-Sonderfälle `CON`, `PRN`, `AUX` und Namen mit
|
||||
abschließendem Punkt: unter Linux erlaubt, beim Rückspielen auf Windows nicht.
|
||||
Für den Sicherungs-/Wiederherstellungsweg über Systemgrenzen hinweg relevant.
|
||||
|
||||
### 3.5 Ablageorte
|
||||
|
||||
[SettingsManager.cs:24](Services/SettingsManager.cs:24) legt `AppSettings.json`
|
||||
neben die Programmdatei (`AppDomain.CurrentDomain.BaseDirectory`). Unter Windows
|
||||
in einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung typisch
|
||||
in `/opt/…` oder `/usr/lib/…` und ist für den Dienstbenutzer **nicht
|
||||
schreibbar**. Dasselbe gilt für die Zielordner `tools/`, `Logs/` und
|
||||
`Instances/`, die die Build-Ziele in `ClawdDotNet.csproj` neben der
|
||||
Programmdatei anlegen.
|
||||
|
||||
Nötig ist eine Trennung von Programm und Daten nach XDG-Konvention:
|
||||
`$XDG_CONFIG_HOME` bzw. `/etc/clawddotnet` für die Konfiguration,
|
||||
`$XDG_DATA_HOME` bzw. `/var/lib/clawddotnet` für Instanzen und Datenbanken,
|
||||
`/var/log/clawddotnet` für Logs. Dazu Dateirechte: Instanzverzeichnisse mit
|
||||
Geheimnissen gehören auf `0700`, Konfigurationsdateien auf `0600` — unter
|
||||
Windows regelt das die ACL des Benutzerprofils, unter Linux muss man es setzen.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 3.6 Tests
|
||||
|
||||
Von 348 Tests sind fast alle portabel. Auffällig ist
|
||||
[WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49):
|
||||
|
||||
```csharp
|
||||
[InlineData(@"C:\Windows\System32\config\SAM")]
|
||||
[InlineData(@"\\server\share\evil.txt")]
|
||||
[InlineData(@"C:\temp\datei.txt")]
|
||||
```
|
||||
|
||||
Unter Linux liefert `Path.IsPathRooted(@"C:\temp\datei.txt")` **`false`** — das
|
||||
ist ein gewöhnlicher relativer Dateiname mit Doppelpunkt und Backslashes darin.
|
||||
Der Test prüft dort also etwas anderes als beabsichtigt. Und da er die
|
||||
Sandbox-Grenze absichert, ist das keine Kleinigkeit: Er muss
|
||||
betriebssystemabhängig aufgeteilt werden, mit einer eigenen Linux-Fassung
|
||||
(`/etc/passwd`, `../../etc/passwd`, Symlinks). Symlinks sind überhaupt ein
|
||||
Prüfpunkt, den es unter Windows so nicht gab — `Path.GetFullPath` löst sie
|
||||
**nicht** auf, `File.ResolveLinkTarget` schon. Ein Agent könnte im
|
||||
Arbeitsverzeichnis einen Symlink nach `/etc` anlegen und die Prüfung ginge
|
||||
durch.
|
||||
|
||||
Ebenso in [YouTubeUrlTests.cs:118](tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs:118)
|
||||
(harmlos, nur Beispieldaten).
|
||||
|
||||
**Aufwand 2–4 PT**, inklusive Symlink-Absicherung in `WorkspacePath` selbst.
|
||||
|
||||
### 3.7 Bau und Auslieferung
|
||||
|
||||
[Deploy-Build.ps1](Deploy-Build.ps1) setzt PowerShell 5.1 voraus, verwendet
|
||||
Backslash-Pfade und den festen Ausgabepfad `bin\Release\net10.0-windows`. Für
|
||||
Linux braucht es entweder eine `pwsh`-taugliche Fassung oder — besser — einen
|
||||
schlichten `dotnet publish -r linux-x64 --self-contained` mit einer
|
||||
systemd-Unit-Datei. Dazu:
|
||||
|
||||
- systemd-Unit mit eigenem Dienstbenutzer, `Restart=on-failure`
|
||||
- Prüfen, ob der bestehende Watchdog-Heartbeat
|
||||
([Program.cs:367](Program.cs:367)) mit `systemd-notify` zusammenspielen soll
|
||||
- optional `.deb` oder AppImage für den Desktop-Fall
|
||||
|
||||
**Aufwand 3–5 PT.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Der empfohlene Schnitt
|
||||
|
||||
Der entscheidende Befund dieser Analyse: **Die Oberfläche ist nicht der Grund,
|
||||
warum wir Linux wollen.** Der Grund ist, dass Agenten auf einem Server laufen
|
||||
sollen. [Program.cs](Program.cs) baut bereits alles — Speicher, Engine,
|
||||
Taskboard-Scanner, Watchdog, Lizenzprüfung — vollständig auf, **bevor**
|
||||
`frm_main` überhaupt entsteht (Zeilen 36–385 gegen 388–401). Diese Trennung
|
||||
existiert faktisch schon; sie muss nur formalisiert werden.
|
||||
|
||||
### Stufe 1 — Kern Linux-fest und kopfloser Host (12–18 PT)
|
||||
|
||||
| Schritt | PT |
|
||||
|---|---:|
|
||||
| Geheimnisse plattformübergreifend (2.3) | 3–5 |
|
||||
| Zeitzonen normalisieren, Fehler nicht mehr schlucken (2.4) | 2–3 |
|
||||
| Pfadvergleiche plattformabhängig + Symlink-Prüfung (2.5, 3.6) | 3–5 |
|
||||
| Prozessaufrufe, `.exe`-Pfade (2.6) | 1–2 |
|
||||
| Ablageorte und Dateirechte nach XDG (3.5) | 2–3 |
|
||||
| `ClawdDotNet.Host` — Startlogik aus `Program.cs` herauslösen, `PeriodicTimer` statt WinForms-Timer, Telegram-Login ohne Dialog | 4–6 |
|
||||
| Tests auf Linux grün, CI-Lauf für linux-x64 | 2–3 |
|
||||
|
||||
**Ergebnis:** Die Anwendung läuft als systemd-Dienst auf einem Linux-Server. Die
|
||||
Windows-GUI bleibt unverändert bestehen und wird weiter benutzt. Das ist der
|
||||
Punkt, an dem der Nutzen anfällt.
|
||||
|
||||
### Stufe 2 — Avalonia-Oberfläche (32–52 PT)
|
||||
|
||||
| Schritt | PT |
|
||||
|---|---:|
|
||||
| Grundgerüst: Avalonia-Projekt, DI, Dispatcher, Shell mit Tabs, MVVM-Schicht | 5–7 |
|
||||
| Logs-Tab (`RichTextBox` → `SelectingItemsControl` mit Filterung) | 2–3 |
|
||||
| Agent-Settings: Liste, Werkzeugauswahl, Aktionsschaltflächen | 5–8 |
|
||||
| Einstellungs-Tabs — PropertyGrid-Ersatz (2.2) | 6–10 |
|
||||
| Jobs / Services / Job History (4 `DataGridView`) | 4–6 |
|
||||
| Backup-Panel | 3–4 |
|
||||
| Instance-Manager und die fünf Dialoge | 4–6 |
|
||||
| Chat-Ansicht (Variante B, siehe 2.1) | 8–12 |
|
||||
| Info, Statusleiste, Werkzeugleisten, Menü, Lokalisierung de/en | 3–4 |
|
||||
|
||||
Die Spanne ist breit, weil zwei Entscheidungen noch offen sind (PropertyGrid-Ersatz
|
||||
und Chat-Variante). Sind die getroffen, lässt sich das auf etwa ±15 % genau
|
||||
angeben.
|
||||
|
||||
### Stufe 3 — Auslieferung und Härtung (6–10 PT)
|
||||
|
||||
Publish-Pipeline, systemd-Unit, Paketierung, Abnahme auf echter Hardware,
|
||||
Dokumentation, Umzugsweg für bestehende Instanzen.
|
||||
|
||||
### Gesamt
|
||||
|
||||
| | PT | bei Vollzeit |
|
||||
|---|---:|---|
|
||||
| Stufe 1 | 12–18 | 2,5–3,5 Wochen |
|
||||
| Stufe 2 | 32–52 | 6,5–10,5 Wochen |
|
||||
| Stufe 3 | 6–10 | 1,5–2 Wochen |
|
||||
| **Summe** | **50–80** | **10–16 Wochen** |
|
||||
|
||||
---
|
||||
|
||||
## 5. LiveCharts2
|
||||
|
||||
Zur Einordnung: **Das Projekt enthält heute keine einzige Diagrammdarstellung.**
|
||||
Die Suche nach `Chart`, `Series` oder `Plot` findet nur JSON-Feldnamen der
|
||||
Yahoo-Finance-Abfrage in
|
||||
[DirectAPITool.cs:126](src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs:126).
|
||||
|
||||
LiveCharts2 ist damit **keine Portierung, sondern neue Funktionalität** — sie
|
||||
gehört zum Trading-Teil, nicht zum Linux-Umzug, und ist in den 50–80 PT oben
|
||||
nicht enthalten. Wenn die Kursansichten kommen, sind dafür grob 5–10 PT
|
||||
zusätzlich zu rechnen. Das passt zu dem, was in
|
||||
[docs/Roadmap.md](docs/Roadmap.md) und der Notiz „Basis vor Trading härten"
|
||||
festgehalten ist: Erst die Basis, dann die Handelsansichten.
|
||||
|
||||
Ein Punkt, der jetzt schon zählt: LiveCharts2 setzt auf SkiaSharp, genau wie
|
||||
Avalonia. Das spricht zusätzlich dafür, die Diagramme erst **nach** der
|
||||
Avalonia-Portierung zu bauen — sonst entstehen sie zweimal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Was vor dem ersten Handgriff zu entscheiden ist
|
||||
|
||||
1. **Ist das Ziel Server oder Desktop?** Bei „Server" reicht Stufe 1, und Stufe 2
|
||||
kann entfallen oder durch eine Weboberfläche ersetzt werden. Das ändert die
|
||||
Schätzung um den Faktor drei.
|
||||
2. **Chat-Ansicht: HTML behalten oder nativ neu bauen?** (2.1)
|
||||
3. **PropertyGrid: Fremdbibliothek oder eigene Formulare?** (2.2)
|
||||
4. ~~**Erlaubt LicenseLabrador den Wechsel der Hardware-ID?**~~ — **geklärt**,
|
||||
siehe 2.8 und
|
||||
[Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md).
|
||||
5. **Bleibt Windows als Zielplattform bestehen?** Wenn ja, muss alles doppelt
|
||||
getestet werden, und die Geheimnis-Verschlüsselung braucht beide Wege plus
|
||||
Umzugspfad. Wenn nein, wird 2.3 deutlich einfacher.
|
||||
|
||||
Frage 1 und 5 beantworten sich vermutlich schnell; 2 und 3 kann man bis zum
|
||||
Beginn von Stufe 2 offenlassen, ohne Stufe 1 zu blockieren. Damit liegt nichts
|
||||
mehr außerhalb unserer Hand — **Stufe 1 kann beginnen.**
|
||||
|
||||
---
|
||||
|
||||
## Anhang — Vollständige Fundstellenliste
|
||||
|
||||
| Thema | Datei:Zeile |
|
||||
|---|---|
|
||||
| DPAPI | [SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42), `:66`, `:90`, `:94` |
|
||||
| DPAPI (Lizenz) | `LicenseLabrador/client-dotnet/.../StateStore.cs:43`, `:92` |
|
||||
| Zeitzone | [TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161), [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32) |
|
||||
| Pfad-Groß-/Kleinschreibung | [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72), [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174), [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155), [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384), [AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) |
|
||||
| `explorer.exe` | [frm_main.cs:1552](frm_main.cs:1552), `:1557`, `:1562`, [BackupPanel.cs:338](UI/BackupPanel.cs:338) |
|
||||
| `VisualBasic.InputBox` | [Program.cs:280](Program.cs:280), `:291`, [frm_main.cs:658](frm_main.cs:658) |
|
||||
| `.exe`-Werkzeugpfade | [SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782), `:825` |
|
||||
| WinForms-Timer | [BackupScheduler.cs:41](Services/BackupScheduler.cs:41), [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38), [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43), [frm_main.License.cs:41](frm_main.License.cs:41) |
|
||||
| WebView2 | [frm_main.cs:235](frm_main.cs:235), [frm_chat.cs:44](frm_chat.cs:44), [WebViewBridge.cs](UI/WebViewBridge.cs), [ClawdDotNet.csproj](ClawdDotNet.csproj) |
|
||||
| PropertyGrid | [frm_main.Designer.cs](frm_main.Designer.cs) (4×), [Models/](Models/) (246 Attribute) |
|
||||
| Datenablage | [SettingsManager.cs:24](Services/SettingsManager.cs:24), [ClawdDotNet.csproj](ClawdDotNet.csproj) (Build-Ziele) |
|
||||
| Testdaten mit Windows-Pfaden | [WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49) |
|
||||
| Build-Skript | [Deploy-Build.ps1](Deploy-Build.ps1) |
|
||||
@@ -0,0 +1,520 @@
|
||||
# Hardware-ID v2 — Implementierungsvorschlag
|
||||
|
||||
Stand: 2026-08-06. Betrifft `LicenseLabrador` (Client + Server) und die
|
||||
Aufrufseite in ClawdDotNet ([Services/LicenseGate.cs](Services/LicenseGate.cs)).
|
||||
|
||||
Anlass: Für den [Linux-Umzug](Linux-Portierung-Analyse.md) muss die
|
||||
Hardware-Bindung auf beiden Plattformen funktionieren. Bei der Durchsicht sind
|
||||
dabei zwei Probleme aufgefallen, die **nichts mit Linux zu tun haben**, aber
|
||||
denselben Code betreffen — die sollten in einem Zug mit erledigt werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Befund
|
||||
|
||||
### 1.1 Der Rechnername steckt im Hash — das ist das eigentliche Problem
|
||||
|
||||
[HardwareId.cs:43](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs):
|
||||
|
||||
```csharp
|
||||
rawBuilder.Append(Environment.MachineName); // "for system isolation"
|
||||
```
|
||||
|
||||
Folge: **Ein umbenannter Rechner ist eine neue Maschine.** Er verbraucht einen
|
||||
weiteren Aktivierungsplatz, und der alte bleibt für immer belegt
|
||||
(`max_activations` ist standardmäßig 2 — nach zwei Umbenennungen ist die Lizenz
|
||||
dicht). Das gilt bereits heute unter Windows.
|
||||
|
||||
Unter Linux wird daraus ein Totalausfall: In einem Container ist der Hostname
|
||||
standardmäßig die gekürzte Container-ID, also **bei jedem Start ein anderer**.
|
||||
Die Lizenz wäre nach dem zweiten `docker run` verbraucht.
|
||||
|
||||
Die Absicht („system isolation") ist auch nicht erfüllt: Der Rechnername steht
|
||||
ohnehin im Feld `hostname`, das der Server bei jeder Prüfung mitschreibt
|
||||
([LicenseService.php:112](../../LicenseLabrador/server/src/LicenseService.php)).
|
||||
Diagnostisch verlieren wir nichts, wenn er aus dem Hash verschwindet.
|
||||
|
||||
### 1.2 Die MAC-Ausweichlösung ist unter Linux instabil
|
||||
|
||||
[HardwareId.cs:86](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)
|
||||
nimmt die alphabetisch erste physische MAC. Unter Linux:
|
||||
|
||||
- Die Stoppwortliste kennt `docker` und `veth`, aber **nicht** `br-` (Bridges),
|
||||
`virbr` (libvirt), `cni`, `flannel`, `cali` (Kubernetes), `zt` (ZeroTier).
|
||||
- `NetworkInterfaceType` meldet unter Linux für die meisten virtuellen Geräte
|
||||
schlicht `Ethernet` — die Typprüfung greift also nicht.
|
||||
- Bridge- und veth-MACs werden von systemd **je Boot neu zufällig** vergeben.
|
||||
|
||||
Sortiert man solche Adressen mit, wechselt die Hardware-ID beim Neustart. Die
|
||||
Ausweichlösung ist damit unter Linux schlimmer als keine.
|
||||
|
||||
### 1.3 Der Zustandsspeicher fällt still auf Klartext zurück
|
||||
|
||||
[StateStore.cs:41](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)
|
||||
beim Lesen und `:90` beim Schreiben:
|
||||
|
||||
```csharp
|
||||
try { decryptedData = ProtectedData.Unprotect(rawData, null, ...); }
|
||||
catch { decryptedData = rawData; } // ← Klartext wird akzeptiert
|
||||
```
|
||||
|
||||
Unter Linux wirft DPAPI immer, also läuft alles über den Klartextzweig. Zwei
|
||||
Folgen:
|
||||
|
||||
- `SECURITY.md` behauptet, der Cache sei „strikt an die `hardware_id` gebunden".
|
||||
Das stimmt für die *Hülle* (die Prüfung in
|
||||
[LicenseClient.cs:198](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)),
|
||||
nicht für die Cache-Datei selbst.
|
||||
- Ernster: `max_seen_time` ist die Uhr-Rückdreh-Sperre
|
||||
([StateStore.cs:107](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)).
|
||||
Wer eine `state.dat` von Hand schreiben kann, setzt den Wert auf 0 und stellt
|
||||
die Systemuhr zurück. Der Klartext-Rückfall beim **Lesen** macht das möglich,
|
||||
und zwar auf jeder Plattform, auf der DPAPI nicht greift.
|
||||
|
||||
### 1.4 Ablageort bricht bei einem systemd-Dienst weg
|
||||
|
||||
[LicenseConfig.cs:22](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseConfig.cs)
|
||||
verwendet `Environment.GetFolderPath(SpecialFolder.ApplicationData)`. Läuft der
|
||||
Dienst unter `User=clawd` ohne Heimatverzeichnis, ist `HOME` nicht gesetzt und
|
||||
`GetFolderPath` liefert einen **leeren String**. `Path.Combine("", slug,
|
||||
"license")` ergibt einen relativen Pfad — die Lizenz landet im Arbeitsverzeichnis
|
||||
oder gar nicht.
|
||||
|
||||
### 1.5 Kein Formatkennzeichen, keine Plattformangabe
|
||||
|
||||
Die Hardware-ID ist heute ein nackter SHA-256-Hex-String. Es gibt keine
|
||||
Möglichkeit, im Server zu erkennen, aus welcher Quelle oder von welchem
|
||||
Betriebssystem eine Aktivierung stammt — und keinen Weg, das Format je zu
|
||||
wechseln, ohne alle bestehenden Aktivierungen zu verlieren.
|
||||
|
||||
**Randnotiz:** `OperatingSystemHelpers.IsWindows()` nutzt
|
||||
`Environment.OSVersion.Platform == PlatformID.Win32NT`. Das funktioniert
|
||||
zufällig richtig (Linux liefert `Unix`), ist aber die veraltete API.
|
||||
`RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` ist in netstandard2.0
|
||||
verfügbar und der korrekte Weg.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielbild: das Format
|
||||
|
||||
```
|
||||
2:<plattform>:<64 Hex-Zeichen>
|
||||
|
||||
Beispiele:
|
||||
2:win:9f3ab7c1… (Windows, MachineGuid)
|
||||
2:lin:41e0d5aa… (Linux, /etc/machine-id)
|
||||
2:lin:7c9182ff… (Linux, Vorgabe per Umgebungsvariable)
|
||||
```
|
||||
|
||||
68 Zeichen — passt in `activations.hardware_id VARCHAR(128)` ohne
|
||||
Schemaänderung. Der Doppelpunkt ist unproblematisch, die Spalte ist
|
||||
`utf8mb4_unicode_ci` und wird nur verglichen.
|
||||
|
||||
Der Hash selbst:
|
||||
|
||||
```
|
||||
sha256( "LicenseLabrador-HWID-v2" ‖ "\n" ‖ plattform ‖ "\n" ‖ quelle ‖ "\n" ‖ rohwert )
|
||||
```
|
||||
|
||||
- **Kein `MachineName`.** (1.1)
|
||||
- Die Domänenzeichenkette verhindert, dass derselbe Rohwert in anderem
|
||||
Zusammenhang wiederverwendbar ist.
|
||||
- `quelle` geht mit in den Hash: Findet der Client später eine bessere Quelle,
|
||||
ändert sich die ID bewusst und nachvollziehbar, statt zufällig.
|
||||
|
||||
Zusätzlich gehen drei neue Felder mit in die Anfrage — **nicht** in den Hash,
|
||||
nur zur Diagnose und für die Migration:
|
||||
|
||||
| Feld | Beispiel | Zweck |
|
||||
|---|---|---|
|
||||
| `hwid_version` | `2` | Formaterkennung serverseitig |
|
||||
| `hwid_source` | `machine-id` | Admin sieht, wie stabil die Bindung ist |
|
||||
| `legacy_hardware_id` | `<v1-Hash>` | Migration ohne Platzverlust (Abschnitt 4) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Quellen je Plattform
|
||||
|
||||
Reihenfolge = Priorität. Die erste Quelle, die einen nichtleeren, plausiblen Wert
|
||||
liefert, gewinnt.
|
||||
|
||||
### 3.1 Vorgabe (alle Plattformen, höchste Priorität)
|
||||
|
||||
```
|
||||
LicenseConfig.HardwareIdOverride (Code)
|
||||
LICENSELABRADOR_HWID (Umgebungsvariable)
|
||||
```
|
||||
|
||||
Quelle: `override`. Der Rohwert wird trotzdem gehasht, damit das Format
|
||||
einheitlich bleibt.
|
||||
|
||||
**Das ist der ehrliche Weg für Container und Serverbetrieb.** Heuristik kann dort
|
||||
nicht gewinnen — in einem Container gibt es keine Hardware, an die man binden
|
||||
könnte. Der Betreiber setzt einen stabilen Wert, hinterlegt ihn im
|
||||
Deployment-Geheimnis, und die Bindung ist so verlässlich wie dieser Wert. Eine
|
||||
Zeile in der systemd-Unit statt eines Ratespiels.
|
||||
|
||||
### 3.2 Windows
|
||||
|
||||
| # | Quelle | `hwid_source` |
|
||||
|---|---|---|
|
||||
| 1 | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (Registry64) | `machine-guid` |
|
||||
| 2 | Stabile physische MAC (Abschnitt 3.4) | `mac` |
|
||||
| 3 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` |
|
||||
|
||||
Unverändert zu heute — nur ohne `MachineName` im Hash.
|
||||
|
||||
### 3.3 Linux
|
||||
|
||||
| # | Quelle | `hwid_source` | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| 1 | `/etc/machine-id` | `machine-id` | Von systemd bei der Installation erzeugt, überlebt Neustarts und Kernel-Updates. Die richtige Wahl auf einem echten System. |
|
||||
| 2 | `/var/lib/dbus/machine-id` | `dbus-machine-id` | Ältere Systeme ohne systemd. |
|
||||
| 3 | `/sys/class/dmi/id/product_uuid` | `dmi-uuid` | SMBIOS-UUID, echte Hardware-Bindung. **Meist nur für root lesbar** (`0400`) — Versuch in `try` einpacken, kein Fehler wenn nicht lesbar. Bei VMs vom Hypervisor gesetzt und dort stabil. |
|
||||
| 4 | Stabile physische MAC (Abschnitt 3.4) | `mac` | |
|
||||
| 5 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | |
|
||||
|
||||
Zwei Fallen bei `/etc/machine-id`, die geprüft werden müssen:
|
||||
|
||||
- **Leer oder nur Zeilenumbruch.** Auf Systemen mit `systemd-firstboot` oder in
|
||||
manchen Images existiert die Datei, ist aber leer. Muss als „nicht vorhanden"
|
||||
behandelt werden, nicht als gültiger Wert — sonst haben *alle* diese
|
||||
Installationen dieselbe ID.
|
||||
- **Der Wert `uninitialized`.** Genau diese Zeichenkette schreibt systemd, wenn
|
||||
die ID im laufenden Betrieb noch nicht festgelegt ist. Ebenfalls verwerfen.
|
||||
|
||||
```csharp
|
||||
private static bool IsPlausibleMachineId(string? v)
|
||||
=> !string.IsNullOrWhiteSpace(v)
|
||||
&& v.Trim().Length >= 16
|
||||
&& !v.Trim().Equals("uninitialized", StringComparison.OrdinalIgnoreCase)
|
||||
&& v.Trim().Trim('0').Length > 0; // nicht alles Nullen
|
||||
```
|
||||
|
||||
### 3.4 MAC-Ausweichlösung, überarbeitet
|
||||
|
||||
Die heutige Fassung nimmt `FirstOrDefault()` der sortierten Liste. Wenn eine
|
||||
Schnittstelle dazukommt oder wegfällt, kann sich damit die gewählte MAC ändern.
|
||||
Besser: **alle** gültigen MACs sortiert verketten — dann ändert sich der Wert
|
||||
nur, wenn sich die Netzwerkausstattung wirklich ändert, und nicht schon, weil
|
||||
eine Adresse hinzukommt, die vorher sortiert davor lag.
|
||||
|
||||
Stoppwortliste erweitern um: `br-`, `virbr`, `cni`, `flannel`, `cali`, `weave`,
|
||||
`zt`, `tailscale`, `ipsec`, `sit`, `gre`, `dummy`, `bond`, `macvlan`, `ovs`.
|
||||
|
||||
Zusätzlich hart ausschließen (unabhängig vom Namen):
|
||||
|
||||
- Schnittstellen mit gesetztem **„locally administered"-Bit** (zweites Bit des
|
||||
ersten Oktetts, `mac[0] & 0x02`). Genau das setzt systemd bei zufällig
|
||||
erzeugten MACs für veth und Bridges. Ein sauberer, namensunabhängiger Filter —
|
||||
und der wirksamste von allen.
|
||||
- Unter Linux zusätzlich prüfen: existiert
|
||||
`/sys/class/net/<name>/device`? Fehlt das Verzeichnis, hat die Schnittstelle
|
||||
kein physisches Gerät und ist virtuell. Das ist zuverlässiger als jede
|
||||
Namensliste.
|
||||
|
||||
```csharp
|
||||
// Namensunabhängig: zufällig erzeugte MACs tragen dieses Bit.
|
||||
private static bool IsLocallyAdministered(PhysicalAddress addr)
|
||||
{
|
||||
var b = addr.GetAddressBytes();
|
||||
return b.Length > 0 && (b[0] & 0x02) != 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Erzeugte Datei als letzte Stufe
|
||||
|
||||
`<StorageDirectory>/machine.key` — 32 Zufallsbytes, Base64, Dateirechte `0600`.
|
||||
Wird nur angelegt, wenn keine Quelle davor greift.
|
||||
|
||||
Das ist eine **Installations-** und keine Hardware-Bindung. Für einen Container
|
||||
ohne Vorgabe ist das aber die Wahrheit, und mit einem gemounteten Datenverzeichnis
|
||||
bleibt sie über Container-Neustarts stabil. `hwid_source` = `keyfile` macht dem
|
||||
Admin sichtbar, dass diese Aktivierung schwächer gebunden ist als die anderen.
|
||||
|
||||
Wichtig: Die Datei gehört ins **Datenverzeichnis**, nicht neben die
|
||||
Programmdatei. Sonst ist sie bei jedem Deployment weg.
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration v1 → v2 ohne Platzverlust
|
||||
|
||||
Der Kern: Der Client kennt **beide** IDs und schickt beide mit. Der Server zieht
|
||||
die alte Aktivierung auf die neue ID um, statt eine zweite anzulegen.
|
||||
|
||||
**Client** — `HardwareId` bekommt neben `GetHardwareId()` (v2) ein
|
||||
`GetLegacyHardwareId()`, das die heutige v1-Berechnung *unverändert* beibehält
|
||||
(inklusive `MachineName`, damit sie zu bestehenden Aktivierungen passt). Beides
|
||||
geht in die Anfrage:
|
||||
|
||||
```csharp
|
||||
hardware_id = "2:lin:41e0…",
|
||||
legacy_hardware_id = "8fa2…", // nur solange v1-Aktivierungen existieren
|
||||
hwid_version = 2,
|
||||
hwid_source = "machine-id",
|
||||
```
|
||||
|
||||
**Server** — in `LicenseService::validate`, an der Stelle der heutigen Suche
|
||||
([LicenseService.php:100](../../LicenseLabrador/server/src/LicenseService.php)):
|
||||
|
||||
```
|
||||
1. Aktivierung mit hardware_id = <v2> suchen
|
||||
→ gefunden: normaler Weg (last_seen, hostname, app_version aktualisieren)
|
||||
|
||||
2. nicht gefunden, und legacy_hardware_id ist gesetzt:
|
||||
Aktivierung mit hardware_id = <v1> suchen
|
||||
→ gefunden: UPDATE activations SET hardware_id = <v2>, hwid_version = 2,
|
||||
hwid_source = <quelle> WHERE id = …
|
||||
+ audit_log-Eintrag 'hwid_migrated'
|
||||
→ weiter wie unter 1. KEIN neuer Platz verbraucht.
|
||||
|
||||
3. weder noch: neue Aktivierung anlegen, max_activations prüfen (wie heute)
|
||||
```
|
||||
|
||||
Damit wandern alle bestehenden Windows-Installationen beim ersten Start nach dem
|
||||
Update lautlos auf v2 — niemand merkt etwas, kein Aktivierungsplatz geht
|
||||
verloren. Das `legacy_hardware_id`-Feld kann nach einer Übergangszeit (etwa zwei
|
||||
Veröffentlichungen) aus dem Client fallen.
|
||||
|
||||
### 4.1 Der lokale Cache muss einmal verworfen werden
|
||||
|
||||
Nicht übersehen: Die Hardware-ID geht in zwei weitere Berechnungen ein —
|
||||
`CalculateHmac(state, licenseKey, _hardwareId)` für die Prüfsumme in
|
||||
`LicenseResult`
|
||||
([LicenseClient.cs:272](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs))
|
||||
und den Seed des Speicherschutzes (`:287`). Nach dem Formatwechsel schlägt
|
||||
`VerifyChecksum` für jede zwischengespeicherte Hülle fehl.
|
||||
|
||||
Das ist kein Fehler, sondern erwartet — muss aber als **Cache-Fehltreffer**
|
||||
behandelt werden (einmal online neu prüfen), nicht als
|
||||
`TamperSuspected`. Sonst sperrt sich jede bestehende Installation beim ersten
|
||||
Start nach dem Update selbst aus. Der Weg dorthin: Cache-Version im
|
||||
`LocalCacheData` mitführen (`schema_version: 2`) und einen Datensatz mit
|
||||
abweichender Version verwerfen, bevor die Prüfsumme überhaupt geprüft wird.
|
||||
|
||||
### 4.2 Schemaerweiterung
|
||||
|
||||
```sql
|
||||
ALTER TABLE activations
|
||||
ADD COLUMN hwid_version TINYINT NOT NULL DEFAULT 1 AFTER hardware_id,
|
||||
ADD COLUMN hwid_source VARCHAR(32) NULL AFTER hwid_version,
|
||||
ADD COLUMN platform VARCHAR(8) NULL AFTER hwid_source;
|
||||
```
|
||||
|
||||
Alles mit Vorgabewerten, also rückwärtskompatibel — ein alter Client, der die
|
||||
Felder nicht schickt, funktioniert unverändert weiter.
|
||||
|
||||
---
|
||||
|
||||
## 5. Umzug Windows → Linux
|
||||
|
||||
Das ist etwas anderes als die Formatmigration: hier wechselt die Maschine
|
||||
wirklich, die ID muss sich also ändern. Drei Wege, alle drei sinnvoll parallel:
|
||||
|
||||
### 5.1 Der Normalfall braucht gar nichts
|
||||
|
||||
`max_activations` ist standardmäßig **2**. Ein Windows-Entwicklungsrechner und
|
||||
ein Linux-Server passen also ohne jeden Eingriff hinein. Für den anstehenden
|
||||
Umzug ist das wahrscheinlich die ganze Antwort — die anderen beiden Punkte sind
|
||||
für den Fall danach.
|
||||
|
||||
### 5.2 Abmelden vor dem Umzug (existiert, aber nicht erreichbar)
|
||||
|
||||
`LicenseService::deactivate` löscht die Aktivierungszeile und gibt den Platz frei
|
||||
([LicenseService.php:153](../../LicenseLabrador/server/src/LicenseService.php)),
|
||||
und `LicenseClient.DeactivateAsync` ruft es auf. In ClawdDotNet ist die Methode
|
||||
aber nur über [LicenseGate.cs:39](Services/LicenseGate.cs) erreichbar und dort
|
||||
an die GUI gebunden.
|
||||
|
||||
Nachzuliefern: ein Kommandozeilenschalter am Host, damit das auch ohne
|
||||
Oberfläche geht.
|
||||
|
||||
```bash
|
||||
clawddotnet --license-deactivate
|
||||
```
|
||||
|
||||
Das braucht der kopflose Betrieb ohnehin (siehe
|
||||
[Linux-Portierung-Analyse.md](Linux-Portierung-Analyse.md), 2.6 — der
|
||||
Lizenzdialog ist ein `MessageBox`, der einen Dienst blockieren würde).
|
||||
|
||||
### 5.3 Umbinden aus der Verwaltung (fehlt noch)
|
||||
|
||||
Für den Fall, dass die alte Maschine schon weg ist: In
|
||||
`public/admin/license_detail.php` je Aktivierungszeile eine Schaltfläche
|
||||
**„Aktivierung freigeben"** (löscht die Zeile, gibt den Platz frei). Ein echtes
|
||||
„Umbinden" auf eine bekannte neue ID ist unnötig — Freigeben plus Neuaktivierung
|
||||
auf dem Zielsystem ist derselbe Vorgang mit weniger Code und einer klareren
|
||||
Spur im Prüfprotokoll.
|
||||
|
||||
Beides sollte in `audit_log` landen, mit altem und neuem Wert.
|
||||
|
||||
---
|
||||
|
||||
## 6. Zustandsspeicher härten
|
||||
|
||||
Zusammen mit dem HW-ID-Umbau, weil dieselbe Datei betroffen ist und die
|
||||
Verschlüsselung den HW-ID als Schlüsselmaterial braucht.
|
||||
|
||||
**Format** — feste Hülle statt „mal so, mal so":
|
||||
|
||||
```
|
||||
Magic "LLS2" (4 Byte) │ Nonce (12) │ Ciphertext │ GCM-Tag (16)
|
||||
```
|
||||
|
||||
- **AES-GCM**, Schlüssel abgeleitet aus HW-ID + `ProductSlug` per HKDF-SHA256.
|
||||
- Auf Windows das Ergebnis **zusätzlich** in DPAPI wickeln (Gürtel und
|
||||
Hosenträger, kostet nichts).
|
||||
- Dateirechte `0600` auf Unix.
|
||||
|
||||
**Der entscheidende Punkt: den Klartext-Rückfall beim Lesen entfernen.** Eine
|
||||
Datei, die sich nicht entschlüsseln oder nicht authentifizieren lässt, ist
|
||||
**kein Cache** — sie wird verworfen und der Client prüft online. Nicht als
|
||||
Klartext akzeptieren. Genau dieser Rückfall macht heute die
|
||||
Uhr-Rückdreh-Sperre umgehbar (1.3).
|
||||
|
||||
Einmalig weiterhin lesbar bleiben muss das alte Format (Datei ohne `LLS2`-Magic):
|
||||
einlesen, in v2 neu schreiben, fertig. Nach einer Veröffentlichung kann der Pfad
|
||||
weg.
|
||||
|
||||
### 6.1 netstandard2.0 hat kein AesGcm — Empfehlung: mehrfach zielen
|
||||
|
||||
`System.Security.Cryptography.AesGcm` gibt es erst ab .NET Core 3.0, `HKDF` erst
|
||||
ab .NET 5, `File.SetUnixFileMode` erst ab .NET 7. Das Projekt zielt heute auf
|
||||
`netstandard2.0`.
|
||||
|
||||
Zwei Wege:
|
||||
|
||||
1. **`<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>`** —
|
||||
*empfohlen*. ClawdDotNet (net10.0) zieht automatisch das net8.0-Ziel und
|
||||
bekommt `AesGcm`, `HKDF` und `File.SetUnixFileMode` ohne Umwege. Der
|
||||
netstandard2.0-Zweig bleibt für andere Abnehmer erhalten und nutzt dort
|
||||
BouncyCastle. Kosten: ein paar `#if NET8_0_OR_GREATER`-Blöcke an genau drei
|
||||
Stellen.
|
||||
2. **Durchgängig BouncyCastle** (`GcmBlockCipher`, `HkdfBytesGenerator`) — die
|
||||
Bibliothek ist mit `BouncyCastle.Cryptography` bereits als Abhängigkeit da,
|
||||
also kein neues Paket. Kein Mehrfachziel nötig, aber die Dateirechte bleiben
|
||||
ein Problem: `chmod` müsste per P/Invoke laufen.
|
||||
|
||||
Weg 1 ist sauberer, weil er nebenbei das Dateirechte-Problem löst.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ablageort (1.4)
|
||||
|
||||
Auflösungskette in `LicenseConfig.StorageDirectory`, erste nutzbare gewinnt:
|
||||
|
||||
1. Explizit gesetzter Wert (ClawdDotNet setzt ihn künftig — der Host hat ohnehin
|
||||
eine eigene XDG-Auflösung).
|
||||
2. `LICENSELABRADOR_STORAGE_DIR`.
|
||||
3. Unix: `$XDG_CONFIG_HOME/<slug>/license`, sonst `$HOME/.config/<slug>/license`.
|
||||
4. Windows: `SpecialFolder.ApplicationData` wie heute.
|
||||
5. Letzter Ausweg: `<AppContext.BaseDirectory>/license`.
|
||||
|
||||
**Und in jedem Fall: nie einen leeren Pfad durchlassen.** Der heutige Code kann
|
||||
`Path.Combine("", …)` erzeugen, ohne dass es auffällt. Ein `if
|
||||
(string.IsNullOrEmpty(...)) throw` an dieser Stelle ist besser als eine
|
||||
Lizenzdatei, die im Arbeitsverzeichnis landet und beim nächsten Start nicht mehr
|
||||
gefunden wird.
|
||||
|
||||
---
|
||||
|
||||
## 8. Änderungsliste
|
||||
|
||||
### LicenseLabrador — Client
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| `HardwareId.cs` | Neuschreiben: v2-Format, Quellenkette je Plattform, `GetLegacyHardwareId()`, `HwidSource`/`Platform` als Eigenschaften, MAC-Filter (locally-administered-Bit, `/sys/class/net/*/device`), Plausibilitätsprüfung für machine-id, `machine.key`-Erzeugung |
|
||||
| `LicenseConfig.cs` | `HardwareIdOverride`, Auflösungskette für `StorageDirectory`, leeren Pfad ausschließen |
|
||||
| `StateStore.cs` | `LLS2`-Hülle, AES-GCM, `schema_version`, **Klartext-Rückfall beim Lesen entfernen**, v1-Einmalmigration, `0600` |
|
||||
| `LicenseClient.cs` | Neue Felder in `validate`/`deactivate` senden; Cache mit abweichender `schema_version` als Fehltreffer behandeln, **nicht** als `TamperSuspected` |
|
||||
| `OperatingSystemHelpers` | `RuntimeInformation.IsOSPlatform`, dazu `IsLinux()`/`IsMacOs()` |
|
||||
| `LicenseLabrador.Client.csproj` | `netstandard2.0;net8.0` |
|
||||
|
||||
### LicenseLabrador — Server
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| `sql/schema.sql` + Migrationsskript | `hwid_version`, `hwid_source`, `platform` |
|
||||
| `src/LicenseService.php` | `legacy_hardware_id` entgegennehmen; Migrationssuche (Abschnitt 4); neue Felder speichern |
|
||||
| `src/Audit.php` | Ereignisart `hwid_migrated`, `activation_released` |
|
||||
| `public/admin/license_detail.php` | Quelle/Plattform je Aktivierung anzeigen, „Aktivierung freigeben" |
|
||||
| `docs/SECURITY.md` | Aussage zur Cache-Bindung korrigieren (1.3) |
|
||||
|
||||
### ClawdDotNet
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| [Services/LicenseGate.cs](Services/LicenseGate.cs) | `StorageDirectory` explizit setzen; `MessageBox`/`frm_License` hinter eine Schnittstelle (`ILicensePrompt`) legen, damit der kopflose Host eine Konsolenfassung einsetzen kann |
|
||||
| Host (neu) | `--license-deactivate`, `--license-set-key`, `--license-status` |
|
||||
| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Format v2 und Migrationsweg nachtragen |
|
||||
|
||||
---
|
||||
|
||||
## 9. Testplan
|
||||
|
||||
Das Wichtigste zuerst — die Fälle, die heute schiefgehen würden:
|
||||
|
||||
| Fall | Erwartung |
|
||||
|---|---|
|
||||
| Rechner umbenennen | **ID unverändert** (Kern von 1.1) |
|
||||
| Container zweimal starten, `/etc/machine-id` im Abbild | beide Male dieselbe ID |
|
||||
| Container ohne `machine-id`, Datenverzeichnis gemountet | ID über Neustarts stabil, `hwid_source = keyfile` |
|
||||
| Container ohne `machine-id`, **ohne** Mount | ID wechselt — muss so sein, und im Protokoll erkennbar |
|
||||
| `LICENSELABRADOR_HWID` gesetzt | gewinnt gegen alles, `hwid_source = override` |
|
||||
| `/etc/machine-id` leer bzw. `uninitialized` | wird verworfen, nächste Quelle greift |
|
||||
| Docker-Bridge und veth vorhanden, keine machine-id | MAC-Wahl ignoriert sie, ID über Neustart stabil |
|
||||
| Bestehende v1-Windows-Aktivierung, Client aktualisiert | Zeile wird auf v2 umgeschrieben, `max_activations` unverändert, Prüfprotokolleintrag |
|
||||
| v1-Cache-Datei nach dem Update | einmal online geprüft, dann v2-Cache — **kein** `TamperSuspected` |
|
||||
| `state.dat` von Hand mit `max_seen_time = 0` | Datei wird verworfen, Uhr-Rückdreh-Sperre bleibt wirksam |
|
||||
| systemd-Dienst ohne `HOME` | Ablageort auflösbar, keine Datei im Arbeitsverzeichnis |
|
||||
| Alter Client gegen neuen Server | funktioniert unverändert (Felder haben Vorgabewerte) |
|
||||
| Neuer Client gegen alten Server | funktioniert, Zusatzfelder werden ignoriert |
|
||||
|
||||
Die letzten beiden Zeilen sind nicht optional: Client und Server werden nicht
|
||||
gleichzeitig ausgerollt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Aufwand
|
||||
|
||||
| Block | PT |
|
||||
|---|---:|
|
||||
| `HardwareId` v2 samt Quellenkette, MAC-Filter, `machine.key` | 2–3 |
|
||||
| Client mehrfach zielen + `StateStore`-Härtung | 2–3 |
|
||||
| Server: Migrationssuche, Schema, Prüfprotokoll, Verwaltungsansicht | 2–3 |
|
||||
| ClawdDotNet: `ILicensePrompt`, Lizenz-Kommandozeile | 1–2 |
|
||||
| Tests (Container-Fälle brauchen echtes Docker) und Abnahme | 1–2 |
|
||||
| **Summe** | **8–13** |
|
||||
|
||||
Das ist mehr als die 3–5 PT, die in der Linux-Analyse für „Lizenz" standen —
|
||||
weil dort nur die Plattformverträglichkeit gerechnet war. Die Punkte 1.1 und 1.3
|
||||
sind bestehende Fehler, die unabhängig vom Umzug behoben werden sollten; sie
|
||||
machen den Unterschied aus.
|
||||
|
||||
Der Block ist **unabhängig vom übrigen Linux-Umzug** und kann sofort beginnen —
|
||||
er hängt an keiner der offenen GUI-Entscheidungen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Was ich anders machen würde als heute — kurz begründet
|
||||
|
||||
Drei Entscheidungen im Vorschlag verdienen eine Begründung, weil sie vom
|
||||
bisherigen Ansatz abweichen:
|
||||
|
||||
**Rechnername raus.** Er ist der Grund, warum die heutige Bindung fragiler ist
|
||||
als nötig, und er trägt nichts bei, was `activations.hostname` nicht schon
|
||||
festhält. Eine Bindung, die bei einer Umbenennung bricht, bindet nicht an
|
||||
Hardware, sondern an eine Konfiguration.
|
||||
|
||||
**Vorgabe per Umgebungsvariable statt besserer Heuristik für Container.** Man
|
||||
kann eine Container-Umgebung nicht sinnvoll erraten — es gibt dort keine
|
||||
Hardware. Jede zusätzliche Heuristik verschiebt nur, wo es falsch wird. Eine
|
||||
explizite Vorgabe ist ein bewusster Betreiberentscheid, in der Unit-Datei
|
||||
sichtbar, im Prüfprotokoll nachvollziehbar.
|
||||
|
||||
**Kein Klartext-Rückfall, auch nicht „zur Sicherheit".** Der heutige Rückfall
|
||||
sollte Robustheit bringen, kostet aber genau die Eigenschaft, für die der Cache
|
||||
existiert. Ein verworfener Cache bedeutet: einmal online prüfen. Das ist der
|
||||
mildere Schaden — und wer keine Verbindung hat, hat immer noch die
|
||||
Offline-Gnadenfrist aus der signierten Hülle, die von dieser Datei nicht abhängt.
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
# Roadmap
|
||||
|
||||
Zentrale Liste aller offenen Vorhaben. Sie löst die beiden „Vorgeschlagene
|
||||
Reihenfolge"-Abschnitte in der [Bestandsaufnahme](Bestandsaufnahme-2026-07.md) und im
|
||||
[Konzepte-Dokument](Konzepte-Backup-Finanz-Analyse.md) ab — die bleiben als Befund bzw.
|
||||
Konzept bestehen, gepflegt wird nur noch hier.
|
||||
|
||||
Kürzel (S4, K2, T4, F-A1, …) verweisen auf die Bestandsaufnahme.
|
||||
|
||||
---
|
||||
|
||||
## A — Beschlossen (aus dem OpenAlice-Vergleich, Juli 2026)
|
||||
|
||||
Hintergrund: Konzeptvergleich mit [OpenAlice](https://github.com/TraderAlice/OpenAlice)
|
||||
(AGPL-3.0 — Konzepte übernehmen ja, Code nein). Übernommen werden Taskboard,
|
||||
Staging-Freigabe, Audit-Log und das Skill-Modell. Die Inbox-Idee entfällt zugunsten
|
||||
der geplanten Matrix-Migration (A5).
|
||||
|
||||
### A1 — Taskboard
|
||||
|
||||
Aufgaben als Markdown-Dateien mit YAML-Frontmatter im `SharedWorkspace`:
|
||||
`title`, `status` (`backlog | todo | in_progress | done | canceled`), `priority`,
|
||||
`assignee`, optional `when` (`at` | `every` | `cron` **mit Zeitzone**).
|
||||
|
||||
- **Scanner statt Delay-Schleifen**: Ein Takt (~60 s) prüft, was fällig ist.
|
||||
Persistiert werden nur Last-Fired-Marker — ein fehlgeschlagener Lauf bleibt der
|
||||
einzige Versuch für diesen Termin, kein automatischer Retry-Sturm.
|
||||
- **Assignee bestimmt die Ausführung**: `@new` = frischer Lauf ohne Historie,
|
||||
`@<agent>` = bestehender Agent mit seinem Kontext, `@human` = wartet auf uns.
|
||||
Das ersetzt das implizite `UseChatContext`-Flag (T7) durch eine explizite Angabe
|
||||
am Auftrag.
|
||||
- **Agenten-Tool**: `task_create`, `task_list`, `task_update`, `task_comment`.
|
||||
Agent-zu-Agent-Delegation läuft künftig über Tasks statt über rekursives
|
||||
`send_message`.
|
||||
- **Migration**: Die improvisierten `coordination/*.md`-Dateien der Agenten
|
||||
(task_*, status_*, broadcast) gehen im Taskboard auf.
|
||||
|
||||
**Detailbauplan** (aus dem Fünf-Repo-Vergleich, Juli 2026 beschlossen):
|
||||
|
||||
- Status zusätzlich mit **`in_review`**; im Frontmatter **`require_approval`**
|
||||
(Task gilt erst nach Review als done) und **`acceptance`** (Abnahmekriterien,
|
||||
gegen die das Ergebnis geprüft wird).
|
||||
- **Task-Typen `approval` und `human_input`** — ein Mensch ist einfach ein
|
||||
Assignee; seine Antwort ist das Task-Ergebnis und Input für Folgetasks.
|
||||
- **Atomares Claiming**: Die DB verhindert, dass zwei Läufe denselben Task
|
||||
ziehen. Der Scanner arbeitet mit **Claim-before-run** (at-most-once — ein
|
||||
doppelter Tick findet den Claim bereits vergeben) und
|
||||
**Startup-Reconciliation**: Beim Start wird Soll (Frontmatter) gegen Ist
|
||||
(Marker/Claims) abgeglichen, verpasste Läufe werden erkannt statt still
|
||||
übersprungen.
|
||||
- **`blocked_by`-Abhängigkeiten** mit Auto-Dispatch: Wird der letzte Blocker
|
||||
fertig, wird der wartende Task automatisch angestoßen. Meldet ein Agent einen
|
||||
Blocker, fällt der Task und der Zuständige (Lead/Benutzer) wird benachrichtigt
|
||||
(**Blocker-Eskalation**).
|
||||
- **Reopen-/Feedback-Semantik**: Ergebnis + Kritik gehen per `task_comment` an
|
||||
denselben Agenten zur Nachbesserung zurück, statt einen neuen Task von vorn
|
||||
zu beginnen.
|
||||
|
||||
Damit erledigt oder aufgegangen:
|
||||
|
||||
| Punkt | Warum |
|
||||
|---|---|
|
||||
| F-A5 Task-Queue | das Taskboard **ist** die Queue |
|
||||
| F-A4 Run-Historie | Läufe werden am Task verknüpft und persistiert |
|
||||
| B8 Rekursion `send_message` | Delegation über Tasks ist strukturell zyklenfrei |
|
||||
| B6 `Task.Delay`-Überlauf | Scanner-Modell kennt keine langen Delays |
|
||||
| B7 Cron in Lokalzeit | Frontmatter-`when` ist zeitzonen-explizit |
|
||||
| T7 `RunAsync` vs. `ChatAsync` | Assignee-Semantik beantwortet die Frage |
|
||||
|
||||
Verzahnung: Das Marktkalender-Flag (`onlyWhenMarketOpen`, siehe C1) gehört ins
|
||||
Frontmatter, nicht in einen eigenen Mechanismus.
|
||||
|
||||
Konzept-Doc: [Taskboard-Konzept](Taskboard-Konzept.md) (Dateiformat,
|
||||
Wahrheitsaufteilung Datei/DB, Scanner-Verhalten, Invarianten, Migration).
|
||||
|
||||
### A2 — Staging-Freigabe für irreversible Aktionen (F-A1 + S4)
|
||||
|
||||
Konzept-Doc: [Staging-Konzept](Staging-Konzept.md).
|
||||
|
||||
Irreversible Aktionen (Mail senden, X posten, DB-Schreibzugriff, Datei löschen,
|
||||
perspektivisch Orders) werden **gestaged statt ausgeführt**: Vorschlag → Review im
|
||||
Hauptfenster → Freigabe/Ablehnung. Pro Tool/Aktion konfigurierbar:
|
||||
`auto | approve | deny`.
|
||||
|
||||
Das bisher wirkungslose `PermissionGate` (S4) wird dabei zum zentralen
|
||||
Durchsetzungspunkt ausgebaut: Policy-Prüfung, Staging-Entscheidung und Audit-Hook
|
||||
(A3) an einer Stelle statt ad-hoc in jedem Tool. S4 wird nicht separat bearbeitet,
|
||||
sondern geht hier auf.
|
||||
|
||||
Ergänzungen (Juli 2026 beschlossen):
|
||||
|
||||
- **Plan-Freeze**: Freigegeben wird ein eingefrorener, konkreter Aufruf — Tool,
|
||||
Aktion und exakte Argumente zum Zeitpunkt des Stagings. Ausgeführt wird genau
|
||||
das Eingefrorene; jede nachträgliche Änderung ist eine neue Freigabe.
|
||||
- **Approval-Records**: Jede Entscheidung (Freigabe wie Ablehnung) wird als
|
||||
Datensatz im Audit-Log (A3) verankert — wer, wann, was, mit welchem Ergebnis.
|
||||
|
||||
Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen Vorschlag
|
||||
erzeugen, keine Ausführung.
|
||||
|
||||
### A3 — Audit-Log (F-A2)
|
||||
|
||||
Konzept-Doc: [Audit-Konzept](Audit-Konzept.md).
|
||||
|
||||
Jeder Tool-Aufruf wird protokolliert: Agent, Lauf/Session, Zeitstempel, Argumente,
|
||||
Ergebnis-Status. Append-only (JSONL oder SQLite-Tabelle auf dem vorhandenen
|
||||
`SqliteStorage`).
|
||||
|
||||
Designregeln (aus dem OpenAlice-Provenance-Konzept):
|
||||
|
||||
- Herkunft wird **von der Engine gestempelt**, nie vom Agenten behauptet.
|
||||
- Einträge sind unveränderlich; Korrekturen sind neue Einträge.
|
||||
- Unbekannte Herkunft wird als unbekannt markiert, nicht geraten.
|
||||
- Worker-Typ (Modell/Engine) und verantwortliche Session sind getrennte Begriffe.
|
||||
|
||||
Ergänzung (Juli 2026 beschlossen) — **Receipts**: Jeder abgeschlossene Task/Lauf
|
||||
erhält einen Abschluss-Beleg mit Ergebnis-Verweis, Schritten, Tokens und Kosten
|
||||
(Verknüpfung `RunUsage` ↔ Task). Damit fällt C7 („Kosten pro Ergebnis")
|
||||
weitgehend als Abfallprodukt ab.
|
||||
|
||||
Das Audit-Log ist zugleich das Fundament für das Ergebnisregister (C7) und die
|
||||
Tool-Fehlerquote aus der Leistungsanalyse.
|
||||
|
||||
### A4 — Skill-/Toolset-Modell (ersetzt T6)
|
||||
|
||||
Dreischichtig statt „alles immer im System-Prompt":
|
||||
|
||||
1. **Dauerhafter Kern** — Identity, Soul, unveränderliche Regeln. Schlank, damit der
|
||||
Prompt-Cache (T1) stabil bleibt.
|
||||
2. **Nachladbare Skills/Toolsets** — fachliche Abläufe und selten genutzte Tools
|
||||
werden erst auf Anforderung geladen (`list_toolsets` → `load_toolset`).
|
||||
3. **Selbstkorrigierende Tool-Fehler** — Fehlermeldungen nennen die gültigen
|
||||
Parameter/Aktionen, statt das Modell raten zu lassen.
|
||||
|
||||
Mechanik (Juli 2026 beschlossen, nach GoClaw-Vorbild): Skills liegen als
|
||||
`SKILL.md` mit Frontmatter (`name`, `description`) im Instanz- bzw.
|
||||
Agenten-Verzeichnis. Bei wenigen Skills werden die Kurzbeschreibungen inline in
|
||||
den Prompt eingebettet, bei vielen gibt es stattdessen ein `skill_search`-Tool.
|
||||
Änderungen an Skill-Dateien werden per Hot-Reload übernommen.
|
||||
|
||||
Dazu Hermes' Selbstverbesserungs-Idee: **Agenten dürfen Skills aus Erfahrung
|
||||
selbst schreiben** (das AgentEditor-Tool ist die Vorstufe). Wichtig: Ein Skill
|
||||
ist Prompt-Input — agentengeschriebene Skills werden erst nach Freigabe (A2)
|
||||
aktiv, sonst wäre das ein Injection-Kanal in künftige Läufe.
|
||||
|
||||
### A5 — Matrix/Element-Migration
|
||||
|
||||
Beschlossene Richtung: Die Kommunikation (Benachrichtigungen, Berichte, Chat mit
|
||||
Agenten) wird auf Element/Matrix umgestellt.
|
||||
|
||||
- Ersetzt die im OpenAlice-Vergleich erwogene Inbox — Berichte landen in
|
||||
Matrix-Räumen.
|
||||
- Der Tool-Kandidat **Notify** entfällt und geht hierin auf.
|
||||
- Betroffen: Telegram-Tool (Rolle klären), WebView-Chat (bleibt er Haupt-UI?),
|
||||
Streaming K4 (Matrix streamt nicht — nach dieser Entscheidung neu bewerten).
|
||||
|
||||
Scope ist noch unbestimmt — braucht ein eigenes Konzept-Doc, bevor es in die
|
||||
Reihenfolge eingeordnet wird.
|
||||
|
||||
### A6 — MySQL-Replikations-Spiegel (optional)
|
||||
|
||||
Beschlossen Juli 2026. **SQLite bleibt die einzige Wahrheit** — gearbeitet wird
|
||||
ausschließlich auf der Instanz-DB. MySQL ist ein reiner, nachgelagerter Spiegel:
|
||||
Er empfängt nur INSERT/UPDATE/DELETE vom Replikator; die App liest im Betrieb
|
||||
**nie** daraus. Einziger Lesezweck: Wiederherstellung, falls die SQLite korrupt
|
||||
ist — daneben steht der Spiegel externen Auswertungen (Dashboards, Ad-hoc-SQL)
|
||||
offen, ohne die Agenten-Maschine zu berühren.
|
||||
|
||||
Leitplanken:
|
||||
|
||||
- **Outbox-Muster** an der vorhandenen Schreib-Warteschlange von `SqliteStorage`:
|
||||
Replikations-Einträge lokal puffern, idempotente Upserts nach MySQL.
|
||||
Nie blockierend — ist MySQL nicht erreichbar, staut die Outbox und holt auf.
|
||||
- Mehrere Instanzen replizieren in denselben Spiegel; Zeilen tragen `instance_id`.
|
||||
- **Restore-Pfad (Spiegel → frische SQLite) muss existieren und getestet sein** —
|
||||
gleiche Regel wie beim Backup: ein ungeprüftes Restore ist eine Vermutung.
|
||||
- Kein Koordinationspunkt: Claiming, Locks, Taskboard-Zustand bleiben lokal.
|
||||
Die Regel „nur der Replikator schreibt, niemand liest im Betrieb" gehört ins
|
||||
Konzept-Doc.
|
||||
- Sicherheit: TLS zur Datenbank, Zugangsdaten über `SecretProtector`;
|
||||
Output-Scrubbing der Tool-Ergebnisse wird wichtiger, weil Kontext-Daten
|
||||
künftig auch im Spiegel liegen.
|
||||
- Ergänzt das ZIP-Backup, ersetzt es nicht — Identity/Soul, Settings, Workspace
|
||||
und Telegram-Session bleiben Sache des Instanz-Backups.
|
||||
|
||||
**Voraussetzung — Historie-Umzug:** `ChatHistory.json` zieht in die Instanz-DB um
|
||||
(aktive Tabelle + Archiv-Tabelle mit FTS5-Volltextindex und `history_search`-Tool,
|
||||
siehe K6 in Abschnitt B). Das löst
|
||||
nebenbei B10 (O(n²)-Schreiblast) und gibt K6 seine Form; erst danach schützt der
|
||||
Spiegel auch die Historie. Zeitlich passt A6 zu dem Server, der ggf. mit A5
|
||||
(Matrix) ohnehin dazukommt.
|
||||
|
||||
---
|
||||
|
||||
## B — Offen aus der Bestandsaufnahme
|
||||
|
||||
| Punkt | Was | Stand |
|
||||
|---|---|---|
|
||||
| T4 | Proaktiv statt reaktiv kompaktieren | offen, unverändert |
|
||||
| K2-Rest | Untrusted Content als Daten rahmen (`<untrusted_content>`) | A2 nimmt die Schärfe; die Rahmung selbst bleibt nötig |
|
||||
| K4 | Streaming | **zurückgestellt** bis A5 entschieden ist |
|
||||
| K6 | Historie **archivieren + durchsuchbar machen** (FTS5-Index, `history_search`-Tool) statt nur rotieren | beschlossen; Teil des Historie-Umzugs (Voraussetzung von A6) |
|
||||
| Memory-Flush vor Compaction | Bevor der ContextCompactor zusammenfasst, bekommt der Agent ein eng begrenztes Fenster, Dauerhaftes per `memory_store` zu sichern — sonst wirft die Compaction Wissen weg | beschlossen (GoClaw-Muster) |
|
||||
| Memory-Auto-Injection | Relevante Memory-Abstracts werden automatisch eingeblendet (Relevanzschwelle, Deckel ~200 Tokens), **in die Nutzernachricht, nie in den System-Prompt** (Prompt-Cache T1) | beschlossen; löst den offenen Punkt „Automatische Einblendung" im [Memory-Konzept](Memory-Konzept.md) |
|
||||
| Output-Scrubbing | Bekannte Secret-Werte (Register des `SecretProtector`) werden zentral aus **allen** Tool-Ergebnissen maskiert, bevor sie in Kontext, Historie oder Spiegel (A6) gelangen | beschlossen; schließt die Lücke, die S3 nur für URLs schloss |
|
||||
| Hygiene-Paket | B9 (`index_Count`-Race), B11/T8 (`max_tokens` setzen), B13 (`instanceId`-Inkonsistenz), F-A6-Rest (UI zum Setzen/Rotieren der Secrets) | Kleinbugs, in einem Aufwasch. B10 geht im Historie-Umzug (A6) auf |
|
||||
|
||||
Erledigt seit der letzten Fortschreibung: B4 vollständig (Preise kommen live vom
|
||||
`/models`-Endpunkt, unbekannte Modelle werden sichtbar gemeldet).
|
||||
|
||||
### B-DC — Deploymentcenter-Anbindung
|
||||
|
||||
WatchDog und LicenseLabrador sind durch das
|
||||
[Deploymentcenter](Deploymentcenter-Integration.md) ersetzt: eine Adresse, ein Token,
|
||||
und dazu Update-Prüfung, Fehler-Stream und Bugtracker. Watchdog läuft wieder pro
|
||||
Instanz (ein Monitor je Instanz, mit Gesundheitsprüfungen und angekündigtem Ende).
|
||||
|
||||
Offen:
|
||||
|
||||
| Punkt | Was | Bemerkung |
|
||||
|---|---|---|
|
||||
| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt |
|
||||
| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar |
|
||||
| DC3 | Release-Strecke: `pack-and-deploy` mit `<Version>` aufrufen, dann `update-agent` | ohne Release hat die Update-Prüfung nichts zu finden. Versionsnummer selbst ist erledigt (`Directory.Build.props` → `ReleaseInfo`) |
|
||||
| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI |
|
||||
| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** |
|
||||
|
||||
---
|
||||
|
||||
## C — Offen aus dem Finanz-/Analyse-Konzept
|
||||
|
||||
Punkte 1–2 von dort (atomares Schreiben, Backup/Restore inkl. UI) sind umgesetzt.
|
||||
|
||||
| # | Was | Bemerkung |
|
||||
|---|---|---|
|
||||
| C1 | Marktkalender (`onlyWhenMarketOpen` + `MarketCalendar`-Tool) | Scheduler-Teil gehört ins Taskboard-Frontmatter (A1) |
|
||||
| C2 | `Indicators`-Tool — deterministische Berechnung | Qualität hoch, Tokens runter |
|
||||
| C3 | Datenaktualität erzwingen (`maxAgeSeconds`) | |
|
||||
| C4 | Termine & Fundamentaldaten (Earnings, EDGAR, Wirtschaftskalender) | |
|
||||
| C5 | Bestandsregister (Positionen) | Grundlage für C7/C8 |
|
||||
| C6 | Nachrichten-Entdopplung (`SeenItems`) | |
|
||||
| C7 | Ergebnisregister (Stufe 2, „Kosten pro Ergebnis") | fällt weitgehend aus den A3-Receipts ab |
|
||||
| C8 | Falsifizierbare Aussagen + Auflösung, Brier-Score (Stufe 3) | braucht C5, C7 und einen Auflösungs-Task (A1) |
|
||||
|
||||
---
|
||||
|
||||
## D — Toolkandidaten (unbeschlossen)
|
||||
|
||||
WebSearch, Http (generisch mit Allowlist), Shell (sandboxed), Git, Vision.
|
||||
Notify ist gestrichen — geht in A5 auf.
|
||||
|
||||
---
|
||||
|
||||
## Vorgeschlagene Reihenfolge
|
||||
|
||||
| # | Vorhaben | Begründung |
|
||||
|---|---|---|
|
||||
| 1 | A1 Taskboard | Fundament; löst sechs bestehende Punkte auf einmal |
|
||||
| 2 | A3 Audit-Log | klein, sofort nützlich; muss vor A2 da sein, damit Freigaben protokolliert werden |
|
||||
| 3 | A2 Staging-Freigabe | größter Sicherheitsgewinn; Voraussetzung für unbeaufsichtigten Betrieb |
|
||||
| 4 | C1 Marktkalender | spart sofort Kosten; nutzt A1-Frontmatter |
|
||||
| 5 | A4 Skills/Toolsets | Token-Hebel, Cache-stabil |
|
||||
| 6 | C2 Indicators | Qualität + Kosten |
|
||||
| 7 | C7 + C8 Ergebnisregister, Aussagen | das eigentliche Leistungsmaß; braucht A3 |
|
||||
| — | Hygiene-Paket (B) | zwischendurch, unabhängig |
|
||||
| — | Historie-Umzug in die Instanz-DB | löst B10 + K6; Voraussetzung für A6 |
|
||||
| — | A6 MySQL-Spiegel | nach dem Historie-Umzug; natürliches Zuhause auf dem A5-Server |
|
||||
| — | A5 Matrix | eigenes Konzept-Doc zuerst; Scope klären, dann einordnen |
|
||||
|
||||
Leitlinie der Reihung: erst Nachvollziehbarkeit und Kontrolle (Audit, Staging),
|
||||
dann Fähigkeiten — ein Agent, der unbeaufsichtigt läuft, braucht zuerst Bremsen,
|
||||
dann PS.
|
||||
|
||||
---
|
||||
|
||||
## Umsetzung mit Opus 4.6 — Einstufung
|
||||
|
||||
Die Entwicklung erfolgt mit Opus 4.6. Die meisten Vorhaben sind damit gut
|
||||
machbar, sofern die hier notierten Vorgaben mitgegeben werden. Zwei Stellen
|
||||
berühren Nebenläufigkeits-Invarianten bzw. Engine-Querschnitte — sie sind für
|
||||
Opus 5 / Fable markiert oder durch eine Architektur-Vorgabe entschärft.
|
||||
|
||||
| Vorhaben | Einstufung | Vorgabe / Begründung |
|
||||
|---|---|---|
|
||||
| A1: Dateiformat, Frontmatter-Parsing, `task_*`-Tool, Migration | 4.6 | klar spezifizierbar, gut testbar |
|
||||
| A1: **Scanner-Kern** (atomares Claiming, Auto-Dispatch, Reconciliation) | ⚠️ **Opus 5 / Fable** | At-most-once-Semantik, Claim-CAS und das Zusammenspiel mit den seit B2 serialisierten Chat-Läufen sind genau die Fehlerklasse, die hier schon einmal schiefging. Falls doch 4.6: erst Konzept-Doc, Umsetzung strikt dagegen, Property-Tests für die Invarianten („nie zwei Claims auf einen Task", „kein Dispatch bei offenem Blocker", „doppelter Tick = ein Lauf") |
|
||||
| A2: Gate, Policy, Staging-Queue, Review-UI, Approval-Records | 4.6 | mit der folgenden Architektur-Vorgabe |
|
||||
| A2: **Fortsetzung nach Freigabe** | 4.6 nur mit Vorgabe | **Kein pausierter, im Speicher gehaltener Lauf.** Vorgabe: Der Lauf endet beim Staging regulär — das Tool liefert „zur Freigabe vorgelegt" als Ergebnis, der Agent schließt ab. Die Freigabe erzeugt einen Folge-Task (A1), der den Agenten mit dem **eingefrorenen** Aufruf weckt. Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser Vereinfachung unnötig |
|
||||
| A3: Audit-Log + Receipts | 4.6 | append-only, klares Schema, keine Nebenläufigkeitsfallen |
|
||||
| A4: Skills | 4.6 | `FileSystemWatcher` mit Debounce (~500 ms); agentengeschriebene Skills erst nach Freigabe aktiv (siehe A4) |
|
||||
| Memory-Flush vor Compaction | 4.6 mit Anleitung | Harte Grenzen: max. 3–5 Schritte, einziges Tool `memory_store`, Timeout, günstiges Modell (wie T3), höchstens einmal je Compaction-Zyklus. Vorsicht: Der ContextCompactor hatte B1/B14 — die bestehenden Paarungs-Tests müssen unverändert grün bleiben |
|
||||
| Memory-Auto-Injection | 4.6 | in die Nutzernachricht, nie in den System-Prompt (sonst verfällt der Prompt-Cache T1); Deckel ~200 Tokens |
|
||||
| Output-Scrubbing | 4.6 | ein zentraler Filter an der Stelle, wo Tool-Ergebnisse in den Kontext gelangen (`ExecuteToolCallAsync`); Werte aus dem Secret-Register |
|
||||
| Historie-Umzug + FTS5 + `history_search` | 4.6 | Migration nur nach frischem Backup; alte JSON-Dateien erst nach verifiziertem Import löschen |
|
||||
| A6: MySQL-Spiegel | 4.6 mit Anleitung | Outbox mit Wasserzeichen, idempotente Upserts, nie blockieren; der getestete Restore-Pfad ist Teil der Definition of Done |
|
||||
| C1 Marktkalender, C2 Indicators | 4.6 | reine Fachlogik, deterministisch testbar |
|
||||
|
||||
Generell: Neue Subsysteme (Scanner, Staging, Audit, Replikator) kommen mit Tests
|
||||
nach der [Teststrategie](Teststrategie.md) — die Invarianten-Tests sind bei den
|
||||
markierten Punkten kein Nice-to-have, sondern die Absicherung dafür, dass ein
|
||||
schwächeres Modell sie umsetzen darf.
|
||||
@@ -0,0 +1,544 @@
|
||||
# Rocket.Chat und Nextcloud — Konzept
|
||||
|
||||
Zwei neue Tools, ein gemeinsamer Zweck: **Rocket.Chat** wird der Ort, an dem wir mit den
|
||||
Agenten reden; **Nextcloud** wird der Ort, an dem die Agenten uns Ergebnisse hinlegen.
|
||||
Der typische Ablauf ist die Kombination aus beidem — „schreib mir die Auswertung und leg
|
||||
sie in die Cloud" im Chat, Datei in Nextcloud, Link zurück in den Chat.
|
||||
|
||||
Dieses Dokument prüft die Machbarkeit, legt den Schnitt fest und benennt die Punkte, die
|
||||
vor der Umsetzung entschieden werden müssen. **Es ist noch keine Umsetzungsfreigabe.**
|
||||
|
||||
Verwandt: [Taskboard-Konzept](Taskboard-Konzept.md) (Scanner/Wake), [Staging-Konzept](Staging-Konzept.md)
|
||||
(Freigaben), [Audit-Konzept](Audit-Konzept.md), [Roadmap](Roadmap.md) (A5 — siehe Konflikt unten).
|
||||
|
||||
---
|
||||
|
||||
## 0 — Kurzfassung des Befunds
|
||||
|
||||
| Frage | Antwort |
|
||||
|---|---|
|
||||
| Ist es umsetzbar? | Ja, beides. Ohne neue Architektur — die vorhandenen Bausteine tragen. |
|
||||
| Braucht es Änderungen am Core? | Für Phase 1: **nein**, nur zwei neue Tool-Projekte + Staging-Defaults. Für den automatischen Rückweg (Antwort landet ohne Zutun des Modells im Raum) und den Notfallkanal: ja, zwei kleine Core-Ergänzungen. |
|
||||
| Größtes technisches Risiko | Nicht die API — sondern **Antwort-Schleifen zwischen Agenten** und **Kosten durch zu häufiges Wecken**. |
|
||||
| Größte Konzeptkollision | Roadmap **A5** sieht Matrix/Element für genau diesen Zweck vor. Rocket.Chat ersetzt A5, oder wir haben zwei Chat-Wege. Muss entschieden werden. |
|
||||
| „Agent erstellt Dokument direkt über die Nextcloud-API" | So nicht. Nextcloud hat keine API, die Inhalte *erzeugt*. Der Weg ist: Datei lokal im Workspace erzeugen → hochladen. Für PDF/XLSX kann **Collabora als Konverter** dienen — das ist der elegante Teil, siehe 5.4. |
|
||||
|
||||
---
|
||||
|
||||
## 1 — Was schon da ist (und deshalb nicht neu gebaut wird)
|
||||
|
||||
Der Rückkanal von außen nach innen existiert vollständig:
|
||||
|
||||
```
|
||||
TaskScanner (60-s-Takt)
|
||||
└─ Task vom Typ tool_job
|
||||
└─ EngineTaskDispatcher.DispatchToolJobAsync
|
||||
└─ IToolJobProvider.ExecuteJobAsync ← kein LLM, kostenlos
|
||||
└─ ToolJobResult.Wake(text) ← nur wenn wirklich etwas da ist
|
||||
└─ AgentEngine.ChatAsync ← hier erst kostet es Tokens
|
||||
```
|
||||
|
||||
Das Telegram-Tool nutzt genau das (`telegram_poll`). **Rocket.Chat bekommt dieselbe
|
||||
Bauform** — `rocketchat_poll`. Damit gilt automatisch:
|
||||
|
||||
- Zustand (letzter gesehener Zeitpunkt) über `IStateStore`, überlebt Neustarts.
|
||||
- Ein Takt ohne neue Nachricht kostet nichts.
|
||||
- Kein eigener Thread, kein eigener Scheduler, keine Sonderbehandlung beim Start.
|
||||
- Jeder Tool-Aufruf läuft ohnehin durch `StagingGate` (A2) und Audit (A3).
|
||||
|
||||
Ebenso vorhanden und wiederverwendbar:
|
||||
|
||||
- **Pro-Agent-Konfiguration** (`AgentConfig.Tools["RocketChat"]`) — jeder Agent bekommt
|
||||
seine eigenen Zugangsdaten, ohne dass ein Agent die eines anderen sehen kann.
|
||||
- **`ConfigSecrets`** verschlüsselt Felder nach Namen (`token`, `password`, `apikey` …) —
|
||||
ein Feld namens `authToken` bzw. `appPassword` ist automatisch geschützt.
|
||||
- **Workspace-Prefixe** `personal:` / `shared:` samt Path-Traversal-Prüfung — aus dem
|
||||
FTP-Tool wortgleich übernehmbar für Nextcloud-Uploads.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Rocket.Chat: Machbarkeit
|
||||
|
||||
Geprüft gegen die REST- und Realtime-API von Rocket.Chat. Alles Folgende ist
|
||||
Standardfunktion einer selbstgehosteten Instanz, kein Enterprise-Feature.
|
||||
|
||||
### 2.1 Identität — ein echter Benutzer je Agent
|
||||
|
||||
Die Anforderung „jeder Agent mit eigenem Benutzer, in Gruppen und im Direktkontakt" ist
|
||||
der richtige Ansatz und wird von Rocket.Chat direkt unterstützt.
|
||||
|
||||
- Admin legt je Agent einen Benutzer an: `POST /api/v1/users.create`
|
||||
(`{ name, username, email, password, roles: ["bot"] }`).
|
||||
- Die Rolle **`bot`** ist wichtig: Sie markiert den Benutzer als Maschine (relevant für
|
||||
Schleifenschutz, siehe 2.5) und wird in neueren Versionen bei der Sitzplatzzählung
|
||||
nicht als normaler Nutzer gewertet. *Gegen die eigene Version zu prüfen.*
|
||||
- Für jeden Agenten wird ein **Personal Access Token** erzeugt
|
||||
(`POST /api/v1/users.generatePersonalAccessToken`, oder im Konto des Benutzers).
|
||||
Dauerhaft gültig, einzeln widerrufbar — deutlich besser als Login mit Passwort, weil
|
||||
kein Session-Ablauf und keine gespeicherten Passwörter im Spiel sind.
|
||||
- Authentifiziert wird jeder Aufruf über zwei Header: `X-Auth-Token` und `X-User-Id`.
|
||||
|
||||
**Entscheidung, die ich empfehle:** Das Anlegen der Benutzer ist **kein Agenten-Tool**.
|
||||
Es ist eine einmalige Einrichtungsfunktion in der WinForms-Oberfläche
|
||||
(Instanz-Einstellungen → Rocket.Chat → „Agenten-Benutzer anlegen"). Sonst müsste ein
|
||||
Agent ein Admin-Token halten — und ein Admin-Token in Reichweite einer Prompt-Injection
|
||||
ist genau das, was A2 verhindern soll. Der Admin-Token liegt in der **Instanz**-Konfiguration,
|
||||
nicht in einer Agenten-Tool-Konfiguration.
|
||||
|
||||
### 2.2 Ausgang — Nachrichten senden
|
||||
|
||||
| Zweck | Endpunkt |
|
||||
|---|---|
|
||||
| In Kanal/Gruppe/DM schreiben | `POST /api/v1/chat.postMessage` (`roomId` oder `channel`) |
|
||||
| Auf eine Nachricht antworten (Thread) | dasselbe, mit `tmid` |
|
||||
| Datei anhängen | `POST /api/v1/rooms.upload/{roomId}` (multipart) |
|
||||
| Reaktion setzen | `POST /api/v1/chat.react` |
|
||||
|
||||
Gesendet wird als der Agenten-Benutzer — Direktnachrichten funktionieren dadurch echt und
|
||||
nicht als „Bot mit Alias".
|
||||
|
||||
### 2.3 Eingang — der Poll-Weg (Phase 1)
|
||||
|
||||
Der sparsame Weg, ohne jede neue Infrastruktur:
|
||||
|
||||
1. `GET /api/v1/subscriptions.get?updatedSince=<zeitstempel>` — **ein einziger Aufruf**
|
||||
liefert für diesen Agenten alle Räume mit Ungelesen-Zähler, Erwähnungs-Zähler und
|
||||
„zuletzt gesehen"-Marke. Auch bei 50 Räumen bleibt es ein Aufruf.
|
||||
2. Nur für Räume mit relevanten Neuigkeiten wird die Historie geholt:
|
||||
`channels.history` (öffentlich) / `groups.history` (privat) / `im.history` (DM),
|
||||
jeweils mit `oldest=<letzte gesehene Zeit>`.
|
||||
3. `POST /api/v1/subscriptions.read` markiert gelesen — der Zähler geht zurück auf null.
|
||||
|
||||
Zustand im `IStateStore`: `rocketchat:{agentId}:lastCheck` sowie je Raum die zuletzt
|
||||
verarbeitete Nachrichtenzeit.
|
||||
|
||||
**Rate-Limits:** Rocket.Chat begrenzt REST-Aufrufe (Standard in der Größenordnung von
|
||||
10 Aufrufen je Minute und Endpunkt). Bei einem Takt von 30–60 Sekunden und einem
|
||||
Sammelaufruf pro Takt ist das unkritisch — es ist aber der Grund, warum der Entwurf über
|
||||
`subscriptions.get` sammelt statt jeden Raum einzeln zu pollen.
|
||||
|
||||
**Latenz:** Bei 60-Sekunden-Takt antwortet ein Agent im Mittel nach ~30 s plus Laufzeit.
|
||||
Für Gespräche mit Agenten ist das spürbar, aber tragbar. Der Takt lässt sich pro Job
|
||||
setzen (`*/1 * * * *` ist das Minimum des Cron-Modells; feiner ginge nur über die
|
||||
Realtime-API).
|
||||
|
||||
### 2.4 Eingang — die Realtime-Variante (Phase 3, optional)
|
||||
|
||||
Rocket.Chat bietet eine WebSocket-/DDP-Schnittstelle (`wss://host/websocket`): nach
|
||||
`login` mit dem Token abonniert man `stream-notify-user/{userId}/notification` und
|
||||
bekommt DMs und Erwähnungen **sofort** gepusht, ohne Polling.
|
||||
|
||||
Das ist die richtige Endstufe (Antwortzeit ~1 s statt ~30 s), aber es ist eine dauerhafte
|
||||
Verbindung je Agent mit Wiederverbindungs-Logik — also eine echte Komponente, keine
|
||||
Ergänzung eines Tools. Vorschlag: **erst nachrüsten, wenn Phase 1 im Alltag steht** und
|
||||
sich die Verzögerung tatsächlich stört.
|
||||
|
||||
Eine dritte Möglichkeit — Rocket.Chats *Outgoing Webhook* auf unsere vorhandene
|
||||
`ClawdDotNetApi` (Port 5082) — wäre die einfachste Push-Lösung, setzt aber voraus, dass
|
||||
der Rocket.Chat-Server den Windows-Rechner über das Netz erreicht. Das ist eine Frage
|
||||
deiner Netztopologie und keine der Software. Falls erreichbar: der kürzeste Weg zu
|
||||
niedriger Latenz.
|
||||
|
||||
### 2.5 Die zwei echten Fallen
|
||||
|
||||
Diese beiden Punkte sind wichtiger als jede API-Frage.
|
||||
|
||||
**(a) Mehrere Agenten im selben Raum.** Wenn drei Agenten denselben Gruppenchat pollen,
|
||||
antworten drei Agenten auf jede Nachricht. Regel im Entwurf:
|
||||
|
||||
> Ein Agent wird nur geweckt bei (1) Direktnachrichten an ihn oder (2) Nachrichten, die
|
||||
> ihn per `@name` erwähnen. Alles andere liest er nicht einmal.
|
||||
|
||||
Ein Raum kann per Konfiguration auf `respondToAll: true` gestellt werden — das ist die
|
||||
bewusste Ausnahme für einen Raum mit genau einem Agenten.
|
||||
|
||||
**(b) Agenten-Schleifen.** Agent A schreibt, Agent B wird geweckt, antwortet, weckt A —
|
||||
und das läuft, bis das Tagesbudget greift. Der `LoopGuard` schützt nur *innerhalb* eines
|
||||
Laufs, nicht über Agenten hinweg. Regel im Entwurf:
|
||||
|
||||
> Nachrichten von Benutzern mit der Rolle `bot` werden **ignoriert**, außer der Agent ist
|
||||
> namentlich erwähnt. Zusätzlich eine Drossel: höchstens N Weckvorgänge je Raum und
|
||||
> Stunde (Zähler im `IStateStore`), danach schweigt der Agent in diesem Raum bis zur
|
||||
> nächsten Stunde und protokolliert das.
|
||||
|
||||
Das Tagesbudget (K5) ist das letzte Netz, nicht das erste.
|
||||
|
||||
### 2.6 Der Rückweg der Antwort
|
||||
|
||||
Der Wake-Mechanismus liefert die Nachricht *in* den Agenten. Seine Antwort geht heute in
|
||||
den Chat-Verlauf, nicht zurück nach Rocket.Chat. Zwei Wege:
|
||||
|
||||
- **(a) Der Agent antwortet selbst** — die Weck-Nachricht enthält die `roomId` und die
|
||||
Anweisung, mit `RocketChat.send_message` zu antworten. Kein Core-Eingriff, funktioniert
|
||||
sofort. Schwäche: Es hängt daran, dass das Modell es tut. Erfahrungsgemäß klappt das
|
||||
gut, aber nicht in 100 % der Fälle.
|
||||
- **(b) Automatischer Rückweg** — der Tool-Job merkt sich „Antwort gehört nach Raum X",
|
||||
und der Dispatcher schickt die Abschlussnachricht des Laufs dorthin. Zuverlässig, aber
|
||||
es braucht einen kleinen Haken in `ToolJobResult`/`EngineTaskDispatcher`
|
||||
(etwa ein `ReplyTo`-Feld, das der Dispatcher nach dem Lauf an dasselbe Tool zurückgibt).
|
||||
|
||||
**Empfehlung:** (a) in Phase 1, (b) in Phase 2 nachziehen — denn (b) ist der Unterschied
|
||||
zwischen „meistens antwortet er" und „er antwortet". Für die Hauptkommunikationsschiene
|
||||
ist das am Ende nicht optional.
|
||||
|
||||
### 2.7 Sicherheit
|
||||
|
||||
- **Nachrichten aus Rocket.Chat sind fremder Text.** Sie müssen als
|
||||
`<untrusted_content>` gerahmt in den Kontext (Roadmap K2-Rest). Bei Telegram fehlt das
|
||||
bis heute; hier sollte es von Anfang an drin sein, weil Gruppenchats mehrere Absender
|
||||
haben.
|
||||
- **Raum-Allowlist** je Agent (`allowedRooms`), analog `allowedChatIds` beim Telegram-Tool.
|
||||
- **Staging-Vorschlag** (siehe 6): Senden in erlaubte Räume `auto`, alles darüber hinaus
|
||||
`approve`.
|
||||
- Zugangsdaten heißen im Konfigurationsfeld `authToken` → `ConfigSecrets` verschlüsselt sie
|
||||
automatisch. Der Admin-Token der Instanz muss in `ConfigSecrets.Apply(InstanceConfig)`
|
||||
ergänzt werden.
|
||||
- **TLS** ist Pflicht; selbstsignierte Zertifikate ausdrücklich konfigurieren müssen statt
|
||||
Validierung generell abschalten.
|
||||
|
||||
### 2.8 Tool-Zuschnitt
|
||||
|
||||
```
|
||||
Tool: RocketChat
|
||||
Aktionen: send_message | reply | send_file | list_rooms | read_room
|
||||
| mark_read | search
|
||||
Job: rocketchat_poll
|
||||
```
|
||||
|
||||
Konfiguration je Agent:
|
||||
|
||||
```json
|
||||
"RocketChat": {
|
||||
"baseUrl": "https://chat.example.org",
|
||||
"userId": "aBcD…",
|
||||
"authToken": "…", // von ConfigSecrets geschützt
|
||||
"allowedRooms": ["GENERAL", "finanz-team"],
|
||||
"defaultRoom": "finanz-team",
|
||||
"mentionOnly": true,
|
||||
"maxWakesPerRoomPerHour": 12
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3 — Redundanz: was passiert, wenn Rocket.Chat ausfällt
|
||||
|
||||
Das ist die Anforderung, die die Architektur bestimmt — nicht der Chat selbst. Der Kern:
|
||||
**Rocket.Chat darf ein Kanal sein, nicht der Kanal.**
|
||||
|
||||
### 3.1 Was heute schon unabhängig funktioniert
|
||||
|
||||
| Kanal | Unabhängig von Rocket.Chat? | Richtung |
|
||||
|---|---|---|
|
||||
| WinForms-Chat (`frm_chat`) | vollständig — läuft in der App selbst | beide |
|
||||
| Telegram-Bot-Tool | ja — fremde Infrastruktur | beide |
|
||||
| Mail-Tool | ja, sofern der Mailserver anderswo läuft | beide |
|
||||
| Web-Chat / `ClawdDotNetApi` | ja, aber nur im lokalen Netz | beide |
|
||||
|
||||
Wir sind also nicht bei null. Was fehlt, ist die **Umschaltung** — heute muss ein Mensch
|
||||
merken, dass nichts mehr ankommt.
|
||||
|
||||
### 3.2 Vorschlag: `ChannelRouter` im Core
|
||||
|
||||
Eine kleine Komponente im Core (kein neues Tool, keine Tool-zu-Tool-Abhängigkeit —
|
||||
sie löst Tools über die vorhandene `ToolRegistry` nach Namen auf, wie es der Dispatcher
|
||||
schon tut):
|
||||
|
||||
- Je Agent eine **geordnete Kanalliste**, z. B. `["RocketChat", "Telegram", "Mail"]`.
|
||||
- Eine Methode „stelle dem Menschen diese Nachricht zu": versucht der Reihe nach, bis
|
||||
einer erfolgreich ist, und protokolliert im Audit-Log, **über welchen Kanal** zugestellt
|
||||
wurde — inklusive des Hinweises „Primärkanal war nicht erreichbar".
|
||||
- Genutzt von: Agenten (`notify_user`), aber vor allem von **systemseitigen** Meldungen,
|
||||
die heute keinen Weg nach außen haben: Staging-Vorschlag wartet auf Freigabe, Budget
|
||||
überschritten, Watchdog-Alarm, Task blockiert.
|
||||
|
||||
Der zweite Teil ist der wichtigere: Gerade wenn etwas kaputt ist, ist die Meldung darüber
|
||||
diejenige, die ankommen muss.
|
||||
|
||||
### 3.3 Gesundheitsprüfung und Eskalation
|
||||
|
||||
Ein Tool-Job `rocketchat_health` (Takt ~5 Minuten, `GET /api/info`):
|
||||
|
||||
- Nach **drei** aufeinanderfolgenden Fehlschlägen: einmalige Meldung über den nächsten
|
||||
Kanal der Liste — „Rocket.Chat ist seit HH:MM nicht erreichbar, ich melde mich hier."
|
||||
Einmalig, nicht je Takt.
|
||||
- Bei Rückkehr: „Rocket.Chat ist wieder da", und der Zustand wird zurückgesetzt.
|
||||
- Nachrichten, die während des Ausfalls nicht gesendet werden konnten, werden **nicht**
|
||||
in einer eigenen Warteschlange gehalten — sie gehen über den Ersatzkanal raus. Eine
|
||||
zweite Zustellwarteschlange wäre eine zweite Fehlerquelle.
|
||||
|
||||
**Eingehend während des Ausfalls:** Der Telegram-Poll-Job bleibt dauerhaft aktiv, nur mit
|
||||
langsamem Takt (z. B. alle 5 Minuten). Er kostet nichts, wenn nichts kommt — und ist im
|
||||
Ernstfall der Weg, auf dem *du* die Agenten erreichst. Der WinForms-Chat ist ohnehin immer
|
||||
da, solange die App läuft.
|
||||
|
||||
**Entschieden (August 2026): Telegram ist der Notfallkanal.** Die Kanalliste lautet damit
|
||||
`["RocketChat", "Telegram"]`. Konsequenzen:
|
||||
|
||||
- Das Telegram-Tool wird **nicht** abgebaut und geht nicht in Rocket.Chat auf. Es behält
|
||||
seine Rolle, verliert aber die Rolle als Alltagskanal.
|
||||
- `Telegram.send_message` bleibt in der Staging-Policy auf `approve` — mit einer Ausnahme:
|
||||
Meldungen, die der `ChannelRouter` selbst erzeugt (Ausfall, Budget, Watchdog, offene
|
||||
Freigabe), laufen **ohne** Freigabe. Sonst bliebe die Warnung, dass eine Freigabe
|
||||
aussteht, selbst in der Freigabewarteschlange hängen — ein Ringschluss, der genau im
|
||||
Ernstfall zuschlägt.
|
||||
- Der Telegram-Poll bleibt dauerhaft eingerichtet, aber mit langsamem Takt. Ein Kanal, der
|
||||
erst im Notfall eingeschaltet wird, ist im Notfall ungetestet.
|
||||
- Mail bleibt außen vor. Zwei Ersatzkanäle zu pflegen lohnt nicht; das Mail-Tool behält
|
||||
seinen fachlichen Zweck.
|
||||
|
||||
### 3.4 Was das für die Prompts heißt
|
||||
|
||||
Ein Agent soll seinen Kanal nicht selbst wählen. Er sagt „ich möchte dem Nutzer das hier
|
||||
mitteilen", der Router entscheidet. Sonst muss das Modell im Fehlerfall improvisieren —
|
||||
und genau dann ist Improvisation das Letzte, was man will.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Konflikt mit Roadmap A5 (Matrix)
|
||||
|
||||
Roadmap-Punkt **A5** legt fest: „Die Kommunikation (Benachrichtigungen, Berichte, Chat mit
|
||||
Agenten) wird auf Element/Matrix umgestellt", und der gestrichene Tool-Kandidat *Notify*
|
||||
geht darin auf.
|
||||
|
||||
Rocket.Chat besetzt exakt dieselbe Rolle. Drei mögliche Auflösungen:
|
||||
|
||||
1. **Rocket.Chat ersetzt A5.** A5 wird umgeschrieben, Matrix entfällt. Vorteil: eine
|
||||
Schiene, ein Betriebsaufwand, die Instanz läuft bereits.
|
||||
2. **A5 bleibt, Rocket.Chat ist nur ein weiteres Tool.** Dann bauen wir zweimal dasselbe.
|
||||
Schwer zu begründen.
|
||||
3. **Rocket.Chat primär, Matrix als späterer Zweitkanal.** Passt formal zur
|
||||
Redundanz-Anforderung, verdoppelt aber den Wartungsaufwand für einen Fall, den
|
||||
Telegram schon abdeckt.
|
||||
|
||||
**Meine Empfehlung: (1).** Der `ChannelRouter` aus 3.2 ist ohnehin die Verallgemeinerung,
|
||||
die A5 gebraucht hätte — mit ihm ist ein späterer Matrix-Kanal ein zusätzlicher Eintrag in
|
||||
der Liste, keine Migration. Das ist eine Entscheidung für dich, keine technische Sachfrage.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Nextcloud: Machbarkeit
|
||||
|
||||
### 5.1 Der Zugriffsweg
|
||||
|
||||
Nextcloud hat zwei Schnittstellen, beide brauchen wir:
|
||||
|
||||
| Zweck | Schnittstelle |
|
||||
|---|---|
|
||||
| Dateien lesen/schreiben/auflisten/verschieben | **WebDAV**: `/remote.php/dav/files/{benutzer}/{pfad}` |
|
||||
| Öffentlichen Link erzeugen | **OCS**: `/ocs/v2.php/apps/files_sharing/api/v1/shares` |
|
||||
|
||||
Authentifiziert wird mit **App-Passwörtern** (Nextcloud → Einstellungen → Sicherheit →
|
||||
„Neues App-Passwort erstellen") per Basic-Auth. Ein App-Passwort ist einzeln widerrufbar
|
||||
und lässt das eigentliche Kontopasswort unangetastet — dieselbe Logik wie das Personal
|
||||
Access Token bei Rocket.Chat.
|
||||
|
||||
WebDAV braucht keine Bibliothek: `HttpClient` mit den Methoden `PUT`, `GET`, `MKCOL`,
|
||||
`PROPFIND`, `MOVE`, `DELETE`. Nur `PROPFIND` liefert XML (Multistatus), das geparst werden
|
||||
muss — überschaubar, und es erspart uns eine weitere Abhängigkeit.
|
||||
|
||||
### 5.2 Ein Benutzer je Agent — oder ein Sammelkonto?
|
||||
|
||||
Zwei Modelle:
|
||||
|
||||
- **Je Agent ein Nextcloud-Benutzer.** Sauber nachvollziehbar („wer hat das abgelegt"),
|
||||
passt zum Rocket.Chat-Modell, kostet je nach Lizenzmodell Nutzer.
|
||||
- **Ein Dienstkonto `clawd-agents` mit Unterordnern je Agent.** Einfacher zu verwalten,
|
||||
Herkunft steht dann im Pfad statt im Konto.
|
||||
|
||||
**Empfehlung:** Ein Dienstkonto mit Ordnerstruktur `/ClawdDotNet/{Agent}/…`, **plus** einen
|
||||
mit dir geteilten Ordner `/ClawdDotNet/Berichte/`. Begründung: Bei Rocket.Chat ist die
|
||||
eigene Identität funktional zwingend (DMs, Erwähnungen), bei Dateien ist sie es nicht —
|
||||
und ein Ordnerbaum ist leichter aufzuräumen als zehn Konten. Falls du die Trennung dennoch
|
||||
willst, ändert das am Tool nichts, nur an der Konfiguration.
|
||||
|
||||
Die Ordnerdurchsetzung gehört ins Tool: eine konfigurierte `rootPath`, aus der der Agent
|
||||
nicht ausbrechen kann — dieselbe Prüfung wie in `FTPTool.ResolveLocalPath`.
|
||||
|
||||
### 5.3 Die ehrliche Antwort zu „direkt über die API erstellen"
|
||||
|
||||
Nextcloud hat **keine** API, die Dokumenteninhalte erzeugt. Es ist ein Dateiablage- und
|
||||
Freigabesystem; Collabora ist ein *Editor* im Browser (über WOPI angebunden), kein
|
||||
Generator, den man von außen mit „erstelle eine Tabelle mit diesen Zahlen" beauftragen
|
||||
kann.
|
||||
|
||||
Der tatsächliche Weg ist deshalb der, den du selbst schon beschrieben hast:
|
||||
|
||||
```
|
||||
Agent erzeugt die Datei im eigenen Workspace (FileRW-Tool, schon vorhanden)
|
||||
→ Nextcloud.upload (WebDAV PUT)
|
||||
→ Nextcloud.share (optional) (OCS, liefert Link)
|
||||
→ RocketChat.send_message mit dem Link
|
||||
```
|
||||
|
||||
Das ist kein Umweg, sondern die richtige Aufteilung: Der Agent kann seine Datei lokal
|
||||
prüfen und korrigieren, bevor sie irgendwo landet.
|
||||
|
||||
### 5.4 Formate — und wo Collabora doch nützlich wird
|
||||
|
||||
Was ein Agent von sich aus gut schreiben kann: **Markdown** (Nextcloud rendert `.md`
|
||||
direkt in der Weboberfläche — für Berichte oft die beste Wahl), **CSV**, **HTML**, JSON.
|
||||
|
||||
Was er nicht von sich aus schreiben kann: `.xlsx`, `.docx`, `.pdf`.
|
||||
|
||||
Hier gibt es einen eleganten Weg, weil du Collabora ohnehin betreibst: Collabora Online
|
||||
bringt einen **Konvertierungs-Endpunkt** mit (`POST /cool/convert-to/{format}`, multipart).
|
||||
Damit gilt:
|
||||
|
||||
| Ziel | Weg |
|
||||
|---|---|
|
||||
| PDF | Agent schreibt HTML oder ODT → Collabora → PDF |
|
||||
| XLSX | Agent schreibt CSV → Collabora → XLSX |
|
||||
| DOCX | Agent schreibt HTML/ODT → Collabora → DOCX |
|
||||
|
||||
Vorteil: **keine zusätzliche PDF- oder Excel-Bibliothek** im Projekt (und keine
|
||||
Lizenzfrage, die wir uns damit einhandeln — mehrere verbreitete .NET-Bibliotheken für
|
||||
XLSX und PDF sind für kommerzielle Nutzung nicht frei).
|
||||
|
||||
Zu prüfen, bevor wir darauf bauen:
|
||||
- Ist der Endpunkt in deiner Collabora-Installation erreichbar? Er muss in `coolwsd.xml`
|
||||
für die IP des ClawdDotNet-Rechners freigegeben sein (`net`/`post_allow`-Allowlist).
|
||||
Standardmäßig ist das eng gefasst.
|
||||
- Der Pfad heißt je nach Version `/cool/convert-to/…` (neu) oder `/lool/convert-to/…` (alt).
|
||||
|
||||
Falls der Endpunkt nicht freigegeben werden soll: Rückfallebene ist Markdown/CSV — für
|
||||
den Alltag völlig ausreichend, PDF wäre dann ein späterer eigener Punkt.
|
||||
|
||||
### 5.5 Freigabe-Links
|
||||
|
||||
`POST /ocs/v2.php/apps/files_sharing/api/v1/shares` (Header `OCS-APIRequest: true`),
|
||||
`shareType=3` = öffentlicher Link. Optional `password`, `expireDate`, `permissions=1`
|
||||
(nur lesen). Die Antwort enthält die fertige URL.
|
||||
|
||||
Zwei Hinweise:
|
||||
- Manche Instanzen erzwingen Passwortschutz für öffentliche Links — dann muss das Tool ein
|
||||
Passwort mitgeben und zurückliefern.
|
||||
- Ein öffentlicher Link ist **irreversibel im Sinne von A2**: Einmal geteilt, kann er
|
||||
weitergegeben worden sein, auch wenn man ihn danach löscht. Deshalb steht er unten in
|
||||
der Staging-Tabelle auf `approve`.
|
||||
|
||||
Innerhalb der eigenen Instanz ist die freundlichere Variante `shareType=0` (an einen
|
||||
konkreten Nextcloud-Benutzer) — kein öffentlicher Link nötig, wenn du ohnehin ein Konto
|
||||
hast. Das sollte der **Standard** sein, öffentlich die Ausnahme.
|
||||
|
||||
### 5.6 Fallstricke
|
||||
|
||||
- **Dateisperren (HTTP 423).** Wenn du eine Datei gerade in Collabora offen hast, kann ein
|
||||
Upload auf dieselbe Datei scheitern. Das Tool muss 423 sauber melden statt kryptisch zu
|
||||
scheitern — und beim Überschreiben eines Berichts lieber einen neuen Dateinamen mit
|
||||
Zeitstempel vergeben.
|
||||
- **Überschreiben ist nicht destruktiv**, solange die Versionierung aktiv ist (Nextcloud
|
||||
legt automatisch eine Vorversion an). Das ist der Grund, warum `Nextcloud.upload` unten
|
||||
auf `auto` steht, `FTP.upload` aber auf `approve`.
|
||||
- **Größenbegrenzung.** Ein einfaches `PUT` reicht für Berichte problemlos; erst bei sehr
|
||||
großen Dateien bräuchte es den Chunked-Upload (`/remote.php/dav/uploads/…`). Für den
|
||||
angedachten Zweck (Berichte, Tabellen, PDFs) nicht nötig — und wenn doch, meldet der
|
||||
Server einen klaren Fehler.
|
||||
- **Quota.** Ein Agent, der stündlich Berichte ablegt, füllt das Konto. Ein Aufräum-Task
|
||||
(„Berichte älter als 90 Tage") gehört mittelfristig ins Taskboard.
|
||||
|
||||
### 5.7 Tool-Zuschnitt
|
||||
|
||||
```
|
||||
Tool: Nextcloud
|
||||
Aktionen: upload | download | list | mkdir | move | delete
|
||||
| share | unshare | convert (convert nur falls Collabora freigegeben)
|
||||
```
|
||||
|
||||
Konfiguration je Agent:
|
||||
|
||||
```json
|
||||
"Nextcloud": {
|
||||
"baseUrl": "https://cloud.example.org",
|
||||
"username": "clawd-agents",
|
||||
"appPassword": "…", // von ConfigSecrets geschützt
|
||||
"rootPath": "/ClawdDotNet/Hermes",
|
||||
"allowPublicShares": false,
|
||||
"collaboraUrl": "https://collabora.example.org"
|
||||
}
|
||||
```
|
||||
|
||||
`appPassword` muss der Schlüsselliste in `ConfigSecrets` hinzugefügt werden — `password`
|
||||
allein greift nicht, weil dort auf ganze Feldnamen verglichen wird.
|
||||
|
||||
---
|
||||
|
||||
## 6 — Verzahnung mit Staging (A2) und Audit (A3)
|
||||
|
||||
Vorschlag für die `StagingPolicy.DefaultRules`:
|
||||
|
||||
| Aktion | Standard | Begründung |
|
||||
|---|---|---|
|
||||
| `RocketChat.send_message` (erlaubter Raum) | **auto** | Sonst ist Chat unbenutzbar — jede Antwort bräuchte einen Klick |
|
||||
| `RocketChat.send_message` (Raum nicht in `allowedRooms`) | **deny** | Wird vom Tool selbst abgewiesen, gar nicht erst vorgelegt |
|
||||
| `RocketChat.send_file` | **approve** | Dateiabfluss in einen Chatraum |
|
||||
| `Nextcloud.upload`, `mkdir`, `move` | **auto** | Versioniert, im eigenen Ordner, umkehrbar |
|
||||
| `Nextcloud.delete` | **approve** | wie `FileRW.delete` |
|
||||
| `Nextcloud.share` (an Benutzer) | **auto** | bleibt innerhalb der Instanz |
|
||||
| `Nextcloud.share` (öffentlicher Link) | **approve** | nicht zurückholbar |
|
||||
|
||||
Der Unterschied zu `Telegram.send_message` (heute `approve`) ist Absicht: Telegram ist ein
|
||||
Benachrichtigungskanal nach außen, Rocket.Chat ist der Arbeitsraum. Ein Arbeitsraum, in
|
||||
dem jede Antwort eine Freigabe braucht, ist kein Arbeitsraum. Der Schutz sitzt hier an der
|
||||
Raum-Allowlist statt an der Einzelfreigabe.
|
||||
|
||||
Für das Audit-Log entstehen keine Sonderfälle — die Tool-Aufrufe laufen ohnehin durch.
|
||||
|
||||
---
|
||||
|
||||
## 7 — Was dieses Konzept **nicht** vorsieht
|
||||
|
||||
Damit der Zuschnitt klar ist:
|
||||
|
||||
- Keine Rocket.Chat-**App** (Apps-Engine, TypeScript im Server) — wir bleiben Client.
|
||||
- Keine Verwaltung von Rocket.Chat durch Agenten (Benutzer anlegen, Räume erstellen,
|
||||
Rechte vergeben). Das ist Admin-Arbeit in der WinForms-Oberfläche.
|
||||
- Keine Sprach-/Videofunktionen, keine Nextcloud Talk-Anbindung.
|
||||
- Kein Ersatz für den WinForms-Chat — der bleibt und ist die unterste Rückfallebene.
|
||||
- Keine Ende-zu-Ende-Verschlüsselung. Rocket.Chat kann das, aber verschlüsselte Räume sind
|
||||
über die REST-API nicht lesbar. Agenten arbeiten in unverschlüsselten Räumen — das ist
|
||||
eine bewusste Einschränkung, die du kennen solltest.
|
||||
|
||||
---
|
||||
|
||||
## 8 — Vorschlag für den Schnitt
|
||||
|
||||
| Phase | Inhalt | Ergebnis |
|
||||
|---|---|---|
|
||||
| **1** | `Nextcloud`-Tool: upload/download/list/mkdir/move/delete/share | Agent kann Berichte ablegen und einen Link liefern |
|
||||
| **2** | `RocketChat`-Tool: senden, lesen, `rocketchat_poll`-Job, Raum-Allowlist, Erwähnungsfilter, Schleifendrossel | Gespräch mit Agenten über Rocket.Chat, Antwort per Prompt |
|
||||
| **3** | Automatischer Rückweg (`ReplyTo` in `ToolJobResult`) | Antwort landet zuverlässig im richtigen Raum/Thread |
|
||||
| **4** | `ChannelRouter` + `rocketchat_health` + Eskalation | Der Notfallkanal — Ausfall wird erkannt und umschifft |
|
||||
| **5** | Collabora-Konvertierung (PDF/XLSX) | Berichte in Büroformaten |
|
||||
| **6** *(optional)* | Realtime/DDP statt Polling | Antwortzeit ~1 s statt ~30 s |
|
||||
|
||||
Nextcloud zuerst, weil es das kleinere, in sich abgeschlossene Stück ist und sofort Nutzen
|
||||
bringt — und weil es sich unabhängig vom Ausgang der A5-Entscheidung lohnt.
|
||||
|
||||
Phase 4 ist **kein Nice-to-have**: Ohne sie ist Rocket.Chat ein Einzelpunkt, dessen Ausfall
|
||||
niemand meldet. Sie sollte nicht hinter Phase 5 rutschen.
|
||||
|
||||
Zur Modell-Einstufung im Sinne der Roadmap: Phasen 1, 2 und 5 sind klar spezifizierbare
|
||||
Tool-Arbeit (4.6-tauglich). Phase 3 und 4 fassen Engine bzw. Zustellwege an und sollten
|
||||
mit vorheriger Festlegung der Invarianten und mit Tests gebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## 9 — Offene Punkte für die Diskussion
|
||||
|
||||
1. **A5/Matrix** — ersetzt Rocket.Chat den Punkt, oder bleibt Matrix als Ziel bestehen?
|
||||
(Abschnitt 4; das entscheidet, ob der `ChannelRouter` Pflicht oder Kür ist.)
|
||||
2. **Nextcloud-Identität** — ein Dienstkonto mit Ordnern je Agent (mein Vorschlag) oder
|
||||
je Agent ein eigener Nextcloud-Benutzer?
|
||||
3. **Rückweg der Antwort** — reicht Phase 2 (Agent antwortet selbst) für den Anfang, oder
|
||||
soll Phase 3 direkt mitgebaut werden?
|
||||
4. ~~**Notfallkanal** — Telegram oder Mail?~~ **Entschieden: Telegram** (siehe 3.3).
|
||||
Offen bleibt nur die Kleinigkeit, ob die Reihenfolge instanzweit gilt (mein Vorschlag)
|
||||
oder pro Agent einstellbar sein soll.
|
||||
5. **Collabora-Konvertierung** — ist der `convert-to`-Endpunkt für den ClawdDotNet-Rechner
|
||||
freigebbar? Falls nein, bleibt es bei Markdown/CSV.
|
||||
6. **Versionen** — welche Rocket.Chat- und welche Nextcloud-Version läuft bei dir?
|
||||
Einzelne Endpunkte und Rollennamen sind versionsabhängig; das prüfe ich vor der
|
||||
Umsetzung gegen deine Instanz statt gegen die Dokumentation.
|
||||
7. **Öffentliche Links** — grundsätzlich erlauben (mit Freigabe) oder ganz sperren
|
||||
(`allowPublicShares: false` als harte Voreinstellung)?
|
||||
@@ -0,0 +1,108 @@
|
||||
# Staging-Freigabe für irreversible Aktionen
|
||||
|
||||
Setzt A2 aus der [Roadmap](Roadmap.md) um (F-A1 + S4). Irreversible Aktionen werden
|
||||
**gestaged statt ausgeführt**: Vorschlag → Review im Hauptfenster → Freigabe/Ablehnung.
|
||||
Aufbauend auf dem Taskboard (A1, Fortsetzung nach Freigabe) und dem Audit-Log (A3,
|
||||
Approval-Records).
|
||||
|
||||
Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen **Vorschlag**
|
||||
erzeugen, keine Ausführung.
|
||||
|
||||
## Das Gate wird zum Durchsetzungspunkt (S4)
|
||||
|
||||
Das bisher wirkungslose `PermissionGate` (es prüfte nur, ob ein Tool zugewiesen ist)
|
||||
wird zur zentralen Stelle: Policy-Prüfung, Staging-Entscheidung und Audit-Hook an
|
||||
**einer** Stelle (`AgentEngine.ExecuteToolCallAsync`), statt ad-hoc in jedem Tool. S4
|
||||
geht hier auf.
|
||||
|
||||
## Policy: `auto | approve | deny` pro Tool/Aktion
|
||||
|
||||
Je (Tool, Aktion) eine Entscheidung:
|
||||
|
||||
- **`auto`** — läuft wie bisher.
|
||||
- **`approve`** — wird gestaged; die Ausführung wartet auf eine menschliche Freigabe.
|
||||
- **`deny`** — wird gar nicht erst vorgeschlagen, sondern abgelehnt.
|
||||
|
||||
Auflösung vom Speziellen zum Allgemeinen: `Tool.Aktion` → `Tool` → Standard (`auto`).
|
||||
Die „Aktion" ist das `action`-Argument des Aufrufs (die meisten Tools haben es).
|
||||
|
||||
**Eingebaute Standardregeln** (nach Sichtung der Tools, überschreibbar per Konfiguration)
|
||||
— genau die in der Roadmap genannten irreversiblen Aktionen:
|
||||
|
||||
| Tool.Aktion | Standard |
|
||||
|---|---|
|
||||
| `Mail.send` | approve |
|
||||
| `Telegram.send_message` | approve |
|
||||
| `Database.insert`, `Database.upsert` | approve |
|
||||
| `FileRW.delete` | approve |
|
||||
| `FTP.upload`, `FTP.delete` | approve |
|
||||
| alles andere | auto |
|
||||
|
||||
Lesende Aktionen (`Mail.read_inbox`, `FileRW.read`, `Database.query`, …) bleiben `auto` —
|
||||
Staging soll schützen, nicht lähmen.
|
||||
|
||||
## Plan-Freeze
|
||||
|
||||
Freigegeben wird ein **eingefrorener, konkreter Aufruf**: Tool, Aktion und die **exakten
|
||||
Argumente** zum Zeitpunkt des Stagings. Ausgeführt wird genau das Eingefrorene (der
|
||||
gespeicherte Argument-JSON), nie eine nachträglich veränderte Fassung. Jede Änderung
|
||||
wäre eine neue Freigabe.
|
||||
|
||||
Das ist auch der Grund, warum der Agent den Aufruf **nicht** nach der Freigabe erneut
|
||||
formuliert (er könnte etwas anderes bauen) — der eingefrorene JSON wird direkt an das
|
||||
Tool gegeben.
|
||||
|
||||
## Fortsetzung nach Freigabe — kein pausierter Lauf
|
||||
|
||||
Die tragende Architektur-Vorgabe (aus der Roadmap-Einstufung): **Kein pausierter, im
|
||||
Speicher gehaltener Lauf.** Der Ablauf:
|
||||
|
||||
1. **Vorschlag.** Der Agent ruft eine `approve`-Aktion auf. Das Gate führt sie nicht aus,
|
||||
sondern legt einen **Pending**-Datensatz an (eingefrorener Aufruf) und gibt dem Agenten
|
||||
„Zur Freigabe vorgelegt (#id)" als Tool-Ergebnis zurück. Der Lauf endet **regulär** —
|
||||
der Agent schließt ab, nichts hängt im Speicher.
|
||||
2. **Review.** Ein Mensch sieht die offenen Vorschläge im Hauptfenster und entscheidet.
|
||||
3a. **Freigabe.** Der eingefrorene Aufruf wird **direkt** ausgeführt (standalone, nicht
|
||||
über einen Chat-Lauf). Das Ergebnis wird festgehalten, und ein **Folge-Task** (A1)
|
||||
weckt den Agenten: „Deine Aktion X wurde freigegeben und ausgeführt, Ergebnis: Y —
|
||||
mach weiter." Der Scanner stellt ihn zu (`ChatAsync`, bestehender Kontext).
|
||||
3b. **Ablehnung.** Ein Folge-Task weckt den Agenten mit der Ablehnung (samt Grund).
|
||||
|
||||
Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser
|
||||
Vereinfachung unnötig.
|
||||
|
||||
## Approval-Records (A3)
|
||||
|
||||
Jede Entscheidung — Freigabe wie Ablehnung — wird an zwei Stellen verankert:
|
||||
|
||||
- Im **Staging-Datensatz** selbst: Status, `DecidedBy`, `DecidedAt`, Ergebnis-Verweis
|
||||
bzw. Ablehnungsgrund.
|
||||
- Als **Audit-Eintrag** (A3): Tool, Ausgang, `source: approval`, „Freigegeben von …" bzw.
|
||||
„Abgelehnt von …". Die Herkunft stempelt auch hier das System, nicht der Agent.
|
||||
|
||||
Der Vorschlag selbst wird beim Anlegen als Audit-Eintrag mit Status **`Staged`** notiert —
|
||||
so ist die ganze Kette (Vorschlag → Entscheidung → Ausführung) im Log nachvollziehbar.
|
||||
|
||||
## Nebenläufigkeit
|
||||
|
||||
Zwei Reviewer dürfen nicht denselben Vorschlag doppelt freigeben. Der Übergang
|
||||
`pending → approved/rejected` ist ein **atomares, bedingtes `UPDATE`** (dieselbe
|
||||
Claim-Technik wie beim Taskboard): Genau einer gewinnt, der zweite Klick läuft ins Leere.
|
||||
Erst nach gewonnenem Übergang wird der eingefrorene Aufruf ausgeführt.
|
||||
|
||||
## Verdrahtung
|
||||
|
||||
- `StagingGate` (Policy + Anlegen des Vorschlags) hängt optional an der Engine — ohne es
|
||||
läuft alles wie bisher (`auto`).
|
||||
- Der eingefrorene Aufruf wird über `IFrozenCallExecutor` (von der Engine implementiert)
|
||||
ausgeführt: gültiger Tool-Kontext, aber ohne LLM-Schleife.
|
||||
- `StagingService` (Freigabe/Ablehnung) nutzt Executor, Audit und das Taskboard für den
|
||||
Folge-Task. Es ist die API, die die Review-Oberfläche aufruft.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Review-Oberfläche** im Hauptfenster (Liste der offenen Vorschläge, Freigeben/Ablehnen)
|
||||
— die Dienst-API steht bereit; die WinForms-Ansicht ist die verbleibende Integration.
|
||||
- **Output-Scrubbing** greift auch hier auf den gespeicherten Argument-JSON, sobald es
|
||||
steht (eigener Roadmap-Punkt).
|
||||
- **Orders** (Handelsaufträge) reihen sich später als weitere `approve`-Aktionen ein.
|
||||
@@ -0,0 +1,334 @@
|
||||
# Taskboard — Aufgaben statt Delay-Schleifen
|
||||
|
||||
Setzt A1 aus der [Roadmap](Roadmap.md) um. Das Taskboard ist das Fundament, auf dem
|
||||
Audit (A3), Staging (A2), Marktkalender (C1) und das Ergebnisregister (C7/C8)
|
||||
aufsetzen. Es löst zugleich sechs Altpunkte auf einmal (F-A5 Queue, F-A4 Run-Historie,
|
||||
B8 Rekursion, B6 `Task.Delay`-Überlauf, B7 Cron in Lokalzeit, T7 `RunAsync` vs.
|
||||
`ChatAsync`).
|
||||
|
||||
Aufbau analog zum [Memory-Konzept](Memory-Konzept.md): erst warum die vorhandenen
|
||||
Mechanismen nicht reichen, dann Dateiformat, Wahrheitsaufteilung, Scanner-Verhalten,
|
||||
Invarianten, Migration.
|
||||
|
||||
## Das Problem
|
||||
|
||||
Heute gibt es zwei getrennte, je für sich unzureichende Wege, einen Agenten Arbeit
|
||||
tun zu lassen:
|
||||
|
||||
- **Der Cron-Scheduler** ([`AgentScheduler`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs),
|
||||
[`ToolJobScheduler`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs)) hängt starr
|
||||
am Agenten: eine Cron-Zeile je Agent, ausgeführt über ein `Task.Delay` bis zum
|
||||
nächsten Termin. Ein jährlicher Termin bedeutet ein `Task.Delay` über Monate (B6).
|
||||
Cron läuft in Lokalzeit ohne explizite Zone (B7). Ob mit oder ohne Kontext gelaufen
|
||||
wird, entscheidet ein implizites Flag (`UseChatContext`, T7).
|
||||
- **Die `coordination/*.md`-Dateien** im SharedWorkspace sind die improvisierte
|
||||
Antwort der Agenten darauf, dass es kein Aufgabenmodell gibt: `task_*`-, `status_*`-
|
||||
und `broadcast`-Dateien, per Konvention beschrieben, ohne Schema, ohne Claiming,
|
||||
ohne Zustandsübergänge. Zwei Agenten, die dieselbe Datei „übernehmen", tun das ohne
|
||||
jede Absicherung.
|
||||
|
||||
Delegation läuft heute über rekursives `send_message`/`spawn` — ein Agent ruft
|
||||
synchron einen anderen, der wieder einen dritten (B8: Zyklengefahr, deshalb ein
|
||||
`LoopGuard` als Notbremse). Es gibt keine Run-Historie am Auftrag und keine Queue.
|
||||
|
||||
## Warum ein neues Subsystem, nicht der vorhandene State-Store
|
||||
|
||||
Dieselbe Überlegung wie beim Gedächtnis: `IStateStore` ist eine Schlüssel-Wert-Tabelle
|
||||
für kleine Marker. Ein Aufgabenmodell mit Status, Zuweisung, Abhängigkeiten und
|
||||
Terminen darin abzulegen hieße, JSON in eine `Value`-Spalte zu schreiben — nicht
|
||||
filterbar, nicht atomar claimbar, nicht auswertbar.
|
||||
|
||||
Der Kern ist eine **atomare Anspruchsnahme** (Claim). Genau das kann ein Dateisystem
|
||||
nicht verlässlich und der Schlüssel-Wert-Store nicht ausdrücken, eine SQL-Zeile mit
|
||||
einem bedingten `UPDATE` aber sehr wohl. Deshalb eine eigene Tabelle auf dem
|
||||
vorhandenen [`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs) (WAL,
|
||||
`busy_timeout`, prozessweite Schreib-Warteschlange) — dieselbe Grundlage, die schon
|
||||
Gedächtnis, Zustand und Verbrauch teilen.
|
||||
|
||||
## Wahrheitsaufteilung — Datei ist Definition, DB ist Koordination
|
||||
|
||||
Die eine Entscheidung, an der alles hängt:
|
||||
|
||||
| Ebene | Wahrheit über | Wer schreibt |
|
||||
|---|---|---|
|
||||
| **Markdown-Datei** (Frontmatter + Rumpf) | die *Definition* der Aufgabe: Titel, Priorität, Assignee, Termin, Abnahme, Abhängigkeiten. Menschen- und agentenlesbar. | Mensch (Editor), Agent (`task_*`-Tool) |
|
||||
| **SQLite-Tabelle `Tasks`** | den *Ausführungszustand*: Status, Claim, Lease, Last-Fired-Marker je Termin, Blocker-Auflösung. | ausschließlich das Taskboard selbst (Importer, Scanner, Tool) |
|
||||
|
||||
**Regel:** Für die Definition ist die Datei die Wahrheit. Für jede
|
||||
Ausführungsentscheidung ist die DB die Wahrheit. Weichen beide ab (Absturz zwischen
|
||||
DB-Claim und Datei-Schreiben), gewinnt die DB, und die Datei wird bei der
|
||||
Reconciliation nachgezogen.
|
||||
|
||||
Warum nicht alles nur in die DB und die Datei als reine Projektion? Weil die Datei der
|
||||
Bedienpunkt ist: Ein Mensch soll eine Aufgabe im Editor anlegen und ändern können, ein
|
||||
Agent über sein Tool, und beides soll im SharedWorkspace sichtbar und versionierbar
|
||||
bleiben. Warum nicht alles nur in Dateien? Weil das Claiming dort nicht atomar geht —
|
||||
siehe oben. Die Aufteilung nimmt von beidem das Belastbare.
|
||||
|
||||
Der **Importer** ist die Brücke: Er liest die Frontmatter-Definition und spiegelt sie
|
||||
idempotent in die DB-Zeile (`UPSERT` auf `task_id`). Er läuft beim Start (alle Dateien),
|
||||
nach jeder `task_*`-Änderung (die betroffene Datei) und optional per
|
||||
`FileSystemWatcher` mit Debounce (~500 ms, wie bei A4), damit von Hand editierte
|
||||
Dateien zeitnah einfließen.
|
||||
|
||||
## Dateiformat
|
||||
|
||||
Aufgaben liegen als eine Datei je Aufgabe unter `SharedWorkspace/tasks/`. Der
|
||||
Dateiname ist beschreibend (`recherche-nvda-earnings.md`); die stabile Identität ist
|
||||
die `id` im Frontmatter, nicht der Name — so überlebt eine Aufgabe das Umbenennen.
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: t-8f3a2c # stabil, beim Anlegen vergeben; Wahrheit der Identität
|
||||
title: NVDA Earnings recherchieren
|
||||
status: todo # backlog | todo | in_progress | in_review | done | canceled | blocked
|
||||
type: work # work | approval | human_input
|
||||
priority: 3 # 1 (niedrig) .. 5 (hoch)
|
||||
assignee: "@crawler" # @new | @<agentId> | @human
|
||||
when: # optional; fehlt = einmalige Aufgabe, sofort fällig
|
||||
kind: cron # at | every | cron
|
||||
value: "0 7 * * 1-5"
|
||||
tz: Europe/Berlin # PFLICHT, wenn when gesetzt ist — kein Termin ohne Zone
|
||||
require_approval: false # true = gilt erst nach Review (in_review) als done
|
||||
acceptance: | # Abnahmekriterien, gegen die das Ergebnis geprüft wird
|
||||
Aktuelle Zahlen mit Datum und Quelle, in SharedWorkspace/data/nvda.json abgelegt.
|
||||
blocked_by: [t-4b1e] # diese Aufgabe startet erst, wenn alle Blocker done sind
|
||||
onlyWhenMarketOpen: false # C1: Termin nur auslösen, wenn der Markt offen ist
|
||||
---
|
||||
|
||||
Freitext-Rumpf: Auftragsbeschreibung, Kontext, Verweise. Geht als Aufgabenstellung
|
||||
an den Agenten. Kommentare (Ergebnisse, Kritik, Reopen) werden unten angehängt.
|
||||
```
|
||||
|
||||
Feldregeln:
|
||||
|
||||
- **`when.kind`**: `at` (einmaliger Zeitpunkt, ISO 8601), `every` (Intervall, z. B.
|
||||
`30m`), `cron` (5-Felder-Ausdruck wie bisher). `tz` ist bei allen dreien Pflicht —
|
||||
das ist die Antwort auf B7. Die vorhandene
|
||||
[`CronExpression`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs) rechnet
|
||||
heute zonen-blind in `DateTime.Now`; sie wird in einen zonen-bewussten Aufruf
|
||||
gekapselt (nächsten Termin in `tz` bestimmen, dann in UTC vergleichen). Das
|
||||
verzahnt sich mit R2 (`TimeProvider`) aus der [Teststrategie](Teststrategie.md).
|
||||
- **`assignee`** ersetzt das `UseChatContext`-Flag durch eine explizite Angabe (T7):
|
||||
- `@new` bzw. `@new:<agentId>` → frischer Lauf ohne Historie (`AgentEngine.RunAsync`).
|
||||
Bloßes `@new` ist nur eindeutig, wenn die Instanz genau **einen** Agenten hat; sonst
|
||||
benennt `@new:<agentId>` den Ziel-Agenten. Ist er nicht auflösbar, scheitert der
|
||||
Dispatch mit klarer Meldung, statt einen falschen Agenten zu raten.
|
||||
- `@<agentId>` → bestehender Agent mit seinem Kontext (`AgentEngine.ChatAsync`)
|
||||
- `@human` → wartet auf einen Menschen; kein Modell-Lauf
|
||||
- **`type`**: `work` (Standard), `approval` und `human_input`. Bei den letzten beiden
|
||||
ist ein Mensch der Assignee; seine Antwort **ist** das Ergebnis und Input für
|
||||
Folgeaufgaben. Kein Sonderpfad — nur ein Assignee, der kein Modell ist.
|
||||
|
||||
## Scanner-Verhalten
|
||||
|
||||
Ein einziger Takt (~60 s) statt vieler langer `Task.Delay`. Damit kennt das System
|
||||
keine Monats-Delays mehr (B6), und ein verpasster Takt ist ein verpasster Termin, kein
|
||||
Zeitbombe.
|
||||
|
||||
Je Takt:
|
||||
|
||||
1. **Fällige Termine bestimmen.** Aus jeder aktiven Task-Zeile den nächsten Termin in
|
||||
ihrer `tz` berechnen und gegen „jetzt" prüfen. Ein Termin ist durch einen
|
||||
**Occurrence-Key** eindeutig: `task_id` + geplante Feuerzeit (bei `at`/`cron`/`every`).
|
||||
2. **Claim vor Lauf (at-most-once).** Bevor gelaufen wird, wird der Occurrence-Key
|
||||
atomar beansprucht:
|
||||
|
||||
```sql
|
||||
UPDATE Tasks
|
||||
SET claim_token = @token, claimed_at = @now, last_occurrence = @occ, status = 'in_progress'
|
||||
WHERE id = @id
|
||||
AND status IN ('todo','backlog')
|
||||
AND (last_occurrence IS NULL OR last_occurrence < @occ)
|
||||
AND (claim_token IS NULL OR claimed_at < @leaseCutoff);
|
||||
```
|
||||
|
||||
Genau eine Zeile betroffen = Anspruch gewonnen. Ein zweiter, gleichzeitiger Takt
|
||||
findet die Bedingung nicht mehr erfüllt (Rowcount 0) und läuft **nicht** — so wird
|
||||
ein doppelter Tick zu einem Lauf.
|
||||
3. **Dispatch nach Assignee.** `@new` → `RunAsync`; `@<agent>` → `ChatAsync`; `@human`
|
||||
→ kein Lauf, Status bleibt/wird `in_review` bzw. `todo`, die UI zeigt die Aufgabe
|
||||
als wartend. Der Rumpf (+ Abnahmekriterien) ist die Nachricht an den Agenten.
|
||||
4. **Abschluss verbuchen.** Last-Fired-Marker setzen (`last_occurrence = @occ`), Status
|
||||
fortschreiben, Claim lösen. Bei `require_approval` → `in_review` statt `done`. Der
|
||||
Lauf wird am Task verknüpft (Grundlage für A3-Receipts und die Run-Historie F-A4).
|
||||
|
||||
**Kein Retry-Sturm.** Der Marker wird auch bei einem **fehlgeschlagenen** Lauf gesetzt:
|
||||
ein fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin. Wiederholung
|
||||
ist eine bewusste Entscheidung (neuer Termin oder Reopen), kein Automatismus. Das
|
||||
schützt vor einem Agenten, der bei jedem Takt erneut in denselben Fehler läuft und
|
||||
Budget verbrennt.
|
||||
|
||||
**Serialisierung mit den Chat-Läufen (B2).** Der Scanner ruft die Engine über
|
||||
dieselben Einstiegspunkte wie WebView, ToolJob und AgentComm. `ChatAsync` ist bereits
|
||||
je Agent über ein `SemaphoreSlim`-Gate serialisiert
|
||||
([`AgentEngine`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs)) — der Scanner fügt
|
||||
sich dort ein, statt einen zweiten, konkurrierenden Pfad in den geteilten
|
||||
Konversationskontext aufzumachen. Der Claim ist eine **DB**-Grenze (welcher Termin wird
|
||||
behandelt), das Agent-Gate eine **Kontext**-Grenze (kein verschränkter Nachrichtenstrom).
|
||||
Beide werden gebraucht; keine ersetzt die andere.
|
||||
|
||||
### Startup-Reconciliation
|
||||
|
||||
Beim Start wird Soll (Frontmatter) gegen Ist (DB-Marker/Claims) abgeglichen, statt
|
||||
verpasste Läufe still zu überspringen:
|
||||
|
||||
- Alle Task-Dateien importieren (UPSERT), gelöschte Dateien in der DB als `archived`
|
||||
markieren.
|
||||
- **Stale Claims freigeben:** Ein Claim, dessen `claimed_at` älter ist als die
|
||||
Lease-Dauer (ein während des Laufs abgestürzter Prozess), wird verworfen — die
|
||||
Bedingung `claimed_at < @leaseCutoff` im Claim-`UPDATE` erledigt das ohnehin, die
|
||||
Reconciliation stellt den Status zusätzlich von `in_progress` auf `todo` zurück.
|
||||
- **Verpasste Termine erkennen:** Liegt der letzte planmäßige Termin nach dem
|
||||
Last-Fired-Marker, ist ein Lauf ausgefallen. Er wird als solcher gemeldet (Log, später
|
||||
A5), nicht heimlich verschluckt. Ob nachgeholt wird, ist Politik — Standard: einmal
|
||||
nachholen, sonst würde ein über Nacht ausgeschalteter Rechner beim Start eine Welle
|
||||
auslösen.
|
||||
|
||||
## Die drei Invarianten
|
||||
|
||||
Der Scanner-Kern ist in der Roadmap als der heikle Teil markiert (die Fehlerklasse, die
|
||||
hier schon einmal schiefging). Er wird strikt gegen diese Invarianten gebaut und mit
|
||||
Property-Tests (FsCheck, siehe Teststrategie) abgesichert:
|
||||
|
||||
1. **Nie zwei Claims auf einen Task-Termin.** Garantiert durch das bedingte `UPDATE`:
|
||||
Rowcount ≤ 1 pro Occurrence-Key. Test: N parallele Claims auf denselben Key → genau
|
||||
einer gewinnt.
|
||||
2. **Kein Dispatch bei offenem Blocker.** Eine Aufgabe mit unerfüllten `blocked_by`
|
||||
ist nicht `todo`, sondern `blocked`, und die Claim-Bedingung (`status IN
|
||||
('todo','backlog')`) greift nicht. Test: Blocker offen → kein Lauf; letzter Blocker
|
||||
`done` → genau ein Auto-Dispatch.
|
||||
3. **Doppelter Tick = ein Lauf.** Zwei Takte im selben Fenster konkurrieren um denselben
|
||||
Occurrence-Key; der Claim lässt nur einen durch. Test: zwei gleichzeitige
|
||||
`ScanOnce` → ein Lauf, ein Marker.
|
||||
|
||||
Diese drei sind keine Kür, sondern die Bedingung dafür, dass der Kern mit einem
|
||||
schwächeren Modell umgesetzt werden darf.
|
||||
|
||||
## Abhängigkeiten und Eskalation
|
||||
|
||||
- **`blocked_by` mit Auto-Dispatch:** Wird eine Aufgabe `done`, sucht das Board alle
|
||||
Aufgaben, deren `blocked_by` sie enthält. Sind für eine davon **alle** Blocker `done`,
|
||||
wechselt sie `blocked → todo` und der Scanner nimmt sie beim nächsten Takt auf.
|
||||
- **Blocker-Eskalation:** Meldet ein Agent einen Blocker (`task_update status=blocked`
|
||||
mit Begründung), fällt die Aufgabe und der Zuständige (Lead/Benutzer) wird
|
||||
benachrichtigt. Bis A5 (Matrix) steht, geht das über den vorhandenen Log-/UI-Weg.
|
||||
|
||||
## Zusammenspiel mit Staging (A2) und Reopen
|
||||
|
||||
- **`require_approval` / `in_review`:** Eine Aufgabe mit `require_approval: true` gilt
|
||||
nach dem Lauf nicht als `done`, sondern als `in_review`. Das ist der natürliche
|
||||
Andockpunkt für A2: Die Freigabe erzeugt einen Folge-Task, der den Agenten mit dem
|
||||
eingefrorenen Aufruf weckt (so bleibt der Lauf regulär beendet, kein pausierter
|
||||
In-Memory-Zustand — genau die Architektur-Vorgabe aus der Roadmap-Einstufung für A2).
|
||||
- **Reopen/Feedback:** Ergebnis + Kritik gehen per `task_comment` an **denselben**
|
||||
Agenten zurück (`ChatAsync` in dessen Kontext), statt eine neue Aufgabe von vorn zu
|
||||
beginnen. Die Aufgabe kehrt nach `todo`/`in_progress` zurück, der Verlauf am Task
|
||||
bleibt erhalten.
|
||||
|
||||
## Agenten-Tool
|
||||
|
||||
Ein Tool `Taskboard` im Muster von `MemoryTool` (eine Aktion je Aufruf), das über einen
|
||||
neuen `ITaskRepository` auf dem `AgentToolContext` arbeitet (analog `IMemoryRepository?
|
||||
Memory`):
|
||||
|
||||
| Aktion | Zweck |
|
||||
|---|---|
|
||||
| `task_create` | Aufgabe anlegen — schreibt Datei **und** DB-Zeile (über den Importer). Vergibt die `id`. |
|
||||
| `task_list` | Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. |
|
||||
| `task_update` | Status/Felder ändern; `blocked` melden; Ergebnis eintragen. |
|
||||
| `task_comment` | Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. |
|
||||
|
||||
Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem `send_message` legt
|
||||
ein Agent eine Aufgabe mit `assignee: @<other>` an. Das ist strukturell zyklenfrei (B8) —
|
||||
eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack.
|
||||
|
||||
**Sicherheit:** Ein Task-Rumpf ist Prompt-Input für den Assignee. Fremdbestimmte Inhalte
|
||||
(Ergebnisse anderer Tools, die in einen Task fließen) werden als Daten gerahmt, nicht als
|
||||
Anweisung — dieselbe Linie wie K2. Irreversibles, das ein Task auslöst, läuft über A2.
|
||||
|
||||
## Migration
|
||||
|
||||
Die `coordination/*.md`-Dateien gehen im Taskboard auf. Die Altdateien haben kein Schema
|
||||
(freies Markdown wie `# Task: …`, `## Status: ASSIGNED`), deshalb bewusst konservativ
|
||||
(`CoordinationMigration`, beim Start ausgeführt):
|
||||
|
||||
- **`task_*`-Dateien** → einmalig als `backlog`-Aufgaben übernommen: Titel aus der
|
||||
`# Task:`-Überschrift (sonst erste Überschrift, sonst Dateiname), das ganze Markdown
|
||||
als Rumpf, `assignee: @human` als sicherer Default, bis ein Mensch sie zuordnet.
|
||||
`backlog` (nicht `todo`), damit der Scanner nichts unbesehen ausführt.
|
||||
- **`status_*`, `broadcast`, Incident-Berichte, `*.json`-Artefakte** → keine Aufgaben;
|
||||
bleiben unangetastet (später nach A5/Matrix bzw. verfallen als Altbestand).
|
||||
|
||||
Idempotent durch **Verschieben statt Löschen**: eine übernommene `task_*`-Datei wandert
|
||||
nach `coordination/migrated/` — die Historie bleibt, ein zweiter Start findet sie nicht
|
||||
mehr. Vor der Migration greift die übliche Regel: frisches Backup.
|
||||
|
||||
## Ein Takt für alles — Ablösung der Alt-Scheduler
|
||||
|
||||
Der Scanner ist der **einzige** periodische Treiber. Die früheren `AgentScheduler` und
|
||||
`ToolJobScheduler` (zwei `Task.Delay`-Schleifen mit B6/B7, ungetestet) sind **gelöscht** —
|
||||
alles Periodische ist jetzt ein Task:
|
||||
|
||||
- **Geplanter Agent-Lauf** (früher `scheduler`-Config) → ein normaler Task mit
|
||||
`when: cron` und Assignee `@new:<agent>`.
|
||||
- **Tool-Job-Poll** (früher `toolJobs`-Config, z. B. `telegram_poll`) → ein Task vom Typ
|
||||
**`tool_job`** mit `tool_name`/`job_type`. Beim fälligen Termin tickt der Dispatcher den
|
||||
`IToolJobProvider` und weckt den Zielagenten (Assignee) nur, wenn der Tick etwas meldet —
|
||||
mit oder ohne Kontext, je nach `ToolJobResult`.
|
||||
|
||||
Zwei Feinheiten, die dabei geradegezogen wurden:
|
||||
|
||||
- **Wiederkehrende Tasks** (`cron`/`every`/`tool_job`) kehren nach dem Feuern auf `todo`
|
||||
zurück statt auf `done` — sonst liefe ein Cron-Task nur ein einziges Mal. Der
|
||||
Last-Fired-Marker verhindert weiterhin, dass **derselbe** Termin doppelt feuert.
|
||||
- **`backlog` ist ein Halte-Status**: Der Scanner claimt nur `todo`. Eine Aufgabe in
|
||||
`backlog` (frisch importiert, migriert, oder ein deaktivierter Poll) ruht, bis ein
|
||||
Mensch sie auf `todo` setzt.
|
||||
|
||||
Die Alt-Konfiguration (`scheduler`, `toolJobs`) wird beim Start einmalig und
|
||||
nicht-destruktiv in Tasks migriert (`SchedulerTaskMigration`, stabile Ids
|
||||
`sched-<agent>` / `tj-<agent>-<job>`). Ein manuelles „Jetzt ausführen" im Host läuft über
|
||||
`TaskScanner.RunTaskNowAsync`.
|
||||
|
||||
## Verzahnung
|
||||
|
||||
- **C1 Marktkalender:** `onlyWhenMarketOpen` gehört ins Frontmatter, nicht in einen
|
||||
eigenen Mechanismus — der Scanner überspringt einen Termin, wenn der Markt zu ist.
|
||||
- **A3 Audit/Receipts:** Jeder Lauf wird am Task verknüpft; der Abschluss-Beleg (Schritte,
|
||||
Tokens, Kosten) fällt daraus ab und macht C7 weitgehend zum Abfallprodukt.
|
||||
- **F-A5/F-A4:** Das Board **ist** die Queue; die verknüpften Läufe **sind** die Historie.
|
||||
|
||||
## Umsetzungsreihenfolge
|
||||
|
||||
Bewusst so geschnitten, dass der heikle Kern zuletzt und gegen grüne Invarianten kommt:
|
||||
|
||||
1. **Modelle + `ITaskRepository` + Schema + Frontmatter-Parser.** Reine, testbare
|
||||
Bausteine. Der Parser wird eng gebaut (die Frontmatter ist ein kleiner, flacher
|
||||
Satz aus Skalaren und kurzen Listen) — keine YAML-Bibliothek, passend zum
|
||||
dependency-armen Stil des Projekts. 4.6-tauglich.
|
||||
2. **`Taskboard`-Tool + Importer.** Datei ↔ DB, `task_*`-Aktionen. 4.6-tauglich.
|
||||
3. **Scanner-Kern** (Claim, Dispatch, Reconciliation, Auto-Dispatch) — strikt gegen die
|
||||
drei Invarianten, mit Property-Tests. Der in der Roadmap für Opus 5/Fable markierte
|
||||
Teil; mit Opus 4.8 nur streng nach diesem Dokument und mit den Invarianten-Tests als
|
||||
Netz.
|
||||
4. **Migration** der `coordination/*.md`.
|
||||
|
||||
Neue Subsysteme kommen mit Tests nach der [Teststrategie](Teststrategie.md); die
|
||||
Invarianten-Tests sind bei Punkt 3 die Absicherung, kein Nice-to-have.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Nachhol-Politik verpasster Termine** — umgesetzt als „höchstens einmal nachholen":
|
||||
der Scanner nimmt den jüngsten verpassten Termin, nicht jeden einzelnen. Eine frische
|
||||
Aufgabe holt zudem keinen Termin von **vor** ihrer Anlage nach. Ob das je Task
|
||||
abschaltbar sein soll (`catchUp: true|false`), ist offen.
|
||||
- **Marktkalender (C1)** — der Scanner fragt eine `IMarketCalendar` (derzeit Platzhalter
|
||||
„immer offen"). C1 liefert später den echten Kalender; die Verzahnung steht.
|
||||
- **DST-Randfall** — eine bei der Zeitumstellung nicht existierende Ortszeit
|
||||
(Frühjahr, „02:30") wird derzeit übersprungen statt verschoben. Für die geplanten
|
||||
Termine unkritisch; die Härtung gehört zur Scheduler-Nacharbeit (Teststrategie R4/R5).
|
||||
- **Priorität als Reihenfolge** — bei mehreren fälligen Aufgaben desselben Agenten
|
||||
bestimmt `priority` die Reihenfolge; ob strikt oder gewichtet, ist noch offen.
|
||||
- **Aufräumen** — `done`/`canceled`-Aufgaben nach einer Frist archivieren, damit
|
||||
`tasks/` nicht zuwächst (dieselbe Überlegung wie „Verfall" beim Gedächtnis).
|
||||
Reference in New Issue
Block a user