diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Bindings.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Bindings.cs index 4c857fddf..7714c4353 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Bindings.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingArtifactService.Bindings.cs @@ -204,7 +204,7 @@ public sealed partial class VisualBriefingArtifactService return context; if (path is "$index") - return JsonSerializer.SerializeToElement(0); + return JsonSerializer.SerializeToElement(0, JSON_OPTIONS); if (path.StartsWith(".", StringComparison.Ordinal)) return GetDataAtPath(context, path[1..]); diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingCanonicalJsonContext.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingCanonicalJsonContext.cs new file mode 100644 index 000000000..6bad49573 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingCanonicalJsonContext.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace AIStudio.Assistants.VisualBriefing; + +/// +/// The contracts with which System.Text.Json writes and reads the canonical JSON of visual briefings, built at +/// compile time. +/// +/// +/// Much of what these contracts write goes into the hashes of stored briefings, see VisualBriefingJson.Canonical. +/// The hashes of the artifacts are generic: every list they hash needs a contract of its own. A test fails for a type +/// which is missing here. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] + +// The header and the data of an exported briefing, and the build which the progress view shows: +[JsonSerializable(typeof(VisualBriefingExportManifest))] +[JsonSerializable(typeof(VisualBriefingBuildRecord))] +[JsonSerializable(typeof(VisualBriefingCompiledData))] +[JsonSerializable(typeof(VisualBriefingContentData))] +[JsonSerializable(typeof(VisualBriefingInteractionData))] +[JsonSerializable(typeof(VisualBriefingProtectedData))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] + +// The payload hashes of the artifacts: +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary>))] +[JsonSerializable(typeof(VisualBriefingLayoutNode))] + +// The prompts of the stages: +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(VisualBriefingRequiredSlot[]))] +[JsonSerializable(typeof(VisualBriefingControlRequirement[]))] +[JsonSerializable(typeof(VisualBriefingEvidencePrompt))] +[JsonSerializable(typeof(VisualBriefingParentDesign))] + +// The answers of the models: +[JsonSerializable(typeof(VisualBriefingEvidenceResponse))] +[JsonSerializable(typeof(VisualBriefingPlanResponse))] +[JsonSerializable(typeof(VisualBriefingContentResponse))] +[JsonSerializable(typeof(VisualBriefingDesignResponse))] + +// The fingerprint which decides whether the editor holds unsaved changes: +[JsonSerializable(typeof(VisualBriefingPersistenceFingerprint))] +internal sealed partial class VisualBriefingCanonicalJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs index b6a181774..c1a8885b2 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingJson.cs @@ -28,7 +28,9 @@ internal static class VisualBriefingJson /// and a mismatch makes the store discard the stored artifact. Any change here — a converter, a /// naming policy, an encoder — therefore invalidates every briefing that was ever written, which /// surfaces as a failed integrity check rather than as a build error. This is why enums stay numeric - /// here even though the persisted manifest writes their member names. + /// here even though the persisted manifest writes their member names. Unlike other options of the app, + /// these are not made read-only right here, because MWAIS0010 wants nothing but the object creation; + /// System.Text.Json locks them with their first use anyway. /// [CanonicalJsonConfiguration] internal static JsonSerializerOptions Canonical { get; } = new() @@ -38,6 +40,7 @@ internal static class VisualBriefingJson WriteIndented = false, Encoder = JavaScriptEncoder.Default, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + TypeInfoResolver = VisualBriefingCanonicalJsonContext.Default, }; /// @@ -52,7 +55,7 @@ internal static class VisualBriefingJson /// defined outside the feature, such as the target language and the audience enums. Reading accepts /// numbers as well, so manifests written before this distinction existed keep loading. /// - internal static JsonSerializerOptions Persistence { get; } = new() + internal static JsonSerializerOptions Persistence { get; } = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = false, @@ -60,5 +63,6 @@ internal static class VisualBriefingJson Encoder = JavaScriptEncoder.Default, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, Converters = { new JsonStringEnumConverter() }, - }; + TypeInfoResolver = VisualBriefingPersistenceJsonContext.Default, + }.AsReadOnly(); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPersistenceJsonContext.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPersistenceJsonContext.cs new file mode 100644 index 000000000..b7e92b637 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingPersistenceJsonContext.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace AIStudio.Assistants.VisualBriefing; + +/// +/// The contracts with which System.Text.Json reads and writes the files of visual briefings, built at compile time. +/// +[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(VisualBriefingManifest))] +[JsonSerializable(typeof(VisualBriefingEvidenceArtifact))] +[JsonSerializable(typeof(VisualBriefingPlanArtifact))] +[JsonSerializable(typeof(VisualBriefingContentArtifact))] +[JsonSerializable(typeof(VisualBriefingPresentationArtifact))] +[JsonSerializable(typeof(VisualBriefingBuildRecord))] +[JsonSerializable(typeof(VisualBriefingOperationDiagnostics))] + +// The briefing selected last: +[JsonSerializable(typeof(Guid?))] +internal sealed partial class VisualBriefingPersistenceJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs index d17986e4b..5bab50640 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingStore.Projects.cs @@ -24,7 +24,7 @@ public sealed partial class VisualBriefingStore try { this.LastSelectedBriefingId = briefingId; - await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(briefingId), overwrite: true, token); + await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(briefingId, JSON_OPTIONS), overwrite: true, token); } finally { @@ -47,7 +47,7 @@ public sealed partial class VisualBriefingStore return; this.LastSelectedBriefingId = null; - await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(null), overwrite: true, token); + await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(null, JSON_OPTIONS), overwrite: true, token); } finally { @@ -67,7 +67,7 @@ public sealed partial class VisualBriefingStore try { var serialized = await File.ReadAllTextAsync(path, token); - var selected = JsonSerializer.Deserialize(serialized); + var selected = JsonSerializer.Deserialize(serialized, JSON_OPTIONS); this.LastSelectedBriefingId = selected is not null && Directory.Exists(this.BriefingDirectory(selected.Value)) ? selected diff --git a/app/Tests/Serialization/JsonCoverageTests.cs b/app/Tests/Serialization/JsonCoverageTests.cs index 716585793..8c5e8d46b 100644 --- a/app/Tests/Serialization/JsonCoverageTests.cs +++ b/app/Tests/Serialization/JsonCoverageTests.cs @@ -20,6 +20,9 @@ public sealed class JsonCoverageTests [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceRequests))] [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.VisualBriefingStored))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.VisualBriefingHashed))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.VisualBriefingAnswers))] public void EveryContractComesFromSourceGeneration(JsonTestRoot root) { var typesWithoutGeneratedContract = JsonTypeGraph.Collect(root.Type, root.Options) diff --git a/app/Tests/Serialization/JsonTestRoots.VisualBriefing.cs b/app/Tests/Serialization/JsonTestRoots.VisualBriefing.cs index 2b4f319ac..735725f8b 100644 --- a/app/Tests/Serialization/JsonTestRoots.VisualBriefing.cs +++ b/app/Tests/Serialization/JsonTestRoots.VisualBriefing.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using AIStudio.Assistants.VisualBriefing; @@ -22,8 +23,8 @@ public static partial class JsonTestRoots yield return Case(new(typeof(VisualBriefingPresentationArtifact), options)); yield return Case(new(typeof(VisualBriefingBuildRecord), options)); - // The briefing selected last, which gets written without options of its own: - yield return Case(new(typeof(Guid?), JsonSerializerOptions.Default), "selected briefing"); + // The briefing selected last: + yield return Case(new(typeof(Guid?), options), "selected briefing"); // // The canonical JSON which gets read back: the header and the data of an exported briefing, the version @@ -60,12 +61,28 @@ public static partial class JsonTestRoots yield return Case(new(typeof(Dictionary>), options)); yield return Case(new(typeof(VisualBriefingLayoutNode), options)); - // The data of a briefing, and the IDs the prompts list: + // The data of a briefing, with what the compilers and the stages put into it: yield return Case(new(typeof(JsonElement), options)); - yield return Case(new(typeof(string[]), options)); + yield return Case(new(typeof(JsonObject), options)); + yield return Case(new(typeof(VisualBriefingCompiledData), options)); + yield return Case(new(typeof(VisualBriefingContentData), options)); + yield return Case(new(typeof(VisualBriefingInteractionData), options)); + yield return Case(new(typeof(VisualBriefingProtectedData), options)); + yield return Case(new(typeof(string), options), "value of a control"); - // The position inside a repeated element, which the data bindings write without options of their own: - yield return Case(new(typeof(int), JsonSerializerOptions.Default), "index"); + // The position inside a repeated element, which the data bindings write: + yield return Case(new(typeof(int), options), "index"); + + // What the prompts of the stages carry: + yield return Case(new(typeof(IEnumerable), options)); + yield return Case(new(typeof(string[]), options)); + yield return Case(new(typeof(VisualBriefingRequiredSlot[]), options)); + yield return Case(new(typeof(VisualBriefingControlRequirement[]), options)); + yield return Case(new(typeof(VisualBriefingEvidencePrompt), options)); + yield return Case(new(typeof(VisualBriefingParentDesign), options)); + + // The fingerprint which decides whether the editor holds unsaved changes: + yield return Case(new(typeof(VisualBriefingPersistenceFingerprint), options)); // The diagnostics a user copies to the clipboard: yield return Case(new(typeof(VisualBriefingOperationDiagnostics), VisualBriefingJson.Persistence));