feat: add session registry and history storage
This commit is contained in:
parent
271ff76909
commit
50a2b0b48b
@ -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);
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace TermRemoteCtl.Agent.Sessions;
|
||||
|
||||
public sealed record SessionRecord(
|
||||
string SessionId,
|
||||
string Name,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user