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>
249 lines
11 KiB
C#
249 lines
11 KiB
C#
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
|
||
namespace Gott.Services;
|
||
|
||
/// <summary>
|
||
/// 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, WorkflowRunRef> _runs
|
||
= new ConcurrentDictionary<string, WorkflowRunRef>();
|
||
|
||
/// <summary>
|
||
/// 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 Register(string sessionId, string chainId, string transcriptDir)
|
||
{
|
||
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
|
||
};
|
||
}
|
||
catch { /* Telemetrie darf den Stream nie stören */ }
|
||
}
|
||
|
||
/// <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>ChainId des registrierten Workflow-Laufs (für die Zuordnung beim Finalize).</summary>
|
||
public string ChainIdFor(string sessionId)
|
||
=> _runs.TryGetValue(sessionId, out var r) ? r.ChainId : null;
|
||
|
||
/// <summary>Entfernt den Eintrag (beim Chain-Finalize aufzurufen).</summary>
|
||
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>
|
||
/// 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
|
||
{
|
||
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 int SubagentCount { get; set; }
|
||
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 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; }
|
||
}
|