mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-06 03:02:17 +00:00
440 lines
17 KiB
C#
440 lines
17 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|