Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions GenHub/GenHub.Core/Constants/FileTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,9 @@ public static class FileTypes
/// File extension for user data manifest files.
/// </summary>
public const string UserDataManifestExtension = ".userdata.json";

/// <summary>
/// File name of the launch receipt written into a workspace; the latest launch wins.
/// </summary>
public const string LaunchReceiptFileName = "launch-receipt.json";
}
48 changes: 48 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Launching/ILaunchReceiptService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using GenHub.Core.Models.Launching;
using GenHub.Core.Models.Results;

namespace GenHub.Core.Interfaces.Launching;

/// <summary>
/// Records a receipt of what each launch consisted of and cheaply revalidates it before
/// subsequent launches so drift is detected without a full re-scan.
/// </summary>
public interface ILaunchReceiptService
{
/// <summary>
/// Records a receipt for a launch into the workspace directory, replacing any previous one.
/// </summary>
/// <param name="context">What the launch consisted of.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>The recorded receipt, or a failure that must not block the launch.</returns>
Task<OperationResult<LaunchReceipt>> RecordLaunchAsync(LaunchReceiptContext context, CancellationToken cancellationToken = default);

/// <summary>
/// Cheaply compares the receipt in a workspace, if one exists, against the current
/// on-disk state. Only existence, counts, sizes and timestamps are recomputed; nothing
/// is hashed.
/// </summary>
/// <param name="workspacePath">The workspace directory the receipt would live in.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A drift report; an absent receipt yields an empty report, not a failure.</returns>
Task<OperationResult<LaunchReceiptDriftReport>> RevalidateAsync(string workspacePath, CancellationToken cancellationToken = default);

/// <summary>
/// Compares an upcoming launch's configuration against a previously recorded receipt:
/// game client, game type, executable path, manifest set and versions, and the archive
/// root paths about to be configured — the configuration itself, where
/// <see cref="RevalidateAsync"/> checks what is on disk. Touches no filesystem state.
/// </summary>
/// <remarks>
/// A separate step because the two halves are known at different times: the receipt must
/// be read before workspace preparation rebuilds the workspace, while the upcoming
/// configuration — the resolved executable path in particular — exists only afterwards.
/// Profile identity is deliberately not compared: the receipt lives in the workspace and
/// the workspace is per-profile, so a mismatch cannot occur without the receipt being a
/// different file.
/// </remarks>
/// <param name="receipt">The receipt from the previous launch.</param>
/// <param name="upcoming">The configuration of the launch about to happen.</param>
/// <returns>A drift report naming each configuration field that changed.</returns>
LaunchReceiptDriftReport CompareUpcomingLaunch(LaunchReceipt receipt, LaunchReceiptContext upcoming);
}
7 changes: 7 additions & 0 deletions GenHub/GenHub.Core/Models/GameProfile/GameLaunchInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public class GameLaunchInfo
/// <summary>Gets or sets the termination timestamp.</summary>
public DateTime? TerminatedAt { get; set; }

/// <summary>
/// Gets or sets the launch receipt drift detected before this launch, one warning per
/// drifted field, so a UI can show it. Informational only — drift never blocks a
/// launch — and empty when no receipt existed or nothing drifted.
/// </summary>
public List<string> ReceiptDriftWarnings { get; set; } = [];

/// <summary>Gets a value indicating whether the game is still running.</summary>
public bool IsRunning => TerminatedAt == null;
}
67 changes: 67 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceipt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using GenHub.Core.Models.Enums;

namespace GenHub.Core.Models.Launching;

