Merge wj: fleet alert notifications and operator report emails

Brings in the fleet alert notification concept, the operator report email
alerts, and the tray work that goes with them (tray 1.6.5).

Two files needed resolution:

- ShopDbEndpointAgent.csproj: wj carried 1.13.1 through 1.13.4, all
  tray-coordination bumps with no agent code behind them. Ours carried
  1.14.0, which is real code. Kept every changelog entry and took 1.14.0
  as the version, since it already supersedes the 1.13.x line.
- plugins/endpoints/api/routes.py auto-merged; verified the errormode
  descriptor survived intact.

KNOWN RED, INHERITED NOT CAUSED: five backend tests fail on this merge -
authz on endpoints.acknowledge_fleet_alert, the public GET surface scan,
two plugin-contract import checks, and the notifications plugin
migration/model agreement. All five were confirmed failing on a clean
worktree of origin/wj before merging, so they arrive with the branch
rather than from the merge. They reflect the fleet alert work being
half-landed: the push path was a delivery proof of concept and the
server-side surface was still being stood up.

These are not fixed here on purpose. The next change replaces that
delivery path with the endpoint agent, so the notification persists on
the endpoint until the operator acknowledges it or the server closes it,
and the affected surfaces get their gating and migration settled as part
of that work rather than being patched twice.
This commit is contained in:
benjamin.baker
2026-09-09 12:44:38 -04:00
19 changed files with 1828 additions and 24 deletions

View File

@@ -0,0 +1,97 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Polls the fleet alert channel to fetch active notifications for this endpoint.
// The server may publish site-wide alerts (maintenance windows, policy changes)
// or endpoint-specific warnings (outdated agent version, failed telemetry push).
// The agent polls GET /api/endpoints/alerts?hostname=X every 60 seconds when idle
// and deserializes the JSON response into FleetAlert DTOs. Shares the endpoints
// collector key via X-API-Key, so the alert channel rides the same kill switch as
// telemetry and commands.
public sealed class FleetAlertClient : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly HttpClient http;
private readonly SocketsHttpHandler handler;
private readonly ILogger logger;
public FleetAlertClient(AgentConfig config, ILogger logger)
{
this.logger = logger;
handler = new SocketsHttpHandler
{
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
http = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
Timeout = TimeSpan.FromSeconds(30),
};
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
}
public async Task<List<FleetAlert>> FetchAlertsAsync(
string hostname, CancellationToken token)
{
var url = "api/endpoints/alerts?hostname="
+ Uri.EscapeDataString(hostname);
using var response = await http.GetAsync(url, token);
if (response.StatusCode == HttpStatusCode.NoContent)
return new List<FleetAlert>();
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
var envelope = JsonSerializer.Deserialize<FleetAlertEnvelope>(
body, JsonOptions);
return envelope?.Data ?? new List<FleetAlert>();
}
public async Task AcknowledgeAlertAsync(
int notificationId, int assetId, string hostname, CancellationToken token)
{
var url = $"api/endpoints/alerts/{notificationId}/acknowledge";
var payload = new
{
assetid = assetId,
hostname
};
var json = JsonSerializer.Serialize(payload, JsonOptions);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var response = await http.PostAsync(url, content, token);
var body = await response.Content.ReadAsStringAsync(token);
if (!response.IsSuccessStatusCode)
throw new CollectorException((int)response.StatusCode, body);
}
public void Dispose() => http.Dispose();
}
public sealed class FleetAlert
{
[JsonPropertyName("notificationId")] public int NotificationId { get; set; }
[JsonPropertyName("type")] public string TypeName { get; set; } = "";
[JsonPropertyName("typeColor")] public string TypeColor { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("message")] public string Message { get; set; } = "";
[JsonPropertyName("timeout")] public int Timeout { get; set; }
}
public sealed class FleetAlertEnvelope
{
[JsonPropertyName("status")] public string Status { get; set; } = "";
[JsonPropertyName("data")] public List<FleetAlert>? Data { get; set; }
}

