feat: add session registry and history storage

This commit is contained in:
sladro 2026-03-27 12:08:15 +08:00
parent 271ff76909
commit 50a2b0b48b
8 changed files with 256 additions and 0 deletions

View File

@ -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<CreateSessionRequest?> ReadCreateSessionRequestAsync(
HttpRequest httpRequest,
CancellationToken cancellationToken)
{
try
{
return await httpRequest.ReadFromJsonAsync<CreateSessionRequest>(cancellationToken);
}
catch (JsonException)
{
return null;
}
catch (BadHttpRequestException)
{
return null;
}
}
}
public sealed record CreateSessionRequest(string Name);

View File

@ -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);
}
}

View File

@ -0,0 +1,66 @@
using System.Text;
namespace TermRemoteCtl.Agent.History;
public sealed class TerminalRingBuffer
{
private readonly int _lineLimit;
private readonly Queue<string> _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<string> 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();
}
}
}

View File

@ -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<IClock, SystemClock>();
builder.Services.AddSingleton<PairingService>();
builder.Services.AddSingleton<TrustedDeviceStore>();
builder.Services.AddSingleton<AuditLog>();
builder.Services.AddSingleton<SessionRegistry>();
builder.Services.AddSingleton(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().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();

View File

@ -0,0 +1,8 @@
namespace TermRemoteCtl.Agent.Sessions;
public sealed record SessionRecord(
string SessionId,
string Name,
string Status,
DateTimeOffset CreatedAtUtc,
DateTimeOffset UpdatedAtUtc);

View File

@ -0,0 +1,30 @@
using System.Collections.Concurrent;
namespace TermRemoteCtl.Agent.Sessions;
public sealed class SessionRegistry
{
private readonly ConcurrentDictionary<string, SessionRecord> _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<SessionRecord> List()
{
return _records.Values
.OrderBy(record => record.Name, StringComparer.Ordinal)
.ToArray();
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}