/// <summary>
/// Record of what a launch consisted of, written into the workspace so subsequent launches
/// can cheaply detect drift and misbehaving launches have something to compare against.
/// </summary>
public class LaunchReceipt
{
/// <summary>Gets or sets the receipt schema version.</summary>
public int SchemaVersion { get; set; } = 1;

/// <summary>Gets or sets when the receipt was recorded, in UTC.</summary>
public DateTime RecordedAtUtc { get; set; }

/// <summary>Gets or sets the launch identifier the receipt belongs to.</summary>
public string LaunchId { get; set; } = string.Empty;

/// <summary>Gets or sets the profile that was launched.</summary>
public string ProfileId { get; set; } = string.Empty;

/// <summary>Gets or sets the game client identifier, when the profile declared one.</summary>
public string? GameClientId { get; set; }

/// <summary>Gets or sets the game that was launched.</summary>
public GameType GameType { get; set; }

/// <summary>Gets or sets the workspace the launch ran from.</summary>
public string WorkspaceId { get; set; } = string.Empty;

/// <summary>Gets or sets the working directory the process was started in.</summary>
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>Gets or sets the fingerprint of the launched executable.</summary>
public LaunchReceiptExecutable Executable { get; set; } = new();

/// <summary>
/// Gets or sets the retail archive roots the engine was pointed at, keyed by the
/// environment variable that carried each root.
/// </summary>
public Dictionary<string, LaunchReceiptArchiveRoot> ArchiveRoots { get; set; } = [];

/// <summary>
/// Gets or sets the environment variables GenHub itself set for the child process: the
/// built launch environment — retail archive roots plus any profile-defined variables.
/// The inherited process environment is deliberately not recorded; it is large, differs
/// between hosts without meaning anything for the launch, and can carry secrets that a
/// receipt on disk must never capture.
/// </summary>
public Dictionary<string, string> EnvironmentVariables { get; set; } = [];

/// <summary>
/// Gets or sets the resolved variant and entry-point identity that determined what was
/// launched. Null when the profile carried no game client manifest: the legacy fallback
/// resolves the executable by filename search and no variant machinery participates, so
/// there is no variant identity to record. Populated whenever a game client manifest is
/// part of the launch, which is what workspace preparation resolves the entry point from.
/// </summary>
public LaunchReceiptVariant? Variant { get; set; }

/// <summary>Gets or sets the manifest identifiers resolved for the launch.</summary>
public List<string> ManifestIds { get; set; } = [];

/// <summary>Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier.</summary>
public Dictionary<string, string> ManifestVersions { get; set; } = [];
}
18 changes: 18 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Cheap fingerprint of one archive within a retail root: name, size and timestamp, never
/// content. An equal-size replacement is visible through the timestamp where a count and
/// byte total alone could not see it.
/// </summary>
public class LaunchReceiptArchiveEntry
{
/// <summary>Gets or sets the archive file name, without its directory.</summary>
public string FileName { get; set; } = string.Empty;

/// <summary>Gets or sets the archive size in bytes.</summary>
public long SizeBytes { get; set; }

/// <summary>Gets or sets the archive's last write time, in UTC.</summary>
public DateTime LastWriteUtc { get; set; }
}
21 changes: 21 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptArchiveRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Cheap fingerprint of one retail archive root: a per-archive list of name, size and
/// timestamp from a single directory listing, never content hashes, so revalidation never
/// rereads gigabytes of archives.
/// </summary>
public class LaunchReceiptArchiveRoot
{
/// <summary>Gets or sets the archive root path.</summary>
public string Path { get; set; } = string.Empty;

/// <summary>Gets or sets the number of archives in the root; a summary of <see cref="Archives"/>.</summary>
public int ArchiveCount { get; set; }

/// <summary>Gets or sets the total size of the archives in the root, in bytes; a summary of <see cref="Archives"/>.</summary>
public long TotalArchiveBytes { get; set; }

/// <summary>Gets or sets the fingerprint of each archive in the root.</summary>
public List<LaunchReceiptArchiveEntry> Archives { get; set; } = [];
}
51 changes: 51 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using GenHub.Core.Models.Enums;

namespace GenHub.Core.Models.Launching;

