feat(sites): record the artifact revision last deployed to a site

Site.ArtifactRevisionHash / ArtifactsDeployedAt (nullable, migration
AddSiteArtifactRevision) so the Sites page can tell whether a site's
system-wide artifacts are current. Not transported. Adds the
GetDeployedRevisionHashesAsync projection for the fleet-wide staleness batch.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 13:33:59 -04:00
parent 0762d69132
commit dd202e5dde
9 changed files with 2333 additions and 4 deletions
@@ -18,6 +18,18 @@ public class Site
public string? GrpcNodeAAddress { get; set; }
/// <summary>gRPC endpoint for site node B used by the central SiteStreamGrpcClient.</summary>
public string? GrpcNodeBAddress { get; set; }
/// <summary>
/// Revision hash (<c>ArtifactRevisionHashService</c>) of the per-site artifact set last
/// deployed to this site successfully; null = artifacts never deployed. Environment-specific
/// deploy state — deliberately NOT carried by Transport bundles (like <c>Instance.State</c>).
/// </summary>
public string? ArtifactRevisionHash { get; set; }
/// <summary>
/// UTC instant at which the artifact set identified by <see cref="ArtifactRevisionHash"/> was
/// last deployed to this site successfully; null = artifacts never deployed. Environment-specific
/// deploy state — deliberately NOT carried by Transport bundles (like <c>Instance.State</c>).
/// </summary>
public DateTimeOffset? ArtifactsDeployedAt { get; set; }
/// <summary>
/// Initializes a new site with the required name and identifier.
@@ -204,6 +204,14 @@ public interface IDeploymentManagerRepository
/// <returns>The deployed config snapshot, or null if not found.</returns>
Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default);
/// <summary>
/// InstanceId → RevisionHash for every <c>DeployedConfigSnapshot</c>. A projection read — never
/// materialises <c>ConfigurationJson</c>, so it is safe to call for the whole fleet (the
/// pending-updates aggregate does, once per cache TTL).
/// </summary>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A dictionary keyed by instance ID; instances with no snapshot are absent.</returns>
Task<IReadOnlyDictionary<int, string>> GetDeployedRevisionHashesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Adds a new deployed config snapshot.
/// </summary>
/// <param name="snapshot">The deployed config snapshot to add.</param>