using System.Text.Json; namespace AIStudio.Assistants.VisualBriefing; public sealed partial class VisualBriefingStore { /// /// Starts a new build or resumes the matching persisted build while superseding stale active builds. /// /// The proposed build identity and fingerprints. /// The cancellation token. /// The durable build record and whether it was resumed. public async Task<(VisualBriefingBuildRecord Build, bool Resumed)> StartOrResumeBuildAsync( VisualBriefingBuildRecord candidate, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(candidate.BriefingId); await gate.WaitAsync(token); try { _ = await this.LoadRequiredWithoutInitializeAsync(candidate.BriefingId, token); var builds = await this.LoadBuildsWithoutLockAsync(candidate.BriefingId, token); var matching = builds .Where(build => build.Status is VisualBriefingBuildStatus.ACTIVE or VisualBriefingBuildStatus.FAILED or VisualBriefingBuildStatus.CANCELED or VisualBriefingBuildStatus.AWAITING_REBUILD) .OrderByDescending(build => build.UpdatedAtUtc) .FirstOrDefault(build => build.Mode == candidate.Mode && build.ParentRevisionId == candidate.ParentRevisionId && string.Equals(build.InputFingerprint, candidate.InputFingerprint, StringComparison.Ordinal) && build.ContentContractVersion == candidate.ContentContractVersion && build.EvidenceContractVersion == candidate.EvidenceContractVersion && build.PlanContractVersion == candidate.PlanContractVersion && build.DesignContractVersion == candidate.DesignContractVersion); if (matching is not null) { matching.OperationId = candidate.OperationId; matching.Status = matching.Status is VisualBriefingBuildStatus.AWAITING_REBUILD ? matching.Status : VisualBriefingBuildStatus.ACTIVE; matching.Failure = null; matching.UpdatedAtUtc = DateTimeOffset.UtcNow; await this.StoreBuildAtomicAsync(matching, token); return (matching, true); } foreach (var stale in builds.Where(build => build.Status is VisualBriefingBuildStatus.ACTIVE or VisualBriefingBuildStatus.FAILED or VisualBriefingBuildStatus.CANCELED or VisualBriefingBuildStatus.AWAITING_REBUILD)) { stale.Status = VisualBriefingBuildStatus.SUPERSEDED; stale.UpdatedAtUtc = DateTimeOffset.UtcNow; await this.StoreBuildAtomicAsync(stale, token); } await this.StoreBuildAtomicAsync(candidate, token, overwrite: false); return (candidate, false); } finally { gate.Release(); } } /// /// Persists a build-record update atomically. /// /// The build record. /// The cancellation token. public async Task SaveBuildAsync(VisualBriefingBuildRecord build, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(build.BriefingId); await gate.WaitAsync(token); try { await this.StoreBuildAtomicAsync(build, token); } finally { gate.Release(); } } /// /// Loads a persisted build record. /// /// The briefing identifier. /// The build identifier. /// The cancellation token. /// The valid build record, or . public async Task LoadBuildAsync( Guid briefingId, Guid buildId, CancellationToken token = default) { await this.InitializeAsync(token); return await LoadBuildWithoutLockAsync(this.BuildPath(briefingId, buildId), briefingId, token); } /// /// Lists build history in reverse update order. /// /// The briefing identifier. /// The cancellation token. /// The valid build records. public async Task> ListBuildsAsync( Guid briefingId, CancellationToken token = default) { await this.InitializeAsync(token); var builds = await this.LoadBuildsWithoutLockAsync(briefingId, token); return [.. builds.OrderByDescending(build => build.UpdatedAtUtc)]; } /// /// Writes an immutable validated evidence artifact. /// public async Task WriteEvidenceArtifactAsync( Guid briefingId, VisualBriefingEvidenceArtifact artifact, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(briefingId); await gate.WaitAsync(token); try { await WriteImmutableArtifactAsync( this.EvidenceArtifactPath(briefingId, artifact.ArtifactId), JsonSerializer.Serialize(artifact, JSON_OPTIONS), token); } finally { gate.Release(); } } /// /// Reads and hash-verifies an immutable evidence artifact. /// public async Task ReadEvidenceArtifactAsync( Guid briefingId, Guid artifactId, CancellationToken token = default) { await this.InitializeAsync(token); var artifact = await ReadJsonAsync( this.EvidenceArtifactPath(briefingId, artifactId), token); if (artifact is null || artifact.ArtifactVersion != VisualBriefingVersions.INTERMEDIATE_ARTIFACT || artifact.ContractVersion != VisualBriefingVersions.EVIDENCE_CONTRACT || artifact.ArtifactId != artifactId) return null; var hash = VisualBriefingPayloadHash.ForEvidence(artifact.Facts, artifact.Metrics, artifact.Tables, artifact.SourceCoverage, artifact.AssetPlan); return string.Equals(hash, artifact.PayloadHash, StringComparison.Ordinal) ? artifact : null; } /// /// Writes an immutable validated plan artifact. /// public async Task WritePlanArtifactAsync( Guid briefingId, VisualBriefingPlanArtifact artifact, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(briefingId); await gate.WaitAsync(token); try { await WriteImmutableArtifactAsync( this.PlanArtifactPath(briefingId, artifact.ArtifactId), JsonSerializer.Serialize(artifact, JSON_OPTIONS), token); } finally { gate.Release(); } } /// /// Reads and hash-verifies an immutable plan artifact. /// public async Task ReadPlanArtifactAsync( Guid briefingId, Guid artifactId, CancellationToken token = default) { await this.InitializeAsync(token); var artifact = await ReadJsonAsync( this.PlanArtifactPath(briefingId, artifactId), token); if (artifact is null || artifact.ArtifactVersion != VisualBriefingVersions.INTERMEDIATE_ARTIFACT || artifact.ContractVersion != VisualBriefingVersions.PLAN_CONTRACT || artifact.ArtifactId != artifactId) return null; var hash = VisualBriefingPayloadHash.ForPlan(artifact.Sections, artifact.StructuralSignature); return string.Equals(hash, artifact.PayloadHash, StringComparison.Ordinal) ? artifact : null; } /// /// Writes an immutable validated content artifact. /// /// The briefing identifier. /// The content artifact. /// The cancellation token. public async Task WriteContentArtifactAsync( Guid briefingId, VisualBriefingContentArtifact artifact, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(briefingId); await gate.WaitAsync(token); try { await this.WriteContentArtifactWithoutLockAsync(briefingId, artifact, token); } finally { gate.Release(); } } /// /// Reads and verifies an immutable content artifact. /// /// The briefing identifier. /// The artifact identifier. /// The cancellation token. /// The verified artifact, or . public async Task ReadContentArtifactAsync( Guid briefingId, Guid artifactId, CancellationToken token = default) { await this.InitializeAsync(token); var artifact = await ReadJsonAsync( this.ContentArtifactPath(briefingId, artifactId), token); if (artifact is null || artifact.ArtifactVersion != VisualBriefingVersions.INTERMEDIATE_ARTIFACT || artifact.ContractVersion != VisualBriefingVersions.CONTENT_CONTRACT || artifact.ArtifactId != artifactId || string.IsNullOrWhiteSpace(artifact.ResetLabel)) return null; var payloadHash = VisualBriefingPayloadHash.ForContent(artifact.Slots, artifact.Charts, artifact.Controls, artifact.Formulas, artifact.AccessibilityTexts, artifact.SourceReferences, artifact.ResetLabel, artifact.SourceCoverage, artifact.AssetPlan, artifact.StructuralSignature); return string.Equals(payloadHash, artifact.PayloadHash, StringComparison.Ordinal) ? artifact : null; } /// /// Writes an immutable validated presentation artifact. /// /// The briefing identifier. /// The presentation artifact. /// The cancellation token. public async Task WritePresentationArtifactAsync( Guid briefingId, VisualBriefingPresentationArtifact artifact, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(briefingId); await gate.WaitAsync(token); try { await this.WritePresentationArtifactWithoutLockAsync(briefingId, artifact, token); } finally { gate.Release(); } } /// /// Reads and verifies an immutable presentation artifact. /// /// The briefing identifier. /// The artifact identifier. /// The cancellation token. /// The verified artifact, or . public async Task ReadPresentationArtifactAsync( Guid briefingId, Guid artifactId, CancellationToken token = default) { await this.InitializeAsync(token); var artifact = await ReadJsonAsync( this.PresentationArtifactPath(briefingId, artifactId), token); if (artifact is null || artifact.ArtifactVersion != VisualBriefingVersions.INTERMEDIATE_ARTIFACT || artifact.ContractVersion != VisualBriefingVersions.DESIGN_CONTRACT || artifact.ArtifactId != artifactId) return null; var payloadHash = VisualBriefingPayloadHash.ForPresentation(artifact.Layout, artifact.Profile, artifact.TemplateHash, artifact.CssHash); return string.Equals(payloadHash, artifact.PayloadHash, StringComparison.Ordinal) && string.Equals( VisualBriefingHashing.Compute(artifact.TemplateHtml), artifact.TemplateHash, StringComparison.Ordinal) && string.Equals( VisualBriefingHashing.Compute(artifact.Css), artifact.CssHash, StringComparison.Ordinal) ? artifact : null; } /// /// Writes an immutable content artifact while the caller owns the project lock. /// /// The briefing identifier. /// The content artifact. /// The cancellation token. private async Task WriteContentArtifactWithoutLockAsync( Guid briefingId, VisualBriefingContentArtifact artifact, CancellationToken token) { var json = JsonSerializer.Serialize(artifact, JSON_OPTIONS); await WriteImmutableArtifactAsync( this.ContentArtifactPath(briefingId, artifact.ArtifactId), json, token); } /// /// Writes an immutable presentation artifact while the caller owns the project lock. /// /// The briefing identifier. /// The presentation artifact. /// The cancellation token. private async Task WritePresentationArtifactWithoutLockAsync( Guid briefingId, VisualBriefingPresentationArtifact artifact, CancellationToken token) { var json = JsonSerializer.Serialize(artifact, JSON_OPTIONS); await WriteImmutableArtifactAsync( this.PresentationArtifactPath(briefingId, artifact.ArtifactId), json, token); } /// /// Writes one build record atomically. /// /// The build record. /// The cancellation token. /// Whether an existing record may be replaced. private async Task StoreBuildAtomicAsync( VisualBriefingBuildRecord build, CancellationToken token, bool overwrite = true) { if (build.BuildVersion != VisualBriefingVersions.BUILD || build.BuildId == Guid.Empty || build.OperationId == Guid.Empty || build.BriefingId == Guid.Empty) throw new InvalidDataException("The visual briefing build record is invalid."); var json = JsonSerializer.Serialize(build, JSON_OPTIONS); await WriteTextAtomicAsync(this.BuildPath(build.BriefingId, build.BuildId), json, token, overwrite); } /// /// Loads all valid build records without acquiring the project lock. /// /// The briefing identifier. /// The cancellation token. /// The valid build records. private async Task> LoadBuildsWithoutLockAsync( Guid briefingId, CancellationToken token) { List builds = []; var directory = this.BuildsDirectory(briefingId); if (!Directory.Exists(directory)) return builds; foreach (var path in Directory.EnumerateFiles(directory, "*.json")) { token.ThrowIfCancellationRequested(); var build = await LoadBuildWithoutLockAsync(path, briefingId, token); if (build is not null) builds.Add(build); } return builds; } /// /// Loads one valid build record without acquiring the project lock. /// /// The build-record path. /// The expected briefing identifier. /// The cancellation token. /// The build record, or . private static async Task LoadBuildWithoutLockAsync( string path, Guid briefingId, CancellationToken token) { var build = await ReadJsonAsync(path, token); return build is not null && build.BuildVersion == VisualBriefingVersions.BUILD && build.BriefingId == briefingId && build.BuildId != Guid.Empty && build.OperationId != Guid.Empty ? build : null; } }