/// <summary>
/// Everything a launch supplies for a receipt to be recorded from.
/// </summary>
public class LaunchReceiptContext
{
/// <summary>Gets or sets the launch identifier.</summary>
public string LaunchId { get; set; } = string.Empty;

/// <summary>Gets or sets the profile being launched.</summary>
public string ProfileId { get; set; } = string.Empty;

/// <summary>Gets or sets the game client identifier, when the profile declares one.</summary>
public string? GameClientId { get; set; }

/// <summary>Gets or sets the game being launched.</summary>
public GameType GameType { get; set; }

/// <summary>Gets or sets the workspace the launch runs from.</summary>
public string WorkspaceId { get; set; } = string.Empty;

/// <summary>Gets or sets the workspace directory the receipt is written into.</summary>
public string WorkspacePath { get; set; } = string.Empty;

/// <summary>Gets or sets the executable being started.</summary>
public string ExecutablePath { get; set; } = string.Empty;

/// <summary>Gets or sets the working directory the process is started in.</summary>
public string WorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the environment GenHub built for the child process — retail archive
/// roots plus profile-defined variables, never the inherited process environment.
/// </summary>
public IReadOnlyDictionary<string, string> EnvironmentVariables { get; set; } = new Dictionary<string, string>();

/// <summary>
/// Gets or sets the resolved variant and entry-point identity, when a game client
/// manifest is part of the launch.
/// </summary>
public LaunchReceiptVariant? Variant { get; set; }

/// <summary>Gets or sets the manifest identifiers resolved for the launch.</summary>
public IReadOnlyList<string> ManifestIds { get; set; } = [];

/// <summary>Gets or sets the manifest versions resolved for the launch, keyed by manifest identifier.</summary>
public IReadOnlyDictionary<string, string> ManifestVersions { get; set; } = new Dictionary<string, string>();
}
29 changes: 29 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptDriftReport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Outcome of cheaply revalidating a launch receipt against the current on-disk state.
/// </summary>
public class LaunchReceiptDriftReport
{
/// <summary>Gets or sets the path the receipt was looked for at.</summary>
public string ReceiptPath { get; set; } = string.Empty;

/// <summary>
/// Gets or sets a value indicating whether a receipt was present. An absent receipt is
/// not an error; there is simply nothing to compare against.
/// </summary>
public bool HasReceipt { get; set; }

/// <summary>
/// Gets or sets the parsed receipt when one was present and readable, so the upcoming
/// launch's configuration can be compared against it after the workspace — and the
/// receipt file with it — has been rebuilt.
/// </summary>
public LaunchReceipt? Receipt { get; set; }

/// <summary>Gets or sets the description of each field that drifted since the receipt was recorded.</summary>
public List<string> DriftedFields { get; set; } = [];

/// <summary>Gets a value indicating whether any drift was detected.</summary>
public bool HasDrift => DriftedFields.Count > 0;
}
19 changes: 19 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptExecutable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// Fingerprint of the executable a launch started.
/// </summary>
public class LaunchReceiptExecutable
{
/// <summary>Gets or sets the executable path.</summary>
public string Path { get; set; } = string.Empty;

/// <summary>Gets or sets the executable size in bytes.</summary>
public long SizeBytes { get; set; }

/// <summary>Gets or sets the executable's last write time, in UTC.</summary>
public DateTime LastWriteUtc { get; set; }

/// <summary>Gets or sets the SHA-256 hash of the executable as a lowercase hex string.</summary>
public string Sha256 { get; set; } = string.Empty;
}
30 changes: 30 additions & 0 deletions GenHub/GenHub.Core/Models/Launching/LaunchReceiptVariant.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace GenHub.Core.Models.Launching;

/// <summary>
/// The resolved variant and entry-point identity that determined what a launch started:
/// the same <c>ManifestVariantResolver</c> resolution workspace preparation applies to the
/// game client manifest, re-run against the same manifest and host runtime at receipt time.
/// </summary>
public class LaunchReceiptVariant
{
/// <summary>Gets or sets the game client manifest the resolution ran against.</summary>
public string GameClientManifestId { get; set; } = string.Empty;

/// <summary>Gets or sets the host runtime identifier the resolution ran on, for example <c>osx-arm64</c>.</summary>
public string RuntimeIdentifier { get; set; } = string.Empty;

/// <summary>Gets or sets a value indicating whether the manifest declares variants at all.</summary>
public bool HasVariants { get; set; }

/// <summary>
/// Gets or sets the runtime identifiers of the variant that matched; empty when the
/// matched variant is platform-neutral or the manifest declares no variants.
/// </summary>
public List<string> VariantRuntimeIdentifiers { get; set; } = [];

/// <summary>Gets or sets the resolved entry point, relative to the workspace, when resolution succeeded.</summary>
public string? EntryPointRelativePath { get; set; }

/// <summary>Gets or sets the resolver's stated reason for the outcome.</summary>
public string? Resolution { get; set; }
}
Loading
Loading