View File

@@ -0,0 +1,113 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace ShopDbEndpointAgent.Core;
// Local JSON file store for tracking acknowledged fleet alerts.
// Stores notification IDs that have been acknowledged so they don't
// reappear in future polls. Thread-safe file access with JSON serialization.
public sealed class FleetAlertStore
{
private static readonly string DataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDbAgent");
private static readonly string StoreFile = Path.Combine(DataDir, "acknowledged-alerts.json");
private readonly object fileLock = new();
private readonly ILogger logger;
private HashSet<int> acknowledged;
public FleetAlertStore(ILogger logger)
{
this.logger = logger;
acknowledged = new HashSet<int>();
Load();
}
public bool IsAcknowledged(int notificationId)
{
lock (fileLock)
{
return acknowledged.Contains(notificationId);
}
}
public void MarkAcknowledged(int notificationId)
{
lock (fileLock)
{
if (acknowledged.Add(notificationId))
{
Save();
PruneOld();
}
}
}
private void Load()
{
lock (fileLock)
{
try
{
if (!File.Exists(StoreFile))
{
acknowledged = new HashSet<int>();
return;
}
var json = File.ReadAllText(StoreFile);
var stored = JsonSerializer.Deserialize<StoredData>(json);
acknowledged = stored?.AcknowledgedIds?.ToHashSet() ?? new HashSet<int>();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not load acknowledged alerts store; starting fresh");
acknowledged = new HashSet<int>();
}
}
}
private void Save()
{
try
{
Directory.CreateDirectory(DataDir);
var data = new StoredData
{
AcknowledgedIds = acknowledged.ToList(),
LastUpdated = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(StoreFile, json);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not save acknowledged alerts store");
}
}
private void PruneOld()
{
// Keep only last 1000 acknowledged IDs to prevent unbounded growth
if (acknowledged.Count > 1000)
{
var toKeep = acknowledged.OrderByDescending(id => id).Take(1000).ToHashSet();
acknowledged = toKeep;
Save();
}
}
private sealed class StoredData
{
public List<int> AcknowledgedIds { get; set; } = new();
public DateTime LastUpdated { get; set; }
}
}

View File

@@ -0,0 +1,356 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
// Displays fleet-wide alert notifications from the ShopDB server in a toast-style
// window. The form appears top-right, plays a system beep, and stays on top until
// dismissed. The left border is colored based on alert type (parsed from the
// TypeColor hex string from the database). The operator acknowledges the alert by
// clicking the button or the X. Countdown timer shows auto-dismiss time if configured.
internal sealed class FleetAlertForm : Form
{
private readonly FleetAlert alert;
private readonly FleetAlertClient? client;
private readonly string? hostname;
private System.Windows.Forms.Timer? countdownTimer;
private System.Windows.Forms.Timer? statusCheckTimer;
private readonly CancellationTokenSource statusCheckCancellation = new();
private int remainingSeconds;
private Label? countdownLabel;
private readonly List<Font> fonts = new();
public event EventHandler? OnAcknowledgeClicked;
public FleetAlertForm(FleetAlert alert, FleetAlertClient? client = null, string? hostname = null)
{
this.alert = alert ?? throw new ArgumentNullException(nameof(alert));
this.client = client;
this.hostname = hostname;
Text = string.Empty;
FormBorderStyle = FormBorderStyle.None;
TopMost = true;
ShowInTaskbar = false;
StartPosition = FormStartPosition.Manual;
ClientSize = new Size(400, 280);
BackColor = Color.White;
var screen = Screen.PrimaryScreen?.WorkingArea ?? Screen.PrimaryScreen?.Bounds ?? new Rectangle(0, 0, 1920, 1080);
Location = new Point(screen.Right - Width - 20, screen.Top + 20);
BuildLayout();
ApplyRoundedCorners();
Load += FleetAlertForm_Load;
FormClosing += FleetAlertForm_FormClosing;
}
private void FleetAlertForm_FormClosing(object? sender, FormClosingEventArgs e)
{
countdownTimer?.Stop();
statusCheckTimer?.Stop();
statusCheckCancellation.Cancel();
}
private void FleetAlertForm_Load(object? sender, EventArgs e)
{
System.Media.SystemSounds.Beep.Play();
if (client != null && hostname != null)
{
StartStatusCheck();
}
}
private void StartStatusCheck()
{
statusCheckTimer = new System.Windows.Forms.Timer { Interval = 30000 };
statusCheckTimer.Tick += StatusCheckTimer_Tick;
statusCheckTimer.Start();
}
private void StatusCheckTimer_Tick(object? sender, EventArgs e)
{
if (client == null || hostname == null)
return;
Task.Run(async () =>
{
try
{
var alerts = await client.FetchAlertsAsync(hostname, statusCheckCancellation.Token)
.ConfigureAwait(false);
var stillActive = alerts?.Any(a => a.NotificationId == alert.NotificationId) ?? false;
if (!stillActive)
{
BeginInvoke(new Action(() =>
{
statusCheckTimer?.Stop();
Close();
}));
}
}
catch (OperationCanceledException)
{
// Form is closing, ignore
}
catch
{
// Ignore errors - keep the form open if we can't check status
}
});
}
private void ApplyRoundedCorners()
{
using var path = new GraphicsPath();
int radius = 8;
var rect = new Rectangle(0, 0, Width, Height);
path.AddArc(rect.X, rect.Y, radius, radius, 180, 90);
path.AddArc(rect.Right - radius, rect.Y, radius, radius, 270, 90);
path.AddArc(rect.Right - radius, rect.Bottom - radius, radius, radius, 0, 90);
path.AddArc(rect.X, rect.Bottom - radius, radius, radius, 90, 90);
path.CloseFigure();
Region = new Region(path);
}
private void StartCountdown()
{
remainingSeconds = alert.Timeout > 0 ? alert.Timeout * 60 : 300;
countdownTimer = new System.Windows.Forms.Timer { Interval = 1000 };
countdownTimer.Tick += CountdownTimer_Tick;
countdownTimer.Start();
UpdateCountdownDisplay();
}
private void CountdownTimer_Tick(object? sender, EventArgs e)
{
remainingSeconds--;
UpdateCountdownDisplay();
if (remainingSeconds <= 0)
{
countdownTimer?.Stop();
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
Close();
}
}
private void UpdateCountdownDisplay()
{
if (countdownLabel != null)
{
int minutes = remainingSeconds / 60;
int seconds = remainingSeconds % 60;
countdownLabel.Text = $"Auto-dismiss in {minutes}:{seconds:D2}";
}
}
private Font CreateFont(string family, float size, FontStyle style = FontStyle.Regular)
{
var font = new Font(family, size, style);
fonts.Add(font);
return font;
}
private void BuildLayout()
{
var borderPanel = new Panel
{
BackColor = ParseTypeColor(alert.TypeColor),
Dock = DockStyle.Left,
Width = 5,
};
Controls.Add(borderPanel);
var contentPanel = new Panel
{
Left = 5,
Top = 0,
Width = 395,
Height = 280,
BackColor = Color.White,
};
Controls.Add(contentPanel);
var headerPanel = new Panel
{
Left = 0,
Top = 0,
Width = 395,
Height = 45,
BackColor = Color.FromArgb(245, 245, 245),
};
contentPanel.Controls.Add(headerPanel);
var typeLabel = new Label
{
Text = alert.TypeName.ToUpper(),
Left = 16,
Top = 14,
Width = 300,
Font = CreateFont("Segoe UI", 10, FontStyle.Bold),
ForeColor = Color.FromArgb(51, 51, 51),
};
headerPanel.Controls.Add(typeLabel);
var closeButton = new Button
{
Text = "×",
Left = 358,
Top = 10,
Width = 24,
Height = 24,
FlatStyle = FlatStyle.Flat,
Font = CreateFont("Segoe UI", 14),
ForeColor = Color.FromArgb(102, 102, 102),
BackColor = Color.Transparent,
Cursor = Cursors.Hand,
};
closeButton.FlatAppearance.BorderSize = 0;
closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(224, 224, 224);
closeButton.Click += CloseButton_Click;
headerPanel.Controls.Add(closeButton);
var titleLabel = new Label
{
Text = alert.Title,
Left = 20,
Top = 58,
Width = 360,
Height = 50,
Font = CreateFont("Segoe UI", 11, FontStyle.Bold),
ForeColor = Color.FromArgb(51, 51, 51),
AutoSize = false,
};
contentPanel.Controls.Add(titleLabel);
var messageLabel = new Label
{
Text = alert.Message,
Left = 20,
Top = 115,
Width = 360,
Height = 80,
Font = CreateFont("Segoe UI", 9.75f),
ForeColor = Color.FromArgb(85, 85, 85),
AutoSize = false,
};
contentPanel.Controls.Add(messageLabel);
var acknowledgeButton = new Button
{
Text = "Acknowledge",
Left = 130,
Top = 202,
Width = 140,
Height = 32,
BackColor = Color.FromArgb(76, 175, 80),
ForeColor = Color.White,
FlatStyle = FlatStyle.Flat,
Font = CreateFont("Segoe UI", 9, FontStyle.Bold),
Cursor = Cursors.Hand,
};
acknowledgeButton.FlatAppearance.BorderSize = 0;
acknowledgeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(69, 160, 73);
acknowledgeButton.Click += AcknowledgeButton_Click;
contentPanel.Controls.Add(acknowledgeButton);
var footerPanel = new Panel
{
Left = 0,
Top = 240,
Width = 395,
Height = 40,
BackColor = Color.White,
};
contentPanel.Controls.Add(footerPanel);
var footerBorder = new Panel
{
Left = 0,
Top = 0,
Width = 395,
Height = 1,
BackColor = Color.FromArgb(238, 238, 238),
};
footerPanel.Controls.Add(footerBorder);
countdownLabel = new Label
{
Text = "Manual acknowledgment required",
Left = 0,
Top = 12,
Width = 395,
Height = 20,
TextAlign = ContentAlignment.MiddleCenter,
Font = CreateFont("Segoe UI", 8.25f),
ForeColor = Color.FromArgb(153, 153, 153),
};
footerPanel.Controls.Add(countdownLabel);
}
private Color ParseTypeColor(string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
return Color.Gray;
var hex = hexColor.TrimStart('#');
if (hex.Length != 6)
return Color.Gray;
try
{
var r = Convert.ToByte(hex.Substring(0, 2), 16);
var g = Convert.ToByte(hex.Substring(2, 2), 16);
var b = Convert.ToByte(hex.Substring(4, 2), 16);
return Color.FromArgb(r, g, b);
}
catch
{
return Color.Gray;
}
}
private void AcknowledgeButton_Click(object? sender, EventArgs e)
{
countdownTimer?.Stop();
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
DialogResult = DialogResult.OK;
Close();
}
private void CloseButton_Click(object? sender, EventArgs e)
{
countdownTimer?.Stop();
OnAcknowledgeClicked?.Invoke(this, EventArgs.Empty);
DialogResult = DialogResult.OK;
Close();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
countdownTimer?.Stop();
countdownTimer?.Dispose();
statusCheckTimer?.Stop();
statusCheckTimer?.Dispose();
statusCheckCancellation.Cancel();
statusCheckCancellation.Dispose();
foreach (var font in fonts)
{
font.Dispose();
}
fonts.Clear();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,197 @@
using Microsoft.Extensions.Logging;
using ShopDbEndpointAgent.Core;
namespace ShopDbEndpointAgent.Tray;
// Polls the server for fleet-wide alerts every 60 seconds and displays new
// alerts to the operator via FleetAlertForm. Acknowledged alerts are tracked in
// FleetAlertStore and posted back to the server so they do not reappear in
// future polls. Runs for the tray's lifetime on a dedicated background thread.
internal sealed class FleetAlertWorker : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15);
private readonly FleetAlertClient client;
private readonly FleetAlertStore store;
private readonly string hostname;
private readonly int assetId;
private readonly ILogger logger;
private readonly SynchronizationContext? uiContext;
private readonly CancellationTokenSource cancellation;
private readonly Thread workerThread;
public FleetAlertWorker(
FleetAlertClient client,
FleetAlertStore store,
string hostname,
int assetId,
ILogger logger,
SynchronizationContext? uiContext = null)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.store = store ?? throw new ArgumentNullException(nameof(store));
this.hostname = hostname ?? throw new ArgumentNullException(nameof(hostname));
this.assetId = assetId;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.uiContext = uiContext ?? SynchronizationContext.Current;
cancellation = new CancellationTokenSource();
workerThread = new Thread(Run)
{
IsBackground = true,
Name = "ShopDB fleet alert poller",
};
}
public void Start()
{
logger.LogInformation(
"Fleet alert worker starting; first poll in {Delay}, then every {Interval}.",
StartupDelay, PollInterval);
workerThread.Start();
}
private void Run()
{
var token = cancellation.Token;
try
{
Thread.Sleep(StartupDelay);
}
catch (Exception)
{
return;
}
while (!token.IsCancellationRequested)
{
try
{
PollAndDisplayAlerts(token);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Fleet alert poll failed; retrying on next cycle.");
}
try
{
Thread.Sleep(PollInterval);
}
catch (Exception)
{
return;
}
}
}
private void PollAndDisplayAlerts(CancellationToken token)
{
var alerts = client.FetchAlertsAsync(hostname, token).GetAwaiter().GetResult();
if (alerts is null || alerts.Count == 0)
{
logger.LogDebug("No active fleet alerts.");
return;
}
var newAlerts = alerts
.Where(alert => !store.IsAcknowledged(alert.NotificationId))
.ToList();
if (newAlerts.Count == 0)
{
logger.LogDebug("All {Count} fleet alerts already acknowledged.", alerts.Count);
return;
}
logger.LogInformation("Found {Count} new fleet alert(s).", newAlerts.Count);
foreach (var alert in newAlerts)
{
if (token.IsCancellationRequested)
return;
ShowAlertOnUIThread(alert, token);
}
}
private void ShowAlertOnUIThread(FleetAlert alert, CancellationToken token)
{
var shown = new ManualResetEventSlim(false);
Exception? formException = null;
var showAction = new SendOrPostCallback(_ =>
{
try
{
using var form = new FleetAlertForm(alert, client, hostname);
form.OnAcknowledgeClicked += (s, e) => OnAlertAcknowledged(alert, token);
var result = form.ShowDialog();
}
catch (Exception exception)
{
formException = exception;
}
finally
{
shown.Set();
}
});
if (uiContext is not null)
uiContext.Post(showAction, null);
else
showAction(null);
shown.Wait(token);
if (formException is not null)
{
logger.LogWarning(formException,
"Could not display fleet alert {NotificationId}.", alert.NotificationId);
}
}
private void OnAlertAcknowledged(FleetAlert alert, CancellationToken token)
{
logger.LogInformation(
"Fleet alert {NotificationId} acknowledged; queuing server post.", alert.NotificationId);
store.MarkAcknowledged(alert.NotificationId);
Task.Run(async () =>
{
try
{
await client.AcknowledgeAlertAsync(alert.NotificationId, assetId, hostname, token);
logger.LogInformation("Fleet alert {NotificationId} posted to server.", alert.NotificationId);
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not post acknowledgment for fleet alert {NotificationId}; already marked locally.",
alert.NotificationId);
}
});
}
public void Dispose()
{
cancellation.Cancel();
if (workerThread.IsAlive)
{
if (!workerThread.Join(TimeSpan.FromSeconds(5)))
{
logger.LogWarning("Fleet alert worker thread did not exit within timeout.");
}
}
cancellation.Dispose();
}
}

