acdream/src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Erik 75a6724d5b wip: Campaign LA LA1 fix round — INCOMPLETE, stopped mid-task
Agent was stopped for token budget partway through the LA1 review fix
round. Landed here: F1 best-effort SessionStatusWriter, F2 App reader
tolerance (paths/mode), F5 argument-parsing hardening, plus new tests.
NOT DONE: F4 shared-fixture production shape (was the next step), F3
reconnect disconnected edge + recorded limitation, F6 exited
idempotency/reasons, F7 structural redaction test, F8 platform-guard
test + comment fix, optional RuntimeOptions PrintMembers redaction.

Build/test state UNVERIFIED at this commit. Next session: finish the
remaining findings, run the suites, then narrow re-review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:32:52 +02:00

184 lines
6.6 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: loads and validates the <c>--session-config</c>
/// document for the graphical host. Same strictness as
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c>
/// (camelCase, <see cref="JsonUnmappedMemberHandling.Disallow"/>, camelCase
/// string enums) — see that type's own doc for why the two readers are
/// independent DTOs rather than a shared type.
/// </summary>
internal static class SessionConfigurationLoader
{
private const int CurrentVersion = 1;
private static readonly JsonSerializerOptions Options = new()
{
AllowTrailingCommas = false,
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
Converters =
{
new JsonStringEnumConverter(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
/// <summary>Loads the document and returns the exact one configured
/// <see cref="SessionDescriptor"/> the graphical host runs — the
/// document itself may only ever declare exactly one session.</summary>
internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load(
string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string fullPath = Path.GetFullPath(path);
using FileStream stream = File.OpenRead(fullPath);
SessionConfiguration? configuration =
JsonSerializer.Deserialize<SessionConfiguration>(stream, Options);
if (configuration is null)
{
throw new SessionConfigurationException(
"The configuration document is empty.");
}
if (configuration.Version != CurrentVersion)
{
throw new SessionConfigurationException(
$"Unsupported configuration version {configuration.Version}; "
+ $"expected {CurrentVersion}.");
}
if (configuration.Sessions is null
|| configuration.Sessions.Count != 1)
{
throw new SessionConfigurationException(
"The graphical host requires exactly one configured session.");
}
SessionDescriptor session = configuration.Sessions[0]
?? throw new SessionConfigurationException(
"The configured session cannot be null.");
ValidateContent(configuration.Process?.Content);
ValidateSession(session);
return (configuration, session);
}
private static void ValidateContent(SessionContentDescriptor? content)
{
if (content is null)
return;
if (string.IsNullOrWhiteSpace(content.DatDirectory)
|| string.IsNullOrWhiteSpace(content.PreparedAssetPath))
{
throw new SessionConfigurationException(
"process.content requires non-empty datDirectory and preparedAssetPath.");
}
}
private static void ValidateSession(SessionDescriptor session)
{
if (string.IsNullOrWhiteSpace(session.Id))
{
throw new SessionConfigurationException(
"The session requires a non-empty id.");
}
if (session.Endpoint is null
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|| session.Endpoint.Port is < 1 or > 65535)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a host and a port from 1 through 65535.");
}
if (string.IsNullOrWhiteSpace(session.Account))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a non-empty account.");
}
if (session.Character is { } selector)
{
int selectorCount =
(selector.Index.HasValue ? 1 : 0)
+ (selector.Id.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0);
if (selectorCount != 1
|| selector.Index is < 0
|| selector.Id == 0u)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' character selector must specify "
+ "exactly one valid index, id, or name.");
}
}
if (session.Credential is null
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a credential reference.");
}
if (session.Plugins is { } plugins)
{
foreach (string? plugin in plugins)
{
if (string.IsNullOrWhiteSpace(plugin))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' plugins entries must be non-empty strings.");
}
}
}
if (session.LoginCommandDelayMs < 0)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
}
if (session.StatusFile is not null
&& string.IsNullOrWhiteSpace(session.StatusFile))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
}
ValidateMode(session);
}
/// <summary>
/// Campaign LA slice LA1 review fix (F2): <c>mode</c> is Headless-only
/// on the App host — the graphical host has no probe concept (LA2
/// builds the probe in Headless only). An absent field is today's ONLY
/// App behavior (play); <c>"probe"</c> gets a specific, actionable
/// message instead of a cryptic unmapped-member JSON error; anything
/// else is a plain configuration error.
/// </summary>
private static void ValidateMode(SessionDescriptor session)
{
if (session.Mode is null)
return;
if (string.Equals(session.Mode, "probe", StringComparison.Ordinal))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' has mode 'probe'; probe sessions "
+ "are headless-only and cannot run on the graphical host.");
}
throw new SessionConfigurationException(
$"Session '{session.Id}' has unsupported mode '{session.Mode}'.");
}
}