Damit mehrere Agenten gleichzeitig Oberflaechen testen koennen, bekommt jedes Projekt eine feste Displaynummer (Vorgabe Richard): :99 PolyTrader, :98 Predictalytics, :97 ClawdDotNet, :96 IBKRTrader, :95 und absteigend fuer kuenftige Projekte. Zwei Prozesse auf demselben Display sehen die Fenster des jeweils anderen - Klicks landen im falschen Programm, Aufnahmen zeigen fremde Anwendungen. Beim Durcharbeiten fielen zwei weitere Kollisionspunkte auf, die zum Parallelbetrieb dazugehoeren und mit aufgenommen wurden: - Screenshot- und Logdateien in /tmp brauchen ein Projektpraefix. /tmp/v_1.png ueberschreiben sich parallel laufende Projekte gegenseitig. - "xvfb-run -a" darf NICHT mehr verwendet werden: Das -a sucht sich selbst ein freies Display und beginnt dabei bei :99 - ausgerechnet PolyTraders Nummer. Stattdessen "xvfb-run -n <nummer>". Die Warnung ist nicht theoretisch: Auf der VM lag ein verwaister X98-Socket, entstanden durch genau so einen "xvfb-run -a"-Aufruf beim smoke-ui-Test, als :99 belegt war. Er blockierte Predictalytics' reservierte Nummer, ohne dass ein Prozess lief. Entfernt, und die Anleitung erklaert jetzt, wie man verwaiste Sockets erkennt (ls /tmp/.X11-unix/ gegen pgrep -a Xvfb lesen) und aufraeumt. Weiter ergaenzt: - pkill nie ohne Displaynummer - "pkill Xvfb" erschlaegt die Sitzungen aller anderen Projekte. - DISPLAY in jedem Skript setzen, sonst landet man auf :0, dem echten Desktop. - Die Skriptvorlage hat jetzt einen Kopf mit PROJEKT/DISP/FENSTER; das ist alles, was sich je Projekt unterscheidet. Praktisch gegengetestet. - Aufraeumabschnitt und Belegungsliste um Displays und Verzeichnisse erweitert. Nebenbei aufgefallen: Das PolyTrader-Publish lag unter ~/pt-test statt ~/polytrader-test und verletzte damit die eigene Verzeichniskonvention. Umbenannt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
8.0 KiB
C#
191 lines
8.0 KiB
C#
using System;
|
||
using System.IO;
|
||
using System.Threading.Tasks;
|
||
using System.Threading.Channels;
|
||
|
||
namespace PolyTraderSharp.Services
|
||
{
|
||
public enum LogLevel { Debug, Info, Warning, Error, Trade, TradeReasoning }
|
||
|
||
public class LogMessageEventArgs : EventArgs
|
||
{
|
||
public string Message { get; }
|
||
public LogLevel Level { get; }
|
||
|
||
/// <summary>
|
||
/// Zeitpunkt in der Anzeige-Zeitzone der Anwendung, mit passendem Offset.
|
||
///
|
||
/// <para>Bis zum 27.08.2026 stand hier <c>DateTime.Now</c> – die Zeit des <i>Systems</i>.
|
||
/// Auf einem UTC-Server (dem Normalfall) stempelten die Logs damit UTC, während die
|
||
/// Oberfläche Ortszeit anzeigte: Wer einen Vorfall von „22:30" suchte, fand ihn unter
|
||
/// 20:30 – zwischen Mitternacht und 2 Uhr sogar in der Logdatei des Vortags
|
||
/// (§8 des Befundberichts vom 26.08.2026).</para>
|
||
/// </summary>
|
||
public DateTimeOffset Timestamp { get; }
|
||
|
||
/// <summary>Korrelations-ID (z. B. SignalId) für die Log-Forensik; leer wenn ohne Kontext.</summary>
|
||
public string CorrelationId { get; }
|
||
|
||
public LogMessageEventArgs(string message, LogLevel level, string correlationId = "")
|
||
{
|
||
Message = message;
|
||
Level = level;
|
||
Timestamp = AppTimeZone.Now;
|
||
CorrelationId = correlationId ?? "";
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reiner JSONL-Formatter für Log-Events (S-0, Supervisor-Konzept): eine JSON-Zeile je Event –
|
||
/// append-fähig, streambar, maschinen-/KI-lesbar. Statisch und seiteneffektfrei → unit-getestet.
|
||
/// </summary>
|
||
public static class LogJson
|
||
{
|
||
public static string Format(LogMessageEventArgs e)
|
||
{
|
||
var obj = new
|
||
{
|
||
ts = e.Timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"),
|
||
level = e.Level.ToString(),
|
||
cid = string.IsNullOrEmpty(e.CorrelationId) ? null : e.CorrelationId,
|
||
msg = e.Message
|
||
};
|
||
return System.Text.Json.JsonSerializer.Serialize(obj,
|
||
new System.Text.Json.JsonSerializerOptions
|
||
{
|
||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||
});
|
||
}
|
||
|
||
/// <summary>Geparste JSONL-Zeile für den Log Viewer.</summary>
|
||
public sealed record ParsedLogLine(string Time, string Level, string Cid, string Message);
|
||
|
||
/// <summary>
|
||
/// Parst eine JSONL-Zeile (Gegenstück zu <see cref="Format"/>). Liefert null bei leeren/
|
||
/// beschädigten Zeilen (der Viewer überspringt sie, statt zu brechen).
|
||
/// </summary>
|
||
public static ParsedLogLine? ParseLine(string line)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(line)) return null;
|
||
try
|
||
{
|
||
using var doc = System.Text.Json.JsonDocument.Parse(line);
|
||
var root = doc.RootElement;
|
||
string ts = root.TryGetProperty("ts", out var t) ? t.GetString() ?? "" : "";
|
||
string level = root.TryGetProperty("level", out var l) ? l.GetString() ?? "" : "";
|
||
string cid = root.TryGetProperty("cid", out var c) ? c.GetString() ?? "" : "";
|
||
string msg = root.TryGetProperty("msg", out var m) ? m.GetString() ?? "" : "";
|
||
return new ParsedLogLine(ts, level, cid, msg);
|
||
}
|
||
catch (System.Text.Json.JsonException)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
public class TerminalLogger
|
||
{
|
||
public event EventHandler<LogMessageEventArgs>? OnLogMessage;
|
||
private readonly List<LogMessageEventArgs> _history = new();
|
||
private readonly object _lock = new();
|
||
|
||
private readonly string _logsDirectory;
|
||
private readonly Channel<LogMessageEventArgs> _logChannel;
|
||
|
||
public TerminalLogger()
|
||
{
|
||
_logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||
if (!Directory.Exists(_logsDirectory))
|
||
{
|
||
Directory.CreateDirectory(_logsDirectory);
|
||
}
|
||
|
||
_logChannel = Channel.CreateUnbounded<LogMessageEventArgs>(new UnboundedChannelOptions
|
||
{
|
||
SingleReader = true
|
||
});
|
||
Task.Run(ProcessLogQueueAsync);
|
||
}
|
||
|
||
private async Task ProcessLogQueueAsync()
|
||
{
|
||
await foreach (var e in _logChannel.Reader.ReadAllAsync())
|
||
{
|
||
try
|
||
{
|
||
// ISO-Datum wie bei der JSONL-Datei. Bis zum 26.08.2026 stand hier
|
||
// "dd-MM-yyyy" - im selben Logs-Ordner lagen damit zwei Namenskonventionen
|
||
// nebeneinander: eine Logrotate-Regel brauchte zwei Muster, und im
|
||
// Dateimanager sortierten die .log-Dateien nach Tag statt nach Datum
|
||
// (Befund B-2 des Linux-Laufs).
|
||
string dateStr = e.Timestamp.ToString("yyyy-MM-dd");
|
||
string fileName = $"{dateStr}-{e.Level}.log";
|
||
string fullPath = Path.Combine(_logsDirectory, fileName);
|
||
|
||
// Remove Emojis (Surrogate pairs and common symbols)
|
||
string safeMsg = System.Text.RegularExpressions.Regex.Replace(e.Message, @"\p{Cs}|[✅❌🌐📈🔴🧪ℹ️🚨🏆💰⬇️⬆️🔹🔸✨🔥📊📝🔄⏸️]", "");
|
||
safeMsg = safeMsg.Replace("\r\n", " | ").Replace("\n", " | ").Replace(" ", " ").Trim();
|
||
|
||
string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}";
|
||
|
||
await File.AppendAllTextAsync(fullPath, logLine);
|
||
|
||
// S-0: zusätzlich JSONL (eine Datei je Tag, alle Level) – maschinen-/KI-lesbar,
|
||
// Grundlage für den Log Viewer. Dual-Sink; Text-Sink später abschaltbar.
|
||
string jsonlPath = Path.Combine(_logsDirectory, $"{e.Timestamp:yyyy-MM-dd}.jsonl");
|
||
await File.AppendAllTextAsync(jsonlPath, LogJson.Format(e) + Environment.NewLine);
|
||
}
|
||
catch
|
||
{
|
||
// Ignored to prevent cascading lockups
|
||
}
|
||
}
|
||
}
|
||
|
||
public void Log(string message, LogLevel level = LogLevel.Info, string correlationId = "")
|
||
{
|
||
var e = new LogMessageEventArgs(message, level, correlationId);
|
||
lock (_lock)
|
||
{
|
||
_history.Add(e);
|
||
// Optimize list pruning to avoid heavy O(N) operations per log
|
||
if (_history.Count > 10500)
|
||
{
|
||
// Remove older items efficiently in a batch
|
||
int itemsToRemove = _history.Count - 9000;
|
||
_history.RemoveRange(0, itemsToRemove);
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
OnLogMessage?.Invoke(this, e);
|
||
}
|
||
catch { }
|
||
|
||
// Standard Console output as fallback/debug
|
||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] {message}");
|
||
|
||
_logChannel.Writer.TryWrite(e);
|
||
}
|
||
|
||
public void Info(string message) => Log(message, LogLevel.Info);
|
||
public void Debug(string message) => Log(message, LogLevel.Debug);
|
||
public void Warning(string message) => Log(message, LogLevel.Warning);
|
||
public void Error(string message) => Log(message, LogLevel.Error);
|
||
public void Trade(string message) => Log(message, LogLevel.Trade);
|
||
public void TradeReasoning(string message) => Log(message, LogLevel.TradeReasoning);
|
||
|
||
public List<LogMessageEventArgs> GetHistory(TimeSpan maxAge)
|
||
{
|
||
lock (_lock)
|
||
{
|
||
var cutoff = DateTimeOffset.UtcNow - maxAge;
|
||
return _history.Where(x => x.Timestamp >= cutoff).ToList();
|
||
}
|
||
}
|
||
}
|
||
}
|