View File

@@ -71,7 +71,22 @@
copies of the exit-code handling. A tray that cannot open the channel
logs and keeps its timer, so the failure mode is "refresh is not
immediate", not "session data stops working". Requires agent 1.11.0. -->
<Version>1.4.0</Version>
<!-- 1.6.1: fix crash when clicking acknowledge button. The countdown timer
is now properly stopped before the form closes to prevent disposal issues. -->
<!-- 1.6.2: disable auto-dismiss timer. Fleet alerts now require manual
acknowledgment only. Footer shows "Manual acknowledgment required" instead
of countdown timer. -->
<!-- 1.6.3: auto-close when notification becomes inactive. Fleet alert forms now
check every 30 seconds if the notification is still active on the server and
automatically close if it has ended or been deactivated. -->
<!-- 1.6.4: fix UI thread deadlock. Acknowledgment and status checks now run async
properly without blocking the UI thread. Acknowledgment is marked locally first,
then posted to server in background to prevent lockups. -->
<!-- 1.6.5: fix resource leaks and disposal issues. GraphicsPath properly disposed,
Font objects tracked and disposed, worker thread joined before client disposal,
Screen.PrimaryScreen null dereference prevented, status check cancellation token
propagated to prevent orphaned HTTP requests. -->
<Version>1.6.5</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>

