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;
}
}