diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs index fc4891a6..f4b04e4e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/ImeLogTrackerAdapterTests.cs @@ -621,5 +621,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Same(priorDo, f.Tracker.OnDoTelemetryReceived); Assert.Same(priorScript, f.Tracker.OnScriptCompleted); } + + [Theory] + // V1 parity: terminal Installed/Error transitions must also emit a shadow + // `download_progress` event with status=completed/failed so the Web UI's + // DownloadProgress panel closes out apps that produced no byte progress + // (WinGet/Store apps emit no DO byte data — without the shadow they'd + // stay stuck on "started" forever). + [InlineData(AppInstallationState.Installed, SharedEventTypes.AppInstallComplete, "completed")] + [InlineData(AppInstallationState.Error, SharedEventTypes.AppInstallFailed, "failed")] + public void AppStateChange_terminal_state_emits_V1_parity_shadow_download_progress( + AppInstallationState terminalState, + string expectedPrimaryEventType, + string expectedShadowStatus) + { + using var f = new ImeLogTrackerAdapterFixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + var app = AppPackageState.Restore( + id: "wg-app", + listPos: 0, + name: "Company Portal", + runAs: AppRunAs.System, + intent: AppIntent.Install, + targeted: AppTargeted.Device, + dependsOn: new HashSet(), + installationState: terminalState, + downloadingOrInstallingSeen: true, + progressPercent: terminalState == AppInstallationState.Installed ? 100 : 0, + bytesDownloaded: terminalState == AppInstallationState.Installed ? 100 : 0, + bytesTotal: 100, + errorPatternId: null, + errorDetail: terminalState == AppInstallationState.Error ? "synthetic" : null, + errorCode: terminalState == AppInstallationState.Error ? "1" : null, + exitCode: null, + hresultFromWin32: null, + appType: "WinGet"); + + adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, terminalState); + + // Primary terminal event still fires once (unchanged). + Assert.Single(f.InfoEvents(expectedPrimaryEventType)); + + // Shadow `download_progress` carries status + the same payload fields. + var shadow = Assert.Single(f.InfoEvents(SharedEventTypes.DownloadProgress)); + Assert.Equal(expectedShadowStatus, shadow.Payload!["status"]); + Assert.Equal("wg-app", shadow.Payload["appId"]); + Assert.Equal("Company Portal", shadow.Payload["appName"]); + Assert.Equal(EventSeverity.Debug.ToString(), shadow.Payload[SignalPayloadKeys.Severity]); + } + + [Fact] + public void AppStateChange_non_terminal_transitions_do_not_emit_shadow_download_progress() + { + using var f = new ImeLogTrackerAdapterFixture(); + using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock); + AppPackageState MakeApp(AppInstallationState state) => AppPackageState.Restore( + id: "app-1", + listPos: 0, + name: "App", + runAs: AppRunAs.System, + intent: AppIntent.Install, + targeted: AppTargeted.Device, + dependsOn: new HashSet(), + installationState: state, + downloadingOrInstallingSeen: true, + progressPercent: 0, + bytesDownloaded: 0, + bytesTotal: 0, + errorPatternId: null, + errorDetail: null, + errorCode: null, + exitCode: null, + hresultFromWin32: null); + + // Downloading → Installing must NOT add a shadow `download_progress` — + // and Skipped/Postponed terminal states must NOT either (V1 parity). + adapter.TriggerAppStateFromTest(MakeApp(AppInstallationState.Downloading), AppInstallationState.Unknown, AppInstallationState.Downloading); + adapter.TriggerAppStateFromTest(MakeApp(AppInstallationState.Installing), AppInstallationState.Downloading, AppInstallationState.Installing); + adapter.TriggerAppStateFromTest(MakeApp(AppInstallationState.Skipped), AppInstallationState.Installing, AppInstallationState.Skipped); + adapter.TriggerAppStateFromTest(MakeApp(AppInstallationState.Postponed), AppInstallationState.Installing, AppInstallationState.Postponed); + + Assert.Empty(f.InfoEvents(SharedEventTypes.DownloadProgress)); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs index 26621528..ef93f818 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs @@ -376,6 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters if (mapped == null) return; var (eventType, severity) = mapped.Value; + var payload = BuildAppStatePayload(app, newState, timing); // All app-state transitions flush immediately, including `download_progress`: // progress ticks run every 3s and only while a download is actively in flight // (bounded by download duration), so live UI responsiveness wins over the small @@ -386,9 +387,31 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters message: BuildAppStateMessage(app, newState, eventType), severity: severity, immediateUpload: true, - data: BuildAppStatePayload(app, newState, timing), + data: payload, occurredAtUtc: now); + // V1 parity: on terminal Installed/Error transitions, also emit a shadow + // `download_progress` event tagged with status=completed/failed. The Web UI's + // DownloadProgress panel relies on this to close out apps that never produced + // byte-level progress (WinGet/Store apps emit no `download_progress` ticks + // because they have no DO byte data). Without this, Company Portal & friends + // stay stuck on "started" forever. + if (newState == AppInstallationState.Installed || newState == AppInstallationState.Error) + { + var status = newState == AppInstallationState.Installed ? "completed" : "failed"; + var shadowPayload = new Dictionary(payload.Count + 1, StringComparer.Ordinal); + foreach (var kv in payload) shadowPayload[kv.Key] = kv.Value; + shadowPayload["status"] = status; + _post.Emit( + eventType: SharedEventTypes.DownloadProgress, + source: SourceLabel, + message: $"{app.Name ?? app.Id}: {status}", + severity: EventSeverity.Debug, + immediateUpload: true, + data: shadowPayload, + occurredAtUtc: now); + } + // PR3-D4: app state transitions are the heaviest cardinality observability target — // a forensic reader needs (oldState->newState, terminal flag, sub-phase emit) to // reconstruct why the UI shows what it shows. diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 947e93bc..71627d71 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -341 +348