AI-Studio/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Builds.cs

440 lines
17 KiB
C#
Raw Normal View History

using System.Text.Json;
namespace AIStudio.Assistants.VisualBriefing;
public sealed partial class VisualBriefingStore
{
/// <summary>
/// Starts a new build or resumes the matching persisted build while superseding stale active builds.
/// </summary>
/// <param name="candidate">The proposed build identity and fingerprints.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The durable build record and whether it was resumed.</returns>
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();
}
}
/// <summary>
/// Persists a build-record update atomically.
/// </summary>
/// <param name="build">The build record.</param>
/// <param name="token">The cancellation token.</param>
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();
}
}
/// <summary>
/// Loads a persisted build record.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="buildId">The build identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The valid build record, or <see langword="null"/>.</returns>
public async Task<VisualBriefingBuildRecord?> LoadBuildAsync(
Guid briefingId,
Guid buildId,
CancellationToken token = default)
{
await this.InitializeAsync(token);
return await LoadBuildWithoutLockAsync(this.BuildPath(briefingId, buildId), briefingId, token);
}
/// <summary>
/// Lists build history in reverse update order.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The valid build records.</returns>
public async Task<IReadOnlyList<VisualBriefingBuildRecord>> ListBuildsAsync(
Guid briefingId,
CancellationToken token = default)
{
await this.InitializeAsync(token);
var builds = await this.LoadBuildsWithoutLockAsync(briefingId, token);
return [.. builds.OrderByDescending(build => build.UpdatedAtUtc)];
}
/// <summary>
/// Writes an immutable validated evidence artifact.
/// </summary>
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();
}
}
/// <summary>
/// Reads and hash-verifies an immutable evidence artifact.
/// </summary>
public async Task<VisualBriefingEvidenceArtifact?> ReadEvidenceArtifactAsync(
Guid briefingId,
Guid artifactId,
CancellationToken token = default)
{
await this.InitializeAsync(token);
var artifact = await ReadJsonAsync<VisualBriefingEvidenceArtifact>(
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;
}
/// <summary>
/// Writes an immutable validated plan artifact.
/// </summary>
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();
}
}
/// <summary>
/// Reads and hash-verifies an immutable plan artifact.
/// </summary>
public async Task<VisualBriefingPlanArtifact?> ReadPlanArtifactAsync(
Guid briefingId,
Guid artifactId,
CancellationToken token = default)
{
await this.InitializeAsync(token);
var artifact = await ReadJsonAsync<VisualBriefingPlanArtifact>(
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;
}
/// <summary>
/// Writes an immutable validated content artifact.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="artifact">The content artifact.</param>
/// <param name="token">The cancellation token.</param>
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();
}
}
/// <summary>
/// Reads and verifies an immutable content artifact.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="artifactId">The artifact identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The verified artifact, or <see langword="null"/>.</returns>
public async Task<VisualBriefingContentArtifact?> ReadContentArtifactAsync(
Guid briefingId,
Guid artifactId,
CancellationToken token = default)
{
await this.InitializeAsync(token);
var artifact = await ReadJsonAsync<VisualBriefingContentArtifact>(
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;
}
/// <summary>
/// Writes an immutable validated presentation artifact.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="artifact">The presentation artifact.</param>
/// <param name="token">The cancellation token.</param>
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();
}
}
/// <summary>
/// Reads and verifies an immutable presentation artifact.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="artifactId">The artifact identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The verified artifact, or <see langword="null"/>.</returns>
public async Task<VisualBriefingPresentationArtifact?> ReadPresentationArtifactAsync(
Guid briefingId,
Guid artifactId,
CancellationToken token = default)
{
await this.InitializeAsync(token);
var artifact = await ReadJsonAsync<VisualBriefingPresentationArtifact>(
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;
}
/// <summary>
/// Writes an immutable content artifact while the caller owns the project lock.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="artifact">The content artifact.</param>
/// <param name="token">The cancellation token.</param>
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);
}
/// <summary>
/// Writes an immutable presentation artifact while the caller owns the project lock.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="artifact">The presentation artifact.</param>
/// <param name="token">The cancellation token.</param>
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);
}
/// <summary>
/// Writes one build record atomically.
/// </summary>
/// <param name="build">The build record.</param>
/// <param name="token">The cancellation token.</param>
/// <param name="overwrite">Whether an existing record may be replaced.</param>
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);
}
/// <summary>
/// Loads all valid build records without acquiring the project lock.
/// </summary>
/// <param name="briefingId">The briefing identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The valid build records.</returns>
private async Task<List<VisualBriefingBuildRecord>> LoadBuildsWithoutLockAsync(
Guid briefingId,
CancellationToken token)
{
List<VisualBriefingBuildRecord> 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;
}
/// <summary>
/// Loads one valid build record without acquiring the project lock.
/// </summary>
/// <param name="path">The build-record path.</param>
/// <param name="briefingId">The expected briefing identifier.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The build record, or <see langword="null"/>.</returns>
private static async Task<VisualBriefingBuildRecord?> LoadBuildWithoutLockAsync(
string path,
Guid briefingId,
CancellationToken token)
{
var build = await ReadJsonAsync<VisualBriefingBuildRecord>(path, token);
return build is not null &&
build.BuildVersion == VisualBriefingVersions.BUILD &&
build.BriefingId == briefingId &&
build.BuildId != Guid.Empty &&
build.OperationId != Guid.Empty
? build
: null;
}
}