using System.Text; using System.Text.Json; namespace TermRemoteCtl.Agent.History; public sealed class SessionIoJournalStore { private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; private static readonly UTF8Encoding Utf8WithoutBom = new(false); private readonly string _sessionRoot; public SessionIoJournalStore(string rootPath) { ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); _sessionRoot = Path.Combine(rootPath, "sessions"); Directory.CreateDirectory(_sessionRoot); } public async Task AppendAsync(SessionIoEvent ioEvent, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(ioEvent); var filePath = Path.Combine(_sessionRoot, $"{ioEvent.SessionId}.io.jsonl"); var line = JsonSerializer.Serialize(ioEvent, SerializerOptions) + Environment.NewLine; await File.AppendAllTextAsync(filePath, line, Utf8WithoutBom, cancellationToken).ConfigureAwait(false); } public async Task ReadAsync( string sessionId, long? beforeSequence, long? afterSequence, int limit, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit); if (beforeSequence.HasValue && afterSequence.HasValue) { throw new ArgumentException("beforeSequence and afterSequence cannot both be specified."); } var items = await ReadAllAsync(sessionId, cancellationToken).ConfigureAwait(false); var currentSequence = items.Count == 0 ? 0 : items[^1].Sequence; if (afterSequence.HasValue) { var newerItems = items .Where(item => item.Sequence > afterSequence.Value) .ToList(); var pageItems = newerItems.Take(limit).ToArray(); return new SessionJournalPage( sessionId, pageItems, HasMoreBefore: afterSequence.Value > 0, HasMoreAfter: newerItems.Count > pageItems.Length, CurrentSequence: currentSequence); } if (beforeSequence.HasValue) { var olderItems = items .Where(item => item.Sequence < beforeSequence.Value) .ToList(); var skipCount = Math.Max(0, olderItems.Count - limit); var pageItems = olderItems.Skip(skipCount).ToArray(); return new SessionJournalPage( sessionId, pageItems, HasMoreBefore: skipCount > 0, HasMoreAfter: beforeSequence.Value <= currentSequence, CurrentSequence: currentSequence); } var latestSkipCount = Math.Max(0, items.Count - limit); var latestItems = items.Skip(latestSkipCount).ToArray(); return new SessionJournalPage( sessionId, latestItems, HasMoreBefore: latestSkipCount > 0, HasMoreAfter: false, CurrentSequence: currentSequence); } public Task DeleteAsync(string sessionId, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); cancellationToken.ThrowIfCancellationRequested(); var filePath = Path.Combine(_sessionRoot, $"{sessionId}.io.jsonl"); if (File.Exists(filePath)) { File.Delete(filePath); } return Task.CompletedTask; } private async Task> ReadAllAsync(string sessionId, CancellationToken cancellationToken) { var filePath = Path.Combine(_sessionRoot, $"{sessionId}.io.jsonl"); if (!File.Exists(filePath)) { return []; } var items = new List(); await using var stream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, FileOptions.Asynchronous); using var reader = new StreamReader(stream, Utf8WithoutBom, detectEncodingFromByteOrderMarks: false); while (!reader.EndOfStream) { cancellationToken.ThrowIfCancellationRequested(); var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(line)) { continue; } var item = JsonSerializer.Deserialize(line, SerializerOptions); if (item is not null) { items.Add(item); } } items.Sort(static (left, right) => left.Sequence.CompareTo(right.Sequence)); return items; } }