From 50a2b0b48bd624128a61605473691b492e4ccfd8 Mon Sep 17 00:00:00 2001 From: sladro Date: Fri, 27 Mar 2026 12:08:15 +0800 Subject: [PATCH] feat: add session registry and history storage --- .../Api/SessionEndpoints.cs | 57 ++++++++++++++++ .../History/SessionHistoryStore.cs | 34 ++++++++++ .../History/TerminalRingBuffer.cs | 66 +++++++++++++++++++ .../src/TermRemoteCtl.Agent/Program.cs | 9 +++ .../Sessions/SessionRecord.cs | 8 +++ .../Sessions/SessionRegistry.cs | 30 +++++++++ .../History/TerminalRingBufferTests.cs | 17 +++++ .../Sessions/SessionRegistryTests.cs | 35 ++++++++++ 8 files changed, 256 insertions(+) create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalRingBuffer.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/History/TerminalRingBufferTests.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs new file mode 100644 index 0000000..68dab4a --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/SessionEndpoints.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using TermRemoteCtl.Agent.History; +using TermRemoteCtl.Agent.Security; +using TermRemoteCtl.Agent.Sessions; + +namespace TermRemoteCtl.Agent.Api; + +public static class SessionEndpoints +{ + public static IEndpointRouteBuilder MapSessionEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/sessions"); + + group.MapGet(string.Empty, (SessionRegistry registry) => Results.Ok(registry.List())); + + group.MapPost(string.Empty, async ( + HttpRequest httpRequest, + SessionRegistry registry, + SessionHistoryStore historyStore, + IClock clock, + CancellationToken cancellationToken) => + { + var request = await ReadCreateSessionRequestAsync(httpRequest, cancellationToken); + if (request is null || string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { error = "invalid_request" }); + } + + var record = registry.Create(request.Name, clock.UtcNow); + await historyStore.AppendAsync(record.SessionId, string.Empty, cancellationToken); + + return Results.Ok(record); + }); + + return endpoints; + } + + private static async Task ReadCreateSessionRequestAsync( + HttpRequest httpRequest, + CancellationToken cancellationToken) + { + try + { + return await httpRequest.ReadFromJsonAsync(cancellationToken); + } + catch (JsonException) + { + return null; + } + catch (BadHttpRequestException) + { + return null; + } + } +} + +public sealed record CreateSessionRequest(string Name); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs new file mode 100644 index 0000000..e14c61f --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/SessionHistoryStore.cs @@ -0,0 +1,34 @@ +using System.Text; + +namespace TermRemoteCtl.Agent.History; + +public sealed class SessionHistoryStore +{ + private static readonly UTF8Encoding Utf8WithoutBom = new(false); + private readonly string _historyRootPath; + + public SessionHistoryStore(string rootPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootPath); + _historyRootPath = Path.Combine(rootPath, "sessions"); + Directory.CreateDirectory(_historyRootPath); + } + + public async Task AppendAsync(string sessionId, string chunk, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(chunk); + + var filePath = Path.Combine(_historyRootPath, $"{sessionId}.log"); + await using var stream = new FileStream( + filePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await using var writer = new StreamWriter(stream, Utf8WithoutBom); + await writer.WriteAsync(chunk.AsMemory(), cancellationToken); + await writer.FlushAsync(cancellationToken); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalRingBuffer.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalRingBuffer.cs new file mode 100644 index 0000000..9746c13 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/History/TerminalRingBuffer.cs @@ -0,0 +1,66 @@ +using System.Text; + +namespace TermRemoteCtl.Agent.History; + +public sealed class TerminalRingBuffer +{ + private readonly int _lineLimit; + private readonly Queue _completedLines = new(); + private readonly StringBuilder _currentLine = new(); + + public TerminalRingBuffer(int lineLimit) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lineLimit); + _lineLimit = lineLimit; + } + + public void Append(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + + foreach (var character in chunk) + { + if (character == '\r') + { + continue; + } + + if (character == '\n') + { + _completedLines.Enqueue(_currentLine.ToString()); + _currentLine.Clear(); + TrimToLimit(); + continue; + } + + _currentLine.Append(character); + } + + TrimToLimit(); + } + + public IReadOnlyList GetSnapshotLines() + { + var lines = _completedLines.ToList(); + if (_currentLine.Length > 0) + { + lines.Add(_currentLine.ToString()); + } + + return lines; + } + + private void TrimToLimit() + { + while ((_completedLines.Count + (_currentLine.Length > 0 ? 1 : 0)) > _lineLimit) + { + if (_completedLines.Count > 0) + { + _completedLines.Dequeue(); + continue; + } + + _currentLine.Clear(); + } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs index e346d32..adfde2f 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.Options; using TermRemoteCtl.Agent.Api; using TermRemoteCtl.Agent.Configuration; +using TermRemoteCtl.Agent.History; using TermRemoteCtl.Agent.Security; +using TermRemoteCtl.Agent.Sessions; var builder = WebApplication.CreateBuilder(args); @@ -10,6 +12,12 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => +{ + var options = serviceProvider.GetRequiredService>().Value; + return new SessionHistoryStore(options.DataRoot); +}); // Task 2 uses ASP.NET Core's local development certificate so HttpsPort is a truthful HTTPS listener. builder.WebHost.ConfigureKestrel(kestrel => { @@ -23,6 +31,7 @@ Directory.CreateDirectory(agentOptions.DataRoot); app.MapGet("/health", () => Results.Json(new { status = "ok" })); app.MapPairingEndpoints(); +app.MapSessionEndpoints(); app.Run(); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs new file mode 100644 index 0000000..30e7113 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs @@ -0,0 +1,8 @@ +namespace TermRemoteCtl.Agent.Sessions; + +public sealed record SessionRecord( + string SessionId, + string Name, + string Status, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs new file mode 100644 index 0000000..04ffa25 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; + +namespace TermRemoteCtl.Agent.Sessions; + +public sealed class SessionRegistry +{ + private readonly ConcurrentDictionary _records = new(); + + public SessionRecord Create(string name, DateTimeOffset now) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var record = new SessionRecord( + Guid.NewGuid().ToString("N"), + name, + "created", + now, + now); + + _records[record.SessionId] = record; + return record; + } + + public IReadOnlyList List() + { + return _records.Values + .OrderBy(record => record.Name, StringComparer.Ordinal) + .ToArray(); + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/History/TerminalRingBufferTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/History/TerminalRingBufferTests.cs new file mode 100644 index 0000000..0b78327 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/History/TerminalRingBufferTests.cs @@ -0,0 +1,17 @@ +using TermRemoteCtl.Agent.History; + +namespace TermRemoteCtl.Agent.Tests.History; + +public class TerminalRingBufferTests +{ + [Fact] + public void GetSnapshotLines_Evicts_Oldest_Lines_When_Line_Limit_Is_Exceeded() + { + var buffer = new TerminalRingBuffer(3); + + buffer.Append("one\n"); + buffer.Append("two\nthree\nfour\n"); + + Assert.Equal(["two", "three", "four"], buffer.GetSnapshotLines()); + } +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs new file mode 100644 index 0000000..d26b9c3 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Sessions/SessionRegistryTests.cs @@ -0,0 +1,35 @@ +using TermRemoteCtl.Agent.Sessions; + +namespace TermRemoteCtl.Agent.Tests.Sessions; + +public class SessionRegistryTests +{ + [Fact] + public void Create_Returns_Record_And_List_Is_Ordered_By_Name() + { + var registry = new SessionRegistry(); + var now = new DateTimeOffset(2026, 03, 27, 03, 00, 00, TimeSpan.Zero); + + var zebra = registry.Create("Zebra", now); + var alpha = registry.Create("Alpha", now.AddMinutes(1)); + + Assert.Equal("Zebra", zebra.Name); + Assert.Equal("Alpha", alpha.Name); + Assert.Equal(["Alpha", "Zebra"], registry.List().Select(record => record.Name).ToArray()); + } + + [Fact] + public void Create_Sets_Record_Metadata() + { + var registry = new SessionRegistry(); + var now = new DateTimeOffset(2026, 03, 27, 03, 15, 00, TimeSpan.Zero); + + var record = registry.Create("Shell", now); + + Assert.False(string.IsNullOrWhiteSpace(record.SessionId)); + Assert.Equal("Shell", record.Name); + Assert.Equal("created", record.Status); + Assert.Equal(now, record.CreatedAtUtc); + Assert.Equal(now, record.UpdatedAtUtc); + } +}