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>
This commit is contained in:
parent
c9fc7f4a66
commit
75a6724d5b
9 changed files with 628 additions and 52 deletions
|
|
@ -0,0 +1,73 @@
|
||||||
|
namespace AcDream.App.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1 review fix (F5): extracted from <c>Program.cs</c>'s
|
||||||
|
/// top-level-statement local functions so the trailing-flag edge case is
|
||||||
|
/// unit testable — a top-level program's local functions are compiler-
|
||||||
|
/// synthesized private members of the generated <c>Program</c> class with
|
||||||
|
/// no stable surface a test assembly can reach.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SessionConfigArgumentParsing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Finds <paramref name="flag"/> in <paramref name="arguments"/> and
|
||||||
|
/// returns its value. Three distinct outcomes, distinguished by
|
||||||
|
/// <paramref name="present"/> and the return value together:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>flag absent: <paramref name="present"/> = <see langword="false"/>,
|
||||||
|
/// returns <see langword="null"/> — the caller's env-var/positional
|
||||||
|
/// fallback stays in effect, unchanged from before this flag
|
||||||
|
/// existed.</item>
|
||||||
|
/// <item>flag present with a following value: <paramref name="present"/>
|
||||||
|
/// = <see langword="true"/>, returns that value.</item>
|
||||||
|
/// <item>flag present but is the LAST argument, with nothing after it:
|
||||||
|
/// <paramref name="present"/> = <see langword="true"/>, returns
|
||||||
|
/// <see langword="null"/> — the caller MUST treat this as a hard error
|
||||||
|
/// (the flag was typed but its value was not), never silently fall
|
||||||
|
/// through to the flag-absent path.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
internal static string? ExtractFlagValue(
|
||||||
|
string[] arguments,
|
||||||
|
string flag,
|
||||||
|
out bool present)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(flag);
|
||||||
|
|
||||||
|
for (int i = 0; i < arguments.Length; i++)
|
||||||
|
{
|
||||||
|
if (!string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
present = true;
|
||||||
|
return i == arguments.Length - 1 ? null : arguments[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
present = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns <paramref name="arguments"/> with <paramref name="flag"/>
|
||||||
|
/// and its following value (if any) removed. A trailing, valueless flag
|
||||||
|
/// is dropped on its own — this helper only strips arguments, it does
|
||||||
|
/// not decide whether a trailing flag is an error (see
|
||||||
|
/// <see cref="ExtractFlagValue"/>'s <c>present</c> output for that).</summary>
|
||||||
|
internal static string[] WithoutFlagAndValue(string[] arguments, string flag)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(arguments);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(flag);
|
||||||
|
|
||||||
|
var result = new List<string>(arguments.Length);
|
||||||
|
for (int i = 0; i < arguments.Length; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
i++; // also skip the flag's value, if any
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.Add(arguments[i]);
|
||||||
|
}
|
||||||
|
return [.. result];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,26 @@ internal sealed class SessionConfiguration
|
||||||
internal sealed class SessionProcessSettings
|
internal sealed class SessionProcessSettings
|
||||||
{
|
{
|
||||||
public SessionContentDescriptor? Content { get; init; }
|
public SessionContentDescriptor? Content { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Campaign LA slice LA1 review fix (F2): accepted so the SAME
|
||||||
|
/// document also satisfies the Headless loader's own
|
||||||
|
/// <c>process.paths</c> member (<c>HeadlessPathOverrides</c>) — parsed
|
||||||
|
/// and ignored here, exactly like <see cref="SessionDescriptor.Policy"/>
|
||||||
|
/// and <see cref="SessionDescriptor.CharacterOptions"/> below. App has
|
||||||
|
/// no config/data/cache directory override concept of its own (those
|
||||||
|
/// come from <c>ApplicationPathSet</c>/env vars on this host); only the
|
||||||
|
/// Headless host consumes overrides composed under this key.</summary>
|
||||||
|
public SessionProcessPathOverrides? Paths { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accepted-but-ignored mirror of Headless's
|
||||||
|
/// <c>HeadlessPathOverrides</c> shape — see
|
||||||
|
/// <see cref="SessionProcessSettings.Paths"/>.</summary>
|
||||||
|
internal sealed class SessionProcessPathOverrides
|
||||||
|
{
|
||||||
|
public string? ConfigDirectory { get; init; }
|
||||||
|
public string? DataDirectory { get; init; }
|
||||||
|
public string? CacheDirectory { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class SessionContentDescriptor
|
internal sealed class SessionContentDescriptor
|
||||||
|
|
@ -75,6 +95,19 @@ internal sealed record SessionDescriptor
|
||||||
/// App has no bot-policy concept.</summary>
|
/// App has no bot-policy concept.</summary>
|
||||||
public SessionPolicyDescriptor? Policy { get; init; }
|
public SessionPolicyDescriptor? Policy { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Campaign LA slice LA1 review fix (F2): pinned-contract
|
||||||
|
/// mode discriminator. ABSENT means today's ONLY App behavior — an
|
||||||
|
/// ordinary play session — so every document written before this field
|
||||||
|
/// existed keeps parsing unchanged. <c>"probe"</c> (LA2's connect
|
||||||
|
/// ▸ characterList ▸ graceful-disconnect flow, no EnterWorld) is
|
||||||
|
/// HEADLESS-ONLY; the App loader rejects it with an explicit message
|
||||||
|
/// naming the field rather than the caller ever seeing a raw unmapped-
|
||||||
|
/// member <see cref="System.Text.Json.JsonException"/>. Any other value
|
||||||
|
/// is a configuration error — the pinned contract defines no other
|
||||||
|
/// mode literal, so a document is either silent about mode (play) or
|
||||||
|
/// says "probe" exactly.</summary>
|
||||||
|
public string? Mode { get; init; }
|
||||||
|
|
||||||
[JsonRequired]
|
[JsonRequired]
|
||||||
public SessionCredentialDescriptor Credential { get; init; } = new();
|
public SessionCredentialDescriptor Credential { get; init; } = new();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,5 +154,31 @@ internal static class SessionConfigurationLoader
|
||||||
throw new SessionConfigurationException(
|
throw new SessionConfigurationException(
|
||||||
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
$"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}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,22 @@ Log.Information(
|
||||||
// existing one positional dat-dir argument and every ACDREAM_* env var keep
|
// existing one positional dat-dir argument and every ACDREAM_* env var keep
|
||||||
// working exactly as before when the flag is absent. See
|
// working exactly as before when the flag is absent. See
|
||||||
// docs/plans/2026-08-14-launcher-campaign.md LA1.
|
// docs/plans/2026-08-14-launcher-campaign.md LA1.
|
||||||
string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config");
|
//
|
||||||
string[] positionalArgs = WithoutFlagAndValue(args, "--session-config");
|
// Review fix F5 (LA1 review round): a trailing, valueless --session-config
|
||||||
|
// (the flag typed as the LAST argument, nothing after it) must be a hard
|
||||||
|
// error, never a silent fall-through to the env-var path — a launcher that
|
||||||
|
// mis-composed its argv would otherwise appear to work while quietly
|
||||||
|
// ignoring the session-config contract entirely.
|
||||||
|
string? sessionConfigFlagPath = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||||
|
args, "--session-config", out bool sessionConfigFlagPresent);
|
||||||
|
if (sessionConfigFlagPath is null && sessionConfigFlagPresent)
|
||||||
|
{
|
||||||
|
Log.Error(
|
||||||
|
"--session-config requires a value (a path to the session-config document).");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
string[] positionalArgs =
|
||||||
|
SessionConfigArgumentParsing.WithoutFlagAndValue(args, "--session-config");
|
||||||
|
|
||||||
var datDirArg = positionalArgs.FirstOrDefault();
|
var datDirArg = positionalArgs.FirstOrDefault();
|
||||||
var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
|
|
@ -240,34 +254,9 @@ finally
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
// Campaign LA slice LA1: --session-config <path> parsing helpers. Kept
|
// Campaign LA slice LA1: --session-config value-presence helper. The
|
||||||
// local/minimal rather than a general-purpose CLI parser — App has exactly
|
// flag/positional-argument extraction itself lives in
|
||||||
// one optional flag-with-value today; the positional dat-dir argument must
|
// AcDream.App.Configuration.SessionConfigArgumentParsing (review fix F5) so
|
||||||
// stay untouched by its presence (see the comment above the flag parse).
|
// its trailing-flag edge case is unit testable.
|
||||||
static string? ExtractFlagValue(string[] arguments, string flag)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < arguments.Length - 1; i++)
|
|
||||||
{
|
|
||||||
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
|
||||||
return arguments[i + 1];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static string[] WithoutFlagAndValue(string[] arguments, string flag)
|
|
||||||
{
|
|
||||||
var result = new List<string>(arguments.Length);
|
|
||||||
for (int i = 0; i < arguments.Length; i++)
|
|
||||||
{
|
|
||||||
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
i++; // also skip the flag's value
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.Add(arguments[i]);
|
|
||||||
}
|
|
||||||
return [.. result];
|
|
||||||
}
|
|
||||||
|
|
||||||
static string? NullIfEmpty(string? value) =>
|
static string? NullIfEmpty(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,49 @@ namespace AcDream.Runtime.Session;
|
||||||
/// event method below takes only identifiers, names, and counts — there is no
|
/// event method below takes only identifiers, names, and counts — there is no
|
||||||
/// parameter shape that could carry a password, by construction.
|
/// parameter shape that could carry a password, by construction.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <strong>This writer can never fail or stall the session transaction it
|
||||||
|
/// observes</strong> (Campaign LA LA1 review fix F1). Every call site sits
|
||||||
|
/// inside a caller-owned try block that treats a throw as a real failure —
|
||||||
|
/// <c>LiveSessionController.StartCore</c>'s connect/roster/enter-world
|
||||||
|
/// sequence, <c>SessionStartCompositionPhase.Start</c> (which calls
|
||||||
|
/// <see cref="Started"/> BEFORE <c>Session.Start</c> even runs),
|
||||||
|
/// <c>GameWindow.CompleteShutdown</c> (which calls <see cref="Disconnected"/>
|
||||||
|
/// BEFORE <c>PublishShutdownRoots</c>, so a throw would skip graceful
|
||||||
|
/// teardown entirely), and <c>HeadlessSessionHost.Dispose</c>'s stage machine
|
||||||
|
/// (a throw from stage 8's <see cref="Exited"/> call leaves
|
||||||
|
/// <c>_disposeStage</c> unadvanced and <c>_disposed</c> unset forever — a
|
||||||
|
/// permanently un-disposable host). An observability sink that can fail the
|
||||||
|
/// transaction it is merely reporting on is a defect in the sink, not a
|
||||||
|
/// reason for every call site to defend itself — so every exception this
|
||||||
|
/// class's own I/O can raise (a missing parent directory on a fresh cache
|
||||||
|
/// dir, a path segment that collides with an existing file, a permissions
|
||||||
|
/// error, a network path some future caller supplies) is caught here, logged
|
||||||
|
/// once to stderr, and LATCHES the writer into a permanent no-op — the exact
|
||||||
|
/// same "cheap null-check forever after" shape a never-configured path
|
||||||
|
/// already gets. The parent directory is created lazily, once, on the first
|
||||||
|
/// write, inside the same protection, so a fresh
|
||||||
|
/// <c>.../launcher/sessions/<id>/status.jsonl</c> path (whose directory
|
||||||
|
/// does not exist yet) is the expected first-run case, not a failure.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Latency posture:</strong> every write is a synchronous local-disk
|
||||||
|
/// file open + line append + flush + close on the calling thread — there is
|
||||||
|
/// no batching, no background writer, no async path. This is fine for the
|
||||||
|
/// low-frequency lifecycle events this class carries (at most a handful per
|
||||||
|
/// second even under LA5/LA6 plugin/login-command load) against a local
|
||||||
|
/// disk. A <c>statusFile</c> path that resolves to a network location (a
|
||||||
|
/// UNC share, a mapped network drive, a FUSE mount with high per-syscall
|
||||||
|
/// latency) is UNSUPPORTED BY DESIGN — every event write would block the
|
||||||
|
/// session transaction's calling thread for the round-trip, and a slow or
|
||||||
|
/// wedged network path would eventually get caught by the same catch clause
|
||||||
|
/// that handles a missing directory and latch off, silently dropping the
|
||||||
|
/// rest of that session's status stream. Callers that need a status stream
|
||||||
|
/// over the network should tail the local file with a separate process,
|
||||||
|
/// never point <c>statusFile</c> at a network path directly.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SessionStatusWriter
|
public sealed class SessionStatusWriter
|
||||||
{
|
{
|
||||||
|
|
@ -46,6 +89,8 @@ public sealed class SessionStatusWriter
|
||||||
private readonly string? _path;
|
private readonly string? _path;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
|
private bool _directoryEnsured;
|
||||||
|
private bool _latchedOff;
|
||||||
|
|
||||||
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
|
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
|
|
@ -54,12 +99,13 @@ public sealed class SessionStatusWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when this writer has a configured path and will actually append
|
/// True when this writer has a configured path and has not latched
|
||||||
/// events. Lets a caller with an expensive report to build (e.g. the
|
/// itself off after a failed write. Lets a caller with an expensive
|
||||||
/// roster projection) skip that work entirely when nobody configured a
|
/// report to build (e.g. the roster projection) skip that work entirely
|
||||||
/// status file for this session.
|
/// when nobody configured a status file for this session, or when this
|
||||||
|
/// writer already gave up after an I/O failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsEnabled => _path is not null;
|
public bool IsEnabled => _path is not null && !_latchedOff;
|
||||||
|
|
||||||
public void Started(string sessionId) =>
|
public void Started(string sessionId) =>
|
||||||
Write(new
|
Write(new
|
||||||
|
|
@ -143,20 +189,71 @@ public sealed class SessionStatusWriter
|
||||||
|
|
||||||
private void Write<T>(T value)
|
private void Write<T>(T value)
|
||||||
{
|
{
|
||||||
if (_path is not { } path)
|
if (_path is not { } path || _latchedOff)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
string line = JsonSerializer.Serialize(value, JsonOptions);
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
using FileStream stream = new(
|
// Re-check inside the lock: another thread may have latched the
|
||||||
path,
|
// writer off (or already ensured the directory) between the
|
||||||
FileMode.Append,
|
// fast check above and taking the gate.
|
||||||
FileAccess.Write,
|
if (_latchedOff)
|
||||||
FileShare.Read);
|
return;
|
||||||
using var writer = new StreamWriter(stream);
|
|
||||||
writer.WriteLine(line);
|
try
|
||||||
writer.Flush();
|
{
|
||||||
|
EnsureDirectory(path);
|
||||||
|
string line = JsonSerializer.Serialize(value, JsonOptions);
|
||||||
|
using FileStream stream = new(
|
||||||
|
path,
|
||||||
|
FileMode.Append,
|
||||||
|
FileAccess.Write,
|
||||||
|
FileShare.Read);
|
||||||
|
using var writer = new StreamWriter(stream);
|
||||||
|
writer.WriteLine(line);
|
||||||
|
writer.Flush();
|
||||||
|
}
|
||||||
|
catch (Exception error) when (IsRecoverableIoFailure(error))
|
||||||
|
{
|
||||||
|
LatchOff(path, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureDirectory(string path)
|
||||||
|
{
|
||||||
|
if (_directoryEnsured)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string? directory = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
_directoryEnsured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LatchOff(string path, Exception error)
|
||||||
|
{
|
||||||
|
_latchedOff = true;
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[status-writer] disabling status stream at '{path}' after a "
|
||||||
|
+ $"write failure ({error.GetType().Name}: {error.Message}); no "
|
||||||
|
+ "further events for this session will be written.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The set of exceptions this class's own file I/O can plausibly raise
|
||||||
|
/// — a missing parent directory, a path segment colliding with an
|
||||||
|
/// existing file, permission failures, an unsupported path shape, or a
|
||||||
|
/// platform security restriction. Anything outside this set (e.g. an
|
||||||
|
/// <see cref="OutOfMemoryException"/>) is deliberately NOT caught —
|
||||||
|
/// this class only promises to survive ITS OWN recoverable I/O
|
||||||
|
/// failures, never to become a blanket exception sink.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRecoverableIoFailure(Exception error) =>
|
||||||
|
error is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or NotSupportedException
|
||||||
|
or ArgumentException
|
||||||
|
or System.Security.SecurityException
|
||||||
|
or DirectoryNotFoundException;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
using AcDream.App.Configuration;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1 review fix (F5): pins
|
||||||
|
/// <see cref="SessionConfigArgumentParsing"/>'s trailing-flag edge case —
|
||||||
|
/// <c>--session-config</c> present as the LAST argument with nothing after
|
||||||
|
/// it must be distinguishable from the flag being entirely absent, so
|
||||||
|
/// <c>Program.cs</c> can turn it into a hard error instead of a silent
|
||||||
|
/// fall-through to the env-var/positional dat-dir path.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionConfigArgumentParsingTests
|
||||||
|
{
|
||||||
|
private const string Flag = "--session-config";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlagWithAFollowingValueReturnsThatValueAndIsPresent()
|
||||||
|
{
|
||||||
|
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||||
|
["D:\\dats", Flag, "session.json"],
|
||||||
|
Flag,
|
||||||
|
out bool present);
|
||||||
|
|
||||||
|
Assert.True(present);
|
||||||
|
Assert.Equal("session.json", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlagAbsentReturnsNullAndIsNotPresent()
|
||||||
|
{
|
||||||
|
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||||
|
["D:\\dats"],
|
||||||
|
Flag,
|
||||||
|
out bool present);
|
||||||
|
|
||||||
|
Assert.False(present);
|
||||||
|
Assert.Null(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TrailingFlagWithNoValueIsPresentWithANullValue()
|
||||||
|
{
|
||||||
|
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||||
|
["D:\\dats", Flag],
|
||||||
|
Flag,
|
||||||
|
out bool present);
|
||||||
|
|
||||||
|
// This is the case Program.cs must turn into exit code 2 — present
|
||||||
|
// but no value is categorically different from "not present at
|
||||||
|
// all", even though both currently yield a null return value.
|
||||||
|
Assert.True(present);
|
||||||
|
Assert.Null(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FlagAloneAsTheOnlyArgumentIsPresentWithANullValue()
|
||||||
|
{
|
||||||
|
string? value = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||||
|
[Flag],
|
||||||
|
Flag,
|
||||||
|
out bool present);
|
||||||
|
|
||||||
|
Assert.True(present);
|
||||||
|
Assert.Null(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutFlagAndValueDropsTheFlagAndItsValue()
|
||||||
|
{
|
||||||
|
string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue(
|
||||||
|
["D:\\dats", Flag, "session.json", "extra"],
|
||||||
|
Flag);
|
||||||
|
|
||||||
|
Assert.Equal(["D:\\dats", "extra"], positional);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutFlagAndValueTrailingFlagDropsOnlyTheFlagItself()
|
||||||
|
{
|
||||||
|
string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue(
|
||||||
|
["D:\\dats", Flag],
|
||||||
|
Flag);
|
||||||
|
|
||||||
|
Assert.Equal(["D:\\dats"], positional);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutFlagAndValueLeavesArgumentsUnchangedWhenFlagIsAbsent()
|
||||||
|
{
|
||||||
|
string[] positional = SessionConfigArgumentParsing.WithoutFlagAndValue(
|
||||||
|
["D:\\dats"],
|
||||||
|
Flag);
|
||||||
|
|
||||||
|
Assert.Equal(["D:\\dats"], positional);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
using AcDream.App.Configuration;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1 review fix (F2): the App session-config reader
|
||||||
|
/// must TOLERATE the two document shapes only the Headless side of the
|
||||||
|
/// pinned contract currently defines meaning for — <c>process.paths</c>
|
||||||
|
/// (<c>HeadlessPathOverrides</c>) and the per-session <c>mode</c>
|
||||||
|
/// discriminator (LA2's probe flow) — so a launcher-composed document does
|
||||||
|
/// not throw a raw unmapped-member <see cref="System.Text.Json.JsonException"/>
|
||||||
|
/// on the App host. See <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
||||||
|
/// LA1's pinned contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SessionConfigurationLoaderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ProcessPathsAreAcceptedButIgnored()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"process": {
|
||||||
|
"paths": {
|
||||||
|
"configDirectory": "/config",
|
||||||
|
"dataDirectory": "/data",
|
||||||
|
"cacheDirectory": "/cache"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "paths-tolerant",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "account",
|
||||||
|
"credential": { "provider": "environment", "reference": "X" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
(SessionConfiguration configuration, SessionDescriptor session) =
|
||||||
|
SessionConfigurationLoader.Load(file.Path);
|
||||||
|
|
||||||
|
Assert.Equal("paths-tolerant", session.Id);
|
||||||
|
Assert.Equal("/config", configuration.Process?.Paths?.ConfigDirectory);
|
||||||
|
Assert.Equal("/data", configuration.Process?.Paths?.DataDirectory);
|
||||||
|
Assert.Equal("/cache", configuration.Process?.Paths?.CacheDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbsentModeIsTreatedAsPlay()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "no-mode",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "account",
|
||||||
|
"credential": { "provider": "environment", "reference": "X" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
|
||||||
|
|
||||||
|
Assert.Null(session.Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProbeModeFailsLoadWithAnExplicitHeadlessOnlyMessage()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "probe-session",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "account",
|
||||||
|
"credential": { "provider": "environment", "reference": "X" },
|
||||||
|
"mode": "probe"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
SessionConfigurationException error = Assert.Throws<SessionConfigurationException>(
|
||||||
|
() => SessionConfigurationLoader.Load(file.Path));
|
||||||
|
Assert.Contains("mode", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("probe", error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("headless-only", error.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnrecognizedModeFailsLoad()
|
||||||
|
{
|
||||||
|
using TemporaryFile file = TemporaryFile.Create(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "bad-mode",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "account",
|
||||||
|
"credential": { "provider": "environment", "reference": "X" },
|
||||||
|
"mode": "bogus"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Assert.Throws<SessionConfigurationException>(
|
||||||
|
() => SessionConfigurationLoader.Load(file.Path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TemporaryFile : IDisposable
|
||||||
|
{
|
||||||
|
private TemporaryFile(string path) => Path = path;
|
||||||
|
|
||||||
|
internal string Path { get; }
|
||||||
|
|
||||||
|
internal static TemporaryFile Create(string json)
|
||||||
|
{
|
||||||
|
string path = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(),
|
||||||
|
$"acdream-app-la1-loader-{Guid.NewGuid():N}.json");
|
||||||
|
File.WriteAllText(path, json);
|
||||||
|
return new TemporaryFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => File.Delete(Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,8 +97,22 @@ public sealed class SessionStatusWriterTests
|
||||||
writer.Started("s1");
|
writer.Started("s1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F7 (Campaign LA LA1 review fix round): replaces the earlier
|
||||||
|
/// "DoesNotContain 'hunter2'/'password'" assertion, which could never
|
||||||
|
/// actually fail — no writer method below accepts a credential-shaped
|
||||||
|
/// parameter in the first place, so the absence of those literal strings
|
||||||
|
/// proved nothing about the SHAPE of what gets serialized. This test
|
||||||
|
/// asserts the structural claim that actually backs the "never write
|
||||||
|
/// credential material into this stream" contract: each event kind
|
||||||
|
/// serializes EXACTLY its pinned property set — the shared envelope
|
||||||
|
/// (<c>v</c>/<c>e</c>/<c>t</c>/<c>sessionId</c>) plus that event's own
|
||||||
|
/// named fields, nothing else. An extra property (a smuggled password,
|
||||||
|
/// or any other accidental field) fails this test by construction,
|
||||||
|
/// regardless of what value it carries.
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PasswordNeverAppearsInTheStatusStream()
|
public void EachEventSerializesExactlyItsPinnedPropertySetAndNothingElse()
|
||||||
{
|
{
|
||||||
using TemporaryFile file = TemporaryFile.Create();
|
using TemporaryFile file = TemporaryFile.Create();
|
||||||
var writer = new SessionStatusWriter(file.Path);
|
var writer = new SessionStatusWriter(file.Path);
|
||||||
|
|
@ -115,9 +129,110 @@ public sealed class SessionStatusWriterTests
|
||||||
writer.Disconnected("bot", "stopped");
|
writer.Disconnected("bot", "stopped");
|
||||||
writer.Exited("bot", 0, "disposed");
|
writer.Exited("bot", 0, "disposed");
|
||||||
|
|
||||||
string contents = File.ReadAllText(file.Path);
|
string[] lines = File.ReadAllLines(file.Path);
|
||||||
Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal);
|
Assert.Equal(6, lines.Length);
|
||||||
Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
|
||||||
|
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
|
||||||
|
AssertExactProperties(
|
||||||
|
lines[2],
|
||||||
|
"v", "e", "t", "sessionId", "accountName", "slotCount", "characters");
|
||||||
|
AssertExactProperties(
|
||||||
|
lines[3], "v", "e", "t", "sessionId", "characterId", "characterName");
|
||||||
|
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "reason");
|
||||||
|
AssertExactProperties(lines[5], "v", "e", "t", "sessionId", "code", "reason");
|
||||||
|
|
||||||
|
// The nested characters[] entries are exact too — the exact shape a
|
||||||
|
// password could otherwise be smuggled through.
|
||||||
|
JsonElement character = Parse(lines[2]).GetProperty("characters")[0];
|
||||||
|
AssertExactProperties(character, "id", "name", "secondsGreyedOut");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertExactProperties(string line, params string[] expected) =>
|
||||||
|
AssertExactProperties(Parse(line), expected);
|
||||||
|
|
||||||
|
private static void AssertExactProperties(JsonElement element, params string[] expected)
|
||||||
|
{
|
||||||
|
string[] actual = element.EnumerateObject()
|
||||||
|
.Select(static property => property.Name)
|
||||||
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
string[] sortedExpected = expected
|
||||||
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(sortedExpected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F1 (Campaign LA LA1 review fix round): a status file whose parent
|
||||||
|
/// directory does not exist yet — the expected first-run shape of
|
||||||
|
/// <c>.../launcher/sessions/<id>/status.jsonl</c> on a fresh cache
|
||||||
|
/// dir — must be created lazily rather than throwing
|
||||||
|
/// <see cref="DirectoryNotFoundException"/> out of the transaction the
|
||||||
|
/// writer is merely observing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void MissingParentDirectoryIsCreatedAndEventsFlow()
|
||||||
|
{
|
||||||
|
string root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"acdream-status-root-{Guid.NewGuid():N}");
|
||||||
|
string path = Path.Combine(root, "nested", "sessions", "s1", "status.jsonl");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(Directory.Exists(Path.GetDirectoryName(path)));
|
||||||
|
var writer = new SessionStatusWriter(path);
|
||||||
|
|
||||||
|
writer.Started("s1");
|
||||||
|
writer.Connected("s1");
|
||||||
|
|
||||||
|
Assert.True(writer.IsEnabled);
|
||||||
|
string[] lines = File.ReadAllLines(path);
|
||||||
|
Assert.Equal(2, lines.Length);
|
||||||
|
Assert.Contains("\"started\"", lines[0]);
|
||||||
|
Assert.Contains("\"connected\"", lines[1]);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F1: a path whose PARENT SEGMENT already exists as an ordinary file
|
||||||
|
/// (so <see cref="Directory.CreateDirectory"/> cannot turn it into a
|
||||||
|
/// directory) is exactly the "unwritable path" case the review asked
|
||||||
|
/// for — the writer must latch itself off instead of throwing, and every
|
||||||
|
/// subsequent call must stay a cheap no-op.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParentSegmentIsAFileLatchesTheWriterInsteadOfThrowing()
|
||||||
|
{
|
||||||
|
string blocker = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"acdream-status-blocker-{Guid.NewGuid():N}");
|
||||||
|
File.WriteAllText(blocker, "not a directory");
|
||||||
|
string path = Path.Combine(blocker, "status.jsonl");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var writer = new SessionStatusWriter(path);
|
||||||
|
Assert.True(writer.IsEnabled);
|
||||||
|
|
||||||
|
// Must not throw — the writer swallows its own I/O failure and
|
||||||
|
// latches off instead of failing the caller's transaction.
|
||||||
|
writer.Started("s1");
|
||||||
|
Assert.False(writer.IsEnabled);
|
||||||
|
|
||||||
|
// Latched-off calls stay cheap no-ops — no exception, no retry.
|
||||||
|
writer.Connected("s1");
|
||||||
|
writer.Exited("s1", 0, "disposed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(blocker))
|
||||||
|
File.Delete(blocker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
"process": {
|
||||||
|
"content": {
|
||||||
|
"datDirectory": "shared-fixture-dats",
|
||||||
|
"preparedAssetPath": "shared-fixture-dats/acdream.pak"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"id": "shared-fixture",
|
"id": "shared-fixture",
|
||||||
|
|
@ -8,8 +14,8 @@
|
||||||
"character": { "name": "SharedToon" },
|
"character": { "name": "SharedToon" },
|
||||||
"policy": { "id": "idle" },
|
"policy": { "id": "idle" },
|
||||||
"credential": {
|
"credential": {
|
||||||
"provider": "environment",
|
"provider": "standardInput",
|
||||||
"reference": "SHARED_FIXTURE_PASSWORD"
|
"reference": "session"
|
||||||
},
|
},
|
||||||
"plugins": ["ExamplePlugin", "AnotherPlugin"],
|
"plugins": ["ExamplePlugin", "AnotherPlugin"],
|
||||||
"loginCommands": ["/tell someone, hi", "/vt start"],
|
"loginCommands": ["/tell someone, hi", "/vt start"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue