mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65842b87bf | |||
| b323c52364 | |||
| 321a6efc81 | |||
| 5ecb2a998b | |||
| bb6df93b51 | |||
| 096a049deb | |||
| b9caf524c3 | |||
| 197de1d718 | |||
| 18d4f08f2b | |||
| ddf450d87d | |||
| e3b2113365 | |||
| 6a85f45604 | |||
| a2d1aedcad | |||
| e955faa97e | |||
| 2b755bf99a | |||
| ce1162a055 | |||
| 447786b447 | |||
| 2b4dbe087a | |||
| 6804bcfd4e | |||
| d2221eb0b9 | |||
| 9ac6de7c7d | |||
| f5613be495 | |||
| 819b6c8e4f | |||
| 9c1fb07f33 |
+1
-1
Submodule .claude/commands updated: b390397891...cb96a972a0
+1
-1
Submodule internal updated: 6c79026c33...aa57d83ea0
+16
@@ -646,6 +646,22 @@
|
||||
"description": "Detects WinGet app installation progress with Operation Phase = Installing (format variant 2).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "PS-AGENT-ARG",
|
||||
"category": "always",
|
||||
"pattern": "^Adding argument (?<kind>[A-Za-z0-9]+) with value ",
|
||||
"action": "agentInvocationArgument",
|
||||
"description": "The first argument line of every AgentExecutor.exe invocation (powershell, remediationScript, detectionScript, requirementScript, ...). Opens the invocation that owns the policyId-less exit/output lines following it in AgentExecutor.log; PS-AGENT-SCRIPT-START upgrades a powershell invocation to its platform script. Also keeps the tracker's overwrite check active while the invocation runs.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "PS-AGENT-COMPLETED",
|
||||
"category": "always",
|
||||
"pattern": "^Agent executor completed\\.",
|
||||
"action": "agentExecutorCompleted",
|
||||
"description": "Last line of an AgentExecutor.exe invocation. Closes the invocation's ownership of the exit/output lines in AgentExecutor.log and triggers the tracker's overwrite check: the executor wrote its end block at its own stream position, which may lie behind the tracker's bookmark when another process appended meanwhile.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "PS-AGENT-ERROR",
|
||||
"category": "always",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "PS-AGENT-ARG",
|
||||
"category": "always",
|
||||
"pattern": "^Adding argument (?<kind>[A-Za-z0-9]+) with value ",
|
||||
"action": "agentInvocationArgument",
|
||||
"description": "The first argument line of every AgentExecutor.exe invocation (powershell, remediationScript, detectionScript, requirementScript, ...). Opens the invocation that owns the policyId-less exit/output lines following it in AgentExecutor.log; PS-AGENT-SCRIPT-START upgrades a powershell invocation to its platform script. Also keeps the tracker's overwrite check active while the invocation runs.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "PS-AGENT-COMPLETED",
|
||||
"category": "always",
|
||||
"pattern": "^Agent executor completed\\.",
|
||||
"action": "agentExecutorCompleted",
|
||||
"description": "Last line of an AgentExecutor.exe invocation. Closes the invocation's ownership of the exit/output lines in AgentExecutor.log and triggers the tracker's overwrite check: the executor wrote its end block at its own stream position, which may lie behind the tracker's bookmark when another process appended meanwhile.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -71,6 +71,8 @@
|
||||
"scriptOutput",
|
||||
"scriptCompleted",
|
||||
"resetPlatformScriptContext",
|
||||
"agentInvocationArgument",
|
||||
"agentExecutorCompleted",
|
||||
"healthScriptResult",
|
||||
"healthScriptDetectionResult",
|
||||
"captureExitCode",
|
||||
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 46749560: IME's trace listener writes every log through one FileMode.Append
|
||||
/// stream per process, so a process that resumes logging after another one appended
|
||||
/// overwrites those bytes at its own stale position. The tracker had read the first version
|
||||
/// and never saw the second — 6 of 16 script results and 4 of 16 executor end blocks were
|
||||
/// lost while the final files looked intact. These tests drive that exact write pattern
|
||||
/// (positional overwrite behind the bookmark) against the two multi-writer logs and pin the
|
||||
/// contract: unchanged bytes never rewind, changed bytes are processed exactly once, and the
|
||||
/// executor lines a rewind uncovers belong to the invocation that owns their file position.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerOverwriteRewindTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static List<ImeLogPattern> MarkerPatterns() => new List<ImeLogPattern>
|
||||
{
|
||||
new ImeLogPattern
|
||||
{
|
||||
PatternId = "T-MARK", Category = "always", Enabled = true,
|
||||
Pattern = @"^marker (?<n>\d+)", Action = "noop",
|
||||
Parameters = new Dictionary<string, string>(),
|
||||
},
|
||||
};
|
||||
|
||||
private static readonly string[] ScriptPatternIds =
|
||||
{
|
||||
"PS-AGENT-INVOCATION", "PS-AGENT-ARG", "PS-AGENT-SCRIPT-START", "PS-AGENT-EXITCODE",
|
||||
"PS-AGENT-OUTPUT", "PS-AGENT-COMPLETED", "PS-SCRIPT-GENERATED", "PS-SCRIPT-CONTEXT", "PS-SCRIPT-RESULT",
|
||||
};
|
||||
|
||||
/// <summary>The shipped pattern JSON — the same source combine.js embeds — so the contract is the real one.</summary>
|
||||
private static List<ImeLogPattern> ScriptPatterns()
|
||||
{
|
||||
DirectoryInfo? dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
string? patternDir = null;
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "rules", "ime-log-patterns");
|
||||
if (Directory.Exists(candidate)) { patternDir = candidate; break; }
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(patternDir);
|
||||
|
||||
var byId = new Dictionary<string, ImeLogPattern>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var file in Directory.GetFiles(patternDir, "*.json"))
|
||||
{
|
||||
var pattern = JsonConvert.DeserializeObject<ImeLogPattern>(File.ReadAllText(file));
|
||||
if (pattern?.PatternId != null) byId[pattern.PatternId] = pattern;
|
||||
}
|
||||
return ScriptPatternIds.Select(id =>
|
||||
{
|
||||
Assert.True(byId.TryGetValue(id, out var p), $"Shipped IME pattern '{id}' not found under {patternDir}.");
|
||||
return p!;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string Entry(string message, string thread = "1")
|
||||
=> $"<![LOG[{message}]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"X\" context=\"\" type=\"1\" thread=\"{thread}\" file=\"\">\r\n";
|
||||
|
||||
private const string PlatformId = "d39eeef3-e88e-41a0-82d7-5e28ee7b5acf";
|
||||
private const string PlatformStartLine =
|
||||
@"Adding argument powershell with value C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\73d664e4-0886-4a73-b745-c694da45ddb4_d39eeef3-e88e-41a0-82d7-5e28ee7b5acf.ps1 to the named argument list.";
|
||||
private const string DetectionStartLine =
|
||||
@"Adding argument detectionScript with value C:\Windows\IMECache\DetectionScripts\App_cc46aa62-e93a-41a1-bdd0-44f57e92a66d.ps1 to the named argument list.";
|
||||
private const string GeneratedLine =
|
||||
@"Script file C:\Program Files (x86)\Microsoft Intune Management Extension\Policies\Scripts\73d664e4-0886-4a73-b745-c694da45ddb4_d39eeef3-e88e-41a0-82d7-5e28ee7b5acf.ps1 is generated.";
|
||||
private const string ResultLine =
|
||||
"[PowerShell] User Id = 73d664e4-0886-4a73-b745-c694da45ddb4, Policy id = " + PlatformId + ", policy result = Success";
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly string _matchLog;
|
||||
private readonly List<ImeLogPattern> _patterns;
|
||||
public ImeLogTracker Tracker { get; private set; }
|
||||
public DateTime Now { get; set; } = T0;
|
||||
public string LogName { get; }
|
||||
public string LogPath => Path.Combine(_tmp.Path, LogName);
|
||||
public string StateDir { get; }
|
||||
public List<ScriptExecutionState> Completed { get; } = new List<ScriptExecutionState>();
|
||||
|
||||
public Harness(string logName, List<ImeLogPattern> patterns)
|
||||
{
|
||||
LogName = logName;
|
||||
_patterns = patterns;
|
||||
var logDir = Path.Combine(_tmp.Path, "agent");
|
||||
Directory.CreateDirectory(logDir);
|
||||
_matchLog = Path.Combine(logDir, "ime-pattern-matches.log");
|
||||
StateDir = Path.Combine(_tmp.Path, "state");
|
||||
Tracker = Build();
|
||||
}
|
||||
|
||||
private ImeLogTracker Build()
|
||||
{
|
||||
var t = new ImeLogTracker(_tmp.Path, _patterns, new AgentLogger(Path.Combine(_tmp.Path, "agent"), AgentLogLevel.Debug),
|
||||
matchLogPath: _matchLog, stateDirectory: StateDir);
|
||||
t.UtcNowProvider = () => Now;
|
||||
t.OnScriptCompleted = s => Completed.Add(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>Agent restart: dispose, rebuild over the same state directory, restore the bookmark.</summary>
|
||||
public void Restart()
|
||||
{
|
||||
Tracker.SaveStateForTest();
|
||||
Tracker.Dispose();
|
||||
Tracker = Build();
|
||||
Tracker.LoadStateForTest();
|
||||
}
|
||||
|
||||
public void Append(string text) => File.AppendAllText(LogPath, text, new UTF8Encoding(false));
|
||||
|
||||
/// <summary>What IME's stale writer does: write at its own position, over whatever is there.</summary>
|
||||
public void Overwrite(long offset, string text)
|
||||
{
|
||||
using (var fs = new FileStream(LogPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite))
|
||||
{
|
||||
fs.Seek(offset, SeekOrigin.Begin);
|
||||
var bytes = new UTF8Encoding(false).GetBytes(text);
|
||||
fs.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
}
|
||||
|
||||
public long Length => new FileInfo(LogPath).Length;
|
||||
|
||||
public Task Pass(CancellationToken token = default) => Tracker.CheckLogFilesAsync(token);
|
||||
|
||||
/// <summary>Marker numbers matched so far, in match order.</summary>
|
||||
public List<int> Matched()
|
||||
{
|
||||
if (!File.Exists(_matchLog)) return new List<int>();
|
||||
return File.ReadAllLines(_matchLog)
|
||||
.Select(l => Regex.Match(l, @"\[T-MARK\] <!\[LOG\[marker (\d+)"))
|
||||
.Where(m => m.Success).Select(m => int.Parse(m.Groups[1].Value)).ToList();
|
||||
}
|
||||
|
||||
public ImeTrackerHealth Health => Tracker.GetHealthSnapshot();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Marker-level contract on AgentExecutor.log
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_bytes_never_rewind_however_often_they_are_verified()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + "<![LOG[marker 2\nsecond line\n]LOG]!><time=\"12:00:00.0000000\" date=\"9-10-2026\" component=\"X\" context=\"\" type=\"1\" thread=\"1\" file=\"\">\r\n" + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
Assert.Equal(0, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(5, h.Health.VerifyPasses);
|
||||
Assert.Equal(3, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_multi_writer_files_keep_no_ledger_and_are_never_verified()
|
||||
{
|
||||
using var h = new Harness("AppWorkload.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, h.Matched());
|
||||
Assert.Equal(0, h.Tracker.LedgerEntryCountForTest("AppWorkload.log"));
|
||||
Assert.Equal(0, h.Health.VerifyPasses);
|
||||
Assert.Equal(0, h.Health.VerifiedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_written_over_read_bytes_is_processed_once_and_the_surviving_tail_is_not_repeated()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
var one = Entry("marker 1");
|
||||
h.Append(one + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
|
||||
// A stale writer lays two entries over the first two (same length: marker 3 survives
|
||||
// intact at its old offset). No growth, no fragment — only the end signal finds it.
|
||||
h.Overwrite(0, Entry("marker 7") + Entry("marker 8"));
|
||||
Assert.Equal(3 * one.Length, h.Length);
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(3 * one.Length, h.Health.OverwriteBytesReprocessed);
|
||||
Assert.Equal(3, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
|
||||
// Nothing left to find.
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Block_that_cuts_an_old_entry_leaves_a_fragment_that_is_never_matched()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
// Longer than two old entries, shorter than three: the tail of marker 3's line survives
|
||||
// as a fragment behind the new block.
|
||||
h.Overwrite(0, Entry("marker 7") + Entry("marker 8 is a little longer"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guard_zone_catches_a_short_block_when_the_file_grows_without_any_signal()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
// Same-length replacement of marker 1 (no fragment), then an ordinary append by
|
||||
// another writer: the growth pass re-checks the last 4 KB and finds the change.
|
||||
h.Overwrite(0, Entry("marker 7"));
|
||||
h.Append(Entry("marker 4"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 4 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(1, h.Health.VerifyPasses); // the full check that follows a guard hit
|
||||
Assert.True(h.Health.VerifiedBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Guard_hit_rewinds_to_the_true_start_of_the_block_not_the_guard_zone()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
// ~6 KB of entries so the guard zone covers only the last few.
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 1; i <= 60; i++) sb.Append(Entry($"marker {i}"));
|
||||
h.Append(sb.ToString());
|
||||
await h.Pass();
|
||||
Assert.Equal(60, h.Matched().Count);
|
||||
|
||||
// A block over everything plus one more entry: the guard sees a change at its
|
||||
// zone start, the full check must place the rewind at offset 0.
|
||||
var block = new StringBuilder();
|
||||
for (var i = 101; i <= 161; i++) block.Append(Entry($"marker {i}"));
|
||||
h.Overwrite(0, block.ToString());
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
var matched = h.Matched();
|
||||
Assert.Equal(60 + 61, matched.Count);
|
||||
Assert.Equal(Enumerable.Range(101, 61), matched.Skip(60));
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fragment_at_the_bookmark_triggers_the_check_when_no_ledger_entry_starts_in_the_guard_zone()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
var one = Entry("marker 1");
|
||||
var big = Entry("marker 2 " + new string('x', 6000));
|
||||
h.Append(one + big);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2 }, h.Matched());
|
||||
|
||||
// The 6 KB entry is the last ledger entry and starts before the 4 KB guard zone, so
|
||||
// the guard has nothing to check. The stale writer's block is longer than that entry:
|
||||
// the pass after it starts mid-line — a fragment — and must not match it raw.
|
||||
h.Overwrite(one.Length, Entry("marker 7 " + new string('y', 3000)) + Entry("marker 8 " + new string('z', 3100)));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2 }, h.Matched());
|
||||
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 7, 8 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Hidden_block_is_found_by_the_cadence_while_a_script_is_in_flight_and_the_cadence_is_throttled()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
// A platform script is pending → contention. First pass in contention verifies at once.
|
||||
h.Tracker.SeedPendingPlatformScriptForTesting("policyX", exitCode: null, exitObservedAtUtc: null, startedAtUtc: T0);
|
||||
h.Now = T0.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.VerifyPasses);
|
||||
|
||||
// Same-length replacement, no growth, no signal: only the cadence can find it —
|
||||
// and not before a second has passed since the last verification.
|
||||
h.Overwrite(0, Entry("marker 7"));
|
||||
h.Now = T0.AddMilliseconds(500);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.VerifyPasses);
|
||||
|
||||
h.Now = T0.AddMilliseconds(1200);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7 }, h.Matched());
|
||||
Assert.Equal(2, h.Health.VerifyPasses);
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_contention_or_signal_nothing_is_verified()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2"));
|
||||
await h.Pass();
|
||||
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
h.Now = h.Now.AddSeconds(1);
|
||||
await h.Pass();
|
||||
}
|
||||
|
||||
Assert.Equal(0, h.Health.VerifyPasses);
|
||||
Assert.Equal(0, h.Health.VerifiedBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Restart_seeds_the_ledger_so_an_overwrite_after_the_restart_is_still_found()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
h.Restart();
|
||||
Assert.Equal(0, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
|
||||
// First pass after the restart: nothing new to read, but the ledger is seeded from
|
||||
// the bytes behind the persisted bookmark.
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(3, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
|
||||
h.Overwrite(0, Entry("marker 7"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3, 7 }, h.Matched());
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// The counter is part of the persisted health.
|
||||
h.Restart();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rollover_clears_the_ledger_and_the_new_file_is_read_without_a_false_rewind()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
|
||||
File.WriteAllText(h.LogPath, Entry("marker 7") + Entry("marker 8"), new UTF8Encoding(false));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddSeconds(2);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
Assert.Equal(0, h.Health.OverwriteRewinds);
|
||||
Assert.Equal(2, h.Tracker.LedgerEntryCountForTest("AgentExecutor.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelled_pass_does_not_lose_a_pending_check()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", MarkerPatterns());
|
||||
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
|
||||
await h.Pass();
|
||||
h.Overwrite(0, Entry("marker 7") + Entry("marker 8"));
|
||||
h.Tracker.RequestOverwriteCheckForTest();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
|
||||
using (var cts = new CancellationTokenSource())
|
||||
{
|
||||
cts.Cancel();
|
||||
await h.Pass(cts.Token);
|
||||
}
|
||||
|
||||
// The cancelled pass touched nothing after the rewind; the next one reads it all.
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(new[] { 1, 2, 3, 7, 8 }, h.Matched());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The field scenario with the shipped patterns
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Executor_end_block_written_over_a_detection_invocation_belongs_to_its_own_script()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
|
||||
// Executor E starts its platform script and waits for PowerShell.
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 12708"));
|
||||
await h.Pass();
|
||||
var executorPosition = h.Length;
|
||||
|
||||
// Detection executor D appends its whole run while E waits — the tracker reads it.
|
||||
var detectionBlock = Entry("ExecutorLog AgentExecutor gets invoked") + Entry(DetectionStartLine)
|
||||
+ Entry("Powershell exit code is 3") + Entry("write output done. output = detected nothing, error = ")
|
||||
+ Entry("Agent executor completed.") + Entry(@"Writing result: False to resultFilePath: C:\x\App_cc46aa62.txt");
|
||||
h.Append(detectionBlock);
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
// E ends: its end block lands at its own stale position, over D's lines.
|
||||
var endBlock = Entry("Powershell exit code is 0") + Entry("write output done. output = Hello from E, error = ") + Entry("Agent executor completed.");
|
||||
Assert.True(endBlock.Length < detectionBlock.Length);
|
||||
h.Overwrite(executorPosition, endBlock);
|
||||
|
||||
// Nothing visible signalled the end; the cadence (E is pending) finds the block.
|
||||
h.Now = h.Now.AddSeconds(1.5);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// IME's result line (in the other file) completes the script with E's own data.
|
||||
h.Tracker.ProcessLogMessageForTest(ResultLine);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal("Hello from E", script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Detection_exit_lines_read_before_the_overwrite_never_reach_the_platform_script()
|
||||
{
|
||||
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 1"));
|
||||
await h.Pass();
|
||||
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(DetectionStartLine)
|
||||
+ Entry("Powershell exit code is 3") + Entry("write output done. output = detected nothing, error = ") + Entry("Agent executor completed."));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
|
||||
// The platform result arrives without any end block ever becoming visible: held for
|
||||
// the grace, then emitted — the detection's exit code 3 must not stand in.
|
||||
h.Tracker.ProcessLogMessageForTest(ResultLine);
|
||||
Assert.Empty(h.Completed);
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Null(script.ExitCode);
|
||||
Assert.Null(script.Stdout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ime_result_line_hidden_by_an_executor_start_line_is_recovered_from_the_ime_log()
|
||||
{
|
||||
using var h = new Harness("IntuneManagementExtension.log", ScriptPatterns());
|
||||
h.Append(Entry(GeneratedLine, "17") + Entry("Launch powershell executor in user session", "17") + Entry("process id = 10608", "17"));
|
||||
await h.Pass();
|
||||
var servicePosition = h.Length;
|
||||
|
||||
// The executor process appends its telemetry start line at the true end of file.
|
||||
h.Append(Entry("[Telemetry] Telemetry is enabled. Starting mananger...", "1"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// The service's first write after the script wait lands at ITS position — over the
|
||||
// executor's line; the block is longer, so the pass sees a fragment at the bookmark.
|
||||
h.Overwrite(servicePosition, Entry("Execution is done, collecting result", "17") + Entry(ResultLine, "17") + Entry("[PowerShell] Success, Result details length: 315", "17"));
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
h.Now = h.Now.AddMilliseconds(100);
|
||||
await h.Pass();
|
||||
Assert.Equal(1, h.Health.OverwriteRewinds);
|
||||
|
||||
// Recovered on the IME side alone (this harness has no AgentExecutor.log): held for
|
||||
// the end block, emitted without an exit code after the grace.
|
||||
Assert.Empty(h.Completed);
|
||||
h.Now = h.Now.Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Tracker.FlushPendingPlatformScriptResults(h.Now);
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
Assert.Equal("User", script.RunContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -111,9 +111,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.ProcessLogMessageForTest("Powershell exit code is 0");
|
||||
tracker.ProcessLogMessageForTest("write output done. output = [Compliant] No Classic Teams found, error = ");
|
||||
|
||||
// Authoritative IME result for the PLATFORM script (keyed by policyId).
|
||||
// Authoritative IME result for the PLATFORM script (keyed by policyId). Its own end
|
||||
// block never comes here, so the result is held until the shutdown flush — which must
|
||||
// not pick up the remediation's exit/output either.
|
||||
tracker.ProcessLogMessageForTest(
|
||||
$"[PowerShell] User Id = 00000000-0000-0000-0000-000000000000, Policy id = {PlatformId}, policy result = Failed");
|
||||
Assert.Empty(emitted);
|
||||
tracker.FlushPendingPlatformScriptResults(System.DateTime.UtcNow, force: true);
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal(PlatformId, script.PolicyId);
|
||||
|
||||
+11
-11
@@ -39,7 +39,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.SeedPendingPlatformScriptForTesting("dece354a", exitCode: 0, exitObservedAtUtc: observedAt, stdout: "Script start.\nScript end.");
|
||||
|
||||
// Well past the grace window — should emit.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal("dece354a", script.PolicyId);
|
||||
@@ -60,11 +60,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.SeedPendingPlatformScriptForTesting("60b43a2e", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
|
||||
// Inside the grace window — IME might still log its result; do not emit yet.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(5));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(5));
|
||||
Assert.Empty(emitted);
|
||||
|
||||
// Past the grace window — emit.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.SeedPendingPlatformScriptForTesting("846cea22", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
|
||||
// Shutdown flush right after exit — grace not elapsed, but force emits anyway.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("bad5c0de", exitCode: 1, exitObservedAtUtc: observedAt);
|
||||
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal(1, script.ExitCode);
|
||||
@@ -109,7 +109,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
// Started but no exit code yet → still running, must not be emitted even on force flush.
|
||||
tracker.SeedPendingPlatformScriptForTesting("running", exitCode: null, exitObservedAtUtc: null);
|
||||
|
||||
tracker.FlushStalePlatformScriptResults(DateTime.UtcNow, force: true);
|
||||
tracker.FlushPendingPlatformScriptResults(DateTime.UtcNow, force: true);
|
||||
|
||||
Assert.Empty(emitted);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var observedAt = new DateTime(2026, 6, 19, 12, 59, 4, DateTimeKind.Utc);
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("dece354a", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
|
||||
// IME finally logs its PS-SCRIPT-RESULT line — must NOT produce a second event.
|
||||
@@ -138,7 +138,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var observedAt = new DateTime(2026, 6, 19, 12, 59, 4, DateTimeKind.Utc);
|
||||
|
||||
tracker.SeedPendingPlatformScriptForTesting("dece354a", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
// The exit-observed timestamp must travel on the emitted state so the adapter can bind
|
||||
// the event to it instead of an unrelated "last matched" line parsed during the grace.
|
||||
@@ -156,14 +156,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
// Run 1 of policy X — fallback emits.
|
||||
tracker.SeedPendingPlatformScriptForTesting("policyX", exitCode: 0, exitObservedAtUtc: firstRun);
|
||||
tracker.FlushStalePlatformScriptResults(firstRun.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(firstRun.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
|
||||
// Run 2 of the SAME policy later in the same agent lifetime (IME re-evaluation / retry)
|
||||
// must NOT be deduped away — the fresh start clears the prior emitted-marker.
|
||||
var secondRun = firstRun.AddMinutes(30);
|
||||
tracker.SeedPendingPlatformScriptForTesting("policyX", exitCode: 0, exitObservedAtUtc: secondRun);
|
||||
tracker.FlushStalePlatformScriptResults(secondRun.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(secondRun.AddSeconds(20));
|
||||
Assert.Equal(2, emitted.Count);
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
|
||||
// Later fallback pass must find nothing pending → no duplicate.
|
||||
tracker.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
}
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// The IME result waits for its executor end block (session e7f3c910): PS-SCRIPT-RESULT can
|
||||
/// reach the tracker before the AgentExecutor.log end block of the same run — an overwritten
|
||||
/// block is recovered one pass after the result that triggers the ledger check, the
|
||||
/// AgentExecutor.log read of a pass may precede the exit line by milliseconds, or the poll
|
||||
/// loop stalled. Emitting at once lost the exit code and, when the late start line reopened
|
||||
/// the slot, produced a fallback duplicate 15 s later.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerResultHoldTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 9, 10, 22, 13, 35, DateTimeKind.Utc);
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
public ImeLogTracker Tracker { get; private set; }
|
||||
public DateTime Now { get; set; } = T0;
|
||||
public List<ScriptExecutionState> Completed { get; } = new List<ScriptExecutionState>();
|
||||
public List<ScriptStartedInfo> Started { get; } = new List<ScriptStartedInfo>();
|
||||
|
||||
public Harness() { Tracker = Build(); }
|
||||
|
||||
private ImeLogTracker Build()
|
||||
{
|
||||
var t = new ImeLogTracker(_tmp.Path, new List<ImeLogPattern>(), new AgentLogger(_tmp.Path, AgentLogLevel.Info), stateDirectory: _tmp.Path);
|
||||
t.UtcNowProvider = () => Now;
|
||||
t.OnScriptCompleted = s => Completed.Add(s);
|
||||
t.OnScriptStarted = s => Started.Add(s);
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>Agent restart over the same state directory.</summary>
|
||||
public void Restart()
|
||||
{
|
||||
Tracker.SaveStateForTest();
|
||||
Tracker.Dispose();
|
||||
Tracker = Build();
|
||||
Tracker.LoadStateForTest();
|
||||
}
|
||||
|
||||
/// <summary>A start line (IME "Script file … is generated" or the executor's argument line) with its source timestamp.</summary>
|
||||
public void Start(string id, DateTime lineTs)
|
||||
{
|
||||
Tracker.LastMatchedLogTimestamp = lineTs;
|
||||
Tracker.NextTestEntry();
|
||||
Tracker.HandlePlatformScriptStarted(id);
|
||||
}
|
||||
|
||||
public void Result(string id, DateTime lineTs, string result = "Success")
|
||||
=> Tracker.CompletePlatformScriptFromImeResultForTesting(id, result, lineTs);
|
||||
|
||||
public void Exit(string id, DateTime lineTs, int exitCode = 0)
|
||||
=> Tracker.RecordPlatformScriptExitCodeForTesting(id, exitCode, lineTs);
|
||||
|
||||
/// <summary>The end-of-pass flush at the harness clock.</summary>
|
||||
public void Flush(bool force = false) => Tracker.FlushPendingPlatformScriptResults(Now, force);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Tracker.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_before_the_end_block_is_held_and_emits_once_with_the_exit_code()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("376ee51a", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("376ee51a", T0.AddSeconds(2));
|
||||
Assert.Empty(h.Completed);
|
||||
h.Now = T0.AddSeconds(2.1);
|
||||
h.Flush();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// The end block surfaces on the next pass (ledger rewind or plain read lag).
|
||||
h.Exit("376ee51a", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2.2);
|
||||
h.Flush();
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
Assert.Equal(T0.AddSeconds(2), script.ResultObservedAtUtc);
|
||||
Assert.Equal("PS-SCRIPT-RESULT", script.ResultPatternId);
|
||||
Assert.Equal(T0, script.StartedAtUtc);
|
||||
|
||||
// Nothing fires twice: later passes, a repeated result line, the 15 s fallback window.
|
||||
h.Now = T0.AddSeconds(30);
|
||||
h.Flush();
|
||||
h.Result("376ee51a", T0.AddSeconds(2));
|
||||
Assert.Single(h.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_with_the_end_block_already_read_emits_at_once()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("847da54e", T0);
|
||||
h.Exit("847da54e", T0.AddSeconds(2.3));
|
||||
h.Now = T0.AddSeconds(2.31);
|
||||
h.Result("847da54e", T0.AddSeconds(2.31));
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Null(script.ResultHeldSinceUtc);
|
||||
Assert.Equal(T0.AddSeconds(2.31), script.ResultObservedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_result_emits_without_exit_code_after_the_grace()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("784fcaf5", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("784fcaf5", T0.AddSeconds(2));
|
||||
|
||||
h.Now = T0.AddSeconds(2).Add(ImeLogTracker.PlatformScriptEndBlockGrace).AddMilliseconds(-100);
|
||||
h.Flush();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Now = T0.AddSeconds(2).Add(ImeLogTracker.PlatformScriptEndBlockGrace);
|
||||
h.Flush();
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Null(script.ExitCode);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal("ime_policy_result", script.ResultSource);
|
||||
|
||||
// An exit line surfacing after the emit is dropped — no second event, no fallback.
|
||||
h.Exit("784fcaf5", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(40);
|
||||
h.Flush();
|
||||
Assert.Single(h.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shutdown_flush_emits_a_held_result_at_once()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("a52e4f47", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("a52e4f47", T0.AddSeconds(2), "Failed");
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Flush(force: true);
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal("Failed", script.Result);
|
||||
Assert.Null(script.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Late_start_line_of_an_emitted_run_opens_no_new_run()
|
||||
{
|
||||
// Session e7f3c910, 376ee51a: the result was emitted, THEN the executor's start and
|
||||
// exit lines of the same run were read (a 2 s poll stall) — the start opened a second
|
||||
// slot and the exit code became a fallback duplicate 15 s later.
|
||||
using var h = new Harness();
|
||||
h.Start("376ee51a", T0);
|
||||
h.Exit("376ee51a", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("376ee51a", T0.AddSeconds(2));
|
||||
Assert.Single(h.Completed);
|
||||
Assert.Single(h.Started);
|
||||
|
||||
h.Start("376ee51a", T0.AddMilliseconds(300));
|
||||
Assert.Single(h.Started);
|
||||
h.Exit("376ee51a", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(30);
|
||||
h.Flush();
|
||||
Assert.Single(h.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_line_newer_than_the_emitted_result_is_the_next_run()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("338d9434", T0);
|
||||
h.Exit("338d9434", T0.AddSeconds(1));
|
||||
h.Now = T0.AddSeconds(1);
|
||||
h.Result("338d9434", T0.AddSeconds(1));
|
||||
Assert.Single(h.Completed);
|
||||
|
||||
// IME re-runs the policy after the user signed in.
|
||||
h.Start("338d9434", T0.AddMinutes(7));
|
||||
Assert.Equal(2, h.Started.Count);
|
||||
h.Exit("338d9434", T0.AddMinutes(7).AddSeconds(3));
|
||||
h.Now = T0.AddMinutes(7).AddSeconds(3);
|
||||
h.Result("338d9434", T0.AddMinutes(7).AddSeconds(3));
|
||||
Assert.Equal(2, h.Completed.Count);
|
||||
Assert.Equal(T0.AddMinutes(7), h.Completed[1].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Newer_start_line_closes_a_held_result_and_starts_the_next_run()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("c81b8053", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("c81b8053", T0.AddSeconds(2));
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
// The end block never surfaced before the policy ran again.
|
||||
h.Start("c81b8053", T0.AddSeconds(4));
|
||||
var first = Assert.Single(h.Completed);
|
||||
Assert.Null(first.ExitCode);
|
||||
Assert.Equal(T0, first.StartedAtUtc);
|
||||
Assert.Equal(2, h.Started.Count);
|
||||
|
||||
h.Exit("c81b8053", T0.AddSeconds(6));
|
||||
h.Now = T0.AddSeconds(6);
|
||||
h.Result("c81b8053", T0.AddSeconds(6));
|
||||
Assert.Equal(2, h.Completed.Count);
|
||||
Assert.Equal(0, h.Completed[1].ExitCode);
|
||||
Assert.Equal(T0.AddSeconds(4), h.Completed[1].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Result_without_a_start_takes_the_recovered_start_line_and_end_block()
|
||||
{
|
||||
// Both the start block and the end block were hidden when the result arrived; the
|
||||
// ledger rewind the result triggers brings both back on the next pass.
|
||||
using var h = new Harness();
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("d39eeef3", T0.AddSeconds(2));
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Start("d39eeef3", T0);
|
||||
Assert.Empty(h.Started);
|
||||
h.Exit("d39eeef3", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2.2);
|
||||
h.Flush();
|
||||
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(T0, script.StartedAtUtc);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_result_survives_a_restart()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("832e27bc", T0);
|
||||
h.Now = T0.AddSeconds(2);
|
||||
h.Result("832e27bc", T0.AddSeconds(2));
|
||||
h.Restart();
|
||||
Assert.Empty(h.Completed);
|
||||
|
||||
h.Exit("832e27bc", T0.AddSeconds(1.99));
|
||||
h.Now = T0.AddSeconds(2.5);
|
||||
h.Flush();
|
||||
var script = Assert.Single(h.Completed);
|
||||
Assert.Equal(0, script.ExitCode);
|
||||
Assert.Equal(T0.AddSeconds(2), script.ResultObservedAtUtc);
|
||||
Assert.Equal("PS-SCRIPT-RESULT", script.ResultPatternId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emitted_marker_keeps_the_run_timestamp_across_a_restart()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Start("89b3cbcd", T0);
|
||||
h.Exit("89b3cbcd", T0.AddSeconds(1));
|
||||
h.Now = T0.AddSeconds(1);
|
||||
h.Result("89b3cbcd", T0.AddSeconds(1));
|
||||
h.Restart();
|
||||
|
||||
h.Start("89b3cbcd", T0.AddMilliseconds(500)); // late line of the emitted run
|
||||
Assert.Single(h.Started);
|
||||
h.Start("89b3cbcd", T0.AddMinutes(5)); // the next run
|
||||
Assert.Equal(2, h.Started.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marker_list_from_an_older_state_file_still_dedups()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var persistence = new ImeTrackerStatePersistence(tmp.Path, new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||
persistence.Save(new ImeTrackerStateData { PlatformScriptResultEmitted = new List<string> { "policyZ" } });
|
||||
|
||||
var completed = new List<ScriptExecutionState>();
|
||||
var started = new List<ScriptStartedInfo>();
|
||||
var tracker = new ImeLogTracker(tmp.Path, new List<ImeLogPattern>(), new AgentLogger(tmp.Path, AgentLogLevel.Info), stateDirectory: tmp.Path);
|
||||
tracker.OnScriptCompleted = s => completed.Add(s);
|
||||
tracker.OnScriptStarted = s => started.Add(s);
|
||||
tracker.LoadStateForTest();
|
||||
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyZ", "Success", T0);
|
||||
Assert.Empty(completed);
|
||||
|
||||
// Without a timestamp on the marker every start line is a new run, as before.
|
||||
tracker.LastMatchedLogTimestamp = T0;
|
||||
tracker.HandlePlatformScriptStarted("policyZ");
|
||||
Assert.Single(started);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -41,7 +41,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
// Run 1: exit code seen, shutdown force-flush emits the fallback completion.
|
||||
var tracker1 = BuildTracker(tmp, out var emitted1);
|
||||
tracker1.SeedPendingPlatformScriptForTesting("policyA", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker1.FlushStalePlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker1.FlushPendingPlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
Assert.Single(emitted1);
|
||||
tracker1.SaveStateForTest();
|
||||
|
||||
@@ -68,7 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
// Restarted tracker continues the grace window instead of dropping the script.
|
||||
var tracker2 = BuildTracker(tmp, out var emitted2);
|
||||
tracker2.LoadStateForTest();
|
||||
tracker2.FlushStalePlatformScriptResults(observedAt.AddSeconds(20));
|
||||
tracker2.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted2);
|
||||
Assert.Equal("policyB", script.PolicyId);
|
||||
@@ -84,7 +84,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
var tracker1 = BuildTracker(tmp, out _);
|
||||
tracker1.SeedPendingPlatformScriptForTesting("policyC", exitCode: 0, exitObservedAtUtc: observedAt);
|
||||
tracker1.FlushStalePlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker1.FlushPendingPlatformScriptResults(observedAt.AddSeconds(1), force: true);
|
||||
tracker1.SaveStateForTest();
|
||||
|
||||
// Restart, then a genuine RE-RUN of the same policy (fresh start clears the marker).
|
||||
@@ -109,8 +109,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var tracker = BuildTracker(tmp, out var emitted);
|
||||
tracker.LoadStateForTest(); // must not throw
|
||||
|
||||
// Degrades to pre-fix behavior: no markers restored, normal emission works.
|
||||
// Degrades to pre-fix behavior: no markers restored, normal emission works (the
|
||||
// result waits for its end block until the shutdown flush).
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyD", "Success");
|
||||
tracker.FlushPendingPlatformScriptResults(DateTime.UtcNow, force: true);
|
||||
Assert.Single(emitted);
|
||||
}
|
||||
|
||||
@@ -128,8 +130,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyE", "Failed", run2Start.AddMinutes(-5));
|
||||
Assert.Empty(emitted);
|
||||
|
||||
// Run 2's own result (newer than its start) still emits normally.
|
||||
// Run 2's own result (newer than its start) still emits normally — held for the end
|
||||
// block until the shutdown flush, since none is seeded here.
|
||||
tracker.CompletePlatformScriptFromImeResultForTesting("policyE", "Success", run2Start.AddMinutes(2));
|
||||
tracker.FlushPendingPlatformScriptResults(run2Start.AddMinutes(2), force: true);
|
||||
var script = Assert.Single(emitted);
|
||||
Assert.Equal("Success", script.Result);
|
||||
Assert.Equal(run2Start, script.StartedAtUtc);
|
||||
|
||||
+2
@@ -247,6 +247,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow;
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("beef7777", "Success", ClockNow);
|
||||
// No end block is seeded — the held result goes out with the shutdown flush.
|
||||
f.Tracker.FlushPendingPlatformScriptResults(f.Clock.UtcNow, force: true);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
// Without the hardening the slot kept the 7-day-old start: the duration would have
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// A held platform-script completion (IME result read before the executor end block) is
|
||||
/// emitted at the end of a later pass, when the tracker's "last matched" line is an unrelated
|
||||
/// one — the event stays bound to the result line's timestamp and pattern.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterResultHoldTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 9, 10, 22, 13, 40, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Held_completion_is_bound_to_the_result_line_not_to_the_last_matched_one()
|
||||
{
|
||||
using var f = new ImeLogTrackerAdapterFixture(ClockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startTs = ClockNow.AddSeconds(-95);
|
||||
var resultTs = ClockNow.AddSeconds(-3);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = startTs;
|
||||
f.Tracker.HandlePlatformScriptStarted("376ee51a");
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = resultTs;
|
||||
f.Tracker.LastMatchedPatternId = "PS-SCRIPT-RESULT";
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("376ee51a", "Success", resultTs);
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptCompleted));
|
||||
|
||||
// Other lines were matched before the end block was read and the pass ended.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-1);
|
||||
f.Tracker.LastMatchedPatternId = "IME-DOWNLOADING";
|
||||
f.Tracker.RecordPlatformScriptExitCodeForTesting("376ee51a", 0, resultTs.AddMilliseconds(-8));
|
||||
f.Tracker.FlushPendingPlatformScriptResults(f.Clock.UtcNow);
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal(resultTs, info.OccurredAtUtc);
|
||||
Assert.Equal("PS-SCRIPT-RESULT", info.Payload!["patternId"]);
|
||||
Assert.Equal("0", info.Payload["exitCode"]);
|
||||
Assert.Equal("ime_policy_result", info.Payload["resultSource"]);
|
||||
Assert.Equal("92.00", info.Payload["durationSeconds"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private readonly Stream _stream;
|
||||
private readonly int _maxLineBytes;
|
||||
private readonly bool _hashLines;
|
||||
private readonly byte[] _readBuffer = new byte[ReadBufferSize];
|
||||
private int _readPos;
|
||||
private int _readLen;
|
||||
@@ -41,21 +42,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
private byte[] _line = new byte[4096];
|
||||
private int _lineLen;
|
||||
private ulong _lineHash;
|
||||
|
||||
/// <param name="stream">Seekable stream positioned at the first byte to read.</param>
|
||||
/// <param name="maxLineBytes">Upper bound of bytes kept per line; the remainder is discarded.</param>
|
||||
public BoundedLineReader(Stream stream, int maxLineBytes)
|
||||
/// <param name="hashLines">Fingerprint the raw bytes of every line (<see cref="LastLineHash"/>) — the
|
||||
/// tracker's byte ledger needs it for multi-writer files only.</param>
|
||||
public BoundedLineReader(Stream stream, int maxLineBytes, bool hashLines = false)
|
||||
{
|
||||
if (stream == null) throw new ArgumentNullException(nameof(stream));
|
||||
if (maxLineBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxLineBytes));
|
||||
_stream = stream;
|
||||
_maxLineBytes = maxLineBytes;
|
||||
_hashLines = hashLines;
|
||||
_readBufferFileOffset = stream.Position;
|
||||
}
|
||||
|
||||
/// <summary>Cap in bytes applied to every line.</summary>
|
||||
public int MaxLineBytes => _maxLineBytes;
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a 64 over the bytes the most recently returned line was decoded from (BOM and
|
||||
/// line terminator excluded; the capped prefix of a truncated line). Zero unless the
|
||||
/// reader was created with hashing on.
|
||||
/// </summary>
|
||||
public ulong LastLineHash => _lineHash;
|
||||
|
||||
/// <summary>Absolute file offset of the next unread byte (exact — no decoder read-ahead).</summary>
|
||||
public long Position => _readBufferFileOffset + _readPos;
|
||||
|
||||
@@ -78,6 +90,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
LastLineTerminated = false;
|
||||
LastLineTruncated = false;
|
||||
_lineLen = 0;
|
||||
_lineHash = _hashLines ? Fnv1a64.OffsetBasis : 0UL;
|
||||
var sawAnyByte = false;
|
||||
|
||||
while (true)
|
||||
@@ -154,6 +167,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// tail (and thus any CR) discarded, so the check is skipped there.
|
||||
if (LastLineTerminated && !LastLineTruncated && len > 0 && _line[start + len - 1] == (byte)'\r')
|
||||
len--;
|
||||
if (_hashLines)
|
||||
{
|
||||
// Fingerprint exactly the bytes the string is decoded from, so a tail that was
|
||||
// first read unterminated and later gained its CRLF hashes the same.
|
||||
var h = Fnv1a64.OffsetBasis;
|
||||
var end = start + len;
|
||||
for (var i = start; i < end; i++)
|
||||
h = (h ^ _line[i]) * Fnv1a64.Prime;
|
||||
_lineHash = h;
|
||||
}
|
||||
return len == 0 ? string.Empty : Encoding.UTF8.GetString(_line, start, len);
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>FNV-1a 64-bit — the entry fingerprint used by the tracker's byte ledger.</summary>
|
||||
internal static class Fnv1a64
|
||||
{
|
||||
public const ulong OffsetBasis = 14695981039346656037UL;
|
||||
public const ulong Prime = 1099511628211UL;
|
||||
|
||||
public static ulong Fold(ulong hash, ulong value) => (hash ^ value) * Prime;
|
||||
}
|
||||
|
||||
/// <summary>One assembled CMTrace entry: its byte range in the file, the fingerprint of those bytes and the text.</summary>
|
||||
internal struct AssembledEntry
|
||||
{
|
||||
public long Offset;
|
||||
public int Length;
|
||||
public ulong Hash;
|
||||
public string Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns physical lines into CMTrace entries: a line is one entry, unless it opens
|
||||
/// <c><![LOG[</c> without its <c>]LOG]!></c> close — then the continuation lines up to the
|
||||
/// closing one belong to it. Oversized lines and capped entries are dropped together with
|
||||
/// their remaining continuation lines, so no fragment is ever matched as an entry.
|
||||
/// <para>
|
||||
/// The read loop and the ledger verification (<c>ImeLogTracker.Overwrite.cs</c>) both feed
|
||||
/// this class, which is what makes their entry boundaries — and therefore the fingerprints
|
||||
/// they compare — identical by construction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class CmTraceEntryAssembler
|
||||
{
|
||||
public enum Outcome
|
||||
{
|
||||
/// <summary>The line was absorbed into an open multiline entry.</summary>
|
||||
Buffered,
|
||||
/// <summary>An entry is complete.</summary>
|
||||
Entry,
|
||||
/// <summary>A line over the byte cap was dropped (its entry with it).</summary>
|
||||
OversizedDropped,
|
||||
/// <summary>An open multiline entry hit the line/char cap and was dropped.</summary>
|
||||
CapDropped,
|
||||
/// <summary>A continuation line of a dropped entry was skipped.</summary>
|
||||
Skipped,
|
||||
}
|
||||
|
||||
private const string LogOpen = "<![LOG[";
|
||||
private const string LogClose = "]LOG]!>";
|
||||
|
||||
private readonly int _maxLines;
|
||||
private readonly int _maxChars;
|
||||
|
||||
private StringBuilder _buffer;
|
||||
private int _lineCount;
|
||||
private long _entryStart;
|
||||
private ulong _entryHash;
|
||||
private bool _skippingDroppedEntry;
|
||||
|
||||
public CmTraceEntryAssembler(int maxMultiLineBufferLines, int maxEntryBytes)
|
||||
{
|
||||
_maxLines = maxMultiLineBufferLines;
|
||||
_maxChars = maxEntryBytes;
|
||||
}
|
||||
|
||||
/// <summary>True while a multiline entry is being assembled.</summary>
|
||||
public bool HasOpenEntry => _buffer != null;
|
||||
|
||||
/// <summary>File offset of the open multiline entry (valid while <see cref="HasOpenEntry"/>).</summary>
|
||||
public long OpenEntryStart => _entryStart;
|
||||
|
||||
/// <summary>Line count of the entry dropped by the last <see cref="Outcome.CapDropped"/>.</summary>
|
||||
public int DroppedLines { get; private set; }
|
||||
|
||||
/// <summary>Char count of the entry dropped by the last <see cref="Outcome.CapDropped"/>.</summary>
|
||||
public int DroppedChars { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Feeds one physical line. <paramref name="lineStart"/> / <paramref name="positionAfter"/>
|
||||
/// are the line's file offsets (start, next unread byte), <paramref name="lineHash"/> the
|
||||
/// reader's raw-byte fingerprint of the line.
|
||||
/// </summary>
|
||||
public Outcome Feed(string line, long lineStart, long positionAfter, bool truncated, ulong lineHash, out AssembledEntry entry)
|
||||
{
|
||||
entry = default(AssembledEntry);
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
// The capped prefix only tells whether the line opened an entry whose remaining
|
||||
// lines must be skipped rather than matched as raw text.
|
||||
if (_buffer != null)
|
||||
{
|
||||
_buffer = null;
|
||||
_lineCount = 0;
|
||||
_skippingDroppedEntry = true;
|
||||
}
|
||||
else if (line.StartsWith(LogOpen) && !line.Contains(LogClose))
|
||||
{
|
||||
_skippingDroppedEntry = true;
|
||||
}
|
||||
return Outcome.OversizedDropped;
|
||||
}
|
||||
|
||||
if (_skippingDroppedEntry)
|
||||
{
|
||||
// A line opening a new entry ends the skip and is processed itself — checked
|
||||
// BEFORE the close-tag test, because a complete single-line entry contains both.
|
||||
if (line.StartsWith(LogOpen))
|
||||
_skippingDroppedEntry = false;
|
||||
else
|
||||
{
|
||||
if (line.Contains(LogClose)) _skippingDroppedEntry = false;
|
||||
return Outcome.Skipped;
|
||||
}
|
||||
}
|
||||
|
||||
if (_buffer != null)
|
||||
{
|
||||
_buffer.Append('\n').Append(line);
|
||||
_lineCount++;
|
||||
_entryHash = Fnv1a64.Fold(_entryHash, lineHash);
|
||||
|
||||
if (line.Contains(LogClose))
|
||||
{
|
||||
entry = new AssembledEntry
|
||||
{
|
||||
Offset = _entryStart,
|
||||
Length = (int)(positionAfter - _entryStart),
|
||||
Hash = _entryHash,
|
||||
Text = _buffer.ToString(),
|
||||
};
|
||||
_buffer = null;
|
||||
_lineCount = 0;
|
||||
return Outcome.Entry;
|
||||
}
|
||||
|
||||
if (_lineCount >= _maxLines || _buffer.Length >= _maxChars)
|
||||
{
|
||||
DroppedLines = _lineCount;
|
||||
DroppedChars = _buffer.Length;
|
||||
_buffer = null;
|
||||
_lineCount = 0;
|
||||
_skippingDroppedEntry = true;
|
||||
return Outcome.CapDropped;
|
||||
}
|
||||
|
||||
return Outcome.Buffered;
|
||||
}
|
||||
|
||||
if (line.StartsWith(LogOpen) && !line.Contains(LogClose))
|
||||
{
|
||||
_buffer = new StringBuilder(line);
|
||||
_lineCount = 1;
|
||||
_entryStart = lineStart;
|
||||
_entryHash = lineHash;
|
||||
return Outcome.Buffered;
|
||||
}
|
||||
|
||||
entry = new AssembledEntry
|
||||
{
|
||||
Offset = lineStart,
|
||||
Length = (int)(positionAfter - lineStart),
|
||||
Hash = lineHash,
|
||||
Text = line,
|
||||
};
|
||||
return Outcome.Entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
+237
-177
@@ -222,20 +222,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// PowerShell script tracking handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Returns the current platform-script accumulator, or null when no platform script is active.
|
||||
// Health-script (remediation) state is no longer tracked line-by-line — the HS-NEW-RESULT
|
||||
// pattern delivers the full pre-detection / remediation / post-detection JSON in one shot
|
||||
// via HandleHealthScriptResult. The platform branch keeps its line-by-line accumulator
|
||||
// because PS-* patterns (PS-SCRIPT-CONTEXT / PS-SCRIPT-EXITCODE / PS-AGENT-OUTPUT / …) still
|
||||
// arrive across multiple log lines.
|
||||
private ScriptExecutionState GetCurrentPlatformScript()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
|
||||
_pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
|
||||
return state;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void HandleScriptStarted(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
@@ -282,27 +268,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// a regex Match).
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null)
|
||||
{
|
||||
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
|
||||
// enrollment whose matching result never appeared (script was mid-run when that
|
||||
// enrollment ended) leaves a pending slot with a days-old StartedAtUtc. When the
|
||||
// same policy genuinely re-runs now, the fresh start must begin a NEW execution —
|
||||
// otherwise the ancient start pairs with the fresh completion and the duration
|
||||
// lies. > 24 h mirrors the adapter's source-timestamp staleness clamp.
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot)
|
||||
&& pendingSlot.StartedAtUtc.HasValue
|
||||
&& currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
|
||||
// A start line older than the result this policy already emitted is the late start
|
||||
// line of THAT run: its AgentExecutor block surfaced after the IME result — the two
|
||||
// files are read in turn (AgentExecutor.log first, but a script's exit line and IME's
|
||||
// result line are 8–30 ms apart), a ledger rewind recovered the block, or the poll
|
||||
// loop stalled (session e7f3c910: the late line opened a second slot, cleared the
|
||||
// emitted-marker and turned the exit code that followed into a fallback duplicate
|
||||
// 15 s later). It owns the lines behind it in its file and opens nothing. Strictly
|
||||
// older: a start line at the result's own timestamp is the next run (source lines
|
||||
// never tie; a low-resolution clock in a test may).
|
||||
if (_platformScriptResultEmitted.TryGetValue(id, out var emittedResultTs) && currentStartTs < emittedResultTs)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: discarding stale pending platform-script slot for {id} (started {pendingSlot.StartedAtUtc:o})");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
RecordInvocationMarker(id, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
_logger.Debug($"ImeLogTracker: platform script start line for {id} (source: {source ?? "ime"}) predates its emitted result — late line of that run, not a new one");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var pendingSlot))
|
||||
{
|
||||
if (pendingSlot.StartedAtUtc.HasValue && currentStartTs - pendingSlot.StartedAtUtc.Value > TimeSpan.FromHours(24))
|
||||
{
|
||||
// Stale-slot hardening (session eaf3d8c4): a replayed start line from a previous
|
||||
// enrollment whose matching result never appeared (script was mid-run when that
|
||||
// enrollment ended) leaves a pending slot with a days-old StartedAtUtc. When the
|
||||
// same policy genuinely re-runs now, the fresh start must begin a NEW execution —
|
||||
// otherwise the ancient start pairs with the fresh completion and the duration
|
||||
// lies. > 24 h mirrors the adapter's source-timestamp staleness clamp.
|
||||
_logger.Debug($"ImeLogTracker: discarding stale pending platform-script slot for {id} (started {pendingSlot.StartedAtUtc:o})");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
}
|
||||
else if (pendingSlot.Result != null && pendingSlot.ResultObservedAtUtc.HasValue && currentStartTs >= pendingSlot.ResultObservedAtUtc.Value)
|
||||
{
|
||||
// The held run is over — a start line at or after its result is the next run.
|
||||
// Emit what the held run has; its end block did not surface in time.
|
||||
EmitPlatformScriptResult(pendingSlot, "the next run of this policy started");
|
||||
}
|
||||
else if (!pendingSlot.StartedAtUtc.HasValue)
|
||||
{
|
||||
// The slot was opened by its result while the start block was still hidden;
|
||||
// the recovered start line dates the run.
|
||||
pendingSlot.StartedAtUtc = currentStartTs;
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
if (!_pendingPlatformScripts.ContainsKey(id))
|
||||
{
|
||||
// A fresh start for this policy (no pending entry) begins a NEW execution.
|
||||
// Clear any emitted-marker from a prior run so IME re-evaluations / retries of
|
||||
// the same platform-script policy within one agent lifetime are not silently
|
||||
// deduped away. Within a single run the start always precedes exit/result, so
|
||||
// this never clears the current run's own marker.
|
||||
// deduped away. A start line of the emitted run itself never reaches this point
|
||||
// (the guard above), so this never clears the current run's own marker.
|
||||
_platformScriptResultEmitted.Remove(id);
|
||||
_pendingPlatformScripts[id] = new ScriptExecutionState
|
||||
{
|
||||
@@ -320,7 +338,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { OnScriptStarted?.Invoke(new ScriptStartedInfo { PolicyId = id, ScriptType = "platform" }); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: OnScriptStarted handler threw: {ex.Message}"); }
|
||||
}
|
||||
_lastPlatformScriptPolicyId = id;
|
||||
RecordInvocationMarker(id, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
// Started lines fire twice per script (agentexecutor + ime source) and carry no
|
||||
// outcome — the matching `platform script completed` line below carries result+exit
|
||||
// and stays on Info. Keep starts on Debug so Info reflects script outcomes only.
|
||||
@@ -329,42 +348,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// Routes line-by-line script-data handlers (context / exitCode / output) to the right
|
||||
// accumulator based on the pattern's scriptType parameter. Health-script lines feed the
|
||||
// single-slot _pendingHealthScript; platform-script lines feed _pendingPlatformScripts.
|
||||
// single-slot _pendingHealthScript (the HS-NEW-RESULT JSON delivers the full cycle in one
|
||||
// shot; the slot only enriches the early HS-COMPLIANCE signal). Platform-script lines feed
|
||||
// _pendingPlatformScripts, and the slot that owns a line is decided by file position (see
|
||||
// ImeLogTracker.Overwrite.cs), never by which script happened to start last.
|
||||
private ScriptExecutionState GetCurrentScriptForLineUpdate(Dictionary<string, string> parameters)
|
||||
=> GetCurrentScriptForLineUpdate(parameters, out _);
|
||||
|
||||
// ownerPolicyId names the platform script that owns the line by file position even when
|
||||
// its pending slot is gone (the completion was already emitted); null for health-script
|
||||
// lines and for lines no platform invocation owns.
|
||||
private ScriptExecutionState GetCurrentScriptForLineUpdate(Dictionary<string, string> parameters, out string ownerPolicyId)
|
||||
{
|
||||
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
|
||||
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
|
||||
? _pendingHealthScript
|
||||
: GetCurrentPlatformScript();
|
||||
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ownerPolicyId = null;
|
||||
return _pendingHealthScript;
|
||||
}
|
||||
return ResolvePlatformScriptForCurrentEntry(out ownerPolicyId);
|
||||
}
|
||||
|
||||
// Session 6b4993e5 fix — platform-script stdout/exit cross-contamination.
|
||||
//
|
||||
// AgentExecutor.exe hosts BOTH platform scripts (…\Policies\Scripts\<id>.ps1) and
|
||||
// proactive-remediation scripts (…\IMECache\HealthScripts\<id>\detect.ps1), and writes
|
||||
// their lines interleaved into the shared AgentExecutor.log. The policyId-less
|
||||
// PS-AGENT-EXITCODE ("Powershell exit code is N") and PS-AGENT-OUTPUT ("write output
|
||||
// done. output = …") lines are routed to whichever platform script
|
||||
// _lastPlatformScriptPolicyId points at. A remediation invocation's start
|
||||
// ("Adding argument remediationScript …") does NOT match PS-AGENT-SCRIPT-START, so it
|
||||
// never moves the pointer — its exit/output then bleed into the last-started PLATFORM
|
||||
// script's slot (observed: platform c3e0124c emitted result=Failed but with exit 0 +
|
||||
// stdout "[Compliant] No Classic Teams found" — the Teams remediation's output).
|
||||
//
|
||||
// Each AgentExecutor process logs "ExecutorLog AgentExecutor gets invoked" first, so a
|
||||
// new banner means the previous invocation's line-capture context is over. Clearing the
|
||||
// pointer here scopes line-capture to a single invocation: the immediately following
|
||||
// "Adding argument powershell …\Policies\Scripts\<id>.ps1" (PS-AGENT-SCRIPT-START)
|
||||
// re-establishes it for a platform invocation, while a remediation invocation leaves it
|
||||
// null so its exit/output are dropped (the remediation's authoritative data still
|
||||
// arrives via HS-NEW-RESULT). The platform script's final result is unaffected — it is
|
||||
// keyed by policyId via PS-SCRIPT-RESULT, not by this pointer.
|
||||
private void HandleAgentExecutorInvocationBoundary()
|
||||
// AgentExecutor.exe hosts platform scripts (…\Policies\Scripts\<id>.ps1) as well as
|
||||
// detection, requirement and proactive-remediation scripts, and its policyId-less
|
||||
// PS-AGENT-EXITCODE / PS-AGENT-OUTPUT lines belong to whichever invocation owns that
|
||||
// file position. The banner closes the previous invocation; the argument line that
|
||||
// follows opens the next one — as a platform script when PS-AGENT-SCRIPT-START matched
|
||||
// it, otherwise as an invocation whose exit/output are not captured (a remediation's
|
||||
// authoritative data arrives via HS-NEW-RESULT; session 6b4993e5 saw its output bleed
|
||||
// into a platform slot). "Agent executor completed" closes the invocation again.
|
||||
private void HandleAgentExecutorInvocation()
|
||||
{
|
||||
if (_lastPlatformScriptPolicyId == null) return;
|
||||
_logger.Debug(
|
||||
$"ImeLogTracker: AgentExecutor invocation boundary — clearing platform-script line-capture pointer (was {_lastPlatformScriptPolicyId}).");
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
RecordInvocationMarker(null, isClose: true);
|
||||
NoteInvocationActivity();
|
||||
}
|
||||
|
||||
private void HandleAgentInvocationArgument(string kind)
|
||||
{
|
||||
RecordInvocationMarker(null, isClose: false);
|
||||
NoteInvocationActivity();
|
||||
if (!string.IsNullOrEmpty(kind) && !string.Equals(kind, "powershell", StringComparison.OrdinalIgnoreCase))
|
||||
_logger.Debug($"ImeLogTracker: AgentExecutor invocation of kind '{kind}' — exit/output lines not captured as a platform script");
|
||||
}
|
||||
|
||||
private void HandleAgentExecutorCompleted()
|
||||
{
|
||||
RecordInvocationMarker(null, isClose: true);
|
||||
// The end block was written at the executor's own stream position — if that lay
|
||||
// behind the bookmark, the ledger check on the next pass finds it.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
|
||||
@@ -372,6 +404,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var context = match.Groups["context"]?.Value;
|
||||
if (string.IsNullOrEmpty(context)) return;
|
||||
|
||||
NoteInvocationActivity();
|
||||
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
if (script != null)
|
||||
@@ -386,18 +419,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
var exitCodeStr = match.Groups["exitCode"]?.Value;
|
||||
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
|
||||
|
||||
var script = GetCurrentScriptForLineUpdate(parameters);
|
||||
var script = GetCurrentScriptForLineUpdate(parameters, out var ownerPolicyId);
|
||||
if (script != null)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
// Stamp when we learned the script's exit code so the deadline-based fallback
|
||||
// (FlushStalePlatformScriptResults) can fire if IME never logs its authoritative
|
||||
// PS-SCRIPT-RESULT line in time. Prefer the source CMTrace timestamp (already
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly.
|
||||
if (string.Equals(script.ScriptType, "platform", StringComparison.OrdinalIgnoreCase))
|
||||
script.ExitObservedAtUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider());
|
||||
else if (ownerPolicyId != null)
|
||||
_logger.Debug($"ImeLogTracker: exit code {exitCode} belongs to platform script {ownerPolicyId}, whose completion was already emitted — dropped");
|
||||
// An exit code means the executor just wrote its end block and IME is about to
|
||||
// write the result — both land at a stale stream position when another process
|
||||
// appended meanwhile. Check the ledgers on the next pass.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
private void ApplyExitCode(ScriptExecutionState script, int exitCode, DateTime observedAtUtc)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
// Stamp when we learned the script's exit code so the deadline-based fallback
|
||||
// (FlushPendingPlatformScriptResults) can fire if IME never logs its authoritative
|
||||
// PS-SCRIPT-RESULT line in time. Prefer the source CMTrace timestamp (already
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly.
|
||||
if (string.Equals(script.ScriptType, "platform", StringComparison.OrdinalIgnoreCase))
|
||||
script.ExitObservedAtUtc = observedAtUtc;
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
|
||||
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
|
||||
@@ -446,22 +488,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
// Dedup: the deadline-based fallback (FlushStalePlatformScriptResults) may already have
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative IME <c>PS-SCRIPT-RESULT</c> line of a platform script. Emits at once
|
||||
/// when the executor end block (exit code, output) has been read already — the normal
|
||||
/// order of the two files — and otherwise holds the completion: the end block reaches the
|
||||
/// tracker after the result whenever a concurrent writer overwrote it (the check this
|
||||
/// result triggers recovers it one pass later), when the AgentExecutor.log read of a pass
|
||||
/// simply preceded the exit line by a few milliseconds, or when the poll loop stalled.
|
||||
/// Emitting immediately lost the exit code in all three cases and, once the late start
|
||||
/// line reopened the slot, produced a fallback duplicate (session e7f3c910).
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits the held completion.
|
||||
/// </summary>
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId)
|
||||
{
|
||||
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
|
||||
// emitted this script from its AgentExecutor exit code because IME's authoritative
|
||||
// PS-SCRIPT-RESULT line arrived late. The fallback carries the same exit code + stdout,
|
||||
// so re-emitting here would duplicate the timeline entry and inflate counts. Drop the
|
||||
// now-redundant pending entry and skip.
|
||||
if (_platformScriptResultEmitted.Contains(id))
|
||||
if (_platformScriptResultEmitted.ContainsKey(policyId))
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {id} PS-SCRIPT-RESULT arrived after fallback emit — skipping duplicate");
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} PS-SCRIPT-RESULT arrived after fallback emit — skipping duplicate");
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge with pending AgentExecutor data if available
|
||||
if (_pendingPlatformScripts.TryGetValue(id, out var script))
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
{
|
||||
// Stale-result guard: on an intra-lifetime re-run of the same policy, run 1's
|
||||
// late PS-SCRIPT-RESULT can arrive after run 2's start line already created a
|
||||
@@ -471,10 +527,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// line in the source log, so a result older than the pending slot's start
|
||||
// belongs to a previous run — drop it and keep the live slot intact.
|
||||
if (script.StartedAtUtc.HasValue
|
||||
&& LastMatchedLogTimestamp.HasValue
|
||||
&& LastMatchedLogTimestamp.Value < script.StartedAtUtc.Value)
|
||||
&& resultLineTimestampUtc.HasValue
|
||||
&& resultLineTimestampUtc.Value < script.StartedAtUtc.Value)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: platform script {id} result predates the current run's start — stale result from a previous run, skipping");
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} result predates the current run's start — stale result from a previous run, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -482,70 +538,111 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
script = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
PolicyId = policyId,
|
||||
ScriptType = "platform"
|
||||
};
|
||||
_pendingPlatformScripts[policyId] = script;
|
||||
}
|
||||
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
|
||||
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted.Add(id);
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
|
||||
script.ResultPatternId = patternId;
|
||||
_stateDirty = true;
|
||||
|
||||
if (script.ExitCode.HasValue)
|
||||
{
|
||||
EmitPlatformScriptResult(script);
|
||||
}
|
||||
else
|
||||
{
|
||||
script.ResultHeldSinceUtc = UtcNowProvider();
|
||||
_logger.Debug($"ImeLogTracker: platform script {policyId} result={result} seen before its executor end block — holding the completion up to {PlatformScriptEndBlockGrace.TotalSeconds:F0}s for the exit code");
|
||||
}
|
||||
// The result block is IME's first write after the script wait — the executor's end
|
||||
// block in the other file may have landed behind the bookmark at the same time.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one emit path for platform-script completions (IME result, held result, exit-code
|
||||
/// fallback): event, emitted-marker with the run's source timestamp, slot closed.
|
||||
/// </summary>
|
||||
private void EmitPlatformScriptResult(ScriptExecutionState script, string note = null)
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {script.PolicyId}, result={script.Result}, exit={script.ExitCode}{(note != null ? $" ({note})" : string.Empty)}");
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted[script.PolicyId] = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Grace period after a platform script's AgentExecutor exit code is observed before
|
||||
/// <see cref="FlushStalePlatformScriptResults"/> emits a completion from that exit code.
|
||||
/// IME logs its authoritative <c>PS-SCRIPT-RESULT</c> line within ~1 s of exit on healthy
|
||||
/// reporting cycles, so 15 s lets the normal path win virtually always — the fallback is a
|
||||
/// true safety net for the case where IME has not flushed its batch-send to the Microsoft
|
||||
/// service before the (often short) Autopilot enrollment ends and the agent terminates.
|
||||
/// <see cref="FlushPendingPlatformScriptResults"/> emits a completion from that exit code.
|
||||
/// IME logs its authoritative <c>PS-SCRIPT-RESULT</c> line within ~1 s of exit; when it
|
||||
/// is missing after 15 s it was hidden by a concurrent writer (the ledger check in
|
||||
/// <c>ImeLogTracker.Overwrite.cs</c> recovers that line) or the enrollment ended before
|
||||
/// IME wrote it — this fallback is the safety net for the remaining cases.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan PlatformScriptResultGrace = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Policy IDs of platform scripts already emitted (via either the authoritative
|
||||
/// PS-SCRIPT-RESULT path or the exit-code fallback). Guards against double emission when
|
||||
/// both fire for the same script.
|
||||
/// How long an IME result waits for the executor end block of its run before the
|
||||
/// completion goes out without an exit code. An overwritten end block surfaces on the
|
||||
/// pass after the result (100 ms, the result triggers the ledger check); a stalled poll
|
||||
/// loop was measured at 2 s (session e7f3c910, VM at 100 % CPU). Well below the 15 s
|
||||
/// exit-code fallback, and the event keeps the result line's timestamp — only its
|
||||
/// delivery waits.
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _platformScriptResultEmitted =
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
internal static readonly TimeSpan PlatformScriptEndBlockGrace = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Emits a <c>script_completed</c>/<c>script_failed</c> for every pending platform script
|
||||
/// that has a known AgentExecutor exit code but whose IME <c>PS-SCRIPT-RESULT</c> line has
|
||||
/// not arrived within <see cref="PlatformScriptResultGrace"/>. Without this, scripts that
|
||||
/// completed shortly before the agent terminates are silently dropped (the
|
||||
/// <c>_pendingPlatformScripts</c> buffer is neither persisted nor flushed on dispose), so a
|
||||
/// device that ran N platform scripts could surface only the long-running one that happened
|
||||
/// to get its IME result logged in time. Result is derived from the exit code (0 → Success,
|
||||
/// else Failed) and tagged <c>resultSource=agentexecutor_fallback</c> so the data is honest
|
||||
/// about its provenance.
|
||||
/// <para>
|
||||
/// Runs on the single-threaded polling loop (no locking needed, same as the handlers).
|
||||
/// <paramref name="force"/> bypasses the grace check for the final pass on shutdown.
|
||||
/// </para>
|
||||
/// Platform scripts already emitted (via the authoritative PS-SCRIPT-RESULT path or the
|
||||
/// exit-code fallback), keyed by policy id with the source timestamp of the emitted run
|
||||
/// (the result line, or the exit line for a fallback emit). Guards against double emission
|
||||
/// when both paths fire for the same script, and tells a late start line of the emitted
|
||||
/// run from the start of the next run.
|
||||
/// </summary>
|
||||
internal void FlushStalePlatformScriptResults(DateTime nowUtc, bool force = false)
|
||||
private readonly Dictionary<string, DateTime> _platformScriptResultEmitted =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// End-of-pass emit for pending platform scripts, two cases:
|
||||
/// <list type="bullet">
|
||||
/// <item>A held IME result (<see cref="ScriptExecutionState.ResultHeldSinceUtc"/>) goes
|
||||
/// out once its executor end block has been read — the block arrives within one pass, so
|
||||
/// stdout/stderr are in by now — or after <see cref="PlatformScriptEndBlockGrace"/>.</item>
|
||||
/// <item>A script with a known AgentExecutor exit code but no IME <c>PS-SCRIPT-RESULT</c>
|
||||
/// line within <see cref="PlatformScriptResultGrace"/> is emitted from the exit code
|
||||
/// (0 → Success, else Failed), tagged <c>resultSource=agentexecutor_fallback</c> so the
|
||||
/// data is honest about its provenance. Without this, scripts that completed shortly
|
||||
/// before the agent terminates were silently dropped.</item>
|
||||
/// </list>
|
||||
/// Runs on the single-threaded polling loop (no locking needed, same as the handlers).
|
||||
/// <paramref name="force"/> bypasses both grace checks for the final pass on shutdown.
|
||||
/// </summary>
|
||||
internal void FlushPendingPlatformScriptResults(DateTime nowUtc, bool force = false)
|
||||
{
|
||||
if (_pendingPlatformScripts.Count == 0) return;
|
||||
|
||||
List<string> toRemove = null;
|
||||
foreach (var kv in _pendingPlatformScripts)
|
||||
var fallbackEmitted = false;
|
||||
foreach (var script in _pendingPlatformScripts.Values.ToList())
|
||||
{
|
||||
var script = kv.Value;
|
||||
|
||||
// Already emitted by the authoritative path — just drop the stale buffer entry.
|
||||
if (_platformScriptResultEmitted.Contains(script.PolicyId))
|
||||
if (_platformScriptResultEmitted.ContainsKey(script.PolicyId))
|
||||
{
|
||||
(toRemove ??= new List<string>()).Add(kv.Key);
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (script.Result != null)
|
||||
{
|
||||
if (script.ExitCode.HasValue)
|
||||
EmitPlatformScriptResult(script, "executor end block read after the result");
|
||||
else if (force || !script.ResultHeldSinceUtc.HasValue || nowUtc - script.ResultHeldSinceUtc.Value >= PlatformScriptEndBlockGrace)
|
||||
EmitPlatformScriptResult(script, $"no executor end block within {PlatformScriptEndBlockGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -560,30 +657,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
script.Result = script.ExitCode.Value == 0 ? "Success" : "Failed";
|
||||
script.ResultSource = "agentexecutor_fallback";
|
||||
|
||||
_logger.Info(
|
||||
$"ImeLogTracker: platform script {script.PolicyId} result not seen within " +
|
||||
$"{PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? " (shutdown flush)" : "")} — " +
|
||||
$"emitting from AgentExecutor exit code {script.ExitCode.Value} (fallback)");
|
||||
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted.Add(script.PolicyId);
|
||||
(toRemove ??= new List<string>()).Add(kv.Key);
|
||||
EmitPlatformScriptResult(script,
|
||||
$"result not seen within {PlatformScriptResultGrace.TotalSeconds:F0}s{(force ? ", shutdown flush" : string.Empty)} — emitted from AgentExecutor exit code {script.ExitCode.Value} (fallback)");
|
||||
fallbackEmitted = true;
|
||||
}
|
||||
|
||||
if (toRemove != null)
|
||||
{
|
||||
// The flush can fire on a quiet polling cycle (grace expiry with no new log
|
||||
// lines), where nothing else marks state dirty — the emitted-markers must reach
|
||||
// the state file before a restart or the restarted tracker re-emits (H1).
|
||||
_stateDirty = true;
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_pendingPlatformScripts.Remove(key);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, key, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
}
|
||||
}
|
||||
// The fallback is an end signal like the others: the result line it stood in for
|
||||
// may sit overwritten behind the bookmark of the other file.
|
||||
if (fallbackEmitted) RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
/// <summary>Test seam: inject a pending platform script as if AgentExecutor.log had reported
|
||||
@@ -602,49 +683,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
Stdout = stdout,
|
||||
StartedAtUtc = startedAtUtc,
|
||||
};
|
||||
_lastPlatformScriptPolicyId = policyId;
|
||||
NextTestEntry();
|
||||
RecordInvocationMarker(policyId, isClose: false);
|
||||
}
|
||||
|
||||
/// <summary>Test seam: simulate the authoritative IME PS-SCRIPT-RESULT path for a platform
|
||||
/// script (same code as <see cref="HandleScriptCompleted"/> minus regex extraction).
|
||||
/// <paramref name="resultLineTimestampUtc"/> stands in for the CMTrace timestamp of the
|
||||
/// PS-SCRIPT-RESULT line (production reads it via <see cref="LastMatchedLogTimestamp"/>).</summary>
|
||||
/// <summary>Test seam: the authoritative IME PS-SCRIPT-RESULT path for a platform script
|
||||
/// (the production handler minus regex extraction). <paramref name="resultLineTimestampUtc"/>
|
||||
/// stands in for the CMTrace timestamp of the PS-SCRIPT-RESULT line (production reads it
|
||||
/// via <see cref="LastMatchedLogTimestamp"/>, the fallback here).</summary>
|
||||
internal void CompletePlatformScriptFromImeResultForTesting(string policyId, string result, DateTime? resultLineTimestampUtc = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
CompletePlatformScriptFromImeResult(policyId, result, resultLineTimestampUtc ?? LastMatchedLogTimestamp, "PS-SCRIPT-RESULT");
|
||||
}
|
||||
|
||||
if (_platformScriptResultEmitted.Contains(policyId))
|
||||
{
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, policyId, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
/// <summary>Test seam: the AgentExecutor exit-code line of a pending platform script (the
|
||||
/// production handler minus regex extraction and positional ownership).</summary>
|
||||
internal void RecordPlatformScriptExitCodeForTesting(string policyId, int exitCode, DateTime observedAtUtc)
|
||||
{
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
{
|
||||
// Mirror of HandleScriptCompleted's stale-result guard: a result older than the
|
||||
// pending slot's start belongs to a previous run of the same policy.
|
||||
if (script.StartedAtUtc.HasValue
|
||||
&& resultLineTimestampUtc.HasValue
|
||||
&& resultLineTimestampUtc.Value < script.StartedAtUtc.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
script = new ScriptExecutionState { PolicyId = policyId, ScriptType = "platform" };
|
||||
}
|
||||
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
EmitScriptEvent(script);
|
||||
_platformScriptResultEmitted.Add(policyId);
|
||||
_pendingPlatformScripts.Remove(policyId);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, policyId, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
_stateDirty = true;
|
||||
ApplyExitCode(script, exitCode, observedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -794,6 +852,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
HandleHealthScriptResultJson(json);
|
||||
// A remediation executor just ended too — its end block may sit behind the bookmark.
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+254
-241
@@ -78,243 +78,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) continue;
|
||||
|
||||
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
|
||||
// against a previously observed state is what makes this pass's lines valid
|
||||
// "written now" anchors — for the per-file measurement AND per-line anchoring.
|
||||
var hadPreviousObservation = _positionTracker.HasSeen(filePath);
|
||||
var lastCheckedUtc = _positionTracker.GetLastCheckedUtc(filePath);
|
||||
var passNowUtc = UtcNowProvider();
|
||||
|
||||
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
|
||||
|
||||
// Every look counts, including "no new data" and an empty first sight: the
|
||||
// NEXT pass's freshness window is measured from here. Restored bookmarks
|
||||
// deliberately carry no LastCheckedUtc — the first pass after a restart reads
|
||||
// downtime backlog and must never count as fresh.
|
||||
_positionTracker.MarkChecked(filePath, passNowUtc);
|
||||
|
||||
if (startPos >= fileInfo.Length)
|
||||
{
|
||||
// M2: guard the interpolated string so it isn't built every 100 ms tick
|
||||
// (per file) when Trace is off — which is the production default (Info).
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: {Path.GetFileName(filePath)} — no new data (pos={startPos}, size={fileInfo.Length})");
|
||||
continue;
|
||||
}
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: reading {Path.GetFileName(filePath)} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})");
|
||||
|
||||
_currentSourceFileName = Path.GetFileName(filePath);
|
||||
_currentPassLinesAreFresh = hadPreviousObservation
|
||||
&& lastCheckedUtc > DateTime.MinValue
|
||||
&& (passNowUtc - lastCheckedUtc) <= FreshLineMaxAge;
|
||||
|
||||
// Newest bias-less line of this pass — the calibration anchor. Bias-carrying
|
||||
// lines are skipped: they already state the writer's offset, so they need no
|
||||
// measurement and must not overwrite one.
|
||||
CmTraceLogEntry calibrationAnchor = null;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
stream.Seek(startPos, SeekOrigin.Begin);
|
||||
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes);
|
||||
var stats = new PassStats();
|
||||
|
||||
// Buffer for multiline CMTrace entries (e.g. AgentExecutor.log
|
||||
// "write output done. output = ..." spans many lines)
|
||||
StringBuilder multiLineBuffer = null;
|
||||
int multiLineCount = 0;
|
||||
// Set after a capped entry is dropped: its remaining physical lines
|
||||
// are skipped instead of being matched as raw text, until the entry
|
||||
// closes or a new entry begins (so no real entry is lost).
|
||||
bool skippingDroppedEntry = false;
|
||||
// File offset where the entry currently being assembled/processed
|
||||
// begins — the bookmark to fall back to when that entry is NOT
|
||||
// consumed (cancellation, or a held-back unterminated tail).
|
||||
long entryStart = startPos;
|
||||
// Where the next pass resumes. EOF unless an entry is left unconsumed.
|
||||
long resumePos;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// CancellationToken.None: cancellation is honoured by the explicit
|
||||
// check below (exact bookmark) rather than by an exception out of
|
||||
// the read, which the poll loop would log as an error.
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None);
|
||||
if (line == null)
|
||||
{
|
||||
// EOF. An in-flight multiline entry here is an entry the writer
|
||||
// has not finished — hold it back (see HoldBackTail) instead of
|
||||
// dropping it and raw-matching its remaining lines next pass.
|
||||
if (multiLineBuffer != null && HoldBackTail(filePath, entryStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
_heldTailCount++;
|
||||
resumePos = entryStart;
|
||||
break;
|
||||
}
|
||||
resumePos = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
// Exact bookmark: nothing of this line (or of the multiline
|
||||
// entry it belongs to) has been handled.
|
||||
resumePos = multiLineBuffer != null ? entryStart : reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
if (multiLineBuffer == null)
|
||||
entryStart = reader.LastLineStart;
|
||||
|
||||
_linesRead++;
|
||||
|
||||
// A physical line over the cap is never matched: its captures would be
|
||||
// cut and the regexes would run over attacker-sized input. The capped
|
||||
// prefix is enough to tell whether it opened an entry whose remaining
|
||||
// lines must be skipped rather than raw-matched.
|
||||
if (reader.LastLineTruncated)
|
||||
{
|
||||
stats.OversizedLines++;
|
||||
_oversizedLines++;
|
||||
if (multiLineBuffer != null)
|
||||
{
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
skippingDroppedEntry = true;
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[") && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
skippingDroppedEntry = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!reader.LastLineTerminated && multiLineBuffer == null
|
||||
&& HoldBackTail(filePath, reader.LastLineStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
// Unterminated single line at EOF: the writer is mid-line.
|
||||
_heldTailCount++;
|
||||
resumePos = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
if (skippingDroppedEntry)
|
||||
{
|
||||
// A line opening a new entry ends the skip and is processed
|
||||
// itself — checked BEFORE the close-tag test, because a complete
|
||||
// single-line entry contains both and must not be consumed as
|
||||
// the dropped entry's close.
|
||||
if (line.StartsWith("<![LOG["))
|
||||
skippingDroppedEntry = false;
|
||||
else
|
||||
{
|
||||
if (line.Contains("]LOG]!>")) skippingDroppedEntry = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multiline CMTrace buffering ---
|
||||
// CMTrace entries: <![LOG[message]LOG]!><time=...>
|
||||
// When message contains newlines, the entry spans multiple lines.
|
||||
// We buffer until we find the closing ]LOG]!> tag.
|
||||
if (multiLineBuffer != null)
|
||||
{
|
||||
// Continuing a multiline entry
|
||||
multiLineBuffer.Append('\n').Append(line);
|
||||
multiLineCount++;
|
||||
|
||||
if (line.Contains("]LOG]!>"))
|
||||
{
|
||||
// Entry complete — use the assembled line
|
||||
line = multiLineBuffer.ToString();
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
}
|
||||
else if (multiLineCount >= MaxMultiLineBufferLines || multiLineBuffer.Length >= MaxEntryBytes)
|
||||
{
|
||||
// Safety limit — discard to bound memory and parser work. Warning
|
||||
// (not Debug) so a capped entry is visible in the client log at the
|
||||
// default level: a real IME entry this large would be news.
|
||||
_logger.Warning($"ImeLogTracker: discarding multiline CMTrace buffer in {Path.GetFileName(filePath)} after {multiLineCount} lines / {multiLineBuffer.Length} chars (cap {MaxMultiLineBufferLines} lines / {MaxEntryBytes} chars) — entry dropped");
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
skippingDroppedEntry = true;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still accumulating — read next line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[") && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
// Start of a multiline CMTrace entry
|
||||
multiLineBuffer = new StringBuilder(line);
|
||||
multiLineCount = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry entry;
|
||||
string messageToMatch;
|
||||
_currentEntryOffset = entryStart;
|
||||
if (CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
messageToMatch = entry.Message;
|
||||
if (entry.HasTimestamp && !entry.BiasMinutes.HasValue)
|
||||
calibrationAnchor = entry;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-CMTrace line - match raw
|
||||
messageToMatch = line;
|
||||
entry = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(messageToMatch)) continue;
|
||||
|
||||
// Simulation mode delay
|
||||
if (SimulationMode && entry != null)
|
||||
{
|
||||
await ApplySimulationDelay(ResolveEntryUtc(entry), token);
|
||||
}
|
||||
|
||||
MatchLine(filePath, line, messageToMatch, entry, stats);
|
||||
}
|
||||
|
||||
ClearHeldTailIfConsumed(filePath, resumePos);
|
||||
_positionTracker.SetPosition(filePath, resumePos);
|
||||
_stateDirty = true;
|
||||
backlogBytes += Math.Max(0, fileInfo.Length - resumePos);
|
||||
|
||||
// One line per pass and file, never per hostile line: the counters are
|
||||
// the operator's only trace that matching was skipped or cut short.
|
||||
if (stats.RegexTimeouts > 0 || stats.BudgetBreaks > 0 || stats.OversizedLines > 0)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ImeLogTracker: {Path.GetFileName(filePath)} pass skipped work — " +
|
||||
$"oversizedLines={stats.OversizedLines} (cap {MaxEntryBytes} bytes), " +
|
||||
$"regexTimeouts={stats.RegexTimeouts}, lineBudgetBreaks={stats.BudgetBreaks}" +
|
||||
(stats.FirstSkippedPatternId != null ? $", firstSkippedPattern={stats.FirstSkippedPatternId}" : string.Empty));
|
||||
RaiseTrackerDegradedOnce(Path.GetFileName(filePath), stats.FirstSkippedPatternId);
|
||||
}
|
||||
|
||||
// Calibrate AFTER the pass: this pass's lines were resolved with the
|
||||
// offset established previously, at most one poll (100 ms) old. Buffering
|
||||
// the pass to calibrate first is not an option — the first pass of
|
||||
// AppWorkload.log can be hundreds of MB. The cost is a warm-up of one
|
||||
// growing pass, during which lines fall back to the reader zone and are
|
||||
// flagged as such.
|
||||
if (hadPreviousObservation && calibrationAnchor != null)
|
||||
CalibrateFrom(_currentSourceFileName, Path.GetFileName(filePath), calibrationAnchor);
|
||||
_currentEntryOffset = -1;
|
||||
}
|
||||
backlogBytes += await ProcessFileAsync(filePath, token);
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
catch (IOException ex)
|
||||
@@ -330,6 +94,246 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One pass over one file: the bytes behind the bookmark, on a multi-writer file preceded
|
||||
/// by the overwrite check that may move the bookmark back first (see
|
||||
/// <c>ImeLogTracker.Overwrite.cs</c>). Returns the file's remaining backlog in bytes.
|
||||
/// </summary>
|
||||
private async Task<long> ProcessFileAsync(string filePath, CancellationToken token)
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) return 0;
|
||||
|
||||
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
|
||||
// against a previously observed state is what makes this pass's lines valid
|
||||
// "written now" anchors — for the per-file measurement AND per-line anchoring.
|
||||
var hadPreviousObservation = _positionTracker.HasSeen(filePath);
|
||||
var lastCheckedUtc = _positionTracker.GetLastCheckedUtc(filePath);
|
||||
var passNowUtc = UtcNowProvider();
|
||||
|
||||
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
|
||||
|
||||
// Every look counts, including "no new data" and an empty first sight: the
|
||||
// NEXT pass's freshness window is measured from here. Restored bookmarks
|
||||
// deliberately carry no LastCheckedUtc — the first pass after a restart reads
|
||||
// downtime backlog and must never count as fresh.
|
||||
_positionTracker.MarkChecked(filePath, passNowUtc);
|
||||
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
var ledger = IsMultiWriterLogFile(fileName) ? GetLedger(fileName) : null;
|
||||
|
||||
// Set by a rewind: entries that still read unchanged behind the old bookmark are
|
||||
// skipped, and nothing below the old bookmark is fresh or a calibration anchor.
|
||||
HashSet<LedgerEntry> rewoundEntries = null;
|
||||
long oldBookmark = -1;
|
||||
|
||||
if (ledger != null)
|
||||
{
|
||||
var rewindTo = await PrepareLedgerAsync(filePath, fileName, ledger, startPos, passNowUtc, token);
|
||||
if (rewindTo >= 0)
|
||||
{
|
||||
rewoundEntries = ApplyRewind(filePath, fileName, ledger, rewindTo, startPos);
|
||||
oldBookmark = startPos;
|
||||
startPos = rewindTo;
|
||||
}
|
||||
}
|
||||
|
||||
if (startPos >= fileInfo.Length)
|
||||
{
|
||||
// M2: guard the interpolated string so it isn't built every 100 ms tick
|
||||
// (per file) when Trace is off — which is the production default (Info).
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: {fileName} — no new data (pos={startPos}, size={fileInfo.Length})");
|
||||
return 0;
|
||||
}
|
||||
if (_logger.LogLevel >= AgentLogLevel.Trace)
|
||||
_logger.Trace($"ImeLogTracker: reading {fileName} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})");
|
||||
|
||||
_currentSourceFileName = fileName;
|
||||
var passLinesAreFresh = hadPreviousObservation
|
||||
&& lastCheckedUtc > DateTime.MinValue
|
||||
&& (passNowUtc - lastCheckedUtc) <= FreshLineMaxAge;
|
||||
_currentPassLinesAreFresh = passLinesAreFresh;
|
||||
|
||||
// Newest bias-less line of this pass — the calibration anchor. Bias-carrying
|
||||
// lines are skipped: they already state the writer's offset, so they need no
|
||||
// measurement and must not overwrite one.
|
||||
CmTraceLogEntry calibrationAnchor = null;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
// Guard zone: the file grew and is open anyway — re-check the entries just behind
|
||||
// the bookmark, where a stale writer's block lands when it is short.
|
||||
if (ledger != null && rewoundEntries == null && ledger.Entries.Count > 0)
|
||||
{
|
||||
var guardFrom = ledger.OffsetOfEntryAtOrAfter(startPos - OverwriteGuardBytes);
|
||||
if (guardFrom >= 0 && guardFrom < startPos)
|
||||
{
|
||||
var divergence = await FindOverwriteAsync(stream, ledger, guardFrom, startPos, token);
|
||||
_verifiedBytes += startPos - guardFrom;
|
||||
if (divergence >= 0)
|
||||
{
|
||||
// The guard saw only the tail of the ledger; the block may start earlier.
|
||||
var earliest = await FindOverwriteAsync(stream, ledger, ledger.StartOffset, startPos, token);
|
||||
_verifiedBytes += startPos - ledger.StartOffset;
|
||||
_verifyPasses++;
|
||||
if (earliest >= 0) divergence = earliest;
|
||||
rewoundEntries = ApplyRewind(filePath, fileName, ledger, divergence, startPos);
|
||||
oldBookmark = startPos;
|
||||
startPos = divergence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.Seek(startPos, SeekOrigin.Begin);
|
||||
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes, hashLines: ledger != null);
|
||||
var assembler = new CmTraceEntryAssembler(MaxMultiLineBufferLines, MaxEntryBytes);
|
||||
var stats = new PassStats();
|
||||
var firstEntry = true;
|
||||
// Where the next pass resumes. EOF unless an entry is left unconsumed.
|
||||
long resumePos;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// CancellationToken.None: cancellation is honoured by the explicit
|
||||
// check below (exact bookmark) rather than by an exception out of
|
||||
// the read, which the poll loop would log as an error.
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None);
|
||||
if (line == null)
|
||||
{
|
||||
// EOF. An in-flight multiline entry here is an entry the writer
|
||||
// has not finished — hold it back (see HoldBackTail) instead of
|
||||
// dropping it and raw-matching its remaining lines next pass.
|
||||
if (assembler.HasOpenEntry && HoldBackTail(filePath, assembler.OpenEntryStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
_heldTailCount++;
|
||||
resumePos = assembler.OpenEntryStart;
|
||||
break;
|
||||
}
|
||||
resumePos = reader.Position;
|
||||
break;
|
||||
}
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
// Exact bookmark: nothing of this line (or of the multiline
|
||||
// entry it belongs to) has been handled.
|
||||
resumePos = assembler.HasOpenEntry ? assembler.OpenEntryStart : reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
_linesRead++;
|
||||
|
||||
if (!reader.LastLineTruncated && !reader.LastLineTerminated && !assembler.HasOpenEntry
|
||||
&& HoldBackTail(filePath, reader.LastLineStart, fileInfo.Length, passNowUtc))
|
||||
{
|
||||
// Unterminated single line at EOF: the writer is mid-line.
|
||||
_heldTailCount++;
|
||||
resumePos = reader.LastLineStart;
|
||||
break;
|
||||
}
|
||||
|
||||
AssembledEntry entry;
|
||||
var outcome = assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out entry);
|
||||
if (outcome == CmTraceEntryAssembler.Outcome.OversizedDropped)
|
||||
{
|
||||
// A physical line over the cap is never matched: its captures would be
|
||||
// cut and the regexes would run over attacker-sized input.
|
||||
stats.OversizedLines++;
|
||||
_oversizedLines++;
|
||||
continue;
|
||||
}
|
||||
if (outcome == CmTraceEntryAssembler.Outcome.CapDropped)
|
||||
{
|
||||
// Warning (not Debug) so a capped entry is visible in the client log at the
|
||||
// default level: a real IME entry this large would be news.
|
||||
_logger.Warning($"ImeLogTracker: discarding multiline CMTrace buffer in {fileName} after {assembler.DroppedLines} lines / {assembler.DroppedChars} chars (cap {MaxMultiLineBufferLines} lines / {MaxEntryBytes} chars) — entry dropped");
|
||||
continue;
|
||||
}
|
||||
if (outcome != CmTraceEntryAssembler.Outcome.Entry) continue;
|
||||
|
||||
if (firstEntry)
|
||||
{
|
||||
firstEntry = false;
|
||||
// A fragment right at the bookmark of a multi-writer file (the previous
|
||||
// entry ended exactly here, this one does not open an entry) is the tail of
|
||||
// a block a stale writer laid over already-read bytes: verify before reading on.
|
||||
if (ledger != null && rewoundEntries == null && entry.Offset == ledger.EndOffset
|
||||
&& ledger.LastFragmentOffset != entry.Offset && !entry.Text.StartsWith("<![LOG["))
|
||||
{
|
||||
ledger.VerifyRequested = true;
|
||||
ledger.LastFragmentOffset = entry.Offset;
|
||||
resumePos = entry.Offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ledger != null) ledger.Record(entry);
|
||||
if (rewoundEntries != null && rewoundEntries.Contains(new LedgerEntry { Offset = entry.Offset, Hash = entry.Hash }))
|
||||
continue; // unchanged behind the old bookmark — processed before the rewind
|
||||
|
||||
_currentPassLinesAreFresh = passLinesAreFresh && entry.Offset >= oldBookmark;
|
||||
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry parsed;
|
||||
string messageToMatch;
|
||||
_currentEntryOffset = entry.Offset;
|
||||
if (CmTraceLogParser.TryParseLine(entry.Text, out parsed))
|
||||
{
|
||||
messageToMatch = parsed.Message;
|
||||
if (parsed.HasTimestamp && !parsed.BiasMinutes.HasValue && entry.Offset >= oldBookmark)
|
||||
calibrationAnchor = parsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-CMTrace line - match raw
|
||||
messageToMatch = entry.Text;
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(messageToMatch)) continue;
|
||||
|
||||
// Simulation mode delay
|
||||
if (SimulationMode && parsed != null)
|
||||
{
|
||||
await ApplySimulationDelay(ResolveEntryUtc(parsed), token);
|
||||
}
|
||||
|
||||
MatchLine(filePath, entry.Text, messageToMatch, parsed, stats);
|
||||
}
|
||||
|
||||
ClearHeldTailIfConsumed(filePath, resumePos);
|
||||
_positionTracker.SetPosition(filePath, resumePos);
|
||||
_stateDirty = true;
|
||||
if (ledger != null) ledger.TrimBelow(RetentionFloor(fileName, resumePos));
|
||||
|
||||
// One line per pass and file, never per hostile line: the counters are
|
||||
// the operator's only trace that matching was skipped or cut short.
|
||||
if (stats.RegexTimeouts > 0 || stats.BudgetBreaks > 0 || stats.OversizedLines > 0)
|
||||
{
|
||||
_logger.Warning(
|
||||
$"ImeLogTracker: {fileName} pass skipped work — " +
|
||||
$"oversizedLines={stats.OversizedLines} (cap {MaxEntryBytes} bytes), " +
|
||||
$"regexTimeouts={stats.RegexTimeouts}, lineBudgetBreaks={stats.BudgetBreaks}" +
|
||||
(stats.FirstSkippedPatternId != null ? $", firstSkippedPattern={stats.FirstSkippedPatternId}" : string.Empty));
|
||||
RaiseTrackerDegradedOnce(fileName, stats.FirstSkippedPatternId);
|
||||
}
|
||||
|
||||
// Calibrate AFTER the pass: this pass's lines were resolved with the
|
||||
// offset established previously, at most one poll (100 ms) old. Buffering
|
||||
// the pass to calibrate first is not an option — the first pass of
|
||||
// AppWorkload.log can be hundreds of MB. The cost is a warm-up of one
|
||||
// growing pass, during which lines fall back to the reader zone and are
|
||||
// flagged as such.
|
||||
if (hadPreviousObservation && calibrationAnchor != null)
|
||||
CalibrateFrom(_currentSourceFileName, fileName, calibrationAnchor);
|
||||
_currentEntryOffset = -1;
|
||||
|
||||
return Math.Max(0, fileInfo.Length - resumePos);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PassStats
|
||||
{
|
||||
public int RegexTimeouts;
|
||||
@@ -828,10 +832,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
break;
|
||||
|
||||
case "resetplatformscriptcontext":
|
||||
// Session 6b4993e5 fix: a fresh AgentExecutor invocation banner
|
||||
// ("ExecutorLog AgentExecutor gets invoked") ends the previous
|
||||
// invocation's line-capture context. See HandleAgentExecutorInvocationBoundary.
|
||||
HandleAgentExecutorInvocationBoundary();
|
||||
// "ExecutorLog AgentExecutor gets invoked": a new process — the executor
|
||||
// lines that follow belong to its own argument line, never to the
|
||||
// previous invocation (session 6b4993e5).
|
||||
HandleAgentExecutorInvocation();
|
||||
break;
|
||||
|
||||
case "agentinvocationargument":
|
||||
HandleAgentInvocationArgument(match.Groups["kind"]?.Value);
|
||||
break;
|
||||
|
||||
case "agentexecutorcompleted":
|
||||
HandleAgentExecutorCompleted();
|
||||
break;
|
||||
|
||||
case "healthscriptresult":
|
||||
@@ -864,6 +876,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
internal void ProcessLogMessageForTest(string message, DateTime? sourceTimestampUtc = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
NextTestEntry();
|
||||
// The test seam hands in an already-resolved UTC instant, so it takes the same route a
|
||||
// writer-declared bias does: TimestampUtc set, no zone left to guess.
|
||||
var entry = sourceTimestampUtc.HasValue
|
||||
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: overwrite-aware tailing of the two IME logs that several processes append to.
|
||||
/// <para>
|
||||
/// IME's trace listener opens each log once per process with FileMode.Append and
|
||||
/// FileShare.ReadWrite and writes at its own stream position afterwards. AgentExecutor.exe
|
||||
/// (two processes per user-context script, one per detection or remediation script) has such
|
||||
/// a listener on AgentExecutor.log AND on IntuneManagementExtension.log. Whenever one process
|
||||
/// resumes writing after another one appended, it overwrites those bytes: the final file
|
||||
/// holds the last writer, while a tailer that trusts its bookmark read the first version and
|
||||
/// never sees the second (session 46749560: 6 of 16 script results, 4 of 16 exit blocks).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the bytes behind the bookmark are not immutable. The tracker keeps a fingerprint ledger
|
||||
/// of every entry it read from these files, re-verifies it while script invocations are in
|
||||
/// flight or right after a signal that a stale writer just wrote, and on the first changed
|
||||
/// entry rewinds the bookmark — processing only entries it has not processed before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class ImeLogTracker
|
||||
{
|
||||
/// <summary>Live logs with more than one writing process; archived rotations are closed files.</summary>
|
||||
internal static readonly string[] MultiWriterLogFiles = { "AgentExecutor.log", "IntuneManagementExtension.log" };
|
||||
|
||||
/// <summary>Ledger depth behind the bookmark while no script is in flight.</summary>
|
||||
internal const int LedgerRetentionBytes = 1024 * 1024;
|
||||
|
||||
/// <summary>Hard cap on the ledger depth, whatever is in flight.</summary>
|
||||
internal const int LedgerMaxBytes = 4 * 1024 * 1024;
|
||||
|
||||
/// <summary>Entries this close to the bookmark are re-checked on every pass that found growth (the file is open anyway).</summary>
|
||||
internal const int OverwriteGuardBytes = 4 * 1024;
|
||||
|
||||
/// <summary>After a restart the ledger is seeded from this many bytes before the persisted bookmark.</summary>
|
||||
internal const int RestartSeedBytes = 64 * 1024;
|
||||
|
||||
/// <summary>Verification work per second while in contention — a deep ledger is checked less often, never skipped.</summary>
|
||||
internal const long VerifyBudgetBytesPerSecond = 256 * 1024;
|
||||
|
||||
internal static readonly TimeSpan VerifyMinInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>How long an observed invocation keeps the files in contention on its own (detection executors have no pending slot).</summary>
|
||||
internal static readonly TimeSpan InvocationContentionWindow = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>A pending script older than this no longer holds the files in contention.</summary>
|
||||
internal static readonly TimeSpan MaxPendingScriptContention = TimeSpan.FromHours(2);
|
||||
|
||||
private const int MaxInvocationMarkersPerFile = 512;
|
||||
private const string TestSourceFileName = "TEST.log";
|
||||
private const string LogOpenTag = "<![LOG[";
|
||||
|
||||
/// <summary>One processed entry: where it starts, how far it reaches, what its bytes hashed to.</summary>
|
||||
internal struct LedgerEntry : IEquatable<LedgerEntry>
|
||||
{
|
||||
public long Offset;
|
||||
public int Length;
|
||||
public ulong Hash;
|
||||
|
||||
// Identity is (offset, hash): a tail first read unterminated and later completed
|
||||
// keeps its hash but grows by its terminator.
|
||||
public bool Equals(LedgerEntry other) => Offset == other.Offset && Hash == other.Hash;
|
||||
public override bool Equals(object obj) => obj is LedgerEntry other && Equals(other);
|
||||
public override int GetHashCode() => unchecked(((int)Offset * 397) ^ (int)Hash ^ (int)(Hash >> 32));
|
||||
}
|
||||
|
||||
internal sealed class FileLedger
|
||||
{
|
||||
public readonly List<LedgerEntry> Entries = new List<LedgerEntry>();
|
||||
public bool VerifyRequested;
|
||||
public DateTime NextCadenceVerifyUtc = DateTime.MinValue;
|
||||
public long LastFragmentOffset = -1;
|
||||
public bool Seeded;
|
||||
public bool RewindLogged;
|
||||
|
||||
public long StartOffset => Entries.Count > 0 ? Entries[0].Offset : -1;
|
||||
public long EndOffset => Entries.Count > 0 ? Entries[Entries.Count - 1].Offset + Entries[Entries.Count - 1].Length : -1;
|
||||
|
||||
public int IndexOfFirstAtOrAfter(long offset)
|
||||
{
|
||||
int lo = 0, hi = Entries.Count;
|
||||
while (lo < hi)
|
||||
{
|
||||
var mid = (lo + hi) >> 1;
|
||||
if (Entries[mid].Offset < offset) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/// <summary>Entry offset at or after <paramref name="offset"/>, or -1.</summary>
|
||||
public long OffsetOfEntryAtOrAfter(long offset)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(offset);
|
||||
return idx < Entries.Count ? Entries[idx].Offset : -1;
|
||||
}
|
||||
|
||||
public void Record(AssembledEntry e)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(e.Offset);
|
||||
if (idx < Entries.Count) Entries.RemoveRange(idx, Entries.Count - idx);
|
||||
Entries.Add(new LedgerEntry { Offset = e.Offset, Length = e.Length, Hash = e.Hash });
|
||||
}
|
||||
|
||||
/// <summary>Drops everything from <paramref name="offset"/> on and returns it — the entries a rewind must not process twice.</summary>
|
||||
public HashSet<LedgerEntry> TruncateFrom(long offset)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(offset);
|
||||
var removed = new HashSet<LedgerEntry>();
|
||||
for (var i = idx; i < Entries.Count; i++) removed.Add(Entries[i]);
|
||||
if (idx < Entries.Count) Entries.RemoveRange(idx, Entries.Count - idx);
|
||||
return removed;
|
||||
}
|
||||
|
||||
public void TrimBelow(long floor)
|
||||
{
|
||||
var idx = IndexOfFirstAtOrAfter(floor);
|
||||
if (idx > 0) Entries.RemoveRange(0, idx);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Entries.Clear();
|
||||
LastFragmentOffset = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by file name (unique within the log folder). Poll thread only.
|
||||
private readonly Dictionary<string, FileLedger> _ledgers = new Dictionary<string, FileLedger>(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTime _contentionUntilUtc = DateTime.MinValue;
|
||||
|
||||
// Cumulative, restart-safe like the other health counters.
|
||||
private long _overwriteRewinds;
|
||||
private long _overwriteBytesReprocessed;
|
||||
private long _verifyPasses;
|
||||
private long _verifiedBytes;
|
||||
|
||||
internal static bool IsMultiWriterLogFile(string fileName)
|
||||
{
|
||||
foreach (var f in MultiWriterLogFiles)
|
||||
if (string.Equals(f, fileName, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private FileLedger GetLedger(string fileName)
|
||||
{
|
||||
FileLedger ledger;
|
||||
if (!_ledgers.TryGetValue(fileName, out ledger))
|
||||
{
|
||||
ledger = new FileLedger();
|
||||
_ledgers[fileName] = ledger;
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/// <summary>A stale writer writes exactly when its script ends: every end signal asks both files for a check on the next pass.</summary>
|
||||
private void RequestOverwriteCheck()
|
||||
{
|
||||
foreach (var ledger in _ledgers.Values) ledger.VerifyRequested = true;
|
||||
}
|
||||
|
||||
private void NoteInvocationActivity()
|
||||
{
|
||||
_contentionUntilUtc = UtcNowProvider() + InvocationContentionWindow;
|
||||
}
|
||||
|
||||
private bool InContention(DateTime nowUtc)
|
||||
{
|
||||
if (nowUtc < _contentionUntilUtc || _pendingHealthScript != null) return true;
|
||||
foreach (var script in _pendingPlatformScripts.Values)
|
||||
{
|
||||
if (script.Result != null) continue;
|
||||
if (!script.StartedAtUtc.HasValue || nowUtc - script.StartedAtUtc.Value < MaxPendingScriptContention) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs before a multi-writer file is read: rollover reset, restart seeding, then the
|
||||
/// overwrite check when one is due. Returns the offset to rewind to, or -1.
|
||||
/// </summary>
|
||||
private async Task<long> PrepareLedgerAsync(string filePath, string fileName, FileLedger ledger, long bookmark, DateTime nowUtc, CancellationToken token)
|
||||
{
|
||||
if (ledger.Entries.Count > 0 && bookmark < ledger.EndOffset)
|
||||
{
|
||||
// The bookmark moved below what was read: the file was replaced (rollover).
|
||||
ledger.Clear();
|
||||
InvalidateInvocationMarkersFrom(fileName, 0);
|
||||
}
|
||||
|
||||
if (!ledger.Seeded)
|
||||
{
|
||||
ledger.Seeded = true;
|
||||
if (ledger.Entries.Count == 0 && bookmark > 0)
|
||||
await SeedLedgerAsync(filePath, ledger, bookmark, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var due = ledger.VerifyRequested || (InContention(nowUtc) && nowUtc >= ledger.NextCadenceVerifyUtc);
|
||||
if (!due || ledger.Entries.Count == 0) return -1;
|
||||
|
||||
var from = ledger.StartOffset;
|
||||
var bytes = bookmark - from;
|
||||
if (bytes <= 0)
|
||||
{
|
||||
ledger.VerifyRequested = false;
|
||||
return -1;
|
||||
}
|
||||
|
||||
long divergence;
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
divergence = await FindOverwriteAsync(stream, ledger, from, bookmark, token).ConfigureAwait(false);
|
||||
if (token.IsCancellationRequested) return -1;
|
||||
|
||||
ledger.VerifyRequested = false;
|
||||
var paceSeconds = Math.Max(VerifyMinInterval.TotalSeconds, (double)bytes / VerifyBudgetBytesPerSecond);
|
||||
ledger.NextCadenceVerifyUtc = nowUtc.AddSeconds(paceSeconds);
|
||||
_verifyPasses++;
|
||||
_verifiedBytes += bytes;
|
||||
return divergence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads [fromOffset, toOffset) with the read loop's assembler and compares against the
|
||||
/// ledger. Returns the offset of the first entry whose bytes changed — a ledger entry not
|
||||
/// reproduced, or an entry the ledger never had — or -1 when everything reads as before.
|
||||
/// </summary>
|
||||
private async Task<long> FindOverwriteAsync(FileStream stream, FileLedger ledger, long fromOffset, long toOffset, CancellationToken token)
|
||||
{
|
||||
var entries = ledger.Entries;
|
||||
var idx = ledger.IndexOfFirstAtOrAfter(fromOffset);
|
||||
if (idx >= entries.Count) return -1;
|
||||
|
||||
stream.Seek(entries[idx].Offset, SeekOrigin.Begin);
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes, hashLines: true);
|
||||
var assembler = new CmTraceEntryAssembler(MaxMultiLineBufferLines, MaxEntryBytes);
|
||||
|
||||
while (reader.Position < toOffset)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (line == null) break;
|
||||
if (token.IsCancellationRequested) return -1;
|
||||
|
||||
AssembledEntry e;
|
||||
if (assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out e)
|
||||
!= CmTraceEntryAssembler.Outcome.Entry)
|
||||
continue;
|
||||
if (e.Offset >= toOffset) break;
|
||||
|
||||
if (idx < entries.Count && entries[idx].Offset < e.Offset) return entries[idx].Offset;
|
||||
if (idx < entries.Count && entries[idx].Offset == e.Offset)
|
||||
{
|
||||
if (entries[idx].Hash != e.Hash) return e.Offset;
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
return e.Offset; // an entry the ledger never had: the bytes changed here
|
||||
}
|
||||
|
||||
return idx < entries.Count && entries[idx].Offset < toOffset ? entries[idx].Offset : -1;
|
||||
}
|
||||
|
||||
/// <summary>After a restart the ledger is empty: fingerprint the entries just behind the persisted bookmark without processing them.</summary>
|
||||
private async Task SeedLedgerAsync(string filePath, FileLedger ledger, long bookmark, CancellationToken token)
|
||||
{
|
||||
var from = Math.Max(0, bookmark - RestartSeedBytes);
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
stream.Seek(from, SeekOrigin.Begin);
|
||||
var reader = new BoundedLineReader(stream, MaxEntryBytes, hashLines: true);
|
||||
var assembler = new CmTraceEntryAssembler(MaxMultiLineBufferLines, MaxEntryBytes);
|
||||
var aligned = from == 0;
|
||||
while (reader.Position < bookmark)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (line == null || token.IsCancellationRequested) break;
|
||||
if (!aligned)
|
||||
{
|
||||
// The seek may have landed mid-entry: wait for an entry boundary.
|
||||
if (!line.StartsWith(LogOpenTag)) continue;
|
||||
aligned = true;
|
||||
}
|
||||
AssembledEntry e;
|
||||
if (assembler.Feed(line, reader.LastLineStart, reader.Position, reader.LastLineTruncated, reader.LastLineHash, out e)
|
||||
== CmTraceEntryAssembler.Outcome.Entry
|
||||
&& e.Offset + e.Length <= bookmark)
|
||||
ledger.Record(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves the bookmark back to the first changed entry; returns the entries to skip if they still read unchanged behind it.</summary>
|
||||
private HashSet<LedgerEntry> ApplyRewind(string filePath, string fileName, FileLedger ledger, long rewindTo, long bookmark)
|
||||
{
|
||||
var removed = ledger.TruncateFrom(rewindTo);
|
||||
_positionTracker.SetPosition(filePath, rewindTo);
|
||||
_heldTails.Remove(filePath);
|
||||
InvalidateInvocationMarkersFrom(fileName, rewindTo);
|
||||
_overwriteRewinds++;
|
||||
_overwriteBytesReprocessed += bookmark - rewindTo;
|
||||
|
||||
var message = $"ImeLogTracker: {fileName} changed behind the bookmark at offset {rewindTo} " +
|
||||
$"(a concurrent writer overwrote already-read bytes) — rewinding {bookmark - rewindTo} bytes";
|
||||
if (!ledger.RewindLogged)
|
||||
{
|
||||
ledger.RewindLogged = true;
|
||||
_logger.Info(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug(message);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
private long RetentionFloor(string fileName, long bookmark)
|
||||
{
|
||||
var floor = bookmark - LedgerRetentionBytes;
|
||||
var oldest = OldestPendingScriptMarkerOffset(fileName);
|
||||
if (oldest >= 0 && oldest < floor) floor = oldest;
|
||||
var hardFloor = bookmark - LedgerMaxBytes;
|
||||
return floor < hardFloor ? hardFloor : floor;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Invocation markers: who owns the policyId-less executor lines at a file position
|
||||
// -----------------------------------------------------------------------
|
||||
//
|
||||
// AgentExecutor.log carries "Powershell exit code is N" / "write output done" without a
|
||||
// policy id. Their owner is decided by FILE POSITION, not by the order lines were read:
|
||||
// an executor writes its end block at its own stream position, i.e. directly after its
|
||||
// own start block in the final bytes, so the nearest preceding invocation marker names
|
||||
// it — and stays right after a rewind re-reads that region. A marker is a platform start
|
||||
// (policy id), some other invocation (detection, remediation, requirement: null) or a
|
||||
// close ("gets invoked" banner, "Agent executor completed").
|
||||
|
||||
private sealed class InvocationMarker
|
||||
{
|
||||
public long Offset;
|
||||
public string PolicyId;
|
||||
public bool IsClose;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, List<InvocationMarker>> _invocationMarkers =
|
||||
new Dictionary<string, List<InvocationMarker>>(StringComparer.OrdinalIgnoreCase);
|
||||
private InvocationMarker _lastPlatformMarker;
|
||||
private long _testEntryOrdinal;
|
||||
|
||||
private void RecordInvocationMarker(string policyId, bool isClose)
|
||||
{
|
||||
if (_currentEntryOffset < 0) return;
|
||||
var file = _currentSourceFileName ?? TestSourceFileName;
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(file, out list))
|
||||
{
|
||||
list = new List<InvocationMarker>();
|
||||
_invocationMarkers[file] = list;
|
||||
}
|
||||
|
||||
if (list.Count > 0 && list[list.Count - 1].Offset == _currentEntryOffset)
|
||||
{
|
||||
// Two patterns on one line: the platform start outranks the generic argument line.
|
||||
var last = list[list.Count - 1];
|
||||
if (!isClose && policyId != null)
|
||||
{
|
||||
last.PolicyId = policyId;
|
||||
last.IsClose = false;
|
||||
_lastPlatformMarker = last;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (list.Count >= MaxInvocationMarkersPerFile) list.RemoveAt(0);
|
||||
var marker = new InvocationMarker { Offset = _currentEntryOffset, PolicyId = policyId, IsClose = isClose };
|
||||
list.Add(marker);
|
||||
if (!isClose && policyId != null) _lastPlatformMarker = marker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pending platform script that owns the entry being processed, or null.
|
||||
/// <paramref name="ownerPolicyId"/> names the owning platform script even when its slot is
|
||||
/// gone (completion already emitted); null when no platform invocation owns the entry.
|
||||
/// </summary>
|
||||
private ScriptExecutionState ResolvePlatformScriptForCurrentEntry(out string ownerPolicyId)
|
||||
{
|
||||
ownerPolicyId = null;
|
||||
InvocationMarker marker = null;
|
||||
List<InvocationMarker> list = null;
|
||||
if (_currentSourceFileName != null && _currentEntryOffset >= 0 && _invocationMarkers.TryGetValue(_currentSourceFileName, out list))
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (list[i].Offset <= _currentEntryOffset)
|
||||
{
|
||||
marker = list[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A file without any marker (a context line after a rotation): the last platform start wins.
|
||||
if (marker == null && (list == null || list.Count == 0)) marker = _lastPlatformMarker;
|
||||
if (marker == null || marker.IsClose || marker.PolicyId == null) return null;
|
||||
|
||||
ownerPolicyId = marker.PolicyId;
|
||||
ScriptExecutionState state;
|
||||
return _pendingPlatformScripts.TryGetValue(marker.PolicyId, out state) ? state : null;
|
||||
}
|
||||
|
||||
private void InvalidateInvocationMarkersFrom(string fileName, long offset)
|
||||
{
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(fileName, out list) || list.Count == 0) return;
|
||||
var removedLast = false;
|
||||
for (var i = list.Count - 1; i >= 0 && list[i].Offset >= offset; i--)
|
||||
{
|
||||
if (ReferenceEquals(list[i], _lastPlatformMarker)) removedLast = true;
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
if (removedLast)
|
||||
{
|
||||
_lastPlatformMarker = null;
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!list[i].IsClose && list[i].PolicyId != null)
|
||||
{
|
||||
_lastPlatformMarker = list[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long OldestPendingScriptMarkerOffset(string fileName)
|
||||
{
|
||||
List<InvocationMarker> list;
|
||||
if (!_invocationMarkers.TryGetValue(fileName, out list)) return -1;
|
||||
long oldest = -1;
|
||||
foreach (var marker in list)
|
||||
{
|
||||
if (marker.IsClose || marker.PolicyId == null || !_pendingPlatformScripts.ContainsKey(marker.PolicyId)) continue;
|
||||
if (oldest < 0 || marker.Offset < oldest) oldest = marker.Offset;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test seams
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>Same effect as an end signal: both multi-writer ledgers are verified on the next pass.</summary>
|
||||
internal void RequestOverwriteCheckForTest() => RequestOverwriteCheck();
|
||||
|
||||
internal int LedgerEntryCountForTest(string fileName)
|
||||
{
|
||||
FileLedger ledger;
|
||||
return _ledgers.TryGetValue(fileName, out ledger) ? ledger.Entries.Count : 0;
|
||||
}
|
||||
|
||||
/// <summary>Gives a seam-fed message a file position of its own so positional attribution behaves like in a real pass.</summary>
|
||||
internal void NextTestEntry()
|
||||
{
|
||||
if (_currentSourceFileName == null) _currentSourceFileName = TestSourceFileName;
|
||||
_currentEntryOffset = ++_testEntryOrdinal * 4096;
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-12
@@ -181,7 +181,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// arrive across multiple log entries and IME may interleave platform scripts).
|
||||
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
|
||||
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastPlatformScriptPolicyId;
|
||||
|
||||
// Health-script (remediation) line-by-line accumulator — single slot because IME
|
||||
// executes health scripts SEQUENTIALLY within a session (verified across multiple
|
||||
@@ -367,6 +366,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
FilesTailed = _filesTailed,
|
||||
BacklogBytes = _backlogBytes,
|
||||
ImeAgentVersion = _imeAgentVersionSeen,
|
||||
OverwriteRewinds = _overwriteRewinds,
|
||||
OverwriteBytesReprocessed = _overwriteBytesReprocessed,
|
||||
VerifyPasses = _verifyPasses,
|
||||
VerifiedBytes = _verifiedBytes,
|
||||
PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
}
|
||||
@@ -953,11 +956,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
await CheckLogFilesAsync(token);
|
||||
|
||||
// Safety net: emit completions for platform scripts whose AgentExecutor
|
||||
// exit code we already have but whose authoritative IME PS-SCRIPT-RESULT
|
||||
// line never arrived within the grace period. Runs on this same loop
|
||||
// Platform-script completions that wait for the other file: an IME result
|
||||
// held for its executor end block, and the exit-code fallback for a
|
||||
// result IME never logged within the grace period. Runs on this same loop
|
||||
// thread (no locking) so it observes the buffer right after parsing.
|
||||
FlushStalePlatformScriptResults(DateTime.UtcNow);
|
||||
FlushPendingPlatformScriptResults(UtcNowProvider());
|
||||
|
||||
// Token-failure grace window: fires only when the failure stayed
|
||||
// unresolved (no IME-TOKEN-SUCCESS) for the whole window. Runs after
|
||||
@@ -985,10 +988,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
// Final pass: force-flush any platform script that completed within the last grace
|
||||
// window before shutdown (exit code known, IME result still pending). Best-effort —
|
||||
// emits only while callbacks are still wired (i.e. the adapter hasn't been disposed).
|
||||
try { FlushStalePlatformScriptResults(DateTime.UtcNow, force: true); }
|
||||
// Final pass: force-flush any platform script still waiting for the other file
|
||||
// (a held IME result, or an exit code whose IME result is still pending).
|
||||
// Best-effort — emits only while callbacks are still wired (i.e. the adapter
|
||||
// hasn't been disposed).
|
||||
try { FlushPendingPlatformScriptResults(UtcNowProvider(), force: true); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogTracker: shutdown script flush failed: {ex.Message}"); }
|
||||
|
||||
// Final state save on shutdown
|
||||
@@ -1100,10 +1104,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// (the in-memory marker set started empty). Old state files have null here —
|
||||
// degrades to the pre-fix behavior, no crash.
|
||||
_platformScriptResultEmitted.Clear();
|
||||
if (state.PlatformScriptResultEmitted != null)
|
||||
if (state.PlatformScriptResultEmittedAt != null)
|
||||
{
|
||||
foreach (var kv in state.PlatformScriptResultEmittedAt)
|
||||
_platformScriptResultEmitted[kv.Key] = kv.Value;
|
||||
}
|
||||
else if (state.PlatformScriptResultEmitted != null)
|
||||
{
|
||||
// State files from before the marker carried the run's timestamp: the dedup
|
||||
// works as before, the late-start-line guard never fires for these runs.
|
||||
foreach (var id in state.PlatformScriptResultEmitted)
|
||||
_platformScriptResultEmitted.Add(id);
|
||||
_platformScriptResultEmitted[id] = DateTime.MinValue;
|
||||
}
|
||||
|
||||
_pendingPlatformScripts.Clear();
|
||||
@@ -1140,6 +1151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HealthScriptResultParseFailures = state.Health.HealthScriptResultParseFailures;
|
||||
_imeAgentVersionSeen = state.Health.ImeAgentVersion;
|
||||
_trackerDegradedFired = state.Health.TrackerDegradedFired;
|
||||
_overwriteRewinds = state.Health.OverwriteRewinds;
|
||||
_overwriteBytesReprocessed = state.Health.OverwriteBytesReprocessed;
|
||||
_verifyPasses = state.Health.VerifyPasses;
|
||||
_verifiedBytes = state.Health.VerifiedBytes;
|
||||
}
|
||||
if (state.PatternHits != null)
|
||||
{
|
||||
@@ -1190,7 +1205,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
// H1 (delta review 2026-07-02): platform-script dedup markers + pending buffer
|
||||
// must survive restarts — see the LoadState comment for the duplicate scenario.
|
||||
PlatformScriptResultEmitted = _platformScriptResultEmitted.ToList(),
|
||||
PlatformScriptResultEmittedAt = new Dictionary<string, DateTime>(_platformScriptResultEmitted, StringComparer.OrdinalIgnoreCase),
|
||||
PendingPlatformScripts = _pendingPlatformScripts.Values.ToList(),
|
||||
ScriptTimeoutSuspectedPosted = _scriptTimeoutSuspectedPosted.ToList(),
|
||||
};
|
||||
@@ -1209,6 +1224,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
HealthScriptResultParseFailures = HealthScriptResultParseFailures,
|
||||
ImeAgentVersion = _imeAgentVersionSeen,
|
||||
TrackerDegradedFired = _trackerDegradedFired,
|
||||
OverwriteRewinds = _overwriteRewinds,
|
||||
OverwriteBytesReprocessed = _overwriteBytesReprocessed,
|
||||
VerifyPasses = _verifyPasses,
|
||||
VerifiedBytes = _verifiedBytes,
|
||||
};
|
||||
state.PatternHits = new Dictionary<string, int>(_patternHits, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
+17
@@ -46,6 +46,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>IME version as logged by IME itself ("Agent version is: …"), null until seen.</summary>
|
||||
public string ImeAgentVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Times the bookmark of a multi-writer log (AgentExecutor.log, IntuneManagementExtension.log)
|
||||
/// was moved back because bytes behind it had been overwritten by a concurrent IME
|
||||
/// process. Expected nonzero whenever user-context scripts or detection scripts ran —
|
||||
/// recovered data, not skipped work.
|
||||
/// </summary>
|
||||
public long OverwriteRewinds { get; set; }
|
||||
|
||||
/// <summary>Bytes re-read by those rewinds (only entries that changed are processed again).</summary>
|
||||
public long OverwriteBytesReprocessed { get; set; }
|
||||
|
||||
/// <summary>Full ledger verifications run (cadence while scripts are in flight, end signals, fragments).</summary>
|
||||
public long VerifyPasses { get; set; }
|
||||
|
||||
/// <summary>Bytes re-read for verification, guard-zone checks included — the cost of the overwrite detection.</summary>
|
||||
public long VerifiedBytes { get; set; }
|
||||
|
||||
/// <summary>Match count per enabled pattern ID — every enabled pattern is present, zeros included.</summary>
|
||||
public IReadOnlyDictionary<string, int> PatternHits { get; set; }
|
||||
|
||||
|
||||
+12
@@ -142,7 +142,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// continues its grace window after the restart instead of being silently dropped.
|
||||
// ScriptExecutionState is a plain DTO, serialized directly (no mapping layer needed).
|
||||
// All three are null on state files from before this fix — LoadState treats null as empty.
|
||||
// Read-only migration input: state files written before the marker carried the run's
|
||||
// source timestamp (agents up to 2.0.1458). Never written any more.
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public List<string> PlatformScriptResultEmitted { get; set; }
|
||||
// Policy id → source timestamp of the emitted run (its result line; the exit line for a
|
||||
// fallback emit): the dedup marker plus the reference that tells a late start line of
|
||||
// that run from the start of the next one.
|
||||
public Dictionary<string, DateTime> PlatformScriptResultEmittedAt { get; set; }
|
||||
public List<ScriptExecutionState> PendingPlatformScripts { get; set; }
|
||||
public List<string> ScriptTimeoutSuspectedPosted { get; set; }
|
||||
|
||||
@@ -165,6 +172,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
public int HealthScriptResultParseFailures { get; set; }
|
||||
public string ImeAgentVersion { get; set; }
|
||||
public bool TrackerDegradedFired { get; set; }
|
||||
// Overwrite detection counters (null-defaulting on older state files).
|
||||
public long OverwriteRewinds { get; set; }
|
||||
public long OverwriteBytesReprocessed { get; set; }
|
||||
public long VerifyPasses { get; set; }
|
||||
public long VerifiedBytes { get; set; }
|
||||
}
|
||||
|
||||
public class PackageStateData
|
||||
|
||||
+24
-1
@@ -61,11 +61,34 @@ public class ScriptExecutionState
|
||||
/// <summary>
|
||||
/// UTC timestamp at which the AgentExecutor.log exit-code line for this platform script was
|
||||
/// observed. Drives the deadline check in
|
||||
/// <see cref="ImeLogTracker.FlushStalePlatformScriptResults"/> — set only on the platform path
|
||||
/// <see cref="ImeLogTracker.FlushPendingPlatformScriptResults"/> — set only on the platform path
|
||||
/// (PS-AGENT exit code); null until an exit code is seen.
|
||||
/// </summary>
|
||||
public System.DateTime? ExitObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Source (CMTrace) timestamp of the IME <c>PS-SCRIPT-RESULT</c> line that delivered
|
||||
/// <see cref="Result"/> (platform scripts, <c>ime_policy_result</c> only). The emitted event is
|
||||
/// bound to it — the emit may happen passes later (see <see cref="ResultHeldSinceUtc"/>), when
|
||||
/// the tracker's "last matched" line is an unrelated one. Also the reference a later start line
|
||||
/// of the same policy is compared against: a start older than the emitted result is the late
|
||||
/// start line of that run, not a new run.
|
||||
/// </summary>
|
||||
public System.DateTime? ResultObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Pattern id of the line that delivered <see cref="Result"/> (surfaced as <c>patternId</c> on the event).</summary>
|
||||
public string ResultPatternId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock time (agent UTC) at which the IME result was seen while the executor end block
|
||||
/// (exit code, stdout, stderr) had not been read yet. The tracker holds the emit until the end
|
||||
/// block arrives — an overwritten block surfaces one pass later through the ledger rewind that
|
||||
/// the result itself triggers, and the AgentExecutor.log read can simply lag behind the IME log
|
||||
/// read — or until <c>ImeLogTracker.PlatformScriptEndBlockGrace</c> elapsed. Null when the
|
||||
/// result was not held.
|
||||
/// </summary>
|
||||
public System.DateTime? ResultHeldSinceUtc { get; set; }
|
||||
|
||||
/// <summary>"True" or "False" compliance result (remediation detection / post-detection only).</summary>
|
||||
public string ComplianceResult { get; set; }
|
||||
|
||||
|
||||
+4
@@ -209,6 +209,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
data["ime_line_budget_breaks"] = health.BudgetBreaks;
|
||||
data["ime_held_tails"] = health.HeldTails;
|
||||
data["ime_unanchored_patterns"] = health.UnanchoredPatterns;
|
||||
data["ime_overwrite_rewinds"] = health.OverwriteRewinds;
|
||||
data["ime_overwrite_bytes_reprocessed"] = health.OverwriteBytesReprocessed;
|
||||
data["ime_verify_passes"] = health.VerifyPasses;
|
||||
data["ime_verified_bytes"] = health.VerifiedBytes;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1254,23 +1254,25 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (!string.IsNullOrEmpty(script.Stdout)) data["stdout"] = script.Stdout!;
|
||||
if (!string.IsNullOrEmpty(script.Stderr)) data["stderr"] = script.Stderr!;
|
||||
|
||||
// The exit-code fallback emits asynchronously to log parsing, so the tracker's
|
||||
// "last matched" pattern/timestamp belong to an unrelated later line. Bind the event to
|
||||
// the script's own exit-observed timestamp and omit the misleading patternId instead.
|
||||
// A platform-script completion is bound to the script's own line, not to the tracker's
|
||||
// "last matched" one: the exit-code fallback emits a grace period after its exit line,
|
||||
// and an IME result held for its executor end block emits at the end of a later pass —
|
||||
// by then LastMatched* name an unrelated line. The fallback carries no patternId (no
|
||||
// line delivered its result). Health scripts carry neither stamp and stay on the last
|
||||
// matched line, which IS their result line.
|
||||
var isFallback = string.Equals(script.ResultSource, "agentexecutor_fallback", StringComparison.Ordinal);
|
||||
if (!isFallback)
|
||||
{
|
||||
var patternId = _tracker.LastMatchedPatternId;
|
||||
var patternId = script.ResultPatternId ?? _tracker.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId)) data["patternId"] = patternId!;
|
||||
}
|
||||
|
||||
bool derivedFromClock;
|
||||
DateTime? rawSourceTs;
|
||||
DateTime now;
|
||||
if (isFallback && script.ExitObservedAtUtc.HasValue)
|
||||
now = ResolveOccurredAt(script.ExitObservedAtUtc, out derivedFromClock, out rawSourceTs);
|
||||
else
|
||||
now = ResolveOccurredAt(out derivedFromClock, out rawSourceTs);
|
||||
var sourceTs = isFallback ? script.ExitObservedAtUtc : script.ResultObservedAtUtc;
|
||||
var now = sourceTs.HasValue
|
||||
? ResolveOccurredAt(sourceTs, out derivedFromClock, out rawSourceTs)
|
||||
: ResolveOccurredAt(out derivedFromClock, out rawSourceTs);
|
||||
|
||||
// Historic replay (a previous enrollment's log) — do not emit at all. The early
|
||||
// return also skips MaybeEmitBootstrapDetected / MaybeEmitScriptTimeoutSuspected,
|
||||
|
||||
@@ -584,6 +584,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
["heldTails"] = health.HeldTails,
|
||||
["unanchoredPatterns"] = health.UnanchoredPatterns,
|
||||
["hsParseFailures"] = health.HealthScriptResultParseFailures,
|
||||
["overwriteRewinds"] = health.OverwriteRewinds,
|
||||
["overwriteBytesReprocessed"] = health.OverwriteBytesReprocessed,
|
||||
["verifyPasses"] = health.VerifyPasses,
|
||||
["verifiedBytes"] = health.VerifiedBytes,
|
||||
["agentVersion"] = _agentVersion ?? string.Empty,
|
||||
};
|
||||
if (!string.IsNullOrEmpty(health.ImeAgentVersion))
|
||||
|
||||
@@ -175,7 +175,7 @@ public class BuiltInRulesTests
|
||||
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
|
||||
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
|
||||
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
|
||||
"resetPlatformScriptContext",
|
||||
"resetPlatformScriptContext", "agentInvocationArgument", "agentExecutorCompleted",
|
||||
"healthScriptResult", "healthScriptDetectionResult",
|
||||
"captureExitCode", "captureHResult",
|
||||
"captureAppVersion", "captureAppTypeWinGet", "captureAppTypeMsi",
|
||||
|
||||
@@ -37,9 +37,9 @@ public class CertTenantBindingTests
|
||||
/// Tenant stamped into <c>device-cert-sample.pem</c> by the Intune MDM Device CA. The sample is
|
||||
/// a public-key-only cert from an internal test device; the GUID is the test tenant's own id.
|
||||
/// </summary>
|
||||
private static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
|
||||
internal static readonly Guid SampleCertTenantId = Guid.Parse("b54dc1af-5320-4f60-b5d4-821e0cf2a359");
|
||||
|
||||
private static string SampleCertBase64()
|
||||
internal static string SampleCertBase64()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(typeof(CertTenantBindingTests).Assembly.Location)!;
|
||||
var pem = File.ReadAllText(Path.Combine(assemblyDir, DeviceSamplePem));
|
||||
@@ -351,7 +351,7 @@ public class CertTenantBindingTests
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
private static SecurityValidator BuildValidator(ILogger logger)
|
||||
internal static SecurityValidator BuildValidator(ILogger logger)
|
||||
{
|
||||
var configRepo = Mock.Of<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
@@ -390,7 +390,7 @@ public class CertTenantBindingTests
|
||||
deviceAssociationValidator: null);
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequestWithCert(
|
||||
internal static HttpRequestData BuildRequestWithCert(
|
||||
string certBase64,
|
||||
IDictionary<object, object>? items = null)
|
||||
{
|
||||
|
||||
+55
-2
@@ -1,12 +1,14 @@
|
||||
using AutopilotMonitor.Functions.Functions.Metrics;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the pure fold behind GET metrics/mcp-usage/organization: one item per account, the
|
||||
/// delegated (MSP) marker from the row's home tenant, and the three windows (today / month / range).
|
||||
/// Tests for the pure folds behind GET metrics/mcp-usage/organization (and its global twin): one item per
|
||||
/// account, the delegated (MSP) marker from the row's home tenant, the three windows (today / month / range),
|
||||
/// and the tenant's organization windows over every account.
|
||||
/// </summary>
|
||||
public class McpOrganizationUsageAggregationTests
|
||||
{
|
||||
@@ -86,6 +88,57 @@ public class McpOrganizationUsageAggregationTests
|
||||
Assert.Equal(newer, items[1].LastRequestAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrganizationQuota_SumsTodayAndMonthOverEveryAccount_AndCarriesTheTenantLimits()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row(Member, "20260815", 100), // last month: neither window
|
||||
Row(Member, "20260901", 20), // month
|
||||
Row(Member, "20260902", 2), // today + month
|
||||
Row(MspAdmin, "20260902", 5, home: MspHome), // delegated reads count against the same windows
|
||||
Row(Member, "20260903", 7), // read window past today (range only)
|
||||
};
|
||||
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
|
||||
|
||||
Assert.Equal("pro", quota.TenantPlan);
|
||||
Assert.Equal(1500, quota.DailyLimit);
|
||||
Assert.Equal(15000, quota.MonthlyLimit);
|
||||
Assert.Equal(7, quota.DailyUsed);
|
||||
Assert.Equal(27, quota.MonthlyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrganizationDaily_SumsEveryAccountPerDay_InsideTheRange_OldestFirst()
|
||||
{
|
||||
var rows = new[]
|
||||
{
|
||||
Row(Member, "20260815", 100), // before the range
|
||||
Row(Member, "20260902", 2),
|
||||
Row(MspAdmin, "20260902", 5, home: MspHome), // same day, delegated — one bar
|
||||
Row(Member, "20260901", 20),
|
||||
Row(Member, "20260903", 7), // after the range (read window)
|
||||
};
|
||||
|
||||
var daily = McpUsageMetricsFunction.BuildOrganizationDaily(rows, "20260901", "20260902");
|
||||
|
||||
Assert.Equal(new[] { ("20260901", 20L), ("20260902", 7L) }, daily.Select(d => (d.Date, d.Requests)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
|
||||
{
|
||||
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
|
||||
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
|
||||
|
||||
Assert.Equal(0, quota.DailyLimit);
|
||||
Assert.Equal(0, quota.MonthlyLimit);
|
||||
Assert.Equal(3, quota.DailyUsed);
|
||||
Assert.Equal(3, quota.MonthlyUsed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-02", "20260902")]
|
||||
[InlineData("20260902", "20260902")]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent security gate stamps two dimensions onto the request row through
|
||||
/// <c>FunctionContext.Items</c>: the tenant it proved (<c>ValidatedTenantId</c>) and the outcome of
|
||||
/// the device-validation chain (<c>DeviceValidation</c>). These tests pin
|
||||
/// - that a request which passes the gate carries both, and one rejected before the chain
|
||||
/// carries neither (a rejected certificate must not attribute the row to the claimed tenant),
|
||||
/// - that the middleware takes the proven tenant before the <c>X-Tenant-Id</c> header and never
|
||||
/// takes the header on a route that does not validate it — register and config take the tenant
|
||||
/// from body/query, so a client-sent header there is attacker-controlled.
|
||||
/// </summary>
|
||||
public class RequestRowMarkersTests
|
||||
{
|
||||
private const string RegisterPath = "/api/agent/register-session";
|
||||
private const string ConfigPath = "/api/agent/config";
|
||||
private const string TelemetryPath = "/api/agent/telemetry";
|
||||
private const string OtherTenant = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
// ---------------------------------------------------------------- validator stamps
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRequest_PassingTheGate_StampsProvenTenantAndDeviceValidation()
|
||||
{
|
||||
var items = new Dictionary<object, object>();
|
||||
var validator = CertTenantBindingTests.BuildValidator(Mock.Of<ILogger>());
|
||||
var tenantId = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items),
|
||||
tenantId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(tenantId, Assert.Contains(RequestRowMarkers.ValidatedTenantKey, items));
|
||||
// The harness runs the tenant without a device validator (AllowInsecureAgentRequests).
|
||||
Assert.Equal(RequestRowMarkers.DeviceValidation.None, Assert.Contains(RequestRowMarkers.DeviceValidationKey, items));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRequest_CertificateOfAnotherTenant_StampsNeither()
|
||||
{
|
||||
var items = new Dictionary<object, object>();
|
||||
var validator = CertTenantBindingTests.BuildValidator(Mock.Of<ILogger>());
|
||||
|
||||
var result = await validator.ValidateRequestAsync(
|
||||
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items),
|
||||
tenantId: OtherTenant);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// The row must not be attributed to the tenant the caller merely claimed …
|
||||
Assert.DoesNotContain(RequestRowMarkers.ValidatedTenantKey, items);
|
||||
// … and the device chain never ran, so there is no outcome to report.
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidationKey, items);
|
||||
// The binding outcome itself still rides along (denominator for the cert-tenant telemetry).
|
||||
Assert.Equal(CertTenantBinding.Outcome.Mismatch, Assert.Contains(CertTenantBinding.RequestItemKey, items));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceValidationValue_IsTheValidatorName_AndNoneForUnknown()
|
||||
{
|
||||
Assert.Equal("None", RequestRowMarkers.DeviceValidationValue(ValidatorType.Unknown));
|
||||
foreach (var validator in Enum.GetValues<ValidatorType>().Where(v => v != ValidatorType.Unknown))
|
||||
Assert.Equal(validator.ToString(), RequestRowMarkers.DeviceValidationValue(validator));
|
||||
|
||||
// The three non-validator values must never collide with a validator name.
|
||||
var validatorNames = Enum.GetNames<ValidatorType>();
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidation.None, validatorNames);
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidation.Transient, validatorNames);
|
||||
Assert.DoesNotContain(RequestRowMarkers.DeviceValidation.Rejected, validatorNames);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- middleware attribution
|
||||
|
||||
[Fact]
|
||||
public void ResolveTenantId_PolicyTenantWins()
|
||||
{
|
||||
var items = new Dictionary<object, object> { [RequestRowMarkers.ValidatedTenantKey] = OtherTenant };
|
||||
Assert.Equal("policy-tenant", RequestTelemetryMiddleware.ResolveTenantId("policy-tenant", items, RegisterPath, OtherTenant));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegisterPath)]
|
||||
[InlineData(ConfigPath)]
|
||||
[InlineData(TelemetryPath)]
|
||||
public void ResolveTenantId_ProvenTenantBeatsTheHeaderOnEveryRoute(string path)
|
||||
{
|
||||
var proven = CertTenantBindingTests.SampleCertTenantId.ToString();
|
||||
var items = new Dictionary<object, object> { [RequestRowMarkers.ValidatedTenantKey] = proven };
|
||||
Assert.Equal(proven, RequestTelemetryMiddleware.ResolveTenantId(null, items, path, OtherTenant));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RegisterPath)]
|
||||
[InlineData(ConfigPath)]
|
||||
[InlineData("/api/agent/upload-url")]
|
||||
[InlineData("/api/bootstrap/register-session")]
|
||||
public void ResolveTenantId_HeaderIsIgnoredOnRoutesThatDoNotValidateIt(string path)
|
||||
{
|
||||
// Without a stamp from the validator (the request never passed the gate) a client-sent
|
||||
// header must not attribute the row to any tenant.
|
||||
Assert.Null(RequestTelemetryMiddleware.ResolveTenantId(null, new Dictionary<object, object>(), path, OtherTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTenantId_HeaderIsHonoredOnlyOnTrustedRoutes_AndOnlyAsGuid()
|
||||
{
|
||||
var none = new Dictionary<object, object>();
|
||||
Assert.Equal(OtherTenant, RequestTelemetryMiddleware.ResolveTenantId(null, none, TelemetryPath, OtherTenant));
|
||||
Assert.Equal(OtherTenant, RequestTelemetryMiddleware.ResolveTenantId(null, none, "/api/agent/distress", OtherTenant));
|
||||
Assert.Null(RequestTelemetryMiddleware.ResolveTenantId(null, none, TelemetryPath, "not-a-guid"));
|
||||
Assert.Null(RequestTelemetryMiddleware.ResolveTenantId(null, none, TelemetryPath, null));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,29 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
return await req.UnauthorizedAsync("Unable to determine tenant identity");
|
||||
}
|
||||
|
||||
return await OrganizationUsageResponseAsync(req, tenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/metrics/mcp-usage/organization?tenantId=&dateFrom=&dateTo= — the same organization
|
||||
/// breakdown for ONE tenant named by a Global Admin / Global Reader (the tenant MCP Usage page under a tenant
|
||||
/// override). There is no aggregate path: tenantId is required. Catalogued with TenantScoping.None on
|
||||
/// purpose — the route enumerates a tenant's accounts, which a delegated (MSP) caller must never reach.
|
||||
/// </summary>
|
||||
[Function("GetGlobalMcpOrganizationUsage")]
|
||||
public async Task<HttpResponseData> GetGlobalOrganizationUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/organization")] HttpRequestData req)
|
||||
{
|
||||
if (!Guid.TryParse(req.Query["tenantId"], out var tenantGuid))
|
||||
{
|
||||
return await req.BadRequestAsync("tenantId is required");
|
||||
}
|
||||
|
||||
return await OrganizationUsageResponseAsync(req, tenantGuid.ToString());
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> OrganizationUsageResponseAsync(HttpRequestData req, string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
@@ -127,6 +150,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var readFrom = string.CompareOrdinal(dateFrom, monthStart) < 0 ? dateFrom : monthStart;
|
||||
var readTo = string.CompareOrdinal(dateTo, today) > 0 ? dateTo : today;
|
||||
var records = await _userUsageRepo.GetTenantUsageAsync(tenantId, readFrom, readTo);
|
||||
var limits = await _quotaService.ResolveTenantPlanAsync(tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new GetMcpOrganizationUsageResponse
|
||||
@@ -135,6 +159,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
Users = AggregateOrganizationUsage(records, tenantId, dateFrom, dateTo, today, monthStart),
|
||||
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
|
||||
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -144,6 +170,46 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pure: requests charged to the tenant per day inside [dateFrom, dateTo], oldest first.</summary>
|
||||
internal static List<McpOrganizationDailyItem> BuildOrganizationDaily(
|
||||
IEnumerable<TenantUsageRecord> records, string dateFrom, string dateTo)
|
||||
{
|
||||
var byDay = new SortedDictionary<string, long>(StringComparer.Ordinal);
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (string.CompareOrdinal(record.Date, dateFrom) < 0 || string.CompareOrdinal(record.Date, dateTo) > 0)
|
||||
continue;
|
||||
byDay[record.Date] = byDay.TryGetValue(record.Date, out var sum) ? sum + record.RequestCount : record.RequestCount;
|
||||
}
|
||||
|
||||
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
|
||||
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
|
||||
/// </summary>
|
||||
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
|
||||
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
|
||||
{
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
foreach (var record in records)
|
||||
{
|
||||
if (record.Date == today) dailyUsed += record.RequestCount;
|
||||
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
|
||||
monthlyUsed += record.RequestCount;
|
||||
}
|
||||
|
||||
return new McpOrganizationQuotaNode
|
||||
{
|
||||
TenantPlan = limits.TenantPlan,
|
||||
DailyLimit = limits.TenantDailyLimit,
|
||||
MonthlyLimit = limits.TenantMonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure: folds the organization counter rows into one item per account. A row whose HomeTenantId is set
|
||||
/// and differs from the tenant was charged by a delegated (MSP) read. Ordered by this month's usage.
|
||||
|
||||
@@ -30,6 +30,9 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
// agent/register-session + agent/upload-url (tenant from body), bootstrap/validate/{code},
|
||||
// bootstrap/sessions{,/{code}}, bootstrap/config, bootstrap/register-session — where a client
|
||||
// could send X-Tenant-Id:<valid-guid> and pollute tenant attribution in telemetry.
|
||||
// Those routes get their TenantId from RequestRowMarkers.ValidatedTenantKey instead, which
|
||||
// SecurityValidator stamps only once the tenant is proven (see ResolveTenantId). The list
|
||||
// stays for the routes that never reach the validator (distress is pre-auth).
|
||||
private static readonly HashSet<string> TenantHeaderTrustedPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/api/agent/telemetry",
|
||||
@@ -43,6 +46,35 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
_telemetryClient = telemetryClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tenant attribution for the request row, most trusted source first: the policy pipeline's
|
||||
/// tenant (portal / MCP routes), then the tenant <c>SecurityValidator</c> proved for an agent
|
||||
/// request (<see cref="RequestRowMarkers.ValidatedTenantKey"/> — the only source for
|
||||
/// register / config / upload-url, which validate no tenant header), and last the
|
||||
/// <c>X-Tenant-Id</c> header on the exact routes that validate it themselves
|
||||
/// (<see cref="TenantHeaderTrustedPaths"/>), when it is a well-formed GUID. Any other route
|
||||
/// gets no tenant: an anonymous caller must not be able to pollute the attribution.
|
||||
/// </summary>
|
||||
internal static string? ResolveTenantId(
|
||||
string? policyTenantId,
|
||||
IDictionary<object, object> items,
|
||||
string path,
|
||||
string? headerTenantId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(policyTenantId))
|
||||
return policyTenantId;
|
||||
|
||||
if (items.TryGetValue(RequestRowMarkers.ValidatedTenantKey, out var validated)
|
||||
&& validated is string validatedTenantId && !string.IsNullOrEmpty(validatedTenantId))
|
||||
return validatedTenantId;
|
||||
|
||||
if (TenantHeaderTrustedPaths.Contains(path)
|
||||
&& !string.IsNullOrEmpty(headerTenantId) && Guid.TryParse(headerTenantId, out _))
|
||||
return headerTenantId;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
@@ -154,23 +186,17 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
|
||||
if (context.Items.TryGetValue(DeviceIdentityBinding.RequestItemKey, out var identityBinding) && identityBinding is string identityOutcome)
|
||||
requestTelemetry.Properties[DeviceIdentityBinding.RequestItemKey] = identityOutcome;
|
||||
|
||||
// DEVICE-VALIDATION — the admitting validator (or None / Transient / Rejected), set by
|
||||
// SecurityValidator once the device-validation chain has run. Same carrier and reasoning.
|
||||
if (context.Items.TryGetValue(RequestRowMarkers.DeviceValidationKey, out var deviceValidation) && deviceValidation is string deviceValidationOutcome)
|
||||
requestTelemetry.Properties[RequestRowMarkers.DeviceValidationKey] = deviceValidationOutcome;
|
||||
|
||||
var reqCtx = context.GetRequestContext();
|
||||
var tenantId = reqCtx.TenantId;
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
// Device ingest endpoints do not run through PolicyEnforcementMiddleware, so
|
||||
// reqCtx.TenantId is empty — but they carry a cert/token-validated tenant in the
|
||||
// X-Tenant-Id header. Only honor the header on the exact routes that actually
|
||||
// validate it (TenantHeaderTrustedPaths) and only when it is a well-formed GUID, so
|
||||
// other/anonymous routes cannot pollute the requests table with arbitrary tenant ids.
|
||||
var path = httpContext.Request.Path.Value ?? string.Empty;
|
||||
if (TenantHeaderTrustedPaths.Contains(path))
|
||||
{
|
||||
var headerTenant = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(headerTenant) && Guid.TryParse(headerTenant, out _))
|
||||
tenantId = headerTenant;
|
||||
}
|
||||
}
|
||||
var tenantId = ResolveTenantId(
|
||||
reqCtx.TenantId,
|
||||
context.Items,
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault());
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
requestTelemetry.Properties["TenantId"] = tenantId;
|
||||
if (!string.IsNullOrEmpty(reqCtx.UserPrincipalName))
|
||||
|
||||
@@ -512,6 +512,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// One tenant's organization usage by account (?tenantId= read by the handler): deliberately None, not
|
||||
// QueryParam — the route enumerates accounts (UPNs), which the delegated path must never reach.
|
||||
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// Mints the HMAC ticket for the anonymous proxied stream above (the MCP's report download).
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Request-row dimensions the agent security gate stamps via <c>FunctionContext.Items</c>;
|
||||
/// <c>RequestTelemetryMiddleware</c> copies them onto the <c>requests</c> row it emits per call.
|
||||
/// Same carrier as <see cref="CertTenantBinding.RequestItemKey"/>: worker-side
|
||||
/// <c>LogInformation</c> never reaches App Insights, so the accepting path would otherwise be
|
||||
/// invisible — only its failures would leave a trace.
|
||||
/// </summary>
|
||||
public static class RequestRowMarkers
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant the request was proven to belong to (certificate tenant binding matched, or
|
||||
/// the bootstrap token belongs to it). Agent routes that take the tenant from the body or
|
||||
/// the query string carry no trusted header, so this is the only safe source for the
|
||||
/// <c>TenantId</c> dimension on register / config / upload-url rows.
|
||||
/// </summary>
|
||||
public const string ValidatedTenantKey = "ValidatedTenantId";
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the device-validation chain: the admitting <see cref="ValidatorType"/> name,
|
||||
/// or one of <see cref="DeviceValidation"/>. Absent when an earlier stage (certificate,
|
||||
/// certificate tenant, hardware, rate limit) ended the request — the chain never ran.
|
||||
/// </summary>
|
||||
public const string DeviceValidationKey = "DeviceValidation";
|
||||
|
||||
/// <summary>Closed set of non-validator values, so <c>summarize by</c> stays stable.</summary>
|
||||
public static class DeviceValidation
|
||||
{
|
||||
/// <summary>Tenant runs without a device validator (<c>AllowInsecureAgentRequests</c>).</summary>
|
||||
public const string None = "None";
|
||||
/// <summary>Every enabled validator failed transiently — the agent got 503 Retry-After.</summary>
|
||||
public const string Transient = "Transient";
|
||||
/// <summary>Every enabled validator missed definitively — the agent got 403.</summary>
|
||||
public const string Rejected = "Rejected";
|
||||
}
|
||||
|
||||
public static string DeviceValidationValue(ValidatorType validatedBy)
|
||||
=> validatedBy == ValidatorType.Unknown ? DeviceValidation.None : validatedBy.ToString();
|
||||
|
||||
public static void Stamp(HttpRequestData req, string key, string value)
|
||||
{
|
||||
var items = req.FunctionContext?.Items;
|
||||
if (items != null)
|
||||
items[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ValidatedTenantKey, tenantId);
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
||||
RequestRowMarkers.DeviceValidationValue(ValidatorType.Bootstrap));
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
@@ -350,6 +353,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
// From here on the tenant is proven (known, not suspended, certificate issued to it).
|
||||
// Register / config / upload-url take the tenant from body or query, not from a
|
||||
// validated header, so this stamp is what gives their request rows a TenantId.
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.ValidatedTenantKey, tenantId);
|
||||
|
||||
// 2. Check rate limit (DoS protection)
|
||||
// Effective limit = per-tenant override if set, otherwise the global default.
|
||||
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
||||
@@ -510,6 +518,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
"Device validation transient failure for tenant {TenantId}, serial {SerialNumber}. Returning 503 Retry-After.",
|
||||
tenantId, serialNumber);
|
||||
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey, RequestRowMarkers.DeviceValidation.Transient);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
@@ -523,6 +532,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
LogRequestRejection("device", tenantId, req, sessionId,
|
||||
extraReason: deviceValidationError,
|
||||
thumbprint: certValidation.Thumbprint);
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey, RequestRowMarkers.DeviceValidation.Rejected);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
@@ -595,7 +605,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
});
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
// All checks passed. The admitting validator rides on the request row: the Sessions
|
||||
// row keeps ValidatedBy per session, this is the per-request counter and timestamp.
|
||||
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
|
||||
RequestRowMarkers.DeviceValidationValue(validatedBy));
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
|
||||
@@ -204,6 +204,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return Compose(userLimits, await ResolveTenantLimitsAsync(chargeTenantId, definitions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One charged tenant's organization windows alone (plan name + tenant limits; no counters, no per-user
|
||||
/// part) — for the organization usage report.
|
||||
/// </summary>
|
||||
public virtual async Task<TenantPlanLimits> ResolveTenantPlanAsync(string chargeTenantId)
|
||||
=> await ResolveTenantLimitsAsync(chargeTenantId, await LoadDefinitionsAsync());
|
||||
|
||||
private async Task<(UserPlanLimits Limits, List<PlanTierDefinition> Definitions)> ResolveUserLimitsAsync(
|
||||
AdminIdentity? identity, string? homeTenantId)
|
||||
{
|
||||
@@ -227,7 +234,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var planName = overridePlan ?? await _entitlementService.GetMcpUsagePlanNameAsync(homeTenantId);
|
||||
|
||||
// 3. Limits: admin-edited SectionUsagePlans definitions, else catalog fallbacks.
|
||||
var definitions = await LoadDefinitionsAsync(planName);
|
||||
var definitions = await LoadDefinitionsAsync();
|
||||
|
||||
// User limits: the definition for the (possibly overridden) plan name, else the catalog fallback
|
||||
// for the edition plans, else Community (fail-closed for overrides naming a plan that exists nowhere).
|
||||
@@ -257,7 +264,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantDefinition?.TenantMonthlyRequestLimit ?? entitlements.McpTenantMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
private async Task<List<PlanTierDefinition>> LoadDefinitionsAsync(string planName)
|
||||
private async Task<List<PlanTierDefinition>> LoadDefinitionsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -266,7 +273,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName);
|
||||
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback");
|
||||
return new List<PlanTierDefinition>();
|
||||
}
|
||||
}
|
||||
@@ -393,7 +400,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
private sealed record UserPlanLimits(string PlanName, int DailyLimit, int MonthlyLimit);
|
||||
private sealed record TenantPlanLimits(string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit);
|
||||
|
||||
/// <summary>The charged tenant's plan name and organization-wide windows (0 = unlimited).</summary>
|
||||
public sealed record TenantPlanLimits(string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+132
-132
@@ -34,6 +34,16 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
@@ -507,9 +517,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -525,13 +535,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -547,20 +557,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -570,9 +580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -586,9 +596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -602,9 +612,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -618,9 +628,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -634,9 +644,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -650,9 +660,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -666,9 +676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -682,9 +692,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -698,9 +708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -714,9 +724,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -730,9 +740,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -748,13 +758,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -770,13 +780,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -792,13 +802,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -814,13 +824,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -836,13 +846,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -858,13 +868,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -880,13 +890,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -902,17 +912,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -921,27 +931,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -951,9 +951,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -970,9 +970,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -989,9 +989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2507,9 +2507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
|
||||
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
@@ -3470,9 +3470,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.1.0",
|
||||
@@ -3486,31 +3486,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.0",
|
||||
"sharp": "^0.35.4",
|
||||
"fast-uri": "^3.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ describe('buildSessionCoverage — IME log tracker', () => {
|
||||
ev('ime_tracker_degraded', 50, { file: 'AppWorkload.log', lineBudgetBreaks: 1, regexTimeouts: 0, oversizedLines: 0 }),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1 }, { severity: 'Debug' }),
|
||||
]);
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1 });
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1, overwriteRewinds: null, overwriteBytesReprocessed: null });
|
||||
expect(c.gaps[0]).toContain('skipped the remaining patterns on 7 line(s)');
|
||||
expect(c.gaps[0]).toContain('gave up on 2 line(s) after a regex timeout');
|
||||
expect(c.gaps[0]).toContain('dropped 1 oversized line(s)');
|
||||
@@ -143,9 +143,30 @@ describe('buildSessionCoverage — IME log tracker', () => {
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0 }),
|
||||
]);
|
||||
expect(c.imeTracker).toEqual({ degraded: false, sessionTotals: { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0 } });
|
||||
expect(c.imeTracker).toEqual({ degraded: false, sessionTotals: { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: null, overwriteBytesReprocessed: null } });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports re-read overwritten log blocks as recovered data — a note, never a gap or degradation', () => {
|
||||
// Agents ≥ 2.0.1458: IME processes overwrite each other's bytes in AgentExecutor.log and
|
||||
// IntuneManagementExtension.log; the tracker re-reads the changed blocks and counts them.
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 8957, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: 6, overwriteBytesReprocessed: 5120 }, { severity: 'Debug' }),
|
||||
]);
|
||||
expect(c.imeTracker.degraded).toBe(false);
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 8957, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: 6, overwriteBytesReprocessed: 5120 });
|
||||
expect(c.imeTracker.note).toBe('6 log block(s) a concurrent IME process had overwritten were re-read (5 KB) — script results and exit codes in them are complete, not missing.');
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds no note when nothing was overwritten or the agent predates the counter', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0, overwriteRewinds: 0, overwriteBytesReprocessed: 0 }),
|
||||
]);
|
||||
expect(c.imeTracker.note).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — collectors, upload, disk', () => {
|
||||
@@ -284,6 +305,40 @@ describe('buildSessionCoverage — apps without a terminal state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — scripts without a terminal state', () => {
|
||||
// Shape of session 2ebcd480: the session's last event was a platform script's start line.
|
||||
// There is no shutdown summary for scripts — the stream itself is the evidence.
|
||||
const started = (policyId: string, at: number, scriptType = 'platform') => ev('script_started', at, { policyId, scriptType });
|
||||
const finished = (policyId: string, at: number, scriptType = 'platform') => ev('script_completed', at, { policyId, scriptType, exitCode: '0' });
|
||||
|
||||
it('names the scripts whose start line never got a result on a terminal session', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'Succeeded' }, [
|
||||
ev('agent_started', 5),
|
||||
started('p-a', 900), finished('p-a', 903),
|
||||
started('p-b', 904), ev('script_failed', 907, { policyId: 'p-b', scriptType: 'platform' }),
|
||||
started('p-last', 910),
|
||||
]);
|
||||
expect(c.scripts).toEqual({ stillRunning: [{ policyId: 'p-last', scriptType: 'platform' }] });
|
||||
expect(c.gaps).toEqual([expect.stringContaining('1 script(s) still running when the agent stopped observing (platform p-last)')]);
|
||||
});
|
||||
|
||||
it('reports nothing while the session is still live, even with a script in flight', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'InProgress' }, [ev('agent_started', 5), started('p-last', 910)]);
|
||||
expect(c.scripts).toEqual({ stillRunning: [] });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('a start after its own earlier result is a second run and counts as still running; snake_case keys are read too', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(0), status: 'Failed' }, [
|
||||
ev('agent_started', 5),
|
||||
finished('r1', 100, 'remediation'),
|
||||
ev('script_started', 200, { policy_id: 'r1', script_type: 'remediation' }),
|
||||
]);
|
||||
expect(c.scripts).toEqual({ stillRunning: [{ policyId: 'r1', scriptType: 'remediation' }] });
|
||||
expect(c.gaps).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverage wiring', () => {
|
||||
it('SUMMARY_EVENT_FIELDS carries every coverage slice, as data.<key> entries only', () => {
|
||||
const fields = SUMMARY_EVENT_FIELDS.split(',');
|
||||
@@ -291,6 +346,10 @@ describe('coverage wiring', () => {
|
||||
expect(f.startsWith('data.')).toBe(true);
|
||||
expect(fields).toContain(f);
|
||||
}
|
||||
// The scripts block also reads scriptType/script_type — they ride in the summary's own
|
||||
// list for the benign-detection guard, so pin them here rather than duplicating them.
|
||||
expect(fields).toContain('data.scriptType');
|
||||
expect(fields).toContain('data.script_type');
|
||||
expect(fields).not.toContain('data');
|
||||
});
|
||||
|
||||
@@ -302,6 +361,7 @@ describe('coverage wiring', () => {
|
||||
'collector_degraded', 'spool_pressure_detected', 'telemetry_upload_poisoned', 'telemetry_upload_blocked',
|
||||
'ingress_backpressure', 'disk_space_low', 'diagnostics_collecting', 'diagnostics_uploaded', 'diagnostics_upload_failed',
|
||||
'app_tracking_summary',
|
||||
'script_started', 'script_completed', 'script_failed',
|
||||
];
|
||||
expect([...COVERAGE_EVENT_TYPES].sort()).toEqual([...consulted].sort());
|
||||
});
|
||||
|
||||
@@ -2013,7 +2013,7 @@ export interface GetLatestVersionsResponse {
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters. */
|
||||
/** Organization-wide MCP usage by user for ONE tenant — the caller's own (GetMcpOrganizationUsage) or the tenant a Global Admin / Global Reader names (GetGlobalMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant — plus the tenant's organization windows. Built from the tenant's organization counters. */
|
||||
export interface GetMcpOrganizationUsageResponse {
|
||||
tenantId: string;
|
||||
/** Effective range start (yyyyMMdd). */
|
||||
@@ -2021,6 +2021,10 @@ export interface GetMcpOrganizationUsageResponse {
|
||||
/** Effective range end (yyyyMMdd, inclusive). */
|
||||
dateTo: string;
|
||||
users: McpOrganizationUsageItem[];
|
||||
/** The tenant's organization windows (plan, limits, today / this month) from the same counters. */
|
||||
quota: McpOrganizationQuotaNode;
|
||||
/** Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. */
|
||||
daily: McpOrganizationDailyItem[];
|
||||
}
|
||||
|
||||
/** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */
|
||||
@@ -2822,6 +2826,22 @@ export interface McpHealthCheckResponse {
|
||||
check: HealthCheck;
|
||||
}
|
||||
|
||||
/** One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse. */
|
||||
export interface McpOrganizationDailyItem {
|
||||
/** yyyyMMdd. */
|
||||
date: string;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant. */
|
||||
export interface McpOrganizationQuotaNode {
|
||||
tenantPlan: string;
|
||||
dailyLimit: number;
|
||||
monthlyLimit: number;
|
||||
dailyUsed: number;
|
||||
monthlyUsed: number;
|
||||
}
|
||||
|
||||
/** One account's share of the organization budget, nested in GetMcpOrganizationUsageResponse. */
|
||||
export interface McpOrganizationUsageItem {
|
||||
userId: string;
|
||||
|
||||
@@ -32,6 +32,9 @@ export const COVERAGE_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'diagnostics_uploaded',
|
||||
'diagnostics_upload_failed',
|
||||
'app_tracking_summary',
|
||||
'script_started',
|
||||
'script_completed',
|
||||
'script_failed',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -43,10 +46,13 @@ export const COVERAGE_EVENT_FIELDS =
|
||||
'data.bootToAgentStartSeconds,data.agentUptimeSeconds,data.outcome,data.note,' +
|
||||
'data.earliestRejectedSourceTimestamp,' +
|
||||
'data.file,data.firstSkippedPattern,data.lineBudgetBreaks,data.regexTimeouts,data.oversizedLines,data.unanchoredPatterns,data.linesRead,' +
|
||||
'data.overwriteRewinds,data.overwriteBytesReprocessed,' +
|
||||
'data.collector,data.reason,data.errorType,' +
|
||||
'data.pendingItemCount,data.pendingBytes,data.kind,data.itemCount,data.disk_free_gb,' +
|
||||
'data.truncated,data.includedFiles,data.includedBytes,data.skippedFiles,data.skippedByReason,data.problemsByKind,' +
|
||||
'data.installingNames,data.downloadingNames';
|
||||
'data.installingNames,data.downloadingNames,' +
|
||||
// Scripts block; scriptType/script_type already ride in SUMMARY_EVENT_FIELDS' own list.
|
||||
'data.policyId,data.policy_id';
|
||||
|
||||
export interface CoverageWindow {
|
||||
/** Timestamp of the first `agent_started` — the earliest moment anything was observed live. */
|
||||
@@ -82,7 +88,15 @@ export interface ImeTrackerCoverage {
|
||||
lineBudgetBreaks: number | null;
|
||||
regexTimeouts: number | null;
|
||||
oversizedLines: number | null;
|
||||
/**
|
||||
* Times the tracker re-read a log block a concurrent IME process had written over bytes
|
||||
* it had already read (agents ≥ 2.0.1458). Recovered data — never a gap and not degradation.
|
||||
*/
|
||||
overwriteRewinds: number | null;
|
||||
overwriteBytesReprocessed: number | null;
|
||||
};
|
||||
/** Present when overwriteRewinds > 0: says in words that those blocks were recovered, not lost. */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface CollectorDegradation {
|
||||
@@ -120,6 +134,11 @@ export interface AppsCoverage {
|
||||
stillDownloading: string[];
|
||||
}
|
||||
|
||||
export interface ScriptsCoverage {
|
||||
/** Scripts whose start line was seen but no result before observation ended (terminal session only). */
|
||||
stillRunning: { policyId: string; scriptType: string }[];
|
||||
}
|
||||
|
||||
export interface SessionCoverage {
|
||||
window: CoverageWindow;
|
||||
imeTracker: ImeTrackerCoverage;
|
||||
@@ -127,6 +146,7 @@ export interface SessionCoverage {
|
||||
upload: UploadCoverage;
|
||||
diagnostics: DiagnosticsCoverage;
|
||||
apps: AppsCoverage;
|
||||
scripts: ScriptsCoverage;
|
||||
/** One calibrated line per observation gap; empty means nothing reported a gap. */
|
||||
gaps: string[];
|
||||
}
|
||||
@@ -265,8 +285,20 @@ export function buildSessionCoverage(
|
||||
lineBudgetBreaks: num(hits.data?.lineBudgetBreaks),
|
||||
regexTimeouts: num(hits.data?.regexTimeouts),
|
||||
oversizedLines: num(hits.data?.oversizedLines),
|
||||
overwriteRewinds: num(hits.data?.overwriteRewinds),
|
||||
overwriteBytesReprocessed: num(hits.data?.overwriteBytesReprocessed),
|
||||
}
|
||||
: undefined;
|
||||
// IME processes append to AgentExecutor.log and IntuneManagementExtension.log with independent
|
||||
// stream positions and overwrite each other; the tracker re-reads what changed. That is
|
||||
// recovered data — it must never read as a gap or as a degraded tracker.
|
||||
const rewinds = sessionTotals?.overwriteRewinds ?? 0;
|
||||
const overwriteNote =
|
||||
rewinds > 0
|
||||
? `${rewinds} log block(s) a concurrent IME process had overwritten were re-read` +
|
||||
`${sessionTotals?.overwriteBytesReprocessed ? ` (${Math.round(sessionTotals.overwriteBytesReprocessed / 1024)} KB)` : ''}` +
|
||||
' — script results and exit codes in them are complete, not missing.'
|
||||
: undefined;
|
||||
let imeTracker: ImeTrackerCoverage;
|
||||
if (degradedEv) {
|
||||
imeTracker = {
|
||||
@@ -298,6 +330,7 @@ export function buildSessionCoverage(
|
||||
} else {
|
||||
imeTracker = { degraded: false, ...(sessionTotals ? { sessionTotals } : {}) };
|
||||
}
|
||||
if (overwriteNote) imeTracker.note = overwriteNote;
|
||||
|
||||
// ── Collectors ──────────────────────────────────────────────────────────────
|
||||
const collectors: CollectorDegradation[] = [];
|
||||
@@ -404,6 +437,44 @@ export function buildSessionCoverage(
|
||||
inFlight('downloading', apps.stillDownloading);
|
||||
}
|
||||
|
||||
// ── Scripts without a terminal state ────────────────────────────────────────
|
||||
// The agent has no shutdown summary for scripts, so the stream itself is the evidence — the
|
||||
// same rule as the web panel's running placeholder: a script_started with no
|
||||
// script_completed/script_failed for the same policy (and type) at or after it.
|
||||
const scripts: ScriptsCoverage = { stillRunning: [] };
|
||||
if (isTerminalSessionStatus(session.status)) {
|
||||
const identity = (e: Ev): { key: string; policyId: string; scriptType: string } | null => {
|
||||
const policyId = str(e.data?.policyId) ?? str(e.data?.policy_id);
|
||||
if (!policyId) return null;
|
||||
const scriptType = str(e.data?.scriptType) ?? str(e.data?.script_type) ?? 'platform';
|
||||
return { key: `${policyId}-${scriptType}`, policyId, scriptType };
|
||||
};
|
||||
const finalsByKey = new Map<string, number[]>();
|
||||
for (const e of [...of('script_completed'), ...of('script_failed')]) {
|
||||
const id = identity(e);
|
||||
const t = ms(e.timestamp);
|
||||
if (!id || t === null) continue;
|
||||
const list = finalsByKey.get(id.key);
|
||||
if (list) list.push(t);
|
||||
else finalsByKey.set(id.key, [t]);
|
||||
}
|
||||
const listed = new Set<string>();
|
||||
for (const e of of('script_started')) {
|
||||
const id = identity(e);
|
||||
const t = ms(e.timestamp);
|
||||
if (!id || t === null || listed.has(id.key)) continue;
|
||||
if ((finalsByKey.get(id.key) ?? []).some((f) => f >= t)) continue;
|
||||
listed.add(id.key);
|
||||
scripts.stillRunning.push({ policyId: id.policyId, scriptType: id.scriptType });
|
||||
}
|
||||
if (scripts.stillRunning.length > 0) {
|
||||
const names = scripts.stillRunning.map((s) => `${s.scriptType} ${s.policyId}`).join(', ');
|
||||
gaps.push(
|
||||
`${scripts.stillRunning.length} script(s) still running when the agent stopped observing (${names}): outcome unknown — not observed to fail, not observed to finish.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
window: {
|
||||
observedFrom,
|
||||
@@ -419,6 +490,7 @@ export function buildSessionCoverage(
|
||||
upload,
|
||||
diagnostics,
|
||||
apps,
|
||||
scripts,
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ const FLEET_OVERVIEW_PAGE_SIZE = 25;
|
||||
|
||||
/**
|
||||
* query_raw_events rows per page when the projection carries DataJson. Measured over 30 days of
|
||||
* tool telemetry: with the payload a row is p50 0.6 KB, p90 3.3 KB, outliers 21-29 KB, so 100 rows
|
||||
* keep the p90 page (330 KB) under the 500 KB cap and the outliers are the hard cap's job
|
||||
* tool telemetry: with the payload a row is p50 0.6 KB, p90 3.3 KB, outliers 21-29 KB. Against the
|
||||
* tool's 250 KB cap (MAX_RESULT_SIZE_CHARS.events) the default keeps a p90 page (165 KB) under it
|
||||
* and the ceiling keeps a p50 page (60 KB; live 2026-09-10: 100 app_install_failed rows = 193 KB)
|
||||
* under it; a p90 page at the ceiling (330 KB) and the outliers are the hard cap's job
|
||||
* (toolResultText names the exact pageSize that fits). Without DataJson the schema's 1000 stands.
|
||||
*/
|
||||
export const RAW_EVENTS_PAYLOAD_PAGE_DEFAULT = 50;
|
||||
|
||||
@@ -126,9 +126,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every
|
||||
/// account whose requests were charged to this tenant's organization budget — its own members and any
|
||||
/// delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters.
|
||||
/// Organization-wide MCP usage by user for ONE tenant — the caller's own (GetMcpOrganizationUsage) or the
|
||||
/// tenant a Global Admin / Global Reader names (GetGlobalMcpOrganizationUsage): every account whose requests
|
||||
/// were charged to this tenant's organization budget — its own members and any delegated (MSP)
|
||||
/// administrators reading the tenant — plus the tenant's organization windows. Built from the tenant's
|
||||
/// organization counters.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class GetMcpOrganizationUsageResponse : IApiResponse
|
||||
@@ -139,6 +141,34 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>Effective range end (yyyyMMdd, inclusive).</summary>
|
||||
public string DateTo { get; set; } = default!;
|
||||
public IReadOnlyList<McpOrganizationUsageItem> Users { get; set; } = default!;
|
||||
/// <summary>The tenant's organization windows (plan, limits, today / this month) from the same counters.</summary>
|
||||
public McpOrganizationQuotaNode Quota { get; set; } = default!;
|
||||
/// <summary>Requests charged to the tenant per day inside the range, oldest first; days without requests are absent.</summary>
|
||||
public IReadOnlyList<McpOrganizationDailyItem> Daily { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>One day of the tenant's organization budget, nested in <see cref="GetMcpOrganizationUsageResponse"/>.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpOrganizationDailyItem
|
||||
{
|
||||
/// <summary>yyyyMMdd.</summary>
|
||||
public string Date { get; set; } = default!;
|
||||
public long Requests { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The organization-wide windows of the tenant in <see cref="GetMcpOrganizationUsageResponse"/>: its tenant
|
||||
/// plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every
|
||||
/// account charged to the tenant.
|
||||
/// </summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpOrganizationQuotaNode
|
||||
{
|
||||
public string TenantPlan { get; set; } = default!;
|
||||
public int DailyLimit { get; set; }
|
||||
public int MonthlyLimit { get; set; }
|
||||
public long DailyUsed { get; set; }
|
||||
public long MonthlyUsed { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One account's share of the organization budget, nested in <see cref="GetMcpOrganizationUsageResponse"/>.</summary>
|
||||
|
||||
@@ -220,6 +220,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
];
|
||||
const imeSkipParts = imeSkipCounters.filter(([, v]) => Number(v) > 0).map(([k, v]) => `${v} ${k}`);
|
||||
const imeSkips = imeSkipParts.length;
|
||||
// Log blocks a concurrent Intune process wrote over after the agent had read them, re-read by
|
||||
// the agent (agents ≥ 2.0.1458). Recovered data — shown apart from "Skipped work" on purpose.
|
||||
const imeOverwriteRewinds = patternHits?.overwriteRewinds ?? imeTracker?.ime_overwrite_rewinds;
|
||||
const imeOverwriteBytes = Number(patternHits?.overwriteBytesReprocessed ?? imeTracker?.ime_overwrite_bytes_reprocessed ?? 0);
|
||||
const apiLatencyMs = session?.avgApiLatencyMs ?? 0;
|
||||
const hasConnectivity = apiLatencyMs > 0 || bandwidthEstimate || metricsSnapshot;
|
||||
|
||||
@@ -676,6 +680,17 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
? "Matching was cut short on some log lines — pattern coverage may be incomplete for this session. Nothing to fix on your side; use Report Session if the stats look wrong."
|
||||
: undefined}
|
||||
/>
|
||||
{imeOverwriteRewinds !== undefined && (
|
||||
<DetailRow
|
||||
label="Overwritten log blocks recovered"
|
||||
value={Number(imeOverwriteRewinds) > 0
|
||||
? `${Number(imeOverwriteRewinds)}${imeOverwriteBytes > 0 ? ` (${formatBytesCompact(imeOverwriteBytes)} re-read)` : ""}`
|
||||
: "none"}
|
||||
title={Number(imeOverwriteRewinds) > 0
|
||||
? "Intune processes wrote over log lines the agent had already read; the agent re-read them, so script results and exit codes in those blocks are complete. Nothing to fix on your side."
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
{(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns) !== undefined && Number(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns) > 0 && (
|
||||
<DetailRow label="Unanchored patterns" value={String(patternHits?.unanchoredPatterns ?? imeTracker?.ime_unanchored_patterns)} />
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { V1_PHASE_NAMES, V2_PHASE_NAMES, V1_PHASE_ORDER, V2_PHASE_ORDER } from "
|
||||
import { detectSkipUserStatusPage } from "../utils/espConfig";
|
||||
import { computeWhiteGloveDurations, computeWhiteGloveSplitSequence, groupEventsByPhase } from "../utils/eventHelpers";
|
||||
import type { WhiteGloveDurations } from "../utils/eventHelpers";
|
||||
import { sumStandbySeconds } from "@/lib/standby";
|
||||
import { enrollmentWindowOf, sumStandbySeconds } from "@/lib/standby";
|
||||
|
||||
interface PhaseGrouping {
|
||||
eventsByPhase: Record<string, EnrollmentEvent[]>;
|
||||
@@ -136,17 +136,7 @@ export function useSessionDerivedData(
|
||||
// agent started — environment observation, not enrollment activity — and would drag the
|
||||
// start toward pre-enrollment OOBE idle time (mirrors the backend anchor eligibility;
|
||||
// field case d0c5b672).
|
||||
const enrollmentWindow = useMemo(() => {
|
||||
const activityEvents = events.filter(e => e.source !== "SystemTimelineWatcher");
|
||||
if (activityEvents.length === 0) return null;
|
||||
const timestamps = activityEvents.map(e => new Date(e.timestamp).getTime());
|
||||
const firstEventTime = Math.min(...timestamps);
|
||||
const completeEvent = activityEvents.find(e => e.eventType === "enrollment_complete");
|
||||
const endTime = completeEvent
|
||||
? new Date(completeEvent.timestamp).getTime()
|
||||
: Math.max(...timestamps);
|
||||
return { startMs: firstEventTime, endMs: endTime };
|
||||
}, [events]);
|
||||
const enrollmentWindow = useMemo(() => enrollmentWindowOf(events), [events]);
|
||||
|
||||
const enrollmentDurationFromEvents = useMemo(() => {
|
||||
if (!enrollmentWindow) return null;
|
||||
|
||||
@@ -43,9 +43,10 @@ export default function NetworkBand({ model }: { model: NetworkModel }) {
|
||||
|
||||
// Headroom above the phase lane for the slanted lifecycle-marker labels
|
||||
// (Reboot / Desktop / Completed / Clock ±… ) written directly at their dots —
|
||||
// sized so even a long "Clock −3h 0m" label stays inside the SVG instead of
|
||||
// sized so even the longest label, "Completed (timeout)" at 10 px slanted 30°
|
||||
// (~105 px long → ~52 px tall above its anchor), stays inside the SVG instead of
|
||||
// running into the HTML legend above it.
|
||||
const MARKER_LABEL_H = 48;
|
||||
const MARKER_LABEL_H = 64;
|
||||
const PHASE_Y = 8 + MARKER_LABEL_H;
|
||||
const PHASE_H = 22;
|
||||
const BAND_Y = PHASE_Y + 32;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
|
||||
import { sessionUrl } from '@/lib/routes';
|
||||
import NetworkBand from './NetworkBand';
|
||||
import { buildNetworkModel, fmtDuration, NetworkModel } from './networkTimelineModel';
|
||||
import { enrollmentWindowOf, sumStandbySeconds } from '@/lib/standby';
|
||||
import type { EnrollmentEvent, Session } from '@/types';
|
||||
import type { GetSessionEventsResponse } from "@/utils/wire-types.generated";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
@@ -119,7 +120,7 @@ function Content() {
|
||||
<span className="font-medium">{session.status}</span> ·{' '}
|
||||
<Link
|
||||
href={sessionUrl(session.sessionId, { tenantId: session.tenantId })}
|
||||
className="text-blue-600 hover:underline"
|
||||
className="text-green-700 hover:text-green-800 hover:underline"
|
||||
>
|
||||
Open session detail
|
||||
</Link>
|
||||
@@ -131,7 +132,7 @@ function Content() {
|
||||
|
||||
{model && session && (
|
||||
<>
|
||||
<StatsRow model={model} />
|
||||
<StatsRow model={model} events={events} />
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 mb-6">
|
||||
<NetworkBand model={model} />
|
||||
{model.hotspotDetected && (
|
||||
@@ -150,14 +151,25 @@ function Content() {
|
||||
|
||||
// ── Stats row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsRow({ model }: { model: NetworkModel }) {
|
||||
function StatsRow({ model, events }: { model: NetworkModel; events: EnrollmentEvent[] }) {
|
||||
// The band and the tiles cover the observed span (until the agent stopped); the session page
|
||||
// measures the enrollment (first activity event → verdict). Where the two differ, the tile
|
||||
// names the enrollment figure so both pages read the same two numbers.
|
||||
const enrollment = enrollmentWindowOf(events);
|
||||
const enrollmentMs = enrollment ? enrollment.endMs - enrollment.startMs : null;
|
||||
const enrollmentAsleepMs = enrollment ? (sumStandbySeconds(events, [enrollment]) ?? 0) * 1000 : null;
|
||||
const differs = (a: number | null, b: number) => a !== null && Math.abs(a - b) >= 1000;
|
||||
const wifiSegs = model.segments.filter((s) => s.kind === 'wifi' && s.signalPercent != null);
|
||||
const avgSignal = wifiSegs.length
|
||||
? Math.round(wifiSegs.reduce((a, s) => a + (s.signalPercent ?? 0), 0) / wifiSegs.length)
|
||||
: null;
|
||||
const lastCheck = model.checks[model.checks.length - 1];
|
||||
const items: { label: string; value: string; tone?: 'bad' | 'good' }[] = [
|
||||
{ label: 'Duration', value: fmtDuration(model.t1 - model.t0) },
|
||||
const items: { label: string; value: string; tone?: 'bad' | 'good'; hint?: string }[] = [
|
||||
{
|
||||
label: 'Duration',
|
||||
value: fmtDuration(model.t1 - model.t0),
|
||||
hint: differs(enrollmentMs, model.t1 - model.t0) ? `enrollment ${fmtDuration(enrollmentMs!)}` : undefined,
|
||||
},
|
||||
{ label: 'Networks', value: String(model.distinctNetworks.size) },
|
||||
{ label: 'Network change events', value: String(model.switchCount) },
|
||||
{
|
||||
@@ -166,7 +178,14 @@ function StatsRow({ model }: { model: NetworkModel }) {
|
||||
tone: model.offlineMs > 0 ? 'bad' : undefined,
|
||||
},
|
||||
];
|
||||
if (model.asleepMs > 0) items.push({ label: 'Asleep total', value: fmtDuration(model.asleepMs) });
|
||||
if (model.asleepMs > 0)
|
||||
items.push({
|
||||
label: 'Asleep total',
|
||||
value: fmtDuration(model.asleepMs),
|
||||
hint: differs(enrollmentAsleepMs, model.asleepMs)
|
||||
? `${enrollmentAsleepMs! > 0 ? fmtDuration(enrollmentAsleepMs!) : 'none'} during enrollment`
|
||||
: undefined,
|
||||
});
|
||||
if (model.clockChangeCount > 0) items.push({ label: 'Clock changes', value: String(model.clockChangeCount) });
|
||||
if (avgSignal != null) {
|
||||
items.push({ label: 'Avg WiFi signal', value: `${avgSignal}%` });
|
||||
@@ -194,6 +213,7 @@ function StatsRow({ model }: { model: NetworkModel }) {
|
||||
>
|
||||
{it.value}
|
||||
</div>
|
||||
{it.hint && <div className="text-[11px] text-gray-500">{it.hint}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -674,6 +674,7 @@ function SessionDetailContent() {
|
||||
showScriptOutput={showScriptOutput}
|
||||
latestBootstrapVersion={latestBootstrapVersion}
|
||||
displayNamesByRefKey={scriptDisplayNamesByRefKey}
|
||||
observedUntilMs={observedUntilMs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { GlobalAdminScope } from "@/hooks";
|
||||
|
||||
// One scope instance for the reporting subtree: the header (ReportingSidebar) owns the banner and the
|
||||
// tenant selector, the sections read the selection. A second useGlobalAdminScope() call would hold its
|
||||
// own selection state and never see the header's changes.
|
||||
const ReportingScopeContext = createContext<GlobalAdminScope | null>(null);
|
||||
|
||||
export const ReportingScopeProvider = ReportingScopeContext.Provider;
|
||||
|
||||
export function useReportingScope(): GlobalAdminScope {
|
||||
const scope = useContext(ReportingScopeContext);
|
||||
if (!scope) throw new Error("useReportingScope must be used inside the reporting layout");
|
||||
return scope;
|
||||
}
|
||||
@@ -1,17 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useGlobalAdminScope } from "@/hooks";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { ReportingScopeProvider } from "./ReportingScopeContext";
|
||||
|
||||
export function ReportingSidebar({ children }: { children: React.ReactNode }) {
|
||||
// Override-only scope (always a concrete tenant, defaulting to the caller's own). A delegated (MSP)
|
||||
// caller gets neither banner nor selector here: the organization route never lists a managed
|
||||
// tenant's accounts, so the reporting sections stay on their own tenant for them.
|
||||
const scope = useGlobalAdminScope();
|
||||
const crossTenant = scope.isGlobalAdmin && !scope.isDelegatedScope;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportingScopeProvider value={scope}>
|
||||
<GlobalAdminBanner show={crossTenant} subtitle={globalAdminSubtitle(scope)} />
|
||||
<header className="bg-white shadow">
|
||||
<div className="py-6 px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-normal text-gray-900">Reporting</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">AI agent access and usage statistics</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Reporting</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">AI agent access and usage statistics</p>
|
||||
</div>
|
||||
{crossTenant && <TenantScopeSelector scope={scope} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
</ReportingScopeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+131
-73
@@ -1,13 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { SegmentedControl, TIME_RANGE_OPTIONS } from "@/components/SegmentedControl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { ApiError, apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { scopedApi } from "@/lib/scopedApi";
|
||||
import { useReportingScope } from "../ReportingScopeContext";
|
||||
import { DocsLink } from "@/components/DocsLink";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type { GetMcpOrganizationUsageResponse, GetMyMcpUsageResponse, McpOrganizationUsageItem, McpUsageQuotaNode } from "@/utils/wire-types.generated";
|
||||
import type {
|
||||
GetMcpOrganizationUsageResponse,
|
||||
GetMyMcpUsageResponse,
|
||||
McpOrganizationQuotaNode,
|
||||
McpUsageQuotaNode,
|
||||
} from "@/utils/wire-types.generated";
|
||||
import { isApplicationKey, principalLabel } from "@/utils/principalKeys";
|
||||
|
||||
interface UsageRecord {
|
||||
@@ -20,10 +27,10 @@ interface UsageRecord {
|
||||
lastRequestAt: string;
|
||||
}
|
||||
|
||||
/** One bar of the daily chart — the caller's own requests, or the selected tenant's charged requests. */
|
||||
interface DailyAggregate {
|
||||
date: string;
|
||||
totalRequests: number;
|
||||
endpoints: number;
|
||||
}
|
||||
|
||||
// GetMyMcpUsageResponse.quota — the caller's own windows plus the organization-wide windows every
|
||||
@@ -76,12 +83,34 @@ function getDateTo(): string {
|
||||
return new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
}
|
||||
|
||||
/** The caller's own per-endpoint records folded to one bar per day, newest first. */
|
||||
function aggregateOwnRecords(records: UsageRecord[]): DailyAggregate[] {
|
||||
const byDate = new Map<string, number>();
|
||||
for (const r of records) byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.requestCount);
|
||||
return Array.from(byDate.entries())
|
||||
.map(([date, totalRequests]) => ({ date, totalRequests }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
}
|
||||
|
||||
export function SectionMcpUsage() {
|
||||
const { getAccessToken, user } = useAuth();
|
||||
const canSeeOrganization = !!(user?.isTenantAdmin || user?.isGlobalAdmin);
|
||||
|
||||
// Global-admin tenant scope from the reporting header (override-only: always a concrete tenant,
|
||||
// defaulting to the caller's own). Viewing ANOTHER tenant is a tenant view: nothing of the caller's
|
||||
// own account is shown — no UPN, no plan badge, no own quota — and the tiles and the daily chart
|
||||
// come from the tenant's organization counters. On the own tenant the page stays the caller's usage
|
||||
// page. A delegated (MSP) caller keeps the member path: the organization route never lists a
|
||||
// managed tenant's accounts (the header shows them no selector).
|
||||
const scope = useReportingScope();
|
||||
const crossTenant = scope.routeGlobal && !scope.isDelegatedScope;
|
||||
const { effectiveTenantId, isGlobalOverride: tenantView } = scope;
|
||||
const tenantLabel = scope.tenants.find((t) => t.tenantId === effectiveTenantId)?.domainName ?? effectiveTenantId;
|
||||
|
||||
const [records, setRecords] = useState<UsageRecord[]>([]);
|
||||
// Organization budget by account — tenant admins only; null = not loaded / not permitted.
|
||||
const [orgUsers, setOrgUsers] = useState<McpOrganizationUsageItem[] | null>(null);
|
||||
// Organization budget by account, the tenant's windows and its daily series — tenant admins / GA only;
|
||||
// null = not loaded / not permitted.
|
||||
const [orgUsage, setOrgUsage] = useState<GetMcpOrganizationUsageResponse | null>(null);
|
||||
const [usagePlan, setUsagePlan] = useState<string | null>(null);
|
||||
const [effectivePlan, setEffectivePlan] = useState<string | null>(null);
|
||||
const [quota, setQuota] = useState<QuotaState | null>(null);
|
||||
@@ -90,38 +119,52 @@ export function SectionMcpUsage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("30d");
|
||||
|
||||
// Latest-wins guard: a tenant switch starts a new fetch while an older one may still be in flight;
|
||||
// only the most recently started request may write state.
|
||||
const fetchSeqRef = useRef(0);
|
||||
|
||||
const fetchUsage = useCallback(async (range: DateRange) => {
|
||||
// The cross-tenant path needs the selected tenant; the member path is JWT-bound.
|
||||
if (crossTenant && !effectiveTenantId) return;
|
||||
const seq = ++fetchSeqRef.current;
|
||||
const isCurrent = () => fetchSeqRef.current === seq;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const dateFrom = getDateFrom(range);
|
||||
const dateTo = getDateTo();
|
||||
const data = await fetchJson<GetMyMcpUsageResponse>(
|
||||
api.mcpUsage.me(dateFrom, dateTo),
|
||||
getAccessToken
|
||||
);
|
||||
setRecords(data.records || []);
|
||||
setUsagePlan(data.usagePlan || null);
|
||||
setEffectivePlan(data.effectivePlan || null);
|
||||
setQuota(data.quota ?? null);
|
||||
setUpn(data.upn || "");
|
||||
|
||||
if (canSeeOrganization) {
|
||||
// Every account charged to this tenant's organization budget — including delegated (MSP)
|
||||
// administrators reading the tenant. A 403 (role changed mid-session) just hides the card.
|
||||
const org = await fetchJson<GetMcpOrganizationUsageResponse>(api.mcpUsage.organization(dateFrom, dateTo), getAccessToken)
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof ApiError) return null;
|
||||
throw err;
|
||||
});
|
||||
setOrgUsers(org ? org.users ?? [] : null);
|
||||
}
|
||||
const orgSelection = { routeGlobal: crossTenant, selectedTenantId: effectiveTenantId, effectiveTenantId };
|
||||
const [own, org] = await Promise.all([
|
||||
// The caller's own usage has no place in another tenant's view.
|
||||
tenantView
|
||||
? Promise.resolve(null)
|
||||
: fetchJson<GetMyMcpUsageResponse>(api.mcpUsage.me(dateFrom, dateTo), getAccessToken),
|
||||
canSeeOrganization
|
||||
? // Every account charged to the tenant's organization budget — including delegated (MSP)
|
||||
// administrators reading it. A 403 (role changed mid-session) just hides the card.
|
||||
fetchJson<GetMcpOrganizationUsageResponse>(
|
||||
scopedApi.mcpOrganizationUsage(orgSelection, dateFrom, dateTo),
|
||||
getAccessToken
|
||||
).catch((err: unknown) => {
|
||||
if (err instanceof ApiError) return null;
|
||||
throw err;
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
if (!isCurrent()) return;
|
||||
setRecords(own?.records ?? []);
|
||||
setUsagePlan(own?.usagePlan || null);
|
||||
setEffectivePlan(own?.effectivePlan || null);
|
||||
setQuota(own?.quota ?? null);
|
||||
setUpn(own?.upn || "");
|
||||
setOrgUsage(org);
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return;
|
||||
setError(apiErrorText(err, "Failed to fetch usage data"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (isCurrent()) setLoading(false);
|
||||
}
|
||||
}, [getAccessToken, canSeeOrganization]);
|
||||
}, [getAccessToken, canSeeOrganization, crossTenant, effectiveTenantId, tenantView]);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
@@ -130,24 +173,33 @@ export function SectionMcpUsage() {
|
||||
void run();
|
||||
}, [fetchUsage, dateRange]);
|
||||
|
||||
const orgUsers = orgUsage?.users ?? null;
|
||||
const delegatedReaders = orgUsers?.filter((u) => u.delegated).length ?? 0;
|
||||
|
||||
// Aggregate records by date
|
||||
const dailyAggregates: DailyAggregate[] = (() => {
|
||||
const byDate = new Map<string, { total: number; endpoints: Set<string> }>();
|
||||
for (const r of records) {
|
||||
const existing = byDate.get(r.date);
|
||||
if (existing) {
|
||||
existing.total += r.requestCount;
|
||||
existing.endpoints.add(r.endpoint);
|
||||
} else {
|
||||
byDate.set(r.date, { total: r.requestCount, endpoints: new Set([r.endpoint]) });
|
||||
}
|
||||
}
|
||||
return Array.from(byDate.entries())
|
||||
.map(([date, v]) => ({ date, totalRequests: v.total, endpoints: v.endpoints.size }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date));
|
||||
})();
|
||||
// Organization windows: from the organization response when the caller may read it; otherwise
|
||||
// (members without an admin role) from the caller's own quota node — but only on the own tenant,
|
||||
// because under a tenant override that node describes the CALLER's tenant, not the selected one.
|
||||
const orgQuota: McpOrganizationQuotaNode | null =
|
||||
orgUsage?.quota ??
|
||||
(!crossTenant && quota
|
||||
? {
|
||||
tenantPlan: quota.tenantPlan,
|
||||
dailyLimit: quota.tenantDailyLimit,
|
||||
monthlyLimit: quota.tenantMonthlyLimit,
|
||||
dailyUsed: quota.tenantDailyUsed,
|
||||
monthlyUsed: quota.tenantMonthlyUsed,
|
||||
}
|
||||
: null);
|
||||
|
||||
// Wording follows the viewpoint: the caller's own tenant, or the tenant a global admin picked.
|
||||
const tenantNoun = tenantView ? "this tenant" : "your tenant";
|
||||
const budgetNoun = tenantView ? "this tenant's organization budget" : "your organization budget";
|
||||
const planNoun = tenantView ? "its plan" : "your plan";
|
||||
|
||||
// Tiles + chart: the tenant's charged requests per day in the tenant view, else the caller's own.
|
||||
const dailyAggregates: DailyAggregate[] = tenantView
|
||||
? (orgUsage?.daily ?? []).map((d) => ({ date: d.date, totalRequests: d.requests })).reverse()
|
||||
: aggregateOwnRecords(records);
|
||||
|
||||
const totalRequests = dailyAggregates.reduce((sum, d) => sum + d.totalRequests, 0);
|
||||
const todayStr = getDateTo();
|
||||
@@ -160,16 +212,18 @@ export function SectionMcpUsage() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">MCP Usage</h2>
|
||||
{upn && <p className="text-sm text-gray-500">{upn}</p>}
|
||||
{tenantView
|
||||
? <p className="text-sm text-gray-500">Tenant: {tenantLabel}</p>
|
||||
: upn && <p className="text-sm text-gray-500">{upn}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Plan Badge */}
|
||||
{usagePlan && (
|
||||
{/* Plan Badge — the caller's own plan, absent in another tenant's view */}
|
||||
{!tenantView && usagePlan && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
|
||||
Plan: {usagePlan}
|
||||
</span>
|
||||
)}
|
||||
{!usagePlan && (
|
||||
{!tenantView && !usagePlan && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
|
||||
Plan: {effectivePlan ? `${effectivePlan} (inherited)` : "inherited"}
|
||||
</span>
|
||||
@@ -198,33 +252,37 @@ export function SectionMcpUsage() {
|
||||
)}
|
||||
|
||||
{/* Quota: the caller's own windows and the organization-wide windows shared by every member */}
|
||||
{quota && (
|
||||
{(quota || orgQuota) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">Your quota</h3>
|
||||
<span className="text-xs text-gray-500">plan {effectivePlan ?? "—"}</span>
|
||||
{quota && (
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">Your quota</h3>
|
||||
<span className="text-xs text-gray-500">plan {effectivePlan ?? "—"}</span>
|
||||
</div>
|
||||
<QuotaBar label="Today" used={quota.dailyUsed} limit={quota.dailyLimit} />
|
||||
<QuotaBar label="This month" used={quota.monthlyUsed} limit={quota.monthlyLimit} />
|
||||
</div>
|
||||
<QuotaBar label="Today" used={quota.dailyUsed} limit={quota.dailyLimit} />
|
||||
<QuotaBar label="This month" used={quota.monthlyUsed} limit={quota.monthlyLimit} />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">Organization quota</h3>
|
||||
<span className="text-xs text-gray-500">tenant plan {quota.tenantPlan}</span>
|
||||
</div>
|
||||
<QuotaBar label="Today (all members)" used={quota.tenantDailyUsed} limit={quota.tenantDailyLimit} />
|
||||
<QuotaBar label="This month (all members)" used={quota.tenantMonthlyUsed} limit={quota.tenantMonthlyLimit} />
|
||||
<p className="text-xs text-gray-500">
|
||||
Shared by every account in your tenant and by delegated (MSP) administrators reading it. A personal
|
||||
plan override widens only the account's own windows, never these.
|
||||
</p>
|
||||
{user?.isDelegated && (
|
||||
)}
|
||||
{orgQuota && (
|
||||
<div className="bg-white rounded-lg shadow p-4 sm:p-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-900">Organization quota</h3>
|
||||
<span className="text-xs text-gray-500">tenant plan {orgQuota.tenantPlan}</span>
|
||||
</div>
|
||||
<QuotaBar label="Today (all members)" used={orgQuota.dailyUsed} limit={orgQuota.dailyLimit} />
|
||||
<QuotaBar label="This month (all members)" used={orgQuota.monthlyUsed} limit={orgQuota.monthlyLimit} />
|
||||
<p className="text-xs text-gray-500">
|
||||
Your reads into tenants you manage are charged to that tenant's own plan, not to these windows.
|
||||
Shared by every account in {tenantNoun} and by delegated (MSP) administrators reading it. A personal
|
||||
plan override widens only the account's own windows, never these.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user?.isDelegated && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Your reads into tenants you manage are charged to that tenant's own plan, not to these windows.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -239,7 +297,7 @@ export function SectionMcpUsage() {
|
||||
</span>
|
||||
</div>
|
||||
{orgUsers.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No requests have been charged to your organization budget yet.</p>
|
||||
<p className="text-sm text-gray-500">No requests have been charged to {budgetNoun} yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
@@ -286,7 +344,7 @@ export function SectionMcpUsage() {
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
Every request counted against the organization windows above, by the account that made it. Delegated
|
||||
(MSP) administrators reading your tenant appear here too — their reads draw on your plan.
|
||||
(MSP) administrators reading {tenantNoun} appear here too — their reads draw on {planNoun}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -333,7 +391,7 @@ export function SectionMcpUsage() {
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && records.length === 0 && !error && (
|
||||
{!loading && dailyAggregates.length === 0 && !error && (
|
||||
<div className="bg-white rounded-lg shadow p-12 text-center">
|
||||
<p className="text-gray-500">No usage data found for the selected period.</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { compareVersions } from "@/utils/bootstrapVersion";
|
||||
import {
|
||||
applyObservationEnd,
|
||||
buildScriptItemLabel,
|
||||
formatScriptDuration,
|
||||
getPhaseBadge,
|
||||
@@ -37,14 +38,20 @@ interface ScriptExecutionsProps {
|
||||
* stay distinct (rare in practice but never silently collide).
|
||||
*/
|
||||
displayNamesByRefKey?: DisplayNamesByRefKey;
|
||||
// Epoch ms of the agent's last report, passed only once the session is terminal. A script
|
||||
// still running at that point renders as Incomplete instead of ticking against the wall clock.
|
||||
observedUntilMs?: number | null;
|
||||
}
|
||||
|
||||
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey }: ScriptExecutionsProps) {
|
||||
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null }: ScriptExecutionsProps) {
|
||||
// Legacy-agent guard: split off script events replayed from a previous enrollment's IME
|
||||
// log (newer agents suppress them at the source) so week-old runs never render as current
|
||||
// executions. The muted note below keeps the gap explainable.
|
||||
const { current, historicCount } = useMemo(() => partitionHistoricScriptEvents(events), [events]);
|
||||
const cards = useMemo(() => groupScriptItems(reduceScriptEvents(current)), [current]);
|
||||
const cards = useMemo(
|
||||
() => groupScriptItems(applyObservationEnd(reduceScriptEvents(current), observedUntilMs)),
|
||||
[current, observedUntilMs]
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
@@ -54,6 +61,9 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
|
||||
// "1 succeeded" rather than "1 succeeded + 2 non-compliant" when its detection /
|
||||
// post-detection were non-compliant en route.
|
||||
const runningCount = cards.filter(c => c.headerState === "Running").length;
|
||||
// Still running when observation ended — unknown outcome, counted apart from "running"
|
||||
// so a finished session never claims a script is executing.
|
||||
const incompleteCount = cards.filter(c => c.headerState === "Incomplete").length;
|
||||
const successCount = cards.filter(c => c.headerState === "Success").length;
|
||||
const nonCompliantCount = cards.filter(c => c.headerState === "NonCompliant").length;
|
||||
const failedCount = cards.filter(c => c.headerState === "Failed").length;
|
||||
@@ -77,6 +87,15 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
|
||||
{runningCount} running
|
||||
</span>
|
||||
)}
|
||||
{incompleteCount > 0 && (
|
||||
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
|
||||
title="Still running when the agent stopped observing — the outcome was never seen. Not a failure."
|
||||
>
|
||||
{incompleteCount} incomplete
|
||||
</span>
|
||||
)}
|
||||
{successCount > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium">
|
||||
{successCount} succeeded
|
||||
@@ -144,7 +163,8 @@ function getIntuneScriptUrl(policyId: string, scriptType: string): string | null
|
||||
function cardContainerClass(state: ScriptCard["headerState"]): string {
|
||||
switch (state) {
|
||||
case "Failed": return "bg-red-50 border border-red-200";
|
||||
case "Running": return "bg-gray-50 border border-gray-200";
|
||||
case "Running":
|
||||
case "Incomplete": return "bg-gray-50 border border-gray-200";
|
||||
case "NonCompliant": return "bg-amber-50 border border-amber-200";
|
||||
default: return "bg-green-50 border border-green-200";
|
||||
}
|
||||
@@ -154,6 +174,7 @@ function cardIconColor(state: ScriptCard["headerState"]): string {
|
||||
switch (state) {
|
||||
case "Failed": return "text-red-500";
|
||||
case "Running": return "text-gray-500";
|
||||
case "Incomplete": return "text-slate-400";
|
||||
case "NonCompliant": return "text-amber-500";
|
||||
default: return "text-green-500";
|
||||
}
|
||||
@@ -163,6 +184,7 @@ function cardStatusTextColor(state: ScriptCard["headerState"]): string {
|
||||
switch (state) {
|
||||
case "Failed": return "text-red-600";
|
||||
case "Running": return "text-gray-600";
|
||||
case "Incomplete": return "text-slate-600 dark:text-slate-300";
|
||||
case "NonCompliant": return "text-amber-700";
|
||||
default: return "text-green-600";
|
||||
}
|
||||
@@ -283,6 +305,14 @@ function CardIcon({ state, className }: { state: ScriptCard["headerState"]; clas
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (state === "Incomplete") {
|
||||
// Question-mark in a circle — "we don't know", the glyph the install panel uses; no spin.
|
||||
return (
|
||||
<svg className={`w-4 h-4 flex-shrink-0 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (state === "Failed") {
|
||||
return (
|
||||
<svg className={`w-4 h-4 flex-shrink-0 ${className}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -306,17 +336,22 @@ function CardIcon({ state, className }: { state: ScriptCard["headerState"]; clas
|
||||
|
||||
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, displayName }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; nested?: boolean; displayName?: string | null }) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
// Re-render every 5s while in Running state so elapsed-time updates live.
|
||||
// Re-render every 5s while live in Running state so the elapsed time updates; an incomplete
|
||||
// placeholder is frozen at the observation end and never ticks.
|
||||
const isLiveRunning = item.state === "Running" && !item.isIncomplete;
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (item.state !== "Running") return;
|
||||
if (!isLiveRunning) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [item.state]);
|
||||
}, [isLiveRunning]);
|
||||
|
||||
const elapsedSeconds = item.state === "Running"
|
||||
const elapsedSeconds = isLiveRunning
|
||||
? Math.max(0, Math.floor((now - new Date(item.timestamp).getTime()) / 1000))
|
||||
: null;
|
||||
// Lower bound for an incomplete row: watched this long after the start line, then the
|
||||
// agent's reports stopped. Never the run time.
|
||||
const observedLabel = item.isIncomplete && item.observedMs != null ? formatScriptDuration(item.observedMs / 1000) : null;
|
||||
const isStale = elapsedSeconds !== null && elapsedSeconds > STALE_RUNNING_THRESHOLD_SECONDS;
|
||||
|
||||
const label = buildScriptItemLabel(item);
|
||||
@@ -343,7 +378,9 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
|
||||
// Status text for the right-hand summary cell
|
||||
let statusText: string;
|
||||
if (item.state === "Running") {
|
||||
if (item.isIncomplete) {
|
||||
statusText = "Incomplete";
|
||||
} else if (item.state === "Running") {
|
||||
statusText = isStale ? `Running (${elapsedSeconds}s — stuck?)` : `Running (${elapsedSeconds}s)`;
|
||||
} else if (item.scriptType === "remediation" && item.complianceResult) {
|
||||
statusText = item.complianceResult === "True" ? "Compliant" : "Non-compliant";
|
||||
@@ -372,7 +409,12 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
<div className={`rounded-lg p-3 ${containerClass}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 min-w-0">
|
||||
{item.state === "Running" ? (
|
||||
{item.isIncomplete ? (
|
||||
// Same question-mark as the install panel's Incomplete row: no spinner, nothing runs any more.
|
||||
<svg className="w-4 h-4 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : item.state === "Running" ? (
|
||||
<svg className={`w-4 h-4 text-gray-500 flex-shrink-0 ${isStale ? "" : "animate-spin"}`} fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -443,14 +485,26 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||
<span className={`font-medium ${
|
||||
item.state === "Failed" ? "text-red-600"
|
||||
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-gray-600")
|
||||
: isNonCompliant ? "text-amber-700"
|
||||
: "text-green-600"
|
||||
}`}>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
item.isIncomplete ? "text-slate-600 dark:text-slate-300"
|
||||
: item.state === "Failed" ? "text-red-600"
|
||||
: item.state === "Running" ? (isStale ? "text-amber-600" : "text-gray-600")
|
||||
: isNonCompliant ? "text-amber-700"
|
||||
: "text-green-600"
|
||||
}`}
|
||||
title={item.isIncomplete ? "Still running when the agent stopped observing. The outcome is unknown — the script may well have finished after the session ended." : undefined}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
{observedLabel && (
|
||||
<span
|
||||
className="font-mono text-gray-400 tabular-nums"
|
||||
title={`Watched for ${observedLabel} until the agent's last report — the run time is unknown.`}
|
||||
>
|
||||
{`≥ ${observedLabel}`}
|
||||
</span>
|
||||
)}
|
||||
{item.exitCode != null && (
|
||||
<span className={`font-mono ${
|
||||
item.state === "Failed" && item.exitCode !== 0 ? "text-red-600"
|
||||
|
||||
@@ -65,6 +65,21 @@ describe("scopedApi routing", () => {
|
||||
expect(scopedApi.appMetrics(corrupt, 30)).not.toContain("tenantId=");
|
||||
});
|
||||
|
||||
it("mcpOrganizationUsage routes by scope and never aggregates", () => {
|
||||
const t = scopedApi.mcpOrganizationUsage(tenantMode, "20260901", "20260910");
|
||||
expect(t).toContain("/api/metrics/mcp-usage/organization");
|
||||
expect(t).not.toContain("tenantId=");
|
||||
expect(t).toContain("dateFrom=20260901");
|
||||
const g = scopedApi.mcpOrganizationUsage(override, "20260901", "20260910");
|
||||
expect(g).toContain("/api/global/metrics/mcp-usage/organization");
|
||||
expect(g).toContain(`tenantId=${OTHER}`);
|
||||
expect(g).toContain("dateTo=20260910");
|
||||
// No aggregate path: an empty selection sends no tenantId (backend 400), never the member URL.
|
||||
const a = scopedApi.mcpOrganizationUsage(aggregated, "20260901", "20260910");
|
||||
expect(a).toContain("/global/");
|
||||
expect(a).not.toContain("tenantId=");
|
||||
});
|
||||
|
||||
it("appSessions places every positional argument on both variants", () => {
|
||||
const t = scopedApi.appSessions(tenantMode, "My App", 7, "failed", 20, 10);
|
||||
const g = scopedApi.appSessions(override, "My App", 7, "failed", 20, 10);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyObservationEnd,
|
||||
buildScriptItemLabel,
|
||||
formatScriptDuration,
|
||||
getPhaseBadge,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
mapRemediationStatus,
|
||||
partitionHistoricScriptEvents,
|
||||
reduceScriptEvents,
|
||||
scriptCardKey,
|
||||
scriptItemKey,
|
||||
toNumber,
|
||||
type ScriptInputEvent,
|
||||
@@ -606,6 +608,7 @@ describe("groupScriptItems", () => {
|
||||
scriptType: "remediation",
|
||||
state: "Success",
|
||||
timestamp: ts(0),
|
||||
isIncomplete: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -883,6 +886,7 @@ describe("groupScriptItems — card durationSeconds is the max phase run time",
|
||||
scriptType: "remediation",
|
||||
state: "Success",
|
||||
timestamp: ts(0),
|
||||
isIncomplete: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -985,3 +989,83 @@ describe("reduceScriptEvents — implausible duration blanking", () => {
|
||||
expect(items[1].reportedAfterSeconds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyObservationEnd", () => {
|
||||
// Shape of session 2ebcd480: six platform scripts ran in the account-setup tail after
|
||||
// enrollment_complete; the sixth started with the agent's very last event and never got a
|
||||
// final — the observation ended on its start line.
|
||||
const started = (id: string, at: number): ScriptInputEvent =>
|
||||
finalEvent({ eventType: "script_started", ts: at, data: { policyId: id, scriptType: "platform" } });
|
||||
const finished = (id: string, at: number, eventType = "script_completed"): ScriptInputEvent =>
|
||||
finalEvent({ eventType, ts: at, data: { policyId: id, scriptType: "platform", exitCode: 0, result: "Success" } });
|
||||
const events: ScriptInputEvent[] = [
|
||||
started("p-a", 0), finished("p-a", 5, "script_failed"),
|
||||
started("p-b", 6), finished("p-b", 9),
|
||||
started("p-c", 9), finished("p-c", 12),
|
||||
started("p-d", 13), finished("p-d", 16),
|
||||
started("p-e", 16), finished("p-e", 23),
|
||||
started("p-last", 24),
|
||||
];
|
||||
const lastReport = Date.parse(ts(24));
|
||||
|
||||
it("leaves every row untouched while the session is still live (null observation end)", () => {
|
||||
const items = reduceScriptEvents(events);
|
||||
expect(applyObservationEnd(items, null)).toBe(items);
|
||||
expect(items.every(i => !i.isIncomplete)).toBe(true);
|
||||
expect(items.find(i => i.state === "Running")?.policyId).toBe("p-last");
|
||||
});
|
||||
|
||||
it("marks the placeholder still running at the observation end as incomplete with the watched span", () => {
|
||||
const items = applyObservationEnd(reduceScriptEvents([started("p-x", 0)]), Date.parse(ts(30)));
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].state).toBe("Running");
|
||||
expect(items[0].isIncomplete).toBe(true);
|
||||
expect(items[0].observedMs).toBe(30 * 1000);
|
||||
});
|
||||
|
||||
it("never touches rows that reached a final", () => {
|
||||
const items = applyObservationEnd(reduceScriptEvents(events), lastReport);
|
||||
const finals = items.filter(i => i.state !== "Running");
|
||||
expect(finals).toHaveLength(5);
|
||||
expect(finals.every(i => !i.isIncomplete && i.observedMs === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps the watched span at zero when the start line was the last event seen", () => {
|
||||
const items = applyObservationEnd(reduceScriptEvents(events), lastReport);
|
||||
const last = items.find(i => i.policyId === "p-last")!;
|
||||
expect(last.isIncomplete).toBe(true);
|
||||
expect(last.observedMs).toBe(0);
|
||||
expect(applyObservationEnd(reduceScriptEvents([started("p-y", 10)]), Date.parse(ts(5)))[0].observedMs).toBe(0);
|
||||
});
|
||||
|
||||
it("folds into cards as Incomplete — outside running, succeeded and failed", () => {
|
||||
const cards = groupScriptItems(applyObservationEnd(reduceScriptEvents(events), lastReport));
|
||||
const states = cards.map(c => c.headerState);
|
||||
expect(states.filter(s => s === "Success")).toHaveLength(4);
|
||||
expect(states.filter(s => s === "Failed")).toHaveLength(1);
|
||||
expect(states.filter(s => s === "Running")).toHaveLength(0);
|
||||
const incomplete = cards.find(c => c.headerState === "Incomplete")!;
|
||||
expect(incomplete.policyId).toBe("p-last");
|
||||
expect(incomplete.headerLabel).toBe("Incomplete");
|
||||
expect(buildScriptItemLabel(incomplete.phases[0])).toBe("Platform Script");
|
||||
});
|
||||
|
||||
it("keeps the card identity across the live → terminal flip so the row is not remounted", () => {
|
||||
const live = groupScriptItems(reduceScriptEvents([started("p-x", 0)]))[0];
|
||||
const ended = groupScriptItems(applyObservationEnd(reduceScriptEvents([started("p-x", 0)]), Date.parse(ts(30))))[0];
|
||||
expect(live.headerState).toBe("Running");
|
||||
expect(ended.headerState).toBe("Incomplete");
|
||||
expect(scriptCardKey(ended)).toBe(scriptCardKey(live));
|
||||
});
|
||||
|
||||
it("a remediation cycle whose running phase was cut off reads Incomplete, a live one Running", () => {
|
||||
const cycle = [
|
||||
finalEvent({ eventType: "script_completed", ts: 0, data: { policyId: "r1", scriptType: "remediation", scriptPart: "detection", complianceResult: "False", exitCode: 1 } }),
|
||||
finalEvent({ eventType: "script_started", ts: 10, data: { policyId: "r1", scriptType: "remediation" } }),
|
||||
];
|
||||
expect(groupScriptItems(reduceScriptEvents(cycle))[0].headerState).toBe("Running");
|
||||
const ended = groupScriptItems(applyObservationEnd(reduceScriptEvents(cycle), Date.parse(ts(40))))[0];
|
||||
expect(ended.headerState).toBe("Incomplete");
|
||||
expect(ended.headerLabel).toBe("Incomplete");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EnrollmentEvent } from "@/types";
|
||||
import { sumStandbySeconds } from "../standby";
|
||||
import { enrollmentWindowOf, sumStandbySeconds } from "../standby";
|
||||
|
||||
const T = (iso: string) => Date.parse(iso);
|
||||
|
||||
@@ -71,3 +71,32 @@ describe("sumStandbySeconds", () => {
|
||||
expect(sumStandbySeconds([other], null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("enrollmentWindowOf", () => {
|
||||
const activity = (seq: number, ts: string, eventType = "log_entry", source = "Agent") =>
|
||||
({ eventType, source, timestamp: ts, sequence: seq, message: "" } as unknown as EnrollmentEvent);
|
||||
|
||||
it("runs from the first activity event to enrollment_complete", () => {
|
||||
const w = enrollmentWindowOf([
|
||||
activity(1, "2026-09-09T06:32:46Z"),
|
||||
activity(2, "2026-09-09T06:40:00Z"),
|
||||
activity(3, "2026-09-09T06:42:55Z", "enrollment_complete"),
|
||||
activity(4, "2026-09-09T06:45:45Z", "agent_shutting_down"),
|
||||
]);
|
||||
expect(w).toEqual({ startMs: Date.parse("2026-09-09T06:32:46Z"), endMs: Date.parse("2026-09-09T06:42:55Z") });
|
||||
});
|
||||
|
||||
it("ends at the last activity event without a verdict and ignores system-timeline rows", () => {
|
||||
const w = enrollmentWindowOf([
|
||||
activity(1, "2026-09-08T10:00:00Z", "system_sleep_episode", "SystemTimelineWatcher"),
|
||||
activity(2, "2026-09-09T06:32:46Z"),
|
||||
activity(3, "2026-09-09T06:40:00Z"),
|
||||
]);
|
||||
expect(w).toEqual({ startMs: Date.parse("2026-09-09T06:32:46Z"), endMs: Date.parse("2026-09-09T06:40:00Z") });
|
||||
});
|
||||
|
||||
it("is null without activity events", () => {
|
||||
expect(enrollmentWindowOf([activity(1, "2026-09-09T06:00:00Z", "system_clock_changed", "SystemTimelineWatcher")])).toBeNull();
|
||||
expect(enrollmentWindowOf([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -765,6 +765,9 @@ export const api = {
|
||||
/** Own tenant's organization budget by account (members + delegated MSP admins). Tenant Admin / Global Reader. */
|
||||
organization: (dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/metrics/mcp-usage/organization${qs({ dateFrom, dateTo })}`,
|
||||
/** One tenant's organization budget by account for a GA / Global Reader; tenantId is required (no aggregate). */
|
||||
globalOrganization: (tenantId: string, dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/global/metrics/mcp-usage/organization${qs({ tenantId, dateFrom, dateTo })}`,
|
||||
global: (tenantId?: string, dateFrom?: string, dateTo?: string) =>
|
||||
`${API_BASE_URL}/api/global/metrics/mcp-usage${qs({ tenantId, dateFrom, dateTo })}`,
|
||||
daily: (tenantId?: string, dateFrom?: string, dateTo?: string) =>
|
||||
|
||||
@@ -92,4 +92,14 @@ export const scopedApi = {
|
||||
sel.routeGlobal
|
||||
? api.audit.globalLogs({ ...opts, tenantId: globalTenantParam(sel) })
|
||||
: api.audit.logs(opts),
|
||||
|
||||
/**
|
||||
* Organization MCP usage (accounts + tenant windows). The global variant has no aggregate — a
|
||||
* missing/corrupt tenant selection sends no tenantId and the backend answers 400 rather than
|
||||
* silently falling back to the caller's own tenant.
|
||||
*/
|
||||
mcpOrganizationUsage: (sel: TenantScopeSelection, dateFrom: string, dateTo: string) =>
|
||||
sel.routeGlobal
|
||||
? api.mcpUsage.globalOrganization(globalTenantParam(sel) ?? "", dateFrom, dateTo)
|
||||
: api.mcpUsage.organization(dateFrom, dateTo),
|
||||
};
|
||||
|
||||
@@ -64,6 +64,13 @@ export interface ScriptItem {
|
||||
state: "Running" | "Success" | "Failed";
|
||||
timestamp: string;
|
||||
bootstrapVersion?: string | null;
|
||||
/**
|
||||
* Set by applyObservationEnd, never by the fold: the script was still running when the
|
||||
* session's observation ended. Terminal, neither a failure nor a success — the outcome was
|
||||
* never seen — and `observedMs` is the span actually watched, a lower bound on the run time.
|
||||
*/
|
||||
isIncomplete: boolean;
|
||||
observedMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +119,11 @@ export interface ScriptCard {
|
||||
phases: ScriptItem[];
|
||||
/** Header outcome label, e.g. "Compliant", "Remediated successfully", "Remediation failed". */
|
||||
headerLabel: string;
|
||||
/** Overall card state — drives the container colour. */
|
||||
headerState: "Running" | "Success" | "Failed" | "NonCompliant";
|
||||
/**
|
||||
* Overall card state — drives the container colour. `Incomplete`: still running when the
|
||||
* session's observation ended (see ScriptItem.isIncomplete).
|
||||
*/
|
||||
headerState: "Running" | "Success" | "Failed" | "NonCompliant" | "Incomplete";
|
||||
/** True when the card represents a multi-phase remediation cycle (header expandable). */
|
||||
isCycle: boolean;
|
||||
/** First-phase timestamp; used for sorting cards chronologically in the UI. */
|
||||
@@ -128,9 +138,12 @@ export interface ScriptCard {
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
/** Stable React key for a ScriptCard. */
|
||||
/**
|
||||
* Stable React key for a ScriptCard. An Incomplete card shares the Running slot: it is the same
|
||||
* placeholder after the session turned terminal, so the flip must not remount it.
|
||||
*/
|
||||
export function scriptCardKey(card: Pick<ScriptCard, "policyId" | "scriptType" | "headerState">): string {
|
||||
const idPart = card.headerState === "Running" ? "_running" : "_card";
|
||||
const idPart = card.headerState === "Running" || card.headerState === "Incomplete" ? "_running" : "_card";
|
||||
return `${card.policyId || "_noid"}-${card.scriptType}-${idPart}`;
|
||||
}
|
||||
|
||||
@@ -213,11 +226,15 @@ export function mapRemediationStatus(status?: number): string | null {
|
||||
* phase (detection / remediation / post-detection) of the cycle this row represents.
|
||||
* The phase is conveyed via the badge next to the title, not the title itself.
|
||||
*/
|
||||
export function buildScriptItemLabel(item: Pick<ScriptItem, "scriptType" | "scriptPart" | "state" | "remediationStatus">): string {
|
||||
export function buildScriptItemLabel(
|
||||
item: Pick<ScriptItem, "scriptType" | "scriptPart" | "state" | "remediationStatus"> & Partial<Pick<ScriptItem, "isIncomplete">>
|
||||
): string {
|
||||
// "(running)" belongs to a live placeholder only — an incomplete row is not running any more.
|
||||
const running = item.state === "Running" && !item.isIncomplete;
|
||||
if (item.scriptType === "remediation") {
|
||||
return item.state === "Running" ? "Remediation (running)" : "Remediation";
|
||||
return running ? "Remediation (running)" : "Remediation";
|
||||
}
|
||||
return item.state === "Running" ? "Platform Script (running)" : "Platform Script";
|
||||
return running ? "Platform Script (running)" : "Platform Script";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,6 +375,7 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
|
||||
state: isFailureSignal ? "Failed" : "Success",
|
||||
timestamp: evt.timestamp,
|
||||
bootstrapVersion: scriptType === "platform" ? extractBootstrapVersion(stdout) : null,
|
||||
isIncomplete: false,
|
||||
};
|
||||
|
||||
// Keep the most-complete entry, but merge the duration fields across both: the
|
||||
@@ -422,12 +440,30 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
|
||||
state: "Running",
|
||||
timestamp: evt.timestamp,
|
||||
bootstrapVersion: null,
|
||||
isIncomplete: false,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks placeholders that were still running when observation ended. `observedUntilMs` is the
|
||||
* last moment the agent reported anything (the session's lastEventAt) and is only passed once
|
||||
* the session is terminal; null (live session) leaves every row untouched. Mirrors the install
|
||||
* panel's applyObservationEnd: no timer runs on such a row, and `observedMs` is the watched
|
||||
* span from the start line — a lower bound, never the run time.
|
||||
*/
|
||||
export function applyObservationEnd(items: ScriptItem[], observedUntilMs: number | null): ScriptItem[] {
|
||||
if (observedUntilMs == null) return items;
|
||||
return items.map(item => {
|
||||
if (item.state !== "Running") return item;
|
||||
const startedMs = new Date(item.timestamp).getTime();
|
||||
const observedMs = Number.isFinite(startedMs) ? Math.max(0, observedUntilMs - startedMs) : 0;
|
||||
return { ...item, isIncomplete: true, observedMs };
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Card grouping — folds multi-phase remediation cycles under a single header
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -447,7 +483,8 @@ function phaseSortKey(part?: string): number {
|
||||
* Derive the human-readable header label + state for a remediation card based on its
|
||||
* phases and the cycle-level RemediationStatus. The label communicates the cycle
|
||||
* outcome at a glance; the state drives the card colour. Priority (highest wins):
|
||||
* 1. Any phase Running → state=Running, label="Running"
|
||||
* 1. Any phase live Running → state=Running, label="Running"; a Running phase cut off by
|
||||
* the observation end (isIncomplete) → state=Incomplete, label="Incomplete"
|
||||
* 2. Any phase Failed (real script crash, e.g. remediation phase exit != 0 or stderr)
|
||||
* → state=Failed, label="Remediation script failed"
|
||||
* 3. Has post-detection AND post-detection compliance=False
|
||||
@@ -464,9 +501,13 @@ function phaseSortKey(part?: string): number {
|
||||
function deriveRemediationHeader(
|
||||
phases: ScriptItem[]
|
||||
): { label: string; state: ScriptCard["headerState"] } {
|
||||
if (phases.some(p => p.state === "Running")) {
|
||||
if (phases.some(p => p.state === "Running" && !p.isIncomplete)) {
|
||||
return { label: "Running", state: "Running" };
|
||||
}
|
||||
// Every running phase was cut off by the observation end: the cycle never finished in view.
|
||||
if (phases.some(p => p.isIncomplete)) {
|
||||
return { label: "Incomplete", state: "Incomplete" };
|
||||
}
|
||||
|
||||
const detection = phases.find(p => p.scriptPart === "detection");
|
||||
const remediation = phases.find(p => p.scriptPart === "remediation");
|
||||
@@ -569,9 +610,11 @@ export function groupScriptItems(items: ScriptItem[]): ScriptCard[] {
|
||||
headerState = derived.state;
|
||||
} else {
|
||||
// Platform scripts: header mirrors the single phase.
|
||||
headerState = first.state === "Running" ? "Running" : (first.state === "Failed" ? "Failed" : "Success");
|
||||
headerState = first.state === "Running"
|
||||
? (first.isIncomplete ? "Incomplete" : "Running")
|
||||
: (first.state === "Failed" ? "Failed" : "Success");
|
||||
headerLabel = first.state === "Running"
|
||||
? "Running"
|
||||
? (first.isIncomplete ? "Incomplete" : "Running")
|
||||
: (first.result ?? (first.state === "Failed" ? "Failed" : "Success"));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,29 @@ export interface StandbyWindow {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The enrollment window as the session page measures its duration: first activity event →
|
||||
* `enrollment_complete`, or the last activity event while no verdict exists. System-timeline
|
||||
* rows (clock steps, sleep episodes) are excluded — they are backfilled from the Windows event
|
||||
* log with timestamps up to 24 h before the agent started and would drag the start into
|
||||
* pre-enrollment idle time. Null when the session has no activity events.
|
||||
*/
|
||||
export function enrollmentWindowOf(events: EnrollmentEvent[]): StandbyWindow | null {
|
||||
let startMs = Number.POSITIVE_INFINITY;
|
||||
let lastMs = Number.NEGATIVE_INFINITY;
|
||||
let completeMs: number | null = null;
|
||||
for (const e of events) {
|
||||
if (e.source === "SystemTimelineWatcher") continue;
|
||||
const t = new Date(e.timestamp).getTime();
|
||||
if (!Number.isFinite(t)) continue;
|
||||
if (t < startMs) startMs = t;
|
||||
if (t > lastMs) lastMs = t;
|
||||
if (completeMs === null && e.eventType === "enrollment_complete") completeMs = t;
|
||||
}
|
||||
if (!Number.isFinite(startMs)) return null;
|
||||
return { startMs, endMs: completeMs ?? lastMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds the device spent asleep inside the given windows, from `system_sleep_episode`
|
||||
* ground truth.
|
||||
|
||||
+173
-148
@@ -394,10 +394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -636,9 +635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -654,13 +653,13 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -676,20 +675,20 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -699,9 +698,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -715,9 +714,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -731,12 +730,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -747,12 +749,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -763,12 +768,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -779,12 +787,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -795,12 +806,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -811,12 +825,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -827,12 +844,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -843,12 +863,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -859,12 +882,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -877,16 +903,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -899,16 +928,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -921,16 +953,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -943,16 +978,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -965,16 +1003,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -987,16 +1028,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1009,16 +1053,19 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1031,17 +1078,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
"@emnapi/runtime": "^1.11.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1050,27 +1097,17 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
"@img/sharp-wasm32": "0.35.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
@@ -1080,9 +1117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1099,9 +1136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1118,9 +1155,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1435,9 +1472,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1454,9 +1488,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1473,9 +1504,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1492,9 +1520,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5636,9 +5661,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -7387,9 +7412,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"version": "0.35.4",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -7404,31 +7429,31 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
"@img/sharp-darwin-arm64": "0.35.4",
|
||||
"@img/sharp-darwin-x64": "0.35.4",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
||||
"@img/sharp-linux-arm": "0.35.4",
|
||||
"@img/sharp-linux-arm64": "0.35.4",
|
||||
"@img/sharp-linux-ppc64": "0.35.4",
|
||||
"@img/sharp-linux-riscv64": "0.35.4",
|
||||
"@img/sharp-linux-s390x": "0.35.4",
|
||||
"@img/sharp-linux-x64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
||||
"@img/sharp-win32-arm64": "0.35.4",
|
||||
"@img/sharp-win32-ia32": "0.35.4",
|
||||
"@img/sharp-win32-x64": "0.35.4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
|
||||
@@ -50,6 +50,6 @@
|
||||
"overrides": {
|
||||
"postcss": "^8.5.12",
|
||||
"@nevware21/ts-utils": "^0.14.0",
|
||||
"sharp": "^0.35.0"
|
||||
"sharp": "^0.35.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9545,7 +9545,7 @@
|
||||
},
|
||||
"GetMcpOrganizationUsageResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters.",
|
||||
"doc": "Organization-wide MCP usage by user for ONE tenant — the caller's own (GetMcpOrganizationUsage) or the tenant a Global Admin / Global Reader names (GetGlobalMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant — plus the tenant's organization windows. Built from the tenant's organization counters.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tenantId",
|
||||
@@ -9579,6 +9579,25 @@
|
||||
"name": "McpOrganizationUsageItem"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "quota",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpOrganizationQuotaNode"
|
||||
},
|
||||
"doc": "The tenant's organization windows (plan, limits, today / this month) from the same counters."
|
||||
},
|
||||
{
|
||||
"name": "daily",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpOrganizationDailyItem"
|
||||
}
|
||||
},
|
||||
"doc": "Requests charged to the tenant per day inside the range, oldest first; days without requests are absent."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -13326,6 +13345,68 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpOrganizationDailyItem": {
|
||||
"kind": "object",
|
||||
"doc": "One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "date",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "yyyyMMdd."
|
||||
},
|
||||
{
|
||||
"name": "requests",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpOrganizationQuotaNode": {
|
||||
"kind": "object",
|
||||
"doc": "The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "tenantPlan",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dailyLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "monthlyLimit",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dailyUsed",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "monthlyUsed",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpOrganizationUsageItem": {
|
||||
"kind": "object",
|
||||
"doc": "One account's share of the organization budget, nested in GetMcpOrganizationUsageResponse.",
|
||||
|
||||
@@ -2013,7 +2013,7 @@ export interface GetLatestVersionsResponse {
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** Organization-wide MCP usage by user for the caller's OWN tenant (GetMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant. Built from the tenant's organization counters. */
|
||||
/** Organization-wide MCP usage by user for ONE tenant — the caller's own (GetMcpOrganizationUsage) or the tenant a Global Admin / Global Reader names (GetGlobalMcpOrganizationUsage): every account whose requests were charged to this tenant's organization budget — its own members and any delegated (MSP) administrators reading the tenant — plus the tenant's organization windows. Built from the tenant's organization counters. */
|
||||
export interface GetMcpOrganizationUsageResponse {
|
||||
tenantId: string;
|
||||
/** Effective range start (yyyyMMdd). */
|
||||
@@ -2021,6 +2021,10 @@ export interface GetMcpOrganizationUsageResponse {
|
||||
/** Effective range end (yyyyMMdd, inclusive). */
|
||||
dateTo: string;
|
||||
users: McpOrganizationUsageItem[];
|
||||
/** The tenant's organization windows (plan, limits, today / this month) from the same counters. */
|
||||
quota: McpOrganizationQuotaNode;
|
||||
/** Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. */
|
||||
daily: McpOrganizationDailyItem[];
|
||||
}
|
||||
|
||||
/** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */
|
||||
@@ -2822,6 +2826,22 @@ export interface McpHealthCheckResponse {
|
||||
check: HealthCheck;
|
||||
}
|
||||
|
||||
/** One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse. */
|
||||
export interface McpOrganizationDailyItem {
|
||||
/** yyyyMMdd. */
|
||||
date: string;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
/** The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant. */
|
||||
export interface McpOrganizationQuotaNode {
|
||||
tenantPlan: string;
|
||||
dailyLimit: number;
|
||||
monthlyLimit: number;
|
||||
dailyUsed: number;
|
||||
monthlyUsed: number;
|
||||
}
|
||||
|
||||
/** One account's share of the organization budget, nested in GetMcpOrganizationUsageResponse. */
|
||||
export interface McpOrganizationUsageItem {
|
||||
userId: string;
|
||||
|
||||
Reference in New Issue
Block a user