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:
1 parent
b6e546f618
commit
2b95161596
9 files changed
+451
-173
No files matched your search
+14
-10
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_<runId>.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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><transcriptDir>/journal.jsonl</c> – LIVE (append-only: started/result pro Subagent)
|
||||
/// • <c><proj>/workflows/<runId>.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_<runId>.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_<runId>.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_<runId>.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_<runId>.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 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>();
|
||||
}
|
||||
|
||||
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 class WorkflowPhaseInfo
|
||||
{
|
||||
public int Index { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Detail { get; set; }
|
||||
}
|
||||
|
||||
// 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 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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user