View File

@@ -82,6 +82,14 @@ internal sealed class TrayContext : ApplicationContext
// guard and launch two probes at the same spool file.
private readonly SynchronizationContext? uiContext;
// Fleet alerts are pulled from the server and displayed to the operator as
// toast notifications in the top-right corner. The worker polls every 60
// seconds, filters out already-acknowledged alerts via the store, and posts
// acknowledgements back to the server when the operator dismisses the form.
private readonly FleetAlertClient? fleetAlertClient;
private readonly FleetAlertStore? fleetAlertStore;
private readonly FleetAlertWorker? fleetAlertWorker;
// The tray logs into the operator's own profile
// (C:\Users\ShopFloor\ShopDB\Logs\<datetime>-trayagent.log), NOT into the
// service's ProgramData directory. The operator cannot write there, and the
@@ -166,6 +174,38 @@ internal sealed class TrayContext : ApplicationContext
+ "data will only update on the {Interval} timer.",
UserContextInterval);
}
// Fleet alert worker. Best-effort: if the config cannot be loaded or the
// worker fails to start, the tray still runs and the other features work.
// The operator just does not receive fleet-wide alerts. Loading the config
// requires reading %ProgramData%\ShopDB\config.json, which is written by
// the installer and owned by SYSTEM but readable by the operator. Loading
// the asset ID requires reading the spool file written by the service.
try
{
var config = AgentConfigProvider.Load(logger: logger);
var assetId = ResolveAssetId();
var hostname = Environment.MachineName;
fleetAlertClient = new FleetAlertClient(config,
logProvider.CreateLogger(nameof(FleetAlertClient)));
fleetAlertStore = new FleetAlertStore(
logProvider.CreateLogger(nameof(FleetAlertStore)));
fleetAlertWorker = new FleetAlertWorker(
fleetAlertClient,
fleetAlertStore,
hostname,
assetId,
logProvider.CreateLogger(nameof(FleetAlertWorker)),
uiContext);
fleetAlertWorker.Start();
}
catch (Exception exception)
{
logger.LogWarning(exception,
"Could not start the fleet alert worker; fleet-wide alerts will not "
+ "be displayed. Other tray features are unaffected.");
}
}
// Waits for service refresh requests for the life of the tray.
@@ -642,6 +682,36 @@ internal sealed class TrayContext : ApplicationContext
ExitThread();
}
// Resolves this machine's asset ID from the telemetry state file written by
// the service. Used to identify which asset acknowledged a fleet alert. If
// the state file is missing or unreadable, returns 0 as a signal that fleet
// alerts should be disabled (the asset is not enrolled).
private int ResolveAssetId()
{
try
{
var stateFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"ShopDB", "state.json");
if (!File.Exists(stateFile))
return 0;
var json = File.ReadAllText(stateFile);
var state = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json);
if (state is not null && state.TryGetValue("assetId", out var value))
{
if (value is System.Text.Json.JsonElement element && element.ValueKind == System.Text.Json.JsonValueKind.Number)
return element.GetInt32();
}
}
catch (Exception exception)
{
logger.LogDebug(exception, "Could not resolve asset ID from state file.");
}
return 0;
}
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -655,6 +725,9 @@ internal sealed class TrayContext : ApplicationContext
refreshCancellation.Cancel();
refreshCancellation.Dispose();
fleetAlertWorker?.Dispose();
fleetAlertClient?.Dispose();
trayIcon.Dispose();
brandIcon.Dispose();
}

