TermRemoteCtl/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionIoJournalStore.cs

143 lines
4.8 KiB
C#

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<SessionJournalPage> 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<List<SessionIoEvent>> ReadAllAsync(string sessionId, CancellationToken cancellationToken)
{
var filePath = Path.Combine(_sessionRoot, $"{sessionId}.io.jsonl");
if (!File.Exists(filePath))
{
return [];
}
var items = new List<SessionIoEvent>();
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<SessionIoEvent>(line, SerializerOptions);
if (item is not null)
{
items.Add(item);
}
}
items.Sort(static (left, right) => left.Sequence.CompareTo(right.Sequence));
return items;
}
}