feat(ui): add decision-log reader and overlay components

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit 2dc711a55c
9 changed files with 469 additions and 0 deletions

View file

@ -0,0 +1,120 @@
using System.Text.Json;
namespace JevOverlay.Core;
public readonly record struct DecisionKey(string Session, int Step);
public sealed class DecisionEntry(DecisionKey key, JsonElement record)
{
private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true };
public DecisionKey Key { get; } = key;
public JsonElement Record { get; } = record;
public string Action => Text(Record, "action");
public string Source => Text(Record, "source", "unknown");
public string Status { get; internal set; } = "Proposal only; execution is not recorded.";
public string? SessionEnd { get; internal set; }
public string Title => $"#{Key.Step} · {Source.ToUpperInvariant()} · {Text(Record, "state_type")}";
public string Summary()
{
string latency = "";
var jev = Field(Record, "jev");
if (Field(jev, "latency_s") is { ValueKind: JsonValueKind.Number } seconds &&
seconds.TryGetDouble(out double number) && double.IsFinite(number))
latency = $" · {number * 1000:0} ms";
return Limit($"{Title}\nSession: {Key.Session} · {Text(Record, "ts")}\n" +
$"Proposed: {Action}\nParameters: {Field(Record, "params")}\n" +
$"{Status}\n" + (SessionEnd is null ? "" : $"{SessionEnd}\n") +
$"\nReason: {Text(Record, "reason", "Not recorded")}\n" +
$"Recorded confidence: {Field(Record, "confidence")?.ToString() ?? ""}{latency}\n" +
(string.IsNullOrEmpty(Text(Record, "error")) ? "" : $"Error: {Text(Record, "error")}\n") +
"Confidence does not prove correctness.");
}
public string Evidence()
{
var jev = Field(Record, "jev");
if (jev is null || jev.Value.ValueKind == JsonValueKind.Null)
return "No Jev response recorded for this decision.\nThe source field identifies the policy path.";
return Limit("Recorded Jev response (not private reasoning).\n" +
"The recorded answer gate may differ from the policy's final gate.\n\n" +
JsonSerializer.Serialize(jev.Value, Pretty));
}
internal static string Limit(string value) => value.Length <= 16000 ? value : value[..16000] + "\n[Display truncated]";
internal static JsonElement? Field(JsonElement? obj, string name) =>
obj is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(name, out var value)
? value : null;
internal static string Text(JsonElement? obj, string name, string fallback = "") =>
Field(obj, name) is { ValueKind: JsonValueKind.String } value ? value.GetString()! : fallback;
}
/// <summary>Joins recorded events by session and step, never by wall-clock time.</summary>
public sealed class DecisionHistory
{
public const int Capacity = 100;
private readonly List<DecisionEntry> entries = [];
public IReadOnlyList<DecisionEntry> Entries => entries;
public int SkippedLines { get; private set; }
public string? Notice { get; private set; }
public void Apply(TailBatch batch)
{
if (batch.Reset)
{
entries.Clear();
Notice = null;
SkippedLines = 0;
}
SkippedLines += batch.SkippedLines;
foreach (string line in batch.Lines)
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var document = JsonDocument.Parse(line);
if (!Consume(document.RootElement)) SkippedLines++;
}
catch (JsonException) { SkippedLines++; }
}
}
private bool Consume(JsonElement root)
{
string session = DecisionEntry.Text(root, "session");
string kind = DecisionEntry.Text(root, "event");
if (session.Length == 0 || kind.Length == 0) return false;
if (kind == "session_end")
{
string message = $"Session ended: {DecisionEntry.Text(root, "reason", "unspecified")}";
foreach (var entry in entries.Where(e => e.Key.Session == session)) entry.SessionEnd = message;
Notice = $"{session}: {message}";
return true;
}
if (kind is not ("decide" or "action_error" or "action_rejected" or "model_error" or
"fallback_error" or "policy_error" or "no_decision"))
return true; // Other runner events do not describe a decision or its status.
if (DecisionEntry.Field(root, "step") is not { ValueKind: JsonValueKind.Number } stepValue ||
!stepValue.TryGetInt32(out int step) || step < 0) return false;
var key = new DecisionKey(session, step);
if (kind == "decide")
{
if (DecisionEntry.Text(root, "action").Length == 0) return false;
entries.RemoveAll(entry => entry.Key == key);
entries.Insert(0, new DecisionEntry(key, root.Clone()));
if (entries.Count > Capacity) entries.RemoveAt(entries.Count - 1);
Notice = null;
}
else if (kind is "action_error" or "action_rejected")
{
var entry = entries.Find(entry => entry.Key == key);
string action = DecisionEntry.Text(root, "action");
if (entry is not null && (action.Length == 0 || action == entry.Action))
entry.Status = kind == "action_error"
? $"Action request error; completion unknown: {DecisionEntry.Text(root, "error")}"
: $"Action request rejected: {DecisionEntry.Text(root, "message")}";
}
else Notice = DecisionEntry.Limit($"{session} #{step} · {kind}: {DecisionEntry.Text(root, "error")}");
return true;
}
}