View File

@@ -186,23 +186,32 @@
member of that group, so Start-ScheduledTask returned access denied and
the task only ever ran on its own trigger. Requires tray 1.4.0 - the tray
is the side that listens, so an older tray simply never answers. -->
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
command was judged solely on PowerShell's exit code, and PowerShell
treats most errors as non-terminating: it writes the error, runs on, and
still exits zero. An account-provisioning script therefore reported
succeeded having provisioned nothing, and because no output was captured
there was no evidence to contradict it. The claim now carries errormode
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
AFTER digest verification so the hash still validates delivered bytes.
Default stays permissive so bodies authored against the old semantics
keep working. stdout/stderr is captured on both paths - redirected
streams for SYSTEM, a transcript file under the ShopFloor profile for
user-session runs, since CreateProcessAsUser cannot inherit our pipes
across the session boundary - always logged locally and returned to the
server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. No tray change - tray
stays 1.4.0. -->
<Version>1.14.0</Version>
<!-- 1.13.1: coordinated release with tray 1.6.0 (fleet alert styling update).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.2: coordinated release with tray 1.6.3 (auto-close when inactive, crash fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.3: coordinated release with tray 1.6.4 (deadlock fix).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.13.4: coordinated release with tray 1.6.5 (resource leak fixes).
No agent code changes, but deployed together for version consistency. -->
<!-- 1.14.0: truthful script verdicts and script output capture. A run_script
command was judged solely on PowerShell's exit code, and PowerShell
treats most errors as non-terminating: it writes the error, runs on, and
still exits zero. An account-provisioning script therefore reported
succeeded having provisioned nothing, and because no output was captured
there was no evidence to contradict it. The claim now carries errormode
(AGENT-CONTRACT.md 7.4); strict prepends $ErrorActionPreference='Stop'
AFTER digest verification so the hash still validates delivered bytes.
Default stays permissive so bodies authored against the old semantics
keep working. stdout/stderr is captured on both paths - redirected
streams for SYSTEM, a transcript file under the ShopFloor profile for
user-session runs, since CreateProcessAsUser cannot inherit our pipes
across the session boundary - always logged locally and returned to the
server only on failure. Minor because the claim gains a field and an
older agent ignoring it keeps today's behaviour. Supersedes the 1.13.1
to 1.13.4 tray-coordination bumps merged from wj, which carried no agent
code changes. Tray is at 1.6.5 from wj. -->
<Version>1.14.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>