fix(v2): restore V1-parity shadow download_progress on terminal app states

V2's ImeLogTrackerAdapter collapsed V1's parallel-channel emission into one
event per state transition, dropping V1's shadow `download_progress` event
on Installed/Error transitions. The Web UI's DownloadProgress panel relies
on that shadow to close out apps that never produced byte-level progress.

WinGet/Store apps (Company Portal, …) emit no `download_progress` ticks at
all because they have no DO byte data — their row in the panel was stuck on
"started" forever even though the install completed and InstallProgress
showed it as installed.

Restores V1 parity: when MapAppStateToEventType yields Installed/Error,
also emit a shadow `download_progress` (Severity=Debug, ImmediateUpload)
carrying the same payload plus `status=completed/failed`. Frontend logic
(unchanged) then flips the row to complete.

Also caches BuildAppStatePayload across both emissions and constructs the
shadow payload via foreach-copy (net48 lacks the IEnumerable+Comparer
Dictionary ctor).

Tests: V2.Core 732 (+3 — Theory[Installed|Error] + non-terminal guard).
Solution build 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-25 21:51:48 +02:00
parent 96191c8740
commit 8a9a9ffe82
3 changed files with 107 additions and 2 deletions
@@ -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<string>(),
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<string>(),
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));
}
}
}
@@ -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<string, string>(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.
@@ -1 +1 @@
341
348