AI-Studio/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.cs
Thorsten Sommer 58cc811a58
Some checks failed
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled
Added the visual briefing assistant (#893)
2026-08-02 21:41:12 +02:00

268 lines
11 KiB
C#

using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using AIStudio.Settings;
namespace AIStudio.Assistants.VisualBriefing;
/// <summary>
/// Defines <c>VisualBriefingStore</c> for the visual briefing feature.
/// </summary>
public sealed partial class VisualBriefingStore(
VisualBriefingArtifactService artifactService,
ILogger<VisualBriefingStore> logger,
VisualBriefingStorageOptions? storageOptions = null)
{
/// <summary>Defines the project manifest filename.</summary>
private const string MANIFEST_FILE_NAME = "manifest.json";
/// <summary>Defines the last-selection filename.</summary>
private const string SELECTION_FILE_NAME = "selection.json";
/// <summary>Defines the intermediate-artifact directory.</summary>
private const string ARTIFACTS_DIRECTORY_NAME = "artifacts";
/// <summary>Defines the evidence-artifact directory.</summary>
private const string EVIDENCE_ARTIFACTS_DIRECTORY_NAME = "evidence";
/// <summary>Defines the plan-artifact directory.</summary>
private const string PLAN_ARTIFACTS_DIRECTORY_NAME = "plan";
/// <summary>Defines the content-artifact directory.</summary>
private const string CONTENT_ARTIFACTS_DIRECTORY_NAME = "content";
/// <summary>Defines the presentation-artifact directory.</summary>
private const string PRESENTATION_ARTIFACTS_DIRECTORY_NAME = "presentation";
/// <summary>Defines the build-history directory.</summary>
private const string BUILDS_DIRECTORY_NAME = "builds";
/// <summary>Defines the immutable-version directory.</summary>
private const string VERSIONS_DIRECTORY_NAME = "versions";
/// <summary>Defines the persistent-transcript directory.</summary>
private const string TRANSCRIPTS_DIRECTORY_NAME = "transcripts";
/// <summary>Gets the shared persistence JSON options.</summary>
private static readonly JsonSerializerOptions JSON_OPTIONS = VisualBriefingJson.Persistence;
/// <summary>Stores per-project process locks.</summary>
private readonly ConcurrentDictionary<Guid, SemaphoreSlim> briefingLocks = [];
/// <summary>
/// Serializes store initialization.
/// </summary>
private readonly SemaphoreSlim initializationLock = new(1, 1);
/// <summary>
/// Serializes last-selection writes.
/// </summary>
private readonly SemaphoreSlim selectionLock = new(1, 1);
/// <summary>Tracks whether initialization and reconciliation completed.</summary>
private bool initialized;
/// <summary>
/// Defines <c>RootDirectory</c> for the visual briefing feature.
/// </summary>
private string RootDirectory => Path.Combine(
storageOptions?.DataDirectory ??
SettingsManager.DataDirectory ??
throw new InvalidOperationException("The AI Studio data directory is not initialized."),
"visualBriefings");
/// <summary>
/// Reads a JSON file while treating malformed persisted diagnostics as unavailable.
/// </summary>
/// <typeparam name="T">The JSON model type.</typeparam>
/// <param name="path">The file path.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The parsed value, or <see langword="null"/>.</returns>
private static async Task<T?> ReadJsonAsync<T>(string path, CancellationToken token)
where T : class
{
if (!File.Exists(path))
return null;
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true);
return await JsonSerializer.DeserializeAsync<T>(stream, JSON_OPTIONS, token);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
return null;
}
}
/// <summary>
/// Writes an immutable intermediate artifact without replacing an existing file.
/// </summary>
/// <param name="path">The artifact path.</param>
/// <param name="json">The serialized artifact.</param>
/// <param name="token">The cancellation token.</param>
private static async Task WriteImmutableArtifactAsync(
string path,
string json,
CancellationToken token)
{
await WriteTextAtomicAsync(path, json, token, overwrite: false);
}
/// <summary>
/// Defines <c>WriteTextAtomicAsync</c> for the visual briefing feature.
/// </summary>
private static async Task WriteTextAtomicAsync(
string targetPath,
string content,
CancellationToken token,
bool overwrite = true)
{
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}";
try
{
await File.WriteAllTextAsync(temporaryPath, content, new UTF8Encoding(false), token);
await using (var stream = new FileStream(temporaryPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None, 4_096, true))
await stream.FlushAsync(token);
File.Move(temporaryPath, targetPath, overwrite);
}
finally
{
TryDeleteFile(temporaryPath);
}
}
/// <summary>
/// Defines <c>TryDeleteFile</c> for the visual briefing feature.
/// </summary>
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// Startup and rollback cleanup are best effort.
}
}
/// <summary>
/// Defines <c>PathComparer</c> for the visual briefing feature.
/// </summary>
private static StringComparer PathComparer() => OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
/// <summary>
/// Defines <c>T</c> for the visual briefing feature.
/// </summary>
private static bool IsNull<T>(T? value) => value is null;
/// <summary>
/// Defines <c>GetLock</c> for the visual briefing feature.
/// </summary>
private SemaphoreSlim GetLock(Guid briefingId) => this.briefingLocks.GetOrAdd(briefingId, _ => new(1, 1));
/// <summary>
/// Defines <c>BriefingDirectory</c> for the visual briefing feature.
/// </summary>
private string BriefingDirectory(Guid briefingId) => Path.Combine(this.RootDirectory, briefingId.ToString("D"));
/// <summary>
/// Defines <c>ManifestPath</c> for the visual briefing feature.
/// </summary>
private string ManifestPath(Guid briefingId) => Path.Combine(this.BriefingDirectory(briefingId), MANIFEST_FILE_NAME);
/// <summary>
/// Defines <c>SelectionPath</c> for the visual briefing feature.
/// </summary>
private string SelectionPath() => Path.Combine(this.RootDirectory, SELECTION_FILE_NAME);
/// <summary>
/// Defines <c>VersionsDirectory</c> for the visual briefing feature.
/// </summary>
private string VersionsDirectory(Guid briefingId) => Path.Combine(this.BriefingDirectory(briefingId), VERSIONS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>TranscriptsDirectory</c> for the visual briefing feature.
/// </summary>
private string TranscriptsDirectory(Guid briefingId) => Path.Combine(this.BriefingDirectory(briefingId), TRANSCRIPTS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>ArtifactsDirectory</c> for the visual briefing feature.
/// </summary>
private string ArtifactsDirectory(Guid briefingId) => Path.Combine(this.BriefingDirectory(briefingId), ARTIFACTS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>EvidenceArtifactsDirectory</c> for the visual briefing feature.
/// </summary>
private string EvidenceArtifactsDirectory(Guid briefingId) =>
Path.Combine(this.ArtifactsDirectory(briefingId), EVIDENCE_ARTIFACTS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>PlanArtifactsDirectory</c> for the visual briefing feature.
/// </summary>
private string PlanArtifactsDirectory(Guid briefingId) =>
Path.Combine(this.ArtifactsDirectory(briefingId), PLAN_ARTIFACTS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>ContentArtifactsDirectory</c> for the visual briefing feature.
/// </summary>
private string ContentArtifactsDirectory(Guid briefingId) =>
Path.Combine(this.ArtifactsDirectory(briefingId), CONTENT_ARTIFACTS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>PresentationArtifactsDirectory</c> for the visual briefing feature.
/// </summary>
private string PresentationArtifactsDirectory(Guid briefingId) =>
Path.Combine(this.ArtifactsDirectory(briefingId), PRESENTATION_ARTIFACTS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>BuildsDirectory</c> for the visual briefing feature.
/// </summary>
private string BuildsDirectory(Guid briefingId) => Path.Combine(this.BriefingDirectory(briefingId), BUILDS_DIRECTORY_NAME);
/// <summary>
/// Defines <c>EvidenceArtifactPath</c> for the visual briefing feature.
/// </summary>
private string EvidenceArtifactPath(Guid briefingId, Guid artifactId) =>
Path.Combine(this.EvidenceArtifactsDirectory(briefingId), $"{artifactId:D}.json");
/// <summary>
/// Defines <c>PlanArtifactPath</c> for the visual briefing feature.
/// </summary>
private string PlanArtifactPath(Guid briefingId, Guid artifactId) =>
Path.Combine(this.PlanArtifactsDirectory(briefingId), $"{artifactId:D}.json");
/// <summary>
/// Defines <c>ContentArtifactPath</c> for the visual briefing feature.
/// </summary>
private string ContentArtifactPath(Guid briefingId, Guid artifactId) =>
Path.Combine(this.ContentArtifactsDirectory(briefingId), $"{artifactId:D}.json");
/// <summary>
/// Defines <c>PresentationArtifactPath</c> for the visual briefing feature.
/// </summary>
private string PresentationArtifactPath(Guid briefingId, Guid artifactId) =>
Path.Combine(this.PresentationArtifactsDirectory(briefingId), $"{artifactId:D}.json");
/// <summary>
/// Defines <c>BuildPath</c> for the visual briefing feature.
/// </summary>
private string BuildPath(Guid briefingId, Guid buildId) =>
Path.Combine(this.BuildsDirectory(briefingId), $"{buildId:D}.json");
/// <summary>
/// Defines <c>TranscriptPath</c> for the visual briefing feature.
/// </summary>
private string TranscriptPath(Guid briefingId, Guid sourceId) => Path.Combine(this.TranscriptsDirectory(briefingId), $"{sourceId:D}.md");
/// <summary>
/// Defines <c>VersionPath</c> for the visual briefing feature.
/// </summary>
private string VersionPath(Guid briefingId, VisualBriefingVersion version) => Path.Combine(this.VersionsDirectory(briefingId), version.FileName);
}