View file

@ -0,0 +1,93 @@
using System.Text;
namespace JevOverlay.Core;
public sealed record TailBatch(IReadOnlyList<string> Lines, bool Reset = false,
string? Error = null, bool CatchingUp = false, int SkippedLines = 0);
/// <summary>Single-worker, bounded JSONL reader. It never writes to the source.</summary>
public sealed class LogTail(string path)
{
public const int MaxReadBytes = 256 * 1024;
public const int MaxLineBytes = 128 * 1024;
private static readonly UTF8Encoding Utf8 = new(false, true);
private readonly MemoryStream pending = new();
private long offset;
private byte[] prefix = [];
private byte[] checkpoint = [];
private bool initialized;
private bool discardLine;
public TailBatch Poll()
{
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
long length = stream.Length;
bool reset = !initialized || length < offset ||
!Matches(stream, 0, prefix) ||
!Matches(stream, offset - checkpoint.Length, checkpoint);
if (reset)
{
// Start near the end of an existing log, not at the start of a long run.
offset = Math.Max(0, length - MaxReadBytes);
pending.SetLength(0);
discardLine = offset > 0;
prefix = ReadAt(stream, 0, (int)Math.Min(64, length));
initialized = true;
}
byte[] bytes = ReadAt(stream, offset, (int)Math.Min(MaxReadBytes, length - offset));
offset += bytes.Length;
checkpoint = ReadAt(stream, Math.Max(0, offset - 64), (int)Math.Min(64, offset));
List<string> lines = [];
int skipped = 0;
foreach (byte value in bytes)
{
if (value == (byte)'\n')
{
if (!discardLine && pending.Length > 0)
{
try { lines.Add(Utf8.GetString(pending.GetBuffer(), 0, (int)pending.Length)); }
catch (DecoderFallbackException) { skipped++; }
}
discardLine = false;
pending.SetLength(0);
}
else if (!discardLine)
{
if (pending.Length == MaxLineBytes)
{
pending.SetLength(0);
discardLine = true;
skipped++;
}
else pending.WriteByte(value);
}
}
return new(lines, reset, CatchingUp: offset < length, SkippedLines: skipped);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return new([], Error: "Log unavailable. Check log_path and file permissions.");
}
}
private static bool Matches(FileStream stream, long position, byte[] expected) =>
expected.Length == 0 || ReadAt(stream, position, expected.Length).AsSpan().SequenceEqual(expected);
private static byte[] ReadAt(FileStream stream, long position, int count)
{
stream.Position = position;
byte[] buffer = new byte[count];
int read = 0;
while (read < count)
{
int next = stream.Read(buffer, read, count - read);
if (next == 0) break;
read += next;
}
return read == count ? buffer : buffer[..read];
}
}

View file

@ -0,0 +1,21 @@
using System.Text.Json;
namespace JevOverlay.Core;
public static class OverlayConfig
{
public const string FileName = "JevOverlay.config.json";
public static string LoadLogPath(string modDirectory)
{
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(modDirectory, FileName)));
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object ||
!root.TryGetProperty("log_path", out var value) ||
value.ValueKind != JsonValueKind.String ||
string.IsNullOrWhiteSpace(value.GetString()) ||
!Path.IsPathFullyQualified(value.GetString()!))
throw new FormatException("log_path must be an absolute file path.");
return Path.GetFullPath(value.GetString()!);
}
}