mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+82
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user