diff --git a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs index cda806ffd..f418ce048 100644 --- a/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs +++ b/src/Fallout.Build/Execution/Extensions/BuildGraphUtility.cs @@ -74,7 +74,23 @@ private static TargetModel ToModel(ExecutableTarget target) SortedNames(target.ExecutionDependencies), SortedNames(target.OrderDependencies), SortedNames(target.TriggerDependencies), - SortedNames(target.Triggers)); + SortedNames(target.Triggers), + ToStatus(target.Status)); + + // Projects the internal execution status onto the small vocabulary the graph control + // renders. Neutral states (not scheduled, not run, collective grouping) emit null, so a + // structural/plan snapshot stays unstyled and only a real run paints the nodes. Additive: + // a consumer that predates the field simply ignores it, so this is not a schema break. + private static string ToStatus(ExecutionStatus status) + => status switch + { + ExecutionStatus.Scheduled => "queued", + ExecutionStatus.Running => "running", + ExecutionStatus.Succeeded => "succeeded", + ExecutionStatus.Failed or ExecutionStatus.Aborted => "failed", + ExecutionStatus.Skipped => "skipped", + _ => null, + }; // Sorted for deterministic output — the graph carries no execution order, so the display // order is irrelevant to consumers and a stable ordering avoids spurious file churn. @@ -95,5 +111,6 @@ internal sealed record TargetModel( IReadOnlyList DependsOn, IReadOnlyList After, IReadOnlyList TriggeredBy, - IReadOnlyList Triggers); + IReadOnlyList Triggers, + string Status); } diff --git a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs index a621f3847..d7598eefc 100644 --- a/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs +++ b/src/Fallout.Build/Execution/Extensions/SerializeBuildGraphAttribute.cs @@ -7,26 +7,58 @@ namespace Fallout.Build.Execution.Extensions; /// -/// Emits build-graph.json into the temporary directory on every build initialization, -/// giving editor tooling (the VS Code extension) a machine-readable projection of the target -/// graph. The projection itself lives in ; this attribute only owns -/// the build-lifecycle hook and the file write. Best-effort — a serialization failure never fails -/// the build. +/// Emits build-graph.json into the temporary directory at build initialization, then re-emits +/// it on every target state transition so editor tooling (the VS Code extension) can watch the file +/// and animate the run live — queued → running → succeeded/failed. The projection itself lives in +/// ; this attribute only owns the build-lifecycle hooks and the file +/// write. Best-effort — a serialization failure never fails the build. /// -internal class SerializeBuildGraphAttribute : BuildExtensionAttributeBase, IOnBuildInitialized +internal class SerializeBuildGraphAttribute : BuildExtensionAttributeBase, + IOnBuildInitialized, IOnTargetRunning, IOnTargetSucceeded, IOnTargetFailed, IOnTargetSkipped { private const string GraphFileName = "build-graph.json"; + // Guards the file write only; the executor updates target.Status in place, so each transition + // re-serializes the same captured collection with its now-current statuses. + private readonly object writeLock = new(); + private IReadOnlyCollection targets; + private string falloutVersion; + private AbsolutePath GraphFile => Build.TemporaryDirectory / GraphFileName; public void OnBuildInitialized( IReadOnlyCollection executableTargets, IReadOnlyCollection executionPlan) { + targets = executableTargets; + falloutVersion = FindFalloutVersion(); + WriteGraph(); + } + + // Each transition re-emits the whole graph with current statuses. The executor sets + // target.Status before invoking these, so the write reflects the transition that just happened. + public void OnTargetRunning(ExecutableTarget target) => WriteGraph(); + + public void OnTargetSucceeded(ExecutableTarget target) => WriteGraph(); + + public void OnTargetFailed(ExecutableTarget target) => WriteGraph(); + + public void OnTargetSkipped(ExecutableTarget target) => WriteGraph(); + + private void WriteGraph() + { + if (targets == null) + { + return; + } + try { - var json = BuildGraphUtility.GetJsonString(executableTargets, FindFalloutVersion()); - GraphFile.WriteAllText(json); + var json = BuildGraphUtility.GetJsonString(targets, falloutVersion); + lock (writeLock) + { + GraphFile.WriteAllText(json); + } } catch (Exception exception) { diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json index 67baa6095..981eccd1d 100644 --- a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.Sample_graph_matches_the_contract_snapshot.verified.json @@ -15,7 +15,8 @@ "triggeredBy": [], "triggers": [ "Publish" - ] + ], + "status": null }, { "name": "Publish", @@ -28,7 +29,8 @@ "triggeredBy": [ "Test" ], - "triggers": [] + "triggers": [], + "status": null }, { "name": "Restore", @@ -39,7 +41,8 @@ "dependsOn": [], "after": [], "triggeredBy": [], - "triggers": [] + "triggers": [], + "status": null }, { "name": "Test", @@ -54,7 +57,8 @@ "Restore" ], "triggeredBy": [], - "triggers": [] + "triggers": [], + "status": null } ] } \ No newline at end of file diff --git a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs index 38776eb30..f3ba187cc 100644 --- a/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs +++ b/tests/Fallout.Build.Specs/BuildGraphUtilitySpecs.cs @@ -143,7 +143,34 @@ public void Root_and_target_property_names_are_camelCase() firstTarget.EnumerateObject().Select(x => x.Name) .Should().Equal( "name", "description", "declaredIn", "default", "listed", - "dependsOn", "after", "triggeredBy", "triggers"); + "dependsOn", "after", "triggeredBy", "triggers", "status"); + } + + [Theory] + [InlineData(ExecutionStatus.Scheduled, "queued")] + [InlineData(ExecutionStatus.Running, "running")] + [InlineData(ExecutionStatus.Succeeded, "succeeded")] + [InlineData(ExecutionStatus.Failed, "failed")] + [InlineData(ExecutionStatus.Aborted, "failed")] + [InlineData(ExecutionStatus.Skipped, "skipped")] + // Neutral states emit null so a structural/plan snapshot stays unstyled; only a real run paints. + [InlineData(ExecutionStatus.None, null)] + [InlineData(ExecutionStatus.NotRun, null)] + [InlineData(ExecutionStatus.Collective, null)] + public void Execution_status_projects_to_the_control_vocabulary(ExecutionStatus status, string expected) + { + var target = new ExecutableTarget { Name = "Compile", Status = status }; + + BuildGraphUtility.GetModel(new[] { target }, SampleVersion).Targets.Single().Status + .Should().Be(expected); + } + + [Fact] + public void Status_is_added_without_bumping_the_schema_version() + { + // The status field is additive — a consumer that predates it ignores the extra key — so it + // rides schema v1. This guard pairs with Schema_version_is_1 to make that intent explicit. + BuildGraphUtility.SchemaVersion.Should().Be(1); } [Theory]