mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b33895c5 | |||
| 157273726d | |||
| 0d994773d8 | |||
| 7dd9f81c31 | |||
| 7d07cb7805 | |||
| 485a5b4604 |
+1
-1
Submodule .claude/commands updated: 3b0198f8fb...c142d824e3
+1
-1
Submodule internal updated: 8ae9625ce3...deed41e4f6
@@ -3,6 +3,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -16,14 +18,16 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression gate for the "soft admin-action" semantics change: backend
|
||||
/// <c>AdminAction=Succeeded</c> is purely informational (timeline entry only, agent keeps
|
||||
/// running its own path) while <c>AdminAction=Failed</c> drives the agent's own soft-shutdown
|
||||
/// pipeline via the injected <c>onAdminFailed</c> callback — without forcing self-destruct.
|
||||
/// Previously both were routed through the full <c>ServerActionDispatcher</c> +
|
||||
/// <c>terminationHandler.Handle</c> path, which (a) hijacked the agent's own completion flow
|
||||
/// for Succeeded and (b) crashed with <c>InvalidOperationException: SignalIngress has been
|
||||
/// stopped</c> when a terminal-drain response arrived after the ingress was torn down.
|
||||
/// Regression gate for the soft admin-action semantics: backend <c>AdminAction=Succeeded</c>
|
||||
/// and <c>AdminAction=Failed</c> (portal Mark-Succeeded / Mark-Failed) both drive the agent's
|
||||
/// own soft-shutdown pipeline via the injected <c>onAdminTerminal</c> callback with that
|
||||
/// outcome — without forcing self-destruct — and only the first AdminAction of a process is
|
||||
/// acted on. The backend repeats AdminAction on every telemetry response; acting on each echo
|
||||
/// re-emitted <c>admin_marked_session</c> with an immediate upload whose response carried the
|
||||
/// next echo. Earlier, both outcomes were routed through the full
|
||||
/// <c>ServerActionDispatcher</c> path, which crashed with
|
||||
/// <c>InvalidOperationException: SignalIngress has been stopped</c> when a terminal-drain
|
||||
/// response arrived after the ingress was torn down.
|
||||
/// </summary>
|
||||
public sealed class HandleAdminActionTests
|
||||
{
|
||||
@@ -34,13 +38,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
ApiBaseUrl = "http://localhost",
|
||||
};
|
||||
|
||||
private static global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch NewLatch() =>
|
||||
new global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch();
|
||||
|
||||
private sealed class Rig : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public AgentLogger Logger { get; }
|
||||
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||
public InformationalEventPost Post { get; }
|
||||
public List<EnrollmentTerminatedEventArgs> OnAdminFailedCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
|
||||
public List<EnrollmentTerminatedEventArgs> OnAdminTerminalCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
|
||||
|
||||
public Rig()
|
||||
{
|
||||
@@ -48,7 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Post = new InformationalEventPost(Ingress, new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
public Action<EnrollmentTerminatedEventArgs> OnAdminFailed => args => OnAdminFailedCalls.Add(args);
|
||||
public Action<EnrollmentTerminatedEventArgs> OnAdminTerminal => args => OnAdminTerminalCalls.Add(args);
|
||||
|
||||
public IEnumerable<FakeSignalIngressSink.PostedSignal> EventsOfType(string eventType) =>
|
||||
Ingress.Posted.Where(p =>
|
||||
@@ -61,15 +68,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Succeeded_emits_admin_marked_session_as_Info_and_does_not_invoke_onAdminFailed()
|
||||
public void Succeeded_emits_admin_marked_session_as_Info_and_terminates_with_Succeeded()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Succeeded",
|
||||
stageNameAccessor: () => "Completed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "EspAccountSetup",
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
@@ -78,24 +86,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Info.ToString(), sev);
|
||||
|
||||
// Informational only — agent keeps running its own path.
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
var args = rig.OnAdminTerminalCalls[0];
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, args.Outcome);
|
||||
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
|
||||
Assert.Equal("EspAccountSetup", args.StageName);
|
||||
Assert.Contains("Admin marked session as Succeeded", args.Details);
|
||||
|
||||
// No server_action_* noise — Succeeded isn't a ServerAction.
|
||||
// No server_action_* noise — an admin mark isn't a ServerAction.
|
||||
Assert.Empty(rig.EventsOfType("server_action_received"));
|
||||
Assert.Empty(rig.EventsOfType("server_action_executed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_emits_admin_marked_session_as_Warning_and_invokes_onAdminFailed_once()
|
||||
public void Failed_emits_admin_marked_session_as_Warning_and_terminates_with_Failed()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "DeviceSetup",
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
@@ -104,8 +117,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Warning.ToString(), sev);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
var args = rig.OnAdminFailedCalls[0];
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
var args = rig.OnAdminTerminalCalls[0];
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, args.Outcome);
|
||||
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
|
||||
Assert.Equal("DeviceSetup", args.StageName);
|
||||
@@ -119,13 +132,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "succeeded",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -137,28 +152,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Quarantined",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_with_null_onAdminFailed_does_not_throw()
|
||||
public void Null_onAdminTerminal_does_not_throw()
|
||||
{
|
||||
// Defensive path: onAdminFailed can be null if the terminate handler wasn't wired yet.
|
||||
// Defensive path: onAdminTerminal can be null if the terminate handler wasn't wired yet.
|
||||
using var rig = new Rig();
|
||||
|
||||
var ex = Record.Exception(() =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminFailed: null!,
|
||||
onAdminTerminal: null!,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger));
|
||||
|
||||
@@ -176,14 +193,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => throw new InvalidOperationException("not started"),
|
||||
post: rig.Post,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Null(rig.OnAdminFailedCalls[0].StageName);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Null(rig.OnAdminTerminalCalls[0].StageName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -202,18 +220,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
var ex = Record.Exception(() =>
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Succeeded",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => null,
|
||||
post: stoppedPost,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger));
|
||||
|
||||
Assert.Null(ex);
|
||||
Assert.Empty(rig.OnAdminFailedCalls);
|
||||
|
||||
// A lost timeline entry never cancels the admin's decision.
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_emit_race_still_invokes_onAdminFailed()
|
||||
public void Failed_emit_race_still_terminates()
|
||||
{
|
||||
// If the timeline emit fails due to a stopped ingress, the agent's own soft-shutdown
|
||||
// must still run — the admin's decision to Mark-Failed outranks a lost timeline entry.
|
||||
@@ -225,14 +247,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: "Failed",
|
||||
latch: NewLatch(),
|
||||
stageNameAccessor: () => "DeviceSetup",
|
||||
post: stoppedPost,
|
||||
onAdminFailed: rig.OnAdminFailed,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
|
||||
Assert.Single(rig.OnAdminFailedCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Succeeded")]
|
||||
[InlineData("Failed")]
|
||||
public void Repeated_echo_is_handled_once(string adminAction)
|
||||
{
|
||||
// The backend repeats AdminAction on every telemetry response. Each echo handled anew
|
||||
// emitted another immediate-upload timeline entry whose response carried the next echo.
|
||||
using var rig = new Rig();
|
||||
var latch = NewLatch();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: adminAction,
|
||||
latch: latch,
|
||||
stageNameAccessor: () => "EspAccountSetup",
|
||||
post: rig.Post,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
}
|
||||
|
||||
Assert.Single(rig.EventsOfType("admin_marked_session"));
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_admin_action_of_the_process_wins()
|
||||
{
|
||||
// The first mark already started the shutdown with its outcome; a later change of
|
||||
// mind in the portal only updates the session row on the backend.
|
||||
using var rig = new Rig();
|
||||
var latch = NewLatch();
|
||||
|
||||
foreach (var adminAction in new[] { "Succeeded", "Failed" })
|
||||
{
|
||||
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
|
||||
adminAction: adminAction,
|
||||
latch: latch,
|
||||
stageNameAccessor: () => null,
|
||||
post: rig.Post,
|
||||
onAdminTerminal: rig.OnAdminTerminal,
|
||||
agentConfig: Config(),
|
||||
logger: rig.Logger);
|
||||
}
|
||||
|
||||
var emitted = rig.EventsOfType("admin_marked_session").ToList();
|
||||
Assert.Single(emitted);
|
||||
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
|
||||
Assert.Equal(EventSeverity.Info.ToString(), sev);
|
||||
|
||||
Assert.Single(rig.OnAdminTerminalCalls);
|
||||
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latch_admits_exactly_one_of_concurrent_callers()
|
||||
{
|
||||
// Responses are raised on whichever thread ran the drain, so two can overlap.
|
||||
var latch = NewLatch();
|
||||
var admitted = 0;
|
||||
|
||||
Parallel.For(0, 64, _ =>
|
||||
{
|
||||
if (latch.TryEnter()) Interlocked.Increment(ref admitted);
|
||||
});
|
||||
|
||||
Assert.Equal(1, admitted);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSink : ISignalIngressSink
|
||||
|
||||
@@ -92,14 +92,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// <c>terminate_session</c> <see cref="ServerAction"/> with
|
||||
/// <c>forceSelfDestruct=true</c> and routes it through the dispatcher, overriding
|
||||
/// local <c>SelfDestructOnComplete=false</c>.</item>
|
||||
/// <item><b>AdminAction=Succeeded</b> — informational. Admin clicked Mark-Succeeded in
|
||||
/// the portal. Emits a single <c>admin_marked_session</c> timeline entry and lets
|
||||
/// the agent finish its own path.</item>
|
||||
/// <item><b>AdminAction=Failed</b> — soft admin-driven shutdown. Calls the agent's own
|
||||
/// <see cref="EnrollmentTerminationHandler.Handle"/> with <c>outcome=Failed</c>,
|
||||
/// which runs the same analyzers / diagnostics / summary / self-destruct sequence
|
||||
/// as a locally-detected failure. Self-destruct is governed by local
|
||||
/// <c>SelfDestructOnComplete</c> — no forced override.</item>
|
||||
/// <item><b>AdminAction=Succeeded|Failed</b> — soft admin-driven shutdown. Admin clicked
|
||||
/// Mark-Succeeded / Mark-Failed in the portal. Calls the agent's own
|
||||
/// <see cref="EnrollmentTerminationHandler.Handle"/> with that outcome, which runs the
|
||||
/// same analyzers / diagnostics / summary / self-destruct sequence as a locally-detected
|
||||
/// completion or failure. Self-destruct is governed by local
|
||||
/// <c>SelfDestructOnComplete</c> — no forced override. The backend repeats AdminAction
|
||||
/// on every response, so only the first one per process is acted on
|
||||
/// (<see cref="HandleAdminAction"/>).</item>
|
||||
/// <item><b>upload.Actions[]</b> — genuinely backend-queued <see cref="ServerAction"/>s
|
||||
/// (<c>rotate_config</c>, <c>request_diagnostics</c>). Still routed through the
|
||||
/// dispatcher because these are real actions, not admin-button echoes.</item>
|
||||
@@ -130,6 +130,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
orchestrator.Transport.SessionGone += (sender, reason) =>
|
||||
HandleSessionGone(reason, emergencyReporter, dispatcher, shutdown, shutdownComplete, logger);
|
||||
|
||||
var adminActionLatch = new AdminActionLatch();
|
||||
|
||||
orchestrator.Transport.ServerResponseReceived += (sender, upload) =>
|
||||
{
|
||||
if (shutdownComplete.IsSet)
|
||||
@@ -163,17 +165,17 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
try { return orchestrator.CurrentState?.Stage.ToString(); }
|
||||
catch (InvalidOperationException) { return null; }
|
||||
};
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminFailed = args =>
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminTerminal = args =>
|
||||
{
|
||||
var handler = terminationHandlerAccessor();
|
||||
if (handler == null)
|
||||
{
|
||||
logger.Warning("AdminAction=Failed received but terminationHandler not constructed yet — ignoring.");
|
||||
logger.Warning($"AdminAction={args.Outcome} received but terminationHandler not constructed yet — ignoring.");
|
||||
return;
|
||||
}
|
||||
handler.Handle(sender: null, args: args);
|
||||
};
|
||||
HandleAdminAction(upload.AdminAction, stageNameAccessor, post, onAdminFailed, agentConfig, logger);
|
||||
HandleAdminAction(upload.AdminAction, adminActionLatch, stageNameAccessor, post, onAdminTerminal, agentConfig, logger);
|
||||
}
|
||||
|
||||
if (upload.Actions != null && upload.Actions.Count > 0)
|
||||
@@ -265,16 +267,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
/// Soft admin-override handling. Portal Mark-Succeeded / Mark-Failed is an echo of an
|
||||
/// admin button click, not a ServerAction: it bypasses the dispatcher (no
|
||||
/// <c>server_action_received / _executed</c> noise) and flows straight into the agent's
|
||||
/// own timeline / termination pipeline.
|
||||
/// own timeline / termination pipeline — one <c>admin_marked_session</c> entry, then
|
||||
/// <paramref name="onAdminTerminal"/> with the admin's outcome.
|
||||
/// <para>
|
||||
/// The backend repeats AdminAction on every telemetry response while the session row
|
||||
/// carries it, so <paramref name="latch"/> admits only the first one per process. Acting
|
||||
/// on every echo looped: each entry is uploaded immediately, and its response carried
|
||||
/// the next echo.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void HandleAdminAction(
|
||||
string adminAction,
|
||||
AdminActionLatch latch,
|
||||
Func<string> stageNameAccessor,
|
||||
InformationalEventPost post,
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminFailed,
|
||||
Action<EnrollmentTerminatedEventArgs> onAdminTerminal,
|
||||
AgentConfiguration agentConfig,
|
||||
AgentLogger logger)
|
||||
{
|
||||
if (!latch.TryEnter())
|
||||
{
|
||||
logger.Debug($"Backend repeated AdminAction={adminAction} — already handled in this process, ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
var outcome = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase)
|
||||
? EnrollmentTerminationOutcome.Succeeded
|
||||
: EnrollmentTerminationOutcome.Failed;
|
||||
@@ -311,8 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
logger.Warning($"admin_marked_session emit failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (outcome != EnrollmentTerminationOutcome.Failed) return;
|
||||
if (onAdminFailed == null) return;
|
||||
if (onAdminTerminal == null) return;
|
||||
|
||||
string stageName = null;
|
||||
try { stageName = stageNameAccessor?.Invoke(); }
|
||||
@@ -320,19 +335,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
try
|
||||
{
|
||||
onAdminFailed(new EnrollmentTerminatedEventArgs(
|
||||
onAdminTerminal(new EnrollmentTerminatedEventArgs(
|
||||
reason: EnrollmentTerminationReason.DecisionTerminalStage,
|
||||
outcome: EnrollmentTerminationOutcome.Failed,
|
||||
outcome: outcome,
|
||||
stageName: stageName,
|
||||
terminatedAtUtc: DateTime.UtcNow,
|
||||
details: "Admin marked session as Failed via portal."));
|
||||
details: $"Admin marked session as {outcome} via portal."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error("onAdminFailed (AdminAction=Failed) threw.", ex);
|
||||
logger.Error($"onAdminTerminal (AdminAction={adminAction}) threw.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admits the first <see cref="HandleAdminAction"/> call of the process. Atomic: responses
|
||||
/// are raised on whichever thread ran the drain — the drain loop or the terminal drain —
|
||||
/// so two can arrive at once.
|
||||
/// </summary>
|
||||
internal sealed class AdminActionLatch
|
||||
{
|
||||
private int _entered;
|
||||
|
||||
public bool TryEnter() => Interlocked.Exchange(ref _entered, 1) == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <c>terminate_session</c> ServerAction's <c>adminOutcome</c> param onto an
|
||||
/// <see cref="EnrollmentTerminationOutcome"/>. Portal Mark-Succeeded was previously
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SignupConsentCta } from "../../components/landing/SignupConsentCta";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
@@ -102,13 +103,14 @@ export default function GetStartedPage() {
|
||||
|
||||
<p className="mt-6 text-sm text-[var(--lp-ink-faint)] leading-relaxed max-w-xl">
|
||||
When you need more later: the{" "}
|
||||
<a
|
||||
href="/plans/"
|
||||
<Link
|
||||
href="/plans"
|
||||
prefetch={false}
|
||||
data-track="get_started_pro_plan"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
Pro plan
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
adds SLAs, support commitments, MSP delegation, higher operating limits and more —
|
||||
same service, nothing to migrate.
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createElement, type ComponentType } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -73,6 +73,18 @@ describe("marketing page tracking", () => {
|
||||
expect(html).toMatch(/<section\b[^>]*\sid="push"[^>]*\sdata-track-section="push"/);
|
||||
});
|
||||
|
||||
// A plain <a> loads a new document: the click's entry channel (lib/entryAttribution.ts) would
|
||||
// turn into the own host. The static markup cannot tell a <Link> from an <a>, so the source is read.
|
||||
it("links to get-started and plans are client-side Links, so the page load and its entry channel carry over", () => {
|
||||
const root = join(__dirname, "..", "..");
|
||||
const plain = ["components", "app"].flatMap(dir =>
|
||||
readdirSync(join(root, dir), { encoding: "utf-8", recursive: true })
|
||||
.filter(file => file.endsWith(".tsx") && !file.includes("__tests__"))
|
||||
.filter(file => /<a\b[^>]*\shref="\/(?:get-started|plans)\b/.test(readFileSync(join(root, dir, file), "utf-8")))
|
||||
);
|
||||
expect(plain).toEqual([]);
|
||||
});
|
||||
|
||||
it("the AI page carries every question text in its static HTML", () => {
|
||||
const html = render(AiPage);
|
||||
const escape = (text: string) => text.replace(/&/g, "&").replace(/'/g, "'");
|
||||
|
||||
@@ -20,9 +20,9 @@ export function ClosingCta() {
|
||||
Free, open source, deployed in minutes.
|
||||
<br />
|
||||
{"Need SLAs, support, or MSP delegation? "}
|
||||
<a href="/plans/" data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
|
||||
<Link href="/plans" prefetch={false} data-track="closing_pro_plan" className="text-[var(--lp-accent-ink)] underline hover:opacity-80">
|
||||
There's a Pro plan
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function FinalCta() {
|
||||
return (
|
||||
<section data-track-section="final_cta" className="py-20 sm:py-24 px-6 border-t border-[var(--lp-line-soft)]">
|
||||
@@ -6,24 +8,26 @@ export function FinalCta() {
|
||||
Your next enrollment doesn't have to be a black box.
|
||||
</h2>
|
||||
<div className="shrink-0 lg:text-right">
|
||||
<a
|
||||
href="/get-started/"
|
||||
<Link
|
||||
href="/get-started"
|
||||
prefetch={false}
|
||||
data-track="final_get_started"
|
||||
className="inline-block px-7 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
|
||||
>
|
||||
Start monitoring now
|
||||
</a>
|
||||
</Link>
|
||||
<p className="mt-3 text-sm text-[var(--lp-ink-faint)] lg:text-right">
|
||||
Free, open source, deployed in minutes.
|
||||
<span className="block mt-0.5">
|
||||
Need SLAs, support, or MSP delegation?{" "}
|
||||
<a
|
||||
href="/plans/"
|
||||
<Link
|
||||
href="/plans"
|
||||
prefetch={false}
|
||||
data-track="final_pro_plan"
|
||||
className="text-[var(--lp-accent-ink)] hover:opacity-80 underline"
|
||||
>
|
||||
There's a Pro plan
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { HeroShots } from "./HeroShots";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
@@ -50,13 +51,14 @@ export function Hero() {
|
||||
</p>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/get-started/"
|
||||
<Link
|
||||
href="/get-started"
|
||||
prefetch={false}
|
||||
data-track="hero_get_started"
|
||||
className="px-6 py-3 rounded-lg bg-[var(--lp-accent-ink)] hover:brightness-110 hover:shadow-lg text-white font-semibold shadow-md transition-all"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</Link>
|
||||
<a
|
||||
href={DOCS_URL}
|
||||
data-track="hero_docs"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { entryAttribution, stampEntryAttribution } from "../entryAttribution";
|
||||
|
||||
const PAGE = "https://www.example.test/";
|
||||
|
||||
describe("entry referrer", () => {
|
||||
it.each([
|
||||
["no referrer", "", "direct"],
|
||||
["a search engine origin", "https://search.example.test/", "search.example.test"],
|
||||
["a full URL, cut to its host", "https://blog.example.test/2026/10/post?ref=feed&id=42", "blog.example.test"],
|
||||
["an Android app referrer", "android-app://com.example.reader/", "com.example.reader"],
|
||||
["upper case", "https://Social.EXAMPLE.test/", "social.example.test"],
|
||||
["a port", "http://intranet.example.test:8080/wiki/page", "intranet.example.test:8080"],
|
||||
["an earlier page load on the own host", "https://www.example.test/plans/", "www.example.test"],
|
||||
["no host", "about:blank", "unknown"],
|
||||
["garbage", "not a url", "unknown"],
|
||||
])("%s", (_name, referrer, expected) => {
|
||||
expect(entryAttribution(referrer, PAGE).entryReferrer).toBe(expected);
|
||||
});
|
||||
|
||||
it("caps a long host", () => {
|
||||
const host = `${"a".repeat(120)}.example.test`;
|
||||
expect(entryAttribution(`https://${host}/`, PAGE).entryReferrer).toBe(host.slice(0, 100));
|
||||
});
|
||||
});
|
||||
|
||||
describe("entry UTM tags", () => {
|
||||
it("reads source, medium and campaign of the document URL", () => {
|
||||
const entry = entryAttribution("", `${PAGE}?utm_source=social-net&utm_medium=social&utm_campaign=launch_2026.10`);
|
||||
expect(entry).toEqual({
|
||||
entryReferrer: "direct",
|
||||
entryUtmSource: "social-net",
|
||||
entryUtmMedium: "social",
|
||||
entryUtmCampaign: "launch_2026.10",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a missing tag out", () => {
|
||||
expect(entryAttribution("", `${PAGE}get-started/?utm_source=newsletter`)).toEqual({
|
||||
entryReferrer: "direct",
|
||||
entryUtmSource: "newsletter",
|
||||
});
|
||||
expect(entryAttribution("", PAGE)).toEqual({ entryReferrer: "direct" });
|
||||
});
|
||||
|
||||
it("never reads utm_content, utm_term or other parameters", () => {
|
||||
const entry = entryAttribution("", `${PAGE}?utm_content=recipient-123&utm_term=abc&ref=x&utm_source=event`);
|
||||
expect(entry).toEqual({ entryReferrer: "direct", entryUtmSource: "event" });
|
||||
});
|
||||
|
||||
it("lower-cases and trims a tag", () => {
|
||||
expect(entryAttribution("", `${PAGE}?utm_source=%20Social-Net%20`).entryUtmSource).toBe("social-net");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["spaces inside", "summit+2026"],
|
||||
["an address", "someone%40example.test"],
|
||||
["an empty value", ""],
|
||||
["a leading separator", "-launch"],
|
||||
["too long", "a".repeat(65)],
|
||||
])("reports a tag with %s as other", (_name, value) => {
|
||||
expect(entryAttribution("", `${PAGE}?utm_campaign=${value}`).entryUtmCampaign).toBe("other");
|
||||
});
|
||||
|
||||
it("keeps the referrer when the document URL cannot be parsed", () => {
|
||||
expect(entryAttribution("https://search.example.test/", "not a url")).toEqual({ entryReferrer: "search.example.test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampEntryAttribution", () => {
|
||||
const entry = { entryReferrer: "search.example.test", entryUtmSource: "newsletter" };
|
||||
|
||||
it.each([["PageviewData"], ["EventData"]])("stamps a %s item and keeps its data", (baseType) => {
|
||||
const envelope = { baseType, data: { tenantId: "t", theme: "dark" } as Record<string, unknown> };
|
||||
expect(stampEntryAttribution(envelope, entry)).toBe(true);
|
||||
expect(envelope.data).toEqual({ tenantId: "t", theme: "dark", ...entry });
|
||||
});
|
||||
|
||||
it("creates the data bag when the item has none", () => {
|
||||
const envelope: { baseType: string; data?: Record<string, unknown> } = { baseType: "EventData" };
|
||||
stampEntryAttribution(envelope, entry);
|
||||
expect(envelope.data).toEqual(entry);
|
||||
});
|
||||
|
||||
it.each([["MetricData"], ["RemoteDependencyData"], ["PageviewPerformanceData"], ["ExceptionData"], [undefined]])(
|
||||
"leaves a %s item alone",
|
||||
(baseType) => {
|
||||
const envelope = { baseType, data: { theme: "dark" } as Record<string, unknown> };
|
||||
expect(stampEntryAttribution(envelope, entry)).toBe(false);
|
||||
expect(envelope.data).toEqual({ theme: "dark" });
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from "web-vitals";
|
||||
import { API_BASE_URL, PORTAL_URL, SITE_URL } from "@/lib/config";
|
||||
import type { TelemetryContext } from "./appInsights";
|
||||
import { readEntryAttribution, stampEntryAttribution } from "./entryAttribution";
|
||||
import { isOnPublicHost } from "./hostRouting";
|
||||
import { normalizeRoute, toWebVitalTelemetry } from "./webVitals";
|
||||
import { buildOwnOriginNonApiPatterns, shouldDropDependency, stripPageViewFragments } from "./webTelemetryExclusions";
|
||||
|
||||
@@ -78,6 +80,13 @@ export function createAppInsights(connectionString: string, context: TelemetryCo
|
||||
appInsights.addTelemetryInitializer((envelope) => {
|
||||
stripPageViewFragments(envelope);
|
||||
});
|
||||
// Public host: page views and custom events carry the channel this page load came in through.
|
||||
const entry = isOnPublicHost() ? readEntryAttribution() : null;
|
||||
if (entry) {
|
||||
appInsights.addTelemetryInitializer((envelope) => {
|
||||
stampEntryAttribution(envelope, entry);
|
||||
});
|
||||
}
|
||||
|
||||
appInsights.loadAppInsights();
|
||||
// Returning false drops the item (D-277); undefined keeps it.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The channel a page load on the public host came in through: the referrer's host and the UTM
|
||||
* tags of the URL the document was loaded with. Both stay the same for the whole page load —
|
||||
* a client-side navigation changes neither `document.referrer` nor the navigation entry — so
|
||||
* nothing is stored. Stamped on page views and custom events, counted per channel, never per
|
||||
* person (D-304, D-305).
|
||||
*/
|
||||
|
||||
export type EntryAttribution = Readonly<Record<string, string>>;
|
||||
|
||||
/** Only these three tags: `utm_content` and `utm_term` are where mail tools put recipient ids. */
|
||||
const UTM_PROPERTIES = [
|
||||
["utm_source", "entryUtmSource"],
|
||||
["utm_medium", "entryUtmMedium"],
|
||||
["utm_campaign", "entryUtmCampaign"],
|
||||
] as const;
|
||||
|
||||
const UTM_VALUE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const MAX_REFERRER_HOST = 100;
|
||||
|
||||
/** `baseType` of the SDK items that carry the entry: page views and custom events. */
|
||||
const STAMPED_BASE_TYPES: readonly string[] = ["PageviewData", "EventData"];
|
||||
|
||||
/** The referrer's host only, never its path or query; `direct` without a referrer. */
|
||||
function referrerHost(referrer: string): string {
|
||||
if (!referrer) return "direct";
|
||||
try {
|
||||
const host = new URL(referrer).host.toLowerCase();
|
||||
return host ? host.slice(0, MAX_REFERRER_HOST) : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function entryAttribution(referrer: string, documentUrl: string): EntryAttribution {
|
||||
const entry: Record<string, string> = { entryReferrer: referrerHost(referrer) };
|
||||
let params: URLSearchParams;
|
||||
try {
|
||||
params = new URL(documentUrl).searchParams;
|
||||
} catch {
|
||||
return entry;
|
||||
}
|
||||
for (const [param, property] of UTM_PROPERTIES) {
|
||||
const value = params.get(param);
|
||||
if (value === null) continue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
entry[property] = UTM_VALUE.test(normalized) ? normalized : "other";
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** This page load's entry, read in the browser. */
|
||||
export function readEntryAttribution(): EntryAttribution {
|
||||
const navigation = performance.getEntriesByType("navigation")[0];
|
||||
return entryAttribution(document.referrer, navigation?.name || window.location.href);
|
||||
}
|
||||
|
||||
/** The fields of an ITelemetryItem the stamp reads and writes. */
|
||||
interface EnvelopeLike {
|
||||
baseType?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* For `addTelemetryInitializer`: adds the entry to a page view or custom event, in place. Other
|
||||
* item kinds are left alone. Returns true when it stamped, for the tests — the initializer must
|
||||
* not pass it on, a returned false drops the item.
|
||||
*/
|
||||
export function stampEntryAttribution(envelope: EnvelopeLike, entry: EntryAttribution): boolean {
|
||||
if (!envelope.baseType || !STAMPED_BASE_TYPES.includes(envelope.baseType)) return false;
|
||||
envelope.data = Object.assign(envelope.data ?? {}, entry);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user