Read workflow-status telemetry from CLI transcript files

Dynamic ultracode workflows run as a detached background task with their
own transcript dir, so their subagent events never flow through the main
-p stream. The previous parent_tool_use_id tap therefore captured nothing
for real workflows and the status stayed "none" the whole run.

Replace the tap with a file-based reader:
- TapWorkflowLaunch parses the "Transcript dir:" line from the Workflow
  tool_result and registers the run.
- WorkflowProgressTracker becomes a per-session registry of transcript
  paths (no longer a stream accumulator).
- WorkflowTelemetryReader reads journal.jsonl for live counts (running)
  and <runId>.json for rich per-agent telemetry (completed); FinalizeChain
  persists the rich JSON into chains.workflow_info.
- Modal renders phases + a per-agent list (label/phase/state/tokens/
  tool-calls/duration) instead of the obsolete tool breakdown.

Sync ARCHITECTURE.md, ARCHITECTURE_QUICK.md and README.md to the new
mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-20 18:14:42 +02:00
1 parent b6e546f618
commit 2b95161596
9 files changed
+451 -173

No files matched your search

+14 -10
View File
@@ -165,7 +165,7 @@ gott/
│ ├── KreaService.cs # Krea Image Generation API
│ ├── ImageService.cs # Bild-Resize + WebP-Konvertierung (512px + 256px Thumbnail)
│ ├── StreamingTracker.cs # Tracking aktiver Streams + CancellationTokenSource für Stop
│ ├── WorkflowProgressTracker.cs # In-Memory-Telemetrie laufender dynamischer Workflows (Ultracode) pro Session
│ ├── WorkflowProgressTracker.cs # Registry der dynamischen Workflows (Ultracode) pro Session + WorkflowTelemetryReader
│ ├── WorkspaceHelper.cs # Workspace-Pfad-Auflösung (relativ → absolut)
│ ├── AppPaths.cs # Zentrale Pfade (WwwRoot) – Dev vs. Publish-Modus
│ ├── KeyScannerService.cs # API-Key-Scanner (GitHub Code Search, GitHub Issues/PRs, HuggingFace Hub, Google/Serper)
@@ -382,7 +382,7 @@ TelegramBot (1) ─── (*) Session (via telegram_bot_id)
- `ToolNames` – Komma-separierte Liste der verwendeten Tools
- `ArchiveId` – Zugehöriges Archiv (wenn archiviert)
- `StreamErrorInfo` – Fehlerdetails bei Stream-Abbrüchen
- `WorkflowInfo` – JSON-Telemetrie eines dynamischen Workflows (Ultracode): Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung), aggregierte Subagent-Tokens. `null` wenn die Chain keinen Workflow gespawnt hat (siehe [Workflow-Status](#workflow-status))
- `WorkflowInfo` – JSON-Telemetrie eines dynamischen Workflows (Ultracode): die reiche, beim Lauf-Ende aus der `<runId>.json` gelesene Telemetrie – Phasen + Per-Agent-Details (Label/Phase/State/Tokens/Tool-Calls/Dauer) + Summen. `null` wenn die Chain keinen Workflow gespawnt hat (siehe [Workflow-Status](#workflow-status))
- `PlanMode` – Ob die Chain im Plan-Modus erstellt wurde (informativ)
- `DurationMs` – Gesamtdauer der Chain
@@ -462,7 +462,7 @@ RemoteConnectionManager → AuthService → DatabaseService → EnsureDefaultUse
| `KreaService` | Singleton | Krea API für Bildgenerierung, Job-Polling, Image-Download |
| `ImageService` | Singleton | Bild-Resize + WebP-Konvertierung (512px + 256px Thumbnail) für Avatare und Session-Bilder |
| `StreamingTracker` | Singleton | Trackt aktive Streams + CancellationTokenSource pro Session für Stop-Button |
| `WorkflowProgressTracker` | Singleton | In-Memory-Telemetrie laufender dynamischer Workflows (Ultracode) pro Session: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung), aggregierte Subagent-Tokens. Wird beim Chain-Finalize in `chains.workflow_info` persistiert (analog `StreamingTracker`) |
| `WorkflowProgressTracker` | Singleton | **Registry** laufender dynamischer Workflows (Ultracode) pro Session: merkt sich pro Session die aus dem Workflow-`tool_result` abgegriffenen Transcript-Pfade (`journal.jsonl` / `<runId>.json`). Die Telemetrie wird **on demand** via `WorkflowTelemetryReader` aus diesen Dateien gelesen (live aus `journal.jsonl`, abgeschlossen aus `<runId>.json`) und beim Chain-Finalize in `chains.workflow_info` persistiert (analog `StreamingTracker`) |
| `KeyScannerService` | Singleton | API-Key-Scanner (GitHub Code Search, GitHub Issues/PRs, HuggingFace Hub, Google/Serper), täglicher Scan |
| `GitHubEventScannerService` | Background | Echtzeit-Scanner: GitHub Public Events API (PushEvent-Diffs nach geleakten Keys), 30s Poll-Intervall |
| `KeyPatterns` | Static | Zentrale Key-Pattern-Definitionen für alle Scanner (Regex, Typ-Erkennung, Display-Names) |
@@ -1520,20 +1520,24 @@ Wenn im Plan-Modus eine `ask_user_choice`-Rückfrage kommt und der User antworte
### Workflow-Status
Da die CLI Subagent-Events (Top-Level-Messages mit gesetztem `parent_tool_use_id`) bewusst aus dem sichtbaren Stream/History ausblendet (zentraler `parent_tool_use_id`-Guard in `ClaudeCliService.RunCliStreamingAsync`), gäbe es ohne separates Tracking keine Sichtbarkeit auf den Workflow-Fortschritt. Der **Workflow-Status** macht diese Subagent-Telemetrie live und nachträglich abrufbar.
Dynamische Ultracode-Workflows laufen als **detachter Hintergrund-Task mit eigenem Transcript-Verzeichnis** – ihre Subagent-Events fließen **nicht** durch den Haupt-(`-p`)-Stream (anders als klassische Task-Subagenten, die mit gesetztem `parent_tool_use_id` inline im Parent-Stream ankommen). Die echte Telemetrie liegt daher in zwei Dateien des CLI-Transcripts und wird **on demand** gelesen, nicht aus dem Stream akkumuliert. Der **Workflow-Status** macht diese Telemetrie live und nachträglich abrufbar.
**Backend:**
- **`WorkflowProgressTracker`** (Singleton, registriert in `Program.cs`) – In-Memory-Tracking pro Session: Subagent-Anzahl (dedupliziert per `parent_tool_use_id`), Tool-Aufrufe + Aufschlüsselung (`ToolBreakdown`), aggregierte Subagent-Input-/Output-Tokens, `RecentTools` (letzte 12). Lock-leicht, defensiv – Telemetrie darf den Stream nie blockieren.
- **Tap statt Filter:** Der `parent_tool_use_id`-Guard blendet Subagent-Events weiterhin aus dem Stream aus (`continue`), greift aber für `type == "assistant"`-Turns via `TapSubagentEvent()` die Tool-Namen + Token ab und meldet sie an `RecordSubagent()`. Der existierende Guard bleibt unverändert.
- **Persistierung:** Neue Spalte `chains.workflow_info` (TEXT, via `TryAddColumn` in `DatabaseService.cs`; geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`), Feld `ChainRecord.WorkflowInfo`. Beim `FinalizeChain` (`AnthropicService.cs`) wird die Telemetrie als JSON in die Chain serialisiert und der Live-Tracker freigegeben – mit **ChainId-Abgleich** (`ChainIdFor`), damit ein anderer Finalize-Pfad derselben Session (z.B. ein Panel-Member) den Workflow-Eintrag nicht fälschlich abräumt.
- **Endpoint:** `GET /api/sessions/{id}/workflow-status` (`SessionEndpoint.HandleWorkflowStatus`) liefert in dieser Reihenfolge: (1) `status: "running"` + Live-Snapshot, solange der Tracker einen Eintrag hat; (2) `status: "completed"` + persistiertes `workflow_info` der jüngsten aktiven Chain; (3) `status: "none"` mit `workflow: null`. Top-Level-Felder: `sessionId`, `streaming`, `status`, `chainId` (entfällt bei `status: "none"`) und das Telemetrie-Objekt `workflow` (`null` bei `none`).
- **Capture (`ClaudeCliService.TapWorkflowLaunch`)** – Im `tool_result`-Pfad: Enthält der Inhalt `"Workflow launched"`, parst die Methode die Zeile `Transcript dir: <absoluter Pfad>` und ruft `_workflowTracker.Register(...)`. Rein additiv, wirft nie. Der `parent_tool_use_id`-Guard in `ClaudeCliService.RunCliStreamingAsync` blendet klassische Subagent-Events weiterhin per `continue` aus dem sichtbaren Stream aus – **tappt sie aber nicht mehr** ab.
- **Registry (`WorkflowProgressTracker`)** (Singleton, registriert in `Program.cs`) – ist **nur eine Registry**, kein Stream-Akkumulator. Speichert pro Session einen `WorkflowRunRef` (`ChainId`, `RunId`, `JournalPath`, `RichJsonPath`, `StartedAt`), abgeleitet aus der Transcript-Dir. Methoden: `Register` / `Get` / `ChainIdFor` / `End`. In-Memory pro Session, defensiv.
- **Reader (`WorkflowTelemetryReader`, statisch)** – liest die Telemetrie aus zwei Dateien:
- **Live:** `<transcriptDir>/journal.jsonl` (append-only; pro Subagent `started`/`result`-Zeilen). `ReadLive` ⇒ `status: "running"`, `subagentCount` (= Anzahl `started`), `completedCount` (= Anzahl `result`), `durationMs`. **Keine** Tokens/Tool-Calls live verfügbar.
- **Abgeschlossen:** `<proj>/workflows/<runId>.json` (wird **erst am Lauf-Ende** geschrieben). `ReadCompleted` ⇒ `status: "completed"`, `workflowName`, `summary`, `durationMs`, `phases[]` (index/title/detail), `agents[]` (index/label/phaseTitle/state/model/tokens/toolCalls/durationMs/resultPreview), `totalTokens`, `totalToolCalls`, `subagentCount`, `completedCount`.
- `Read()` bevorzugt die reiche, fertige `<runId>.json` und fällt sonst auf das Live-`journal.jsonl` zurück.
- **Persistierung:** Spalte `chains.workflow_info` (TEXT, via `TryAddColumn` in `DatabaseService.cs`; geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`), Feld `ChainRecord.WorkflowInfo`. Beim `FinalizeChain` (`AnthropicService.cs`) wird die reiche `WorkflowProgress`-JSON in die Chain serialisiert und der Tracker-Eintrag freigegeben – mit **ChainId-Abgleich** (`ChainIdFor`), damit ein anderer Finalize-Pfad derselben Session (z.B. ein Panel-Member) den Workflow-Eintrag nicht fälschlich abräumt.
- **Endpoint:** `GET /api/sessions/{id}/workflow-status` (`SessionEndpoint.HandleWorkflowStatus`) liefert in dieser Reihenfolge: (1) ist für die Session ein Lauf registriert → Telemetrie aus den Transcript-Dateien lesen (`running` aus `journal.jsonl` / `completed` aus `<runId>.json`); (2) sonst die jüngste aktive Chain mit nicht-leerem `workflow_info` → `status: "completed"`; (3) sonst `status: "none"`, `workflow: null`. JSON-Shape: `running`/`completed` → `{ sessionId, streaming, status, chainId, workflow }`; `none` → `{ sessionId, streaming, status: "none", workflow: null }` (**kein `chainId` im `none`-Zweig**).
**Felder im `workflow`-Objekt** (camelCase): `subagentCount`, `subagentToolCalls`, `subagentInputTokens`, `subagentOutputTokens`, `toolBreakdown`, `recentTools`, `startedAt`, `lastActivityAt`.
**Felder im `workflow`-Objekt** (camelCase, via `WriteAsJsonAsync`): `status`, `chainId`, `workflowName`, `summary`, `startedAt`, `subagentCount`, `completedCount`, `totalTokens`, `totalToolCalls`, `durationMs`, `phases[]` (`index`, `title`, `detail`), `agents[]` (`index`, `label`, `phaseTitle`, `state`, `model`, `tokens`, `toolCalls`, `durationMs`, `resultPreview`).
**Frontend:**
- Header-Menü-Eintrag **"Workflow-Status"** (Button `#workflowStatusBtn` in `index.html`, direkt nach "Payload anzeigen")
- API-Wrapper `apiGetWorkflowStatus(sessionId)` in `js/api.js`
- `showWorkflowStatusModal(sessionId)` in `js/modals.js` – rendert Status-Head, Statistik-Grid (Subagenten/Tool-Aufrufe/Input-/Output-Tokens), Tool-Aufschlüsselung und Zeitstempel. **Pollt alle 2 s**, solange `status === "running"`, und stoppt automatisch, sobald das Modal geschlossen wird (Overlay nicht mehr im DOM) oder der Workflow endet.
- `showWorkflowStatusModal(sessionId)` in `js/modals.js` – rendert Status-Head, Summen-Kacheln (Subagenten/Fertig/Tokens/Tool-Aufrufe), die **Phasen** und eine **Per-Agent-Liste** (Label, Phase, State, Tokens, Tool-Calls, Dauer). **Pollt alle 2 s**, solange `status === "running"`, und stoppt automatisch, sobald das Modal geschlossen wird (Overlay nicht mehr im DOM) oder der Workflow endet.
- Click-Handler in `js/pages/chat-page.js`, Styles `.workflow-status-modal` (+ `.wf-*`) in `css/chat.css`
---
+16 -6
View File
@@ -162,7 +162,7 @@ User 1──* TelegramBot 1──* Session (via telegram_bot_id)
| **EmbeddingBackgroundService** | Backfill bestehender Messages + Live-Embedding neuer Messages/Memory-Files |
| **RemoteConnectionManager** | WebSocket-basierte Remote-Verbindungen, Command-Execution auf Remote-Clients |
| **StreamingTracker** | Tracking aktiver Streams + CancellationTokenSource pro Session für Stop |
| **WorkflowProgressTracker** | In-Memory-Telemetrie laufender dynamischer ultracode-Workflows pro Session (Subagent-Anzahl, Tool-Aufrufe + Aufschlüsselung, aggregierte Subagent-Tokens); LIVE während des Laufs, danach in `chains.workflow_info` persistiert (analog StreamingTracker) |
| **WorkflowProgressTracker** | **Registry** der Transcript-Pfade pro Session für dynamische ultracode-Workflows (`WorkflowRunRef`: ChainId/RunId/JournalPath/RichJsonPath/StartedAt) – kein Stream-Akkumulator. Die Telemetrie wird on demand über `WorkflowTelemetryReader` aus den CLI-Transcript-Dateien gelesen: `journal.jsonl` (live) bzw. `<runId>.json` (abgeschlossen, reich) und beim Finalize in `chains.workflow_info` persistiert |
| **AppPaths** | Zentrale Pfade (WwwRoot, Dev vs. Publish-Modus) (static) |
| **KeyScannerService** | API-Key-Scanner (GitHub Code Search, GitHub Issues/PRs, HuggingFace Hub, Google/Serper), täglicher Scan |
| **GitHubEventScannerService** | Echtzeit-Scanner: GitHub Public Events API (PushEvent-Diffs nach geleakten Keys), 30s Poll-Intervall |
@@ -358,7 +358,7 @@ Zusätzlich zum Worker kann eine Session ein **Panel** aus mehreren Agenten habe
**Member streamen LIVE** (wie der Session-Agent): CLI forwardet `TextDelta`/`ToolStart`/`ToolResult`, Anthropic-API streamt pro Tool-Runde über `NonStreamingLoopCallbacks`, OpenAI = one-shot (Text am Ende). Ein `SemaphoreSlim`-Write-Lock in `SseHelper.TryWriteSse` serialisiert die SSE-Byte-Flushes paralleler Member (sonst vermischen sich die Frames).
**Subagent-Filterung:** Spawnt ein CLI-Member intern einen Subagenten (Claude-CLI-`Agent`-Tool), sendet die CLI dessen Interna als separate Top-Level-Events mit `parent_tool_use_id`. Der Parser (`ClaudeCliService.RunCliStreamingAsync`) verwirft diese **zentral am Ursprung** (Guard auf nicht-leeres `parent_tool_use_id`) → keine namenlosen Tool-Boxen, weder im Live-Stream noch in der History (beide lesen aus denselben gefilterten `CliStreamEvent`s). Die `Agent`-Tool-Box selbst (`parent_tool_use_id=null`) bleibt sichtbar; der finale `result`-Event (mit den **aggregierten** Subagent-Tokens) wird nicht gefiltert → Token-Bilanz vollständig. Dieselben unterdrückten Subagent-Events werden zusätzlich **additiv** in den `WorkflowProgressTracker` abgegriffen (`TapSubagentEvent`, wirft nie) → speist den Workflow-Status, ohne den Guard/Stream zu verändern.
**Subagent-Filterung:** Spawnt ein CLI-Member intern einen Subagenten (Claude-CLI-`Agent`-Tool), sendet die CLI dessen Interna als separate Top-Level-Events mit `parent_tool_use_id`. Der Parser (`ClaudeCliService.RunCliStreamingAsync`) verwirft diese **zentral am Ursprung** (Guard auf nicht-leeres `parent_tool_use_id`) → keine namenlosen Tool-Boxen, weder im Live-Stream noch in der History (beide lesen aus denselben gefilterten `CliStreamEvent`s). Die `Agent`-Tool-Box selbst (`parent_tool_use_id=null`) bleibt sichtbar; der finale `result`-Event (mit den **aggregierten** Subagent-Tokens) wird nicht gefiltert → Token-Bilanz vollständig. Der Guard blendet diese klassischen Subagent-Events nur **aus** (`continue`) – er tappt sie nicht mehr ab. Der Workflow-Status der dynamischen ultracode-Läufe liest seine Telemetrie ohnehin aus den Transcript-Dateien (siehe unten), nicht aus dem Stream.
**Backend:** `ChatService.PrepareContext` baut `ChatContext.PanelAgents` (`List<SubAgentContext>`) + `MoaLayers`. `RunPanel(layers)` (AnthropicService.Panel.cs) läuft im ChatEndpoint-try VOR dem Worker-Dispatch: pro Runde Member parallel (`Task.WhenAll` + `SemaphoreSlim`), Provider-Weiche pro Member, **jede** Runde als `[Panel: Name]`-Message mit `Tag="panel"` persistiert (aus Worker-History gefiltert); JSON-Transkript = `{name, model, layer, totalLayers, segments:[Text/Tool…]}`. `NonStreamingLoopCallbacks.Persist=false` verhindert Doppel-Persistenz. Member-Tokens via `seedUsage` in Worker-`usage` gefaltet → Live-Badge **und** DB (kein separates Panel-Usage-Event). Synthese-Block in letzte User-Message gemergt. Panel-Fehler graceful.
@@ -375,13 +375,23 @@ Zusätzlich zum Worker kann eine Session ein **Panel** aus mehreren Agenten habe
## Workflow-Status (dynamische ultracode-Workflows)
Live-Telemetrie der Subagenten, die ein **ultracode**-Lauf über das `Workflow`/`Task`-Tool der Claude CLI spawnt (diese Subagent-Events sind im Haupt-Stream/Verlauf bewusst ausgeblendet, siehe Subagent-Filterung oben).
Telemetrie eines **ultracode**-Laufs, den die Claude CLI über das `Workflow`-Tool startet. **Entscheidend:** Solche Workflows laufen als **detachter Hintergrund-Task mit eigenem Transcript-Verzeichnis** – ihre Subagent-Events fließen NICHT durch den Haupt-(`-p`)-Stream (anders als klassische Task-Subagenten mit gesetztem `parent_tool_use_id`). Die echte Telemetrie liegt in zwei Dateien des CLI-Transcripts und wird **on demand** gelesen, nicht aus dem Stream abgegriffen.
**Backend:** `WorkflowProgressTracker` (Singleton, lokale Instanz in `Program.cs`, weil `ClaudeCliService` befüllt und `AnthropicService` beim Finalize persistiert – dieselbe Instanz). `ClaudeCliService.TapSubagentEvent` → `RecordSubagent(...)` führt pro Session In-Memory: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung), aggregierte Input/Output-Tokens. Beim `FinalizeChain` (`AnthropicService.cs`) wird der Stand als JSON in `chains.workflow_info` (`ChainRecord.WorkflowInfo`, Migration via `TryAddColumn` in `DatabaseService.cs`, geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`) geschrieben – mit ChainId-Abgleich, damit ein Panel-Member derselben Session ihn nicht löscht.
**Capture:** Im tool_result-Pfad ruft `ClaudeCliService.TapWorkflowLaunch(sessionId, chainId, resultContent)` auf; enthält der Inhalt „Workflow launched", parst die Methode die Zeile „Transcript dir: <absoluter Pfad>" und ruft `_workflowTracker.Register(...)`. Rein additiv, wirft nie. (Der `parent_tool_use_id`-Guard blendet klassische Subagent-Events weiterhin aus dem sichtbaren Stream aus – er tappt sie aber NICHT mehr.)
**Endpoint:** `GET /api/sessions/{id}/workflow-status` → LIVE-Snapshot aus dem Tracker, sonst persistiertes `workflow_info` der jüngsten Chain, sonst `status: "none"`. JSON: `{ sessionId, streaming, status (running/completed/none), chainId, workflow }`; das `workflow`-Objekt trägt camelCase `subagentCount`, `subagentToolCalls`, `subagentInputTokens`, `subagentOutputTokens`, `toolBreakdown`, `recentTools`, `startedAt`, `lastActivityAt`.
**Registry:** `WorkflowProgressTracker` (Singleton, lokale Instanz in `Program.cs`, weil `ClaudeCliService` befüllt und `AnthropicService` beim Finalize liest/persistiert – dieselbe Instanz) ist NUR eine Registry, kein Stream-Akkumulator. Speichert pro Session einen `WorkflowRunRef { ChainId, RunId, JournalPath, RichJsonPath, StartedAt }`, abgeleitet aus der Transcript-Dir. Methoden: `Register` / `Get` / `ChainIdFor` / `End` – in-memory pro Session, defensiv.
**Frontend:** Header-Menü-Eintrag **„Workflow-Status"** (`#workflowStatusBtn` in `index.html`, direkt nach „Payload anzeigen"), Wrapper `apiGetWorkflowStatus` (`api.js`), `showWorkflowStatusModal` (`modals.js`, pollt alle 2 s solange `status === 'running'`, stoppt von selbst beim Schließen des Modals), Click-Handler in `pages/chat-page.js`, `.workflow-status-modal`-Styles in `css/chat.css`.
**Reader:** Die statische `WorkflowTelemetryReader`-Klasse liest die Telemetrie aus zwei Quellen:
- LIVE `<transcriptDir>/journal.jsonl` (append-only; pro Subagent `started`/`result`-Zeilen) → `ReadLive` liefert `status: "running"`, `subagentCount` (= Anzahl `started`), `completedCount` (= Anzahl `result`), `durationMs`. Keine Tokens/Tool-Calls live verfügbar.
- ABGESCHLOSSEN `<proj>/workflows/<runId>.json` (erst am Laufende geschrieben) → `ReadCompleted` liefert `status: "completed"`, `workflowName`, `summary`, `durationMs`, `phases[]` (index/title/detail), `agents[]` (index/label/phaseTitle/state/model/tokens/toolCalls/durationMs/resultPreview), `totalTokens`, `totalToolCalls`, `subagentCount`, `completedCount`.
`Read()` bevorzugt die reiche, fertige `<runId>.json` und fällt sonst auf das Live-`journal.jsonl` zurück.
**Persistenz:** Beim `FinalizeChain` (`AnthropicService.cs`) wird die reiche `WorkflowProgress`-JSON in `chains.workflow_info` (`ChainRecord.WorkflowInfo`, Migration via `TryAddColumn` in `DatabaseService.cs`, geschrieben in `DatabaseService.Sessions.cs` `UpdateChain`) geschrieben – mit ChainId-Abgleich (`ChainIdFor`), damit ein Panel-Member derselben Session ihn nicht löscht; danach wird der Tracker-Eintrag via `End` freigegeben.
**Endpoint:** `GET /api/sessions/{id}/workflow-status` (`SessionEndpoint.HandleWorkflowStatus`), Reihenfolge: (1) ist für die Session ein Lauf registriert → Telemetrie aus den Transcript-Dateien (running aus `journal.jsonl` / completed aus `<runId>.json`); (2) sonst die jüngste aktive Chain mit nicht-leerem `workflow_info` → `status: "completed"`; (3) sonst `status: "none"`, `workflow: null`. JSON (running/completed): `{ sessionId, streaming, status, chainId, workflow }`; im `none`-Zweig: `{ sessionId, streaming, status: "none", workflow: null }` (KEIN `chainId`). Das `workflow`-Objekt trägt camelCase `status`, `chainId`, `workflowName`, `summary`, `startedAt`, `subagentCount`, `completedCount`, `totalTokens`, `totalToolCalls`, `durationMs`, `phases[]` (index/title/detail), `agents[]` (index/label/phaseTitle/state/model/tokens/toolCalls/durationMs/resultPreview).
**Frontend:** Header-Menü-Eintrag **„Workflow-Status"** (`#workflowStatusBtn` in `index.html`, direkt nach „Payload anzeigen"), Wrapper `apiGetWorkflowStatus` (`api.js`), `showWorkflowStatusModal` (`modals.js`, pollt alle 2 s solange `status === 'running'`, stoppt von selbst beim Schließen des Modals), Click-Handler in `pages/chat-page.js`, `.workflow-status-modal`-Styles in `css/chat.css`. Das Modal rendert **Phasen** + eine **Per-Agent-Liste** (Label, Phase, State, Tokens, Tool-Calls, Dauer) + Summen-Kacheln (`totalTokens`/`totalToolCalls`) – keine `toolBreakdown`/`recentTools` mehr.
---
+9 -4
View File
@@ -456,13 +456,18 @@ public static class SessionEndpoint
var streaming = streamingTracker.IsStreaming(sessionId);
// 1) Live: ein Workflow läuft gerade (Tracker hat einen Eintrag).
var live = workflowTracker.GetSnapshot(sessionId);
if (live != null)
// 1) Aktueller Lauf registriert: Telemetrie aus den Transcript-Dateien lesen –
// live aus journal.jsonl (Counts), nach Abschluss aus wf_<runId>.json (reich).
var run = workflowTracker.Get(sessionId);
if (run != null)
{
await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = "running", chainId = live.ChainId, workflow = live });
var wp = WorkflowTelemetryReader.Read(run);
if (wp != null)
{
await ctx.Response.WriteAsJsonAsync(new { sessionId, streaming, status = wp.Status, chainId = wp.ChainId, workflow = wp });
return;
}
}
// 2) Sonst: zuletzt persistierte Workflow-Telemetrie aus den aktiven Chains der Session.
var chains = await db.GetActiveChains(sessionId); // ORDER BY id DESC
+1 -1
View File
@@ -17,7 +17,7 @@ Self-hosted AI-Coding-Agent mit **Anthropic Claude API** als Haupt-Provider (ink
- **Modus-Menü** – Dropdown im Composer (Normal / Plan / Ultracode / Fusion / MoA); alle Modi sind transient und gelten nur für die nächste Anfrage
- **Plan-Modus** – Read-only-Modus zum Analysieren und Planen ohne Änderungen
- **Ultracode** – Composite-Modus, der eine einzelne Anfrage als nativen dynamischen Workflow ausführt (effort `xhigh`, gibt der Claude CLI die `Workflow`/`Task`-Tools frei; fächert die Arbeit über mehrere Subagenten auf). Nur Claude-Modelle (nicht GLM, nicht Plan-Modus)
- **Workflow-Status** – Live-Telemetrie laufender Ultracode-Workflows (Subagent-Anzahl, Tool-Aufrufe inkl. Aufschlüsselung, aggregierte Token); im Header-Menü pro Session abrufbar, persistiert nach Lauf-Ende
- **Workflow-Status** – Telemetrie laufender Ultracode-Workflows: live der Subagent-Fortschritt (gestartet/fertig) während des Laufs, nach Abschluss reiche Per-Agent-/Phasen-Telemetrie (Tokens, Tool-Calls, Dauer je Subagent); im Header-Menü pro Session abrufbar, persistiert nach Lauf-Ende
- **Web-Search** – Integrierte Web-Suche mit Quellenangaben (URLs + Titel) in Chat und geteilten Nachrichten
- **Multi-Provider** – Anthropic Claude (Haupt-Provider, inkl. Claude CLI), z.ai/GLM (Modell-IDs mit `glm:`-Prefix, laufen über die Claude CLI mit z.ai-Endpoint), Nvidia (g4f.space), Pollinations (kostenlos), Google Gemini (kostenloser Free Tier), OpenAI
- **19 Tools** – `read_file`, `write_file`, `edit_file`, `list_files`, `delete_file`, `execute`, `background_execute`, `glob`, `grep`, `todo_write`, `memory_read`, `memory_write`, `memory_list`, `memory_search`, `remote_execute`, `send_to_session`, `ask_user_choice`, `github_code_search`, `serper_web_search`
+12 -1
View File
@@ -329,7 +329,18 @@ public partial class AnthropicService
if (_workflowTracker != null && !string.IsNullOrEmpty(chain.SessionId)
&& _workflowTracker.ChainIdFor(chain.SessionId) == chain.ChainId)
{
chain.WorkflowInfo = _workflowTracker.GetJson(chain.SessionId);
try
{
// Reiche End-Telemetrie aus der wf_<runId>.json des Laufs lesen und persistieren
// (Fallback: Live-Counts aus dem journal.jsonl, falls die json noch nicht da ist).
var wp = WorkflowTelemetryReader.Read(_workflowTracker.Get(chain.SessionId));
if (wp != null)
{
wp.ChainId = chain.ChainId;
chain.WorkflowInfo = JsonSerializer.Serialize(wp);
}
}
catch { /* Persistenz der Telemetrie darf den Finalize nie crashen */ }
_workflowTracker.End(chain.SessionId);
}
return SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo);
+30 -29
View File
@@ -93,39 +93,35 @@ public class ClaudeCliService
}
/// <summary>
/// Greift aus einem (im Haupt-Stream unterdrückten) Subagent-Turn die Telemetrie ab:
/// die vom Subagenten aufgerufenen Tool-Namen und seine Token-Nutzung. Rein additiv für den
/// Workflow-Status – wirft nie (Telemetrie darf den Stream-Parser nicht stören).
/// Erkennt den tool_result des dynamischen Workflow-Tools ("Workflow launched in background …
/// Transcript dir: …") und registriert den Lauf im WorkflowProgressTracker. Der Workflow läuft
/// als detachter Hintergrund-Task mit eigenem Transcript – seine Live-/End-Telemetrie wird
/// später aus den dort liegenden Dateien (journal.jsonl / wf_&lt;runId&gt;.json) gelesen, nicht aus
/// dem Stream. Rein additiv – wirft nie (Telemetrie darf den Stream-Parser nicht stören).
/// </summary>
private void TapSubagentEvent(string sessionId, string chainId, string parentToolUseId, JsonElement json)
private void TapWorkflowLaunch(string sessionId, string chainId, string resultContent)
{
try
{
if (!json.TryGetProperty("message", out var msg))
if (_workflowTracker == null || string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(resultContent))
return;
if (resultContent.IndexOf("Workflow launched", StringComparison.OrdinalIgnoreCase) < 0)
return;
var toolNames = new List<string>();
if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
// Zeile "Transcript dir: <absoluter Pfad>" extrahieren.
string transcriptDir = null;
foreach (var raw in resultContent.Split('\n'))
{
foreach (var item in content.EnumerateArray())
var line = raw.Trim();
const string marker = "Transcript dir:";
if (line.StartsWith(marker, StringComparison.OrdinalIgnoreCase))
{
if (item.TryGetProperty("type", out var it) && it.GetString() == "tool_use"
&& item.TryGetProperty("name", out var nm))
{
var n = nm.GetString();
if (!string.IsNullOrEmpty(n)) toolNames.Add(n);
transcriptDir = line.Substring(marker.Length).Trim();
break;
}
}
}
long inTok = 0, outTok = 0;
if (msg.TryGetProperty("usage", out var usage))
{
if (usage.TryGetProperty("input_tokens", out var it2) && it2.ValueKind == JsonValueKind.Number) inTok = it2.GetInt64();
if (usage.TryGetProperty("output_tokens", out var ot2) && ot2.ValueKind == JsonValueKind.Number) outTok = ot2.GetInt64();
}
_workflowTracker.RecordSubagent(sessionId, chainId, parentToolUseId, toolNames, inTok, outTok);
if (!string.IsNullOrEmpty(transcriptDir))
_workflowTracker.Register(sessionId, chainId, transcriptDir);
}
catch { /* Telemetrie darf den Stream nie stören */ }
}
@@ -553,12 +549,11 @@ public class ClaudeCliService
&& parentToolUseId.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(parentToolUseId.GetString()))
{
// Subagent-Event: weiterhin aus dem Haupt-Stream AUSBLENDEN (continue) –
// hier wird nur die Telemetrie für den Workflow-Status ABGEGRIFFEN
// (Tool-Aufrufe + Subagent-Tokens), ohne den sichtbaren Verlauf zu verändern.
// Die konsolidierten Subagent-Turns kommen als type:"assistant" (mit usage).
if (_workflowTracker != null && !string.IsNullOrEmpty(sessionId) && type == "assistant")
TapSubagentEvent(sessionId, chainId, parentToolUseId.GetString(), json);
// Subagent-Event aus dem Haupt-Stream AUSBLENDEN (continue). Hinweis: Die
// dynamischen Workflow-Subagenten laufen out-of-band (eigener Hintergrund-
// Task + Transcript) und tauchen hier ohnehin NICHT auf – ihre Telemetrie
// wird aus den Transcript-Dateien gelesen (siehe TapWorkflowLaunch). Dieser
// Guard betrifft nur klassische Task-Subagenten (sichtbarer Verlauf bleibt sauber).
continue;
}
@@ -775,6 +770,12 @@ public class ClaudeCliService
ToolResultContent = resultContent,
ToolResultIsError = resultIsError
});
// Dynamischen Workflow-Start abgreifen (Transcript-Pfad merken),
// damit der /workflow-status-Endpoint die Telemetrie aus den
// Transcript-Dateien lesen kann. Stört den Stream nie.
if (!resultIsError)
TapWorkflowLaunch(sessionId, chainId, resultContent);
}
}
}
+211 -94
View File
@@ -1,131 +1,248 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Gott.Services;
/// <summary>
/// Sammelt Live-Telemetrie laufender dynamischer Workflows ("ultracode"): die Aktivität der
/// Subagenten, die die Claude CLI über das Workflow-/Task-Tool spawnt. Diese Subagent-Events
/// werden im Haupt-Stream bewusst unterdrückt (parent_tool_use_id-Guard in ClaudeCliService) –
/// hier werden sie abgegriffen, damit der Workflow-Fortschritt LIVE (während des Laufs) und
/// danach (persistiert in chains.workflow_info) abrufbar ist. In-Memory pro Session, analog
/// StreamingTracker. Telemetrie darf den Stream nie blockieren → alles defensiv + lock-leicht.
/// Verfolgt dynamische Workflows ("ultracode"), die die Claude CLI über das Workflow-Tool
/// startet. WICHTIG: Diese Workflows laufen als <b>detachter Hintergrund-Task</b> mit eigenem
/// Transcript-Verzeichnis – ihre Subagent-Events fließen NICHT durch den Haupt-Stream (anders
/// als klassische Task-Subagenten mit parent_tool_use_id). Die echte Telemetrie liegt in zwei
/// Dateien des CLI-Transcripts:
/// • <c>&lt;transcriptDir&gt;/journal.jsonl</c> – LIVE (append-only: started/result pro Subagent)
/// • <c>&lt;proj&gt;/workflows/&lt;runId&gt;.json</c> – am ENDE geschrieben (reiche Per-Agent-Telemetrie)
/// Beides empirisch verifiziert. Dieser Tracker ist daher nur eine <b>Registry</b>: er merkt sich
/// pro Session die aus dem Workflow-tool_result abgegriffenen Pfade; gelesen wird erst bei Abruf
/// (Endpoint) bzw. beim Finalize. In-Memory pro Session, defensiv (nie werfend).
/// </summary>
public class WorkflowProgressTracker
{
private readonly ConcurrentDictionary<string, WorkflowProgress> _progress
= new ConcurrentDictionary<string, WorkflowProgress>();
private readonly ConcurrentDictionary<string, WorkflowRunRef> _runs
= new ConcurrentDictionary<string, WorkflowRunRef>();
/// <summary>
/// Verbucht ein Subagent-Event (Tool-Aufrufe + Token-Nutzung eines Subagenten).
/// Lazy: legt den Eintrag bei der ERSTEN Subagent-Aktivität einer Session an, d.h. nur
/// echte Workflow-Läufe (mit gespawnten Subagenten) erzeugen überhaupt einen Eintrag.
/// Registriert einen gestarteten Workflow-Lauf für die Session (aus dem Workflow-tool_result
/// abgegriffene Transcript-Dir). Überschreibt einen früheren Lauf derselben Session.
/// </summary>
public void RecordSubagent(string sessionId, string chainId, string parentToolUseId,
IReadOnlyList<string> toolNames, long inputTokens, long outputTokens)
public void Register(string sessionId, string chainId, string transcriptDir)
{
if (string.IsNullOrEmpty(sessionId)) return;
var wp = _progress.GetOrAdd(sessionId, _ => new WorkflowProgress
if (string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(transcriptDir)) return;
try
{
var runId = Path.GetFileName(transcriptDir.TrimEnd('/'));
// <transcriptDir> = <proj>/subagents/workflows/<runId>
var workflowsDir = Path.GetDirectoryName(transcriptDir); // <proj>/subagents/workflows
var subagentsDir = Path.GetDirectoryName(workflowsDir); // <proj>/subagents
var projDir = Path.GetDirectoryName(subagentsDir); // <proj>
var journalPath = Path.Combine(transcriptDir, "journal.jsonl");
var richJsonPath = projDir != null ? Path.Combine(projDir, "workflows", runId + ".json") : null;
_runs[sessionId] = new WorkflowRunRef
{
ChainId = chainId,
RunId = runId,
JournalPath = journalPath,
RichJsonPath = richJsonPath,
StartedAt = DateTime.UtcNow
});
lock (wp.Sync)
{
// Stale-Eintrag eines früheren, nicht sauber finalisierten Laufs (gleiche Session,
// andere Chain) → atomar für die neue Chain zurücksetzen, statt fälschlich zu mischen.
if (wp.ChainId != chainId)
{
wp.ChainId = chainId;
wp.StartedAt = DateTime.UtcNow;
wp.SubagentIds.Clear();
wp.SubagentCount = 0;
wp.SubagentToolCalls = 0;
wp.SubagentInputTokens = 0;
wp.SubagentOutputTokens = 0;
wp.ToolBreakdown.Clear();
wp.RecentTools.Clear();
}
if (!string.IsNullOrEmpty(parentToolUseId) && wp.SubagentIds.Add(parentToolUseId))
wp.SubagentCount = wp.SubagentIds.Count;
if (toolNames != null)
{
foreach (var tn in toolNames)
{
if (string.IsNullOrEmpty(tn)) continue;
wp.SubagentToolCalls++;
wp.ToolBreakdown[tn] = wp.ToolBreakdown.TryGetValue(tn, out var c) ? c + 1 : 1;
wp.RecentTools.Add(tn);
if (wp.RecentTools.Count > 12) wp.RecentTools.RemoveAt(0);
}
}
wp.SubagentInputTokens += inputTokens;
wp.SubagentOutputTokens += outputTokens;
wp.LastActivityAt = DateTime.UtcNow;
};
}
catch { /* Telemetrie darf den Stream nie stören */ }
}
/// <summary>Threadsicherer Live-Snapshot – null, wenn für die Session kein Workflow läuft.</summary>
public WorkflowProgress GetSnapshot(string sessionId)
=> _progress.TryGetValue(sessionId, out var wp) ? wp.Snapshot() : null;
/// <summary>Registry-Eintrag der Session – null, wenn kein Workflow registriert ist.</summary>
public WorkflowRunRef Get(string sessionId)
=> _runs.TryGetValue(sessionId, out var r) ? r : null;
/// <summary>Aktuellen Stand als JSON serialisieren (threadsicher) – null wenn kein Eintrag.</summary>
public string GetJson(string sessionId)
=> _progress.TryGetValue(sessionId, out var wp) ? wp.ToJson() : null;
/// <summary>ChainId des laufenden Workflow-Eintrags (für die Zuordnung beim Finalize).</summary>
/// <summary>ChainId des registrierten Workflow-Laufs (für die Zuordnung beim Finalize).</summary>
public string ChainIdFor(string sessionId)
=> _progress.TryGetValue(sessionId, out var wp) ? wp.ChainId : null;
=> _runs.TryGetValue(sessionId, out var r) ? r.ChainId : null;
/// <summary>Entfernt den Eintrag (beim Chain-Finalize aufzurufen).</summary>
public void End(string sessionId) => _progress.TryRemove(sessionId, out _);
public void End(string sessionId) => _runs.TryRemove(sessionId, out _);
}
/// <summary>Registrierter Workflow-Lauf einer Session: ChainId + die Pfade seiner Telemetrie-Dateien.</summary>
public class WorkflowRunRef
{
public string ChainId { get; set; }
public string RunId { get; set; }
public string JournalPath { get; set; }
public string RichJsonPath { get; set; }
public DateTime StartedAt { get; set; }
}
/// <summary>
/// Telemetrie eines dynamischen Workflows: Subagent-Anzahl, Tool-Aufrufe (+ Aufschlüsselung)
/// und aggregierte Subagent-Tokens. Wird live geführt und beim Chain-Finalize als JSON in
/// chains.workflow_info persistiert.
/// Liest die Workflow-Telemetrie aus den CLI-Transcript-Dateien. Zwei Quellen, je nach Phase:
/// das append-only journal.jsonl (live, während des Laufs) und die reiche wf_&lt;runId&gt;.json
/// (erst bei Abschluss). Alle Methoden sind defensiv und liefern null statt zu werfen.
/// </summary>
public static class WorkflowTelemetryReader
{
/// <summary>
/// Bevorzugt die reiche, fertige wf_&lt;runId&gt;.json (existiert nur nach Abschluss);
/// fällt sonst auf das live journal.jsonl zurück. Liefert null, wenn nichts lesbar ist.
/// </summary>
public static WorkflowProgress Read(WorkflowRunRef run)
{
if (run == null) return null;
var done = ReadCompleted(run.RichJsonPath);
if (done != null) { done.ChainId = run.ChainId; done.StartedAt = run.StartedAt; return done; }
var live = ReadLive(run.JournalPath, run.StartedAt);
if (live != null) { live.ChainId = run.ChainId; return live; }
return null;
}
/// <summary>Reiche Telemetrie aus der wf_&lt;runId&gt;.json (am Laufende geschrieben).</summary>
public static WorkflowProgress ReadCompleted(string richJsonPath)
{
if (string.IsNullOrEmpty(richJsonPath) || !File.Exists(richJsonPath)) return null;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(richJsonPath));
var root = doc.RootElement;
var wp = new WorkflowProgress
{
Status = "completed",
WorkflowName = GetStr(root, "workflowName"),
Summary = GetStr(root, "summary"),
DurationMs = GetLong(root, "durationMs"),
StartedAt = DateTime.UtcNow
};
// Phasen
if (root.TryGetProperty("phases", out var phases) && phases.ValueKind == JsonValueKind.Array)
{
int pi = 0;
foreach (var p in phases.EnumerateArray())
wp.Phases.Add(new WorkflowPhaseInfo
{
Index = ++pi,
Title = GetStr(p, "title"),
Detail = GetStr(p, "detail")
});
}
// Per-Agent aus workflowProgress (type == "workflow_agent")
if (root.TryGetProperty("workflowProgress", out var prog) && prog.ValueKind == JsonValueKind.Array)
{
foreach (var ev in prog.EnumerateArray())
{
if (GetStr(ev, "type") != "workflow_agent") continue;
var a = new WorkflowAgentInfo
{
Index = (int)GetLong(ev, "index"),
Label = GetStr(ev, "label"),
PhaseTitle = GetStr(ev, "phaseTitle"),
State = GetStr(ev, "state"),
Model = GetStr(ev, "model"),
Tokens = GetLong(ev, "tokens"),
ToolCalls = (int)GetLong(ev, "toolCalls"),
DurationMs = GetLong(ev, "durationMs"),
ResultPreview = Trim(GetStr(ev, "resultPreview"), 240)
};
wp.Agents.Add(a);
wp.TotalTokens += a.Tokens;
wp.TotalToolCalls += a.ToolCalls;
}
}
wp.SubagentCount = wp.Agents.Count;
wp.CompletedCount = wp.Agents.Count(x => x.State == "done");
if (wp.SubagentCount == 0) wp.SubagentCount = (int)GetLong(root, "agentCount");
return wp;
}
catch { return null; }
}
/// <summary>Live-Fortschritt aus dem append-only journal.jsonl (started/result pro Subagent).</summary>
public static WorkflowProgress ReadLive(string journalPath, DateTime startedAt)
{
if (string.IsNullOrEmpty(journalPath) || !File.Exists(journalPath)) return null;
try
{
var started = new HashSet<string>();
var done = new HashSet<string>();
// ReadAllText statt ReadAllLines: robust gegen halb geschriebene letzte Zeile.
foreach (var raw in File.ReadAllText(journalPath).Split('\n'))
{
var line = raw.Trim();
if (line.Length == 0) continue;
JsonElement ev;
try { using var d = JsonDocument.Parse(line); ev = d.RootElement.Clone(); }
catch { continue; }
var t = GetStr(ev, "type");
var aid = GetStr(ev, "agentId");
if (string.IsNullOrEmpty(aid)) continue;
if (t == "started") started.Add(aid);
else if (t == "result") done.Add(aid);
}
if (started.Count == 0 && done.Count == 0) return null;
return new WorkflowProgress
{
Status = "running",
StartedAt = startedAt,
SubagentCount = started.Count,
CompletedCount = done.Count,
DurationMs = (long)Math.Max(0, (DateTime.UtcNow - startedAt).TotalMilliseconds)
};
}
catch { return null; }
}
private static string GetStr(JsonElement e, string name)
=> e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString() : null;
private static long GetLong(JsonElement e, string name)
=> e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
? v.GetInt64() : 0L;
private static string Trim(string s, int max)
=> string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "…";
}
/// <summary>
/// Telemetrie eines dynamischen Workflows. Live (aus journal.jsonl): nur Status + Subagent-Zähler.
/// Abgeschlossen (aus wf_&lt;runId&gt;.json): zusätzlich Phasen, Per-Agent-Details (Tokens, Tool-Calls,
/// Dauer, Label, Phase) und Summen. Wird beim Chain-Finalize als JSON in chains.workflow_info
/// persistiert und vom /workflow-status-Endpoint ausgeliefert.
/// </summary>
public class WorkflowProgress
{
[JsonIgnore] public object Sync { get; } = new object();
// Dedup-Hilfsstruktur (nicht persistiert; SubagentCount trägt den Wert).
[JsonIgnore] public HashSet<string> SubagentIds { get; set; } = new HashSet<string>();
public string Status { get; set; } // "running" | "completed"
public string ChainId { get; set; }
public string WorkflowName { get; set; }
public string Summary { get; set; }
public DateTime StartedAt { get; set; }
public DateTime LastActivityAt { get; set; }
public int SubagentCount { get; set; }
public int SubagentToolCalls { get; set; }
public long SubagentInputTokens { get; set; }
public long SubagentOutputTokens { get; set; }
public Dictionary<string, int> ToolBreakdown { get; set; } = new Dictionary<string, int>();
public List<string> RecentTools { get; set; } = new List<string>();
public WorkflowProgress Snapshot()
{
lock (Sync)
{
return new WorkflowProgress
{
ChainId = ChainId,
StartedAt = StartedAt,
LastActivityAt = LastActivityAt,
SubagentCount = SubagentCount,
SubagentToolCalls = SubagentToolCalls,
SubagentInputTokens = SubagentInputTokens,
SubagentOutputTokens = SubagentOutputTokens,
ToolBreakdown = new Dictionary<string, int>(ToolBreakdown),
RecentTools = new List<string>(RecentTools)
};
}
public int CompletedCount { get; set; }
public long TotalTokens { get; set; }
public int TotalToolCalls { get; set; }
public long DurationMs { get; set; }
public List<WorkflowPhaseInfo> Phases { get; set; } = new List<WorkflowPhaseInfo>();
public List<WorkflowAgentInfo> Agents { get; set; } = new List<WorkflowAgentInfo>();
}
// Serialisiert einen entkoppelten Snapshot (Snapshot() kopiert unter Lock) – so läuft die
// Serialisierung außerhalb jeder kritischen Sektion und kann nie auf eine live mutierte
// Collection treffen.
public string ToJson() => JsonSerializer.Serialize(Snapshot());
public class WorkflowPhaseInfo
{
public int Index { get; set; }
public string Title { get; set; }
public string Detail { get; set; }
}
public class WorkflowAgentInfo
{
public int Index { get; set; }
public string Label { get; set; }
public string PhaseTitle { get; set; }
public string State { get; set; } // "done" | "running" | "queued" | …
public string Model { get; set; }
public long Tokens { get; set; }
public int ToolCalls { get; set; }
public long DurationMs { get; set; }
public string ResultPreview { get; set; }
}
+90 -8
View File
@@ -1025,9 +1025,27 @@
opacity: var(--semi-transparent);
}
.wf-title {
display: flex;
flex-direction: column;
gap: .15em;
margin-bottom: var(--box-spacing);
}
.wf-name {
font-family: var(--mono);
color: var(--text-accent);
font-weight: 600;
}
.wf-summary {
font-size: .9em;
opacity: var(--semi-transparent);
}
.wf-stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
gap: var(--badge-spacing);
}
@@ -1060,27 +1078,91 @@
margin-bottom: var(--badge-spacing);
}
.wf-tool-list {
/* Phasen-Liste */
.wf-phase-list {
display: flex;
flex-direction: column;
gap: var(--border-width);
}
.wf-tool-row {
.wf-phase-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--badge-spacing);
padding: .35em var(--badge-spacing);
background: var(--f3-bg);
border-radius: var(--border-radius);
}
.wf-tool-name {
font-family: var(--mono);
.wf-phase-title {
font-weight: 600;
}
.wf-tool-count {
color: var(--primary);
.wf-phase-detail {
font-size: .85em;
opacity: var(--semi-transparent);
}
/* Subagenten-Liste */
.wf-agent-list {
display: flex;
flex-direction: column;
gap: var(--border-width);
}
.wf-agent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--badge-spacing);
padding: .4em var(--badge-spacing);
background: var(--f3-bg);
border-radius: var(--border-radius);
}
.wf-agent-main {
display: flex;
align-items: center;
gap: var(--badge-spacing);
min-width: 0;
}
.wf-agent-state {
width: .6em;
height: .6em;
border-radius: 50%;
flex-shrink: 0;
background: var(--text);
}
.wf-agent-state.wf-ag-done { background: var(--secondary); }
.wf-agent-state.wf-ag-running { background: var(--info); }
.wf-agent-state.wf-ag-other { background: var(--primary); opacity: var(--semi-transparent); }
.wf-agent-label {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wf-agent-phase {
font-size: .8em;
opacity: var(--semi-transparent);
white-space: nowrap;
}
.wf-agent-metrics {
display: flex;
gap: var(--box-spacing);
font-family: var(--mono);
font-size: .82em;
opacity: var(--semi-transparent);
flex-shrink: 0;
}
.wf-agent-metrics span {
white-space: nowrap;
}
.wf-meta {
+68 -20
View File
@@ -100,6 +100,14 @@ async function showPayloadPreviewModal(sessionId) {
function _wfNum(n) { return Number(n || 0).toLocaleString('de-DE'); }
function _wfTime(t) { try { return new Date(t).toLocaleTimeString('de-DE'); } catch (e) { return ''; } }
function _wfDur(ms) {
ms = Number(ms || 0);
if (ms <= 0) return '–';
var s = Math.round(ms / 1000);
if (s < 60) return s + 's';
var m = Math.floor(s / 60);
return m + 'm ' + (s % 60) + 's';
}
function _renderWorkflowStatus(body, data) {
var status = (data && data.status) || 'none';
@@ -127,11 +135,22 @@ function _renderWorkflowStatus(body, data) {
return;
}
// Name / Zusammenfassung des Workflows (nur nach Abschluss vorhanden).
if (wf.workflowName || wf.summary) {
var title = document.createElement('div');
title.className = 'wf-title';
title.innerHTML = (wf.workflowName ? '<span class="wf-name">' + esc(wf.workflowName) + '</span>' : '') +
(wf.summary ? '<span class="wf-summary">' + esc(wf.summary) + '</span>' : '');
body.appendChild(title);
}
// Kennzahlen.
var stats = [
{ label: 'Subagenten', value: _wfNum(wf.subagentCount) },
{ label: 'Tool-Aufrufe', value: _wfNum(wf.subagentToolCalls) },
{ label: 'Input-Tokens', value: _wfNum(wf.subagentInputTokens) },
{ label: 'Output-Tokens', value: _wfNum(wf.subagentOutputTokens) }
{ label: 'Fertig', value: _wfNum(wf.completedCount) + ' / ' + _wfNum(wf.subagentCount) },
{ label: 'Tokens', value: _wfNum(wf.totalTokens) },
{ label: 'Tool-Aufrufe', value: _wfNum(wf.totalToolCalls) },
{ label: status === 'running' ? 'Läuft seit' : 'Dauer', value: _wfDur(wf.durationMs) }
];
var grid = document.createElement('div');
grid.className = 'wf-stat-grid';
@@ -144,30 +163,59 @@ function _renderWorkflowStatus(body, data) {
});
body.appendChild(grid);
var tb = wf.toolBreakdown || {};
var keys = Object.keys(tb).sort(function (a, b) { return tb[b] - tb[a]; });
if (keys.length) {
var sec = document.createElement('div');
sec.className = 'wf-section';
sec.innerHTML = '<div class="wf-section-lbl">Tool-Aufschlüsselung</div>';
var list = document.createElement('div');
list.className = 'wf-tool-list';
keys.forEach(function (k) {
// Phasen (nur nach Abschluss).
var phases = wf.phases || [];
if (phases.length) {
var psec = document.createElement('div');
psec.className = 'wf-section';
psec.innerHTML = '<div class="wf-section-lbl">Phasen</div>';
var plist = document.createElement('div');
plist.className = 'wf-phase-list';
phases.forEach(function (p) {
var row = document.createElement('div');
row.className = 'wf-tool-row';
row.innerHTML = '<span class="wf-tool-name">' + esc(k) +
'</span><span class="wf-tool-count">' + esc(String(tb[k])) + '</span>';
list.appendChild(row);
row.className = 'wf-phase-row';
row.innerHTML = '<span class="wf-phase-title">' + esc(p.title || '') + '</span>' +
(p.detail ? '<span class="wf-phase-detail">' + esc(p.detail) + '</span>' : '');
plist.appendChild(row);
});
sec.appendChild(list);
body.appendChild(sec);
psec.appendChild(plist);
body.appendChild(psec);
}
// Subagenten-Details (nur nach Abschluss; live liegen noch keine Labels vor).
var agents = wf.agents || [];
if (agents.length) {
var asec = document.createElement('div');
asec.className = 'wf-section';
asec.innerHTML = '<div class="wf-section-lbl">Subagenten</div>';
var alist = document.createElement('div');
alist.className = 'wf-agent-list';
agents.forEach(function (a) {
var st = a.state || '';
var stCls = st === 'done' ? 'wf-ag-done' : (st === 'running' ? 'wf-ag-running' : 'wf-ag-other');
var row = document.createElement('div');
row.className = 'wf-agent-row';
row.innerHTML =
'<div class="wf-agent-main">' +
'<span class="wf-agent-state ' + stCls + '"></span>' +
'<span class="wf-agent-label">' + esc(a.label || ('Agent ' + (a.index || ''))) + '</span>' +
(a.phaseTitle ? '<span class="wf-agent-phase">' + esc(a.phaseTitle) + '</span>' : '') +
'</div>' +
'<div class="wf-agent-metrics">' +
'<span title="Tokens"><i class="fa fa-database"></i> ' + _wfNum(a.tokens) + '</span>' +
'<span title="Tool-Aufrufe"><i class="fa fa-wrench"></i> ' + _wfNum(a.toolCalls) + '</span>' +
'<span title="Dauer"><i class="fa fa-clock-o"></i> ' + _wfDur(a.durationMs) + '</span>' +
'</div>';
alist.appendChild(row);
});
asec.appendChild(alist);
body.appendChild(asec);
}
if (wf.startedAt) {
var ts = document.createElement('div');
ts.className = 'wf-meta';
ts.textContent = 'Start: ' + _wfTime(wf.startedAt) +
(wf.lastActivityAt ? ' · letzte Aktivität: ' + _wfTime(wf.lastActivityAt) : '');
ts.textContent = 'Start: ' + _wfTime(wf.startedAt);
body.appendChild(ts);
}
}