docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1
The LA3 Opus review process note was right: the contract both sides implement lived only in orchestrator prompts, which is exactly the drift mode the pin exists to prevent (and it produced the paths-key CRITICAL). The schema, field rules, probe-mode discriminator, and status vocabulary are now a binding plan section; amendments change this text first, implementations second. Ledger: LA3 fix round dispatched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0bcc7ba3a3
commit
db9ad53c1c
38 changed files with 2397 additions and 40 deletions
162
src/AcDream.Runtime/Session/SessionStatusWriter.cs
Normal file
162
src/AcDream.Runtime/Session/SessionStatusWriter.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: appends one JSON object per line to a per-session
|
||||
/// status-event file the launcher tails
|
||||
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1,
|
||||
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6).
|
||||
///
|
||||
/// <para>
|
||||
/// This is a SEPARATE sink from <c>HeadlessDiagnosticWriter</c> — that class
|
||||
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
||||
/// file; this class writes one file per session, meant to be read by an
|
||||
/// external process (the launcher) rather than scraped from console output.
|
||||
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
|
||||
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
|
||||
/// breaking an existing reader.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Every write opens the file in append mode with <see cref="FileShare.Read"/>
|
||||
/// so an external tailer can read the file concurrently, writes exactly one
|
||||
/// line, flushes, and closes — there is no long-lived file handle to leak or
|
||||
/// to dispose. A writer constructed with a <see langword="null"/> or blank
|
||||
/// path is a permanent no-op: every method becomes a cheap null-check, so
|
||||
/// callers never need to guard construction sites on whether a status file
|
||||
/// was configured.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Never write credential material into this stream.</strong> Every
|
||||
/// event method below takes only identifiers, names, and counts — there is no
|
||||
/// parameter shape that could carry a password, by construction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionStatusWriter
|
||||
{
|
||||
private const int VocabularyVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly string? _path;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this writer has a configured path and will actually append
|
||||
/// events. Lets a caller with an expensive report to build (e.g. the
|
||||
/// roster projection) skip that work entirely when nobody configured a
|
||||
/// status file for this session.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _path is not null;
|
||||
|
||||
public void Started(string sessionId) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "started",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
public void Connected(string sessionId) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "connected",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
public void CharacterList(string sessionId, LiveSessionRosterReport roster)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "characterList",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
accountName = roster.AccountName,
|
||||
slotCount = roster.SlotCount,
|
||||
characters = roster.Entries
|
||||
.Select(static entry => new
|
||||
{
|
||||
id = entry.Id,
|
||||
name = entry.Name,
|
||||
secondsGreyedOut = entry.SecondsGreyedOut,
|
||||
})
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
public void EnteredWorld(string sessionId, uint characterId, string characterName) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "enteredWorld",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
characterId,
|
||||
characterName,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "disconnected",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
public void Exited(string sessionId, int code, string reason) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "exited",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
code,
|
||||
reason,
|
||||
});
|
||||
|
||||
private string Now() =>
|
||||
_timeProvider.GetUtcNow().ToString(
|
||||
"O",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private void Write<T>(T value)
|
||||
{
|
||||
if (_path is not { } path)
|
||||
return;
|
||||
|
||||
string line = JsonSerializer.Serialize(value, JsonOptions);
|
||||
lock (_gate)
|
||||
{
|
||||
using FileStream stream = new(
|
||||
path,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.Read);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.WriteLine(line);
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue