using System.Collections.Concurrent; using System.Text; using System.Text.Json; using AIStudio.Settings; namespace AIStudio.Assistants.BeyondFactsLab; /// /// Stores the preparations of the BeyondFacts Lab as files below the data directory. /// /// /// Each preparation lives in a folder named by its ID; the conversation about whether the lab fits a task, which /// belongs to no preparation, lives in a folder of its own. The store keeps no paths: it derives every path from /// IDs, so a backup works on another computer as well. It writes each file atomically, guards each preparation /// with a lock of its own, and never deletes anything on its own; only the person deletes a preparation. /// /// The logger. /// Overrides the data directory, for tests. public sealed partial class BeyondFactsLabStore(ILogger logger, BflStorageOptions? storageOptions = null) { /// /// The name of the folder below the data directory. /// public const string ROOT_DIRECTORY_NAME = "beyondFactsLab"; /// /// The longest name of a preparation the store keeps. /// public const int MAX_NAME_LENGTH = 200; /// /// The file of a preparation which holds its name and dates. /// private const string PROJECT_FILE_NAME = "project.json"; /// /// The file which holds the preparation selected last. /// private const string SELECTION_FILE_NAME = "selection.json"; /// /// The JSON options of all files of the lab. /// private static readonly JsonSerializerOptions JSON_OPTIONS = BeyondFactsLabJson.PERSISTENCE; /// /// The lock of each preparation. /// private readonly ConcurrentDictionary projectLocks = []; /// /// Serializes the first start of the store. /// private readonly SemaphoreSlim initializationLock = new(1, 1); /// /// Serializes writing the selection. /// private readonly SemaphoreSlim selectionLock = new(1, 1); /// /// Whether the store cleaned up and read the selection already. /// private bool initialized; /// /// Gets the folder of the lab below the data directory. /// private string RootDirectory => Path.Combine( storageOptions?.DataDirectory ?? SettingsManager.DataDirectory ?? throw new InvalidOperationException("The AI Studio data directory is not initialized."), ROOT_DIRECTORY_NAME); /// /// Writes a text file so that readers see either the old or the new content, never a part of it. /// /// The file to write. /// The new content. /// The cancellation token. private static Task WriteTextAtomicAsync(string targetPath, string content, CancellationToken token) => WriteAtomicAsync(targetPath, temporaryPath => File.WriteAllTextAsync(temporaryPath, content, new UTF8Encoding(false), token), token); /// /// Writes a binary file so that readers see either the old or the new content, never a part of it. /// /// The file to write. /// The new content. /// The cancellation token. private static Task WriteBytesAtomicAsync(string targetPath, ReadOnlyMemory content, CancellationToken token) => WriteAtomicAsync(targetPath, temporaryPath => File.WriteAllBytesAsync(temporaryPath, content, token), token); /// /// Writes a file beside its target first and moves it there once it is complete. /// /// The file to write. /// Writes the new content into the file of the path given. /// The cancellation token. private static async Task WriteAtomicAsync(string targetPath, Func writeTemporaryFile, CancellationToken token) { Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); var temporaryPath = $"{targetPath}.tmp-{Guid.NewGuid():N}"; try { await writeTemporaryFile(temporaryPath); 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: true); } finally { TryDeleteFile(temporaryPath); } } /// /// Deletes a file when it exists, without failing. /// /// The file to delete. private static void TryDeleteFile(string path) { try { if (File.Exists(path)) File.Delete(path); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { // Cleaning up a temporary file is best effort; the next start tries again. } } /// /// Removes control characters from a name, trims it, and cuts it to the longest name the store keeps. /// /// The name as entered or as read from a file. /// The cleaned name, empty when nothing remains. internal static string SanitizeName(string? name) { if (string.IsNullOrWhiteSpace(name)) return string.Empty; var sanitized = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim(); return sanitized.Length <= MAX_NAME_LENGTH ? sanitized : sanitized[..MAX_NAME_LENGTH].TrimEnd(); } /// /// Gets the lock of a preparation. /// /// The ID of the preparation. private SemaphoreSlim GetLock(Guid projectId) => this.projectLocks.GetOrAdd(projectId, _ => new(1, 1)); /// /// Drops the lock of a preparation which does not exist anymore. /// /// /// The semaphore is not disposed: another operation might still wait on it. /// /// The ID of the preparation. private void ForgetLock(Guid projectId) => this.projectLocks.TryRemove(projectId, out _); /// /// Gets the folder of a preparation. /// /// The ID of the preparation. private string ProjectDirectory(Guid projectId) => Path.Combine(this.RootDirectory, projectId.ToString("D")); /// /// Gets the file which holds the name and the dates of a preparation. /// /// The ID of the preparation. private string ProjectPath(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), PROJECT_FILE_NAME); /// /// Gets the file which holds the preparation selected last. /// private string SelectionPath() => Path.Combine(this.RootDirectory, SELECTION_FILE_NAME); /// /// Creates the folder of the lab, removes temporary files a crash left behind, and reads the selection. /// /// The cancellation token. private async Task InitializeAsync(CancellationToken token) { if (this.initialized) return; await this.initializationLock.WaitAsync(token); try { if (this.initialized) return; Directory.CreateDirectory(this.RootDirectory); foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories)) TryDeleteFile(temporaryPath); await this.LoadSelectionAsync(token); this.initialized = true; } finally { this.initializationLock.Release(); } } }