mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4f26401f5 | |||
| 2b5004fb33 | |||
| 00f4cb1f26 | |||
| e091e50862 | |||
| 27ce07b111 | |||
| f06fe5afc0 | |||
| 37e920237c | |||
| 7279650772 | |||
| d8ab70b166 | |||
| fb8703a4c6 | |||
| ac3fa8b2b6 | |||
| efcb4f9ca1 | |||
| 7a7a31e8e3 | |||
| 3fdcbabd48 | |||
| b2e8c7627f | |||
| bd08d13011 | |||
| 10c69f592e | |||
| fe0b2dc7fe | |||
| a0f007fd43 | |||
| 1b27babd17 | |||
| 100d2242fa | |||
| 089b1b38fd | |||
| ac465839a5 | |||
| ef280b7bb5 | |||
| 233737f333 | |||
| 909904e928 |
@@ -30,6 +30,7 @@
|
||||
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
|
||||
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
|
||||
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
|
||||
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
|
||||
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
|
||||
|
||||
## Code Quality
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: 315ad0e736...a4d15f376f
@@ -1,5 +1,9 @@
|
||||
version: 2
|
||||
|
||||
# Cooldown: a version is proposed only once it has been public for 7 days, so a
|
||||
# release that is yanked or found malicious within its first week never reaches a
|
||||
# PR. Security updates are exempt from the cooldown by Dependabot's own rules.
|
||||
#
|
||||
# Grouping: every ecosystem gets one batched PR for minor/patch version
|
||||
# updates and one batched PR for security updates. Groups apply to version
|
||||
# updates only by default, so the security-updates groups are declared
|
||||
@@ -10,6 +14,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
dotnet:
|
||||
@@ -23,6 +29,8 @@ updates:
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
web:
|
||||
@@ -41,6 +49,8 @@ updates:
|
||||
directory: /src/McpServer/autopilot-monitor-mcp
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
mcp:
|
||||
@@ -55,6 +65,22 @@ updates:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
# Validator tooling for rules/ (ajv-cli); combine-rules.yml installs it from this
|
||||
# lockfile with contents:write in hand, so the pins must not go stale.
|
||||
- package-ecosystem: npm
|
||||
directory: /rules/scripts
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
rules-tooling:
|
||||
patterns: ['*']
|
||||
rules-tooling-security:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Every action is referenced by commit SHA with the version in a trailing comment;
|
||||
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
|
||||
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
|
||||
@@ -63,6 +89,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
groups:
|
||||
actions:
|
||||
patterns: ['*']
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
name: Action Pin Gate
|
||||
name: Workflow gates
|
||||
|
||||
# Two checks over the workflow files themselves:
|
||||
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
|
||||
# version comment (stricter than zizmor's unpinned-uses audit).
|
||||
# * zizmor -- static analysis of every workflow (permissions, credential
|
||||
# persistence, template injection, ...). Configuration and the
|
||||
# reasoned ignores live in zizmor.yml at the repository root; the
|
||||
# same run is expected locally before a workflow change is committed.
|
||||
#
|
||||
# Every third-party action must be referenced by a full commit SHA, never by a tag.
|
||||
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
|
||||
# and that code runs inside jobs that hold the code-signing identity, the storage
|
||||
@@ -8,7 +16,8 @@ name: Action Pin Gate
|
||||
#
|
||||
# Lives in its own workflow rather than in ci.yml: CI deliberately ignores changes to
|
||||
# the other workflow files, so a guard placed there would stay silent for exactly the
|
||||
# commits it needs to inspect.
|
||||
# commits it needs to inspect. Dependabot's action-pin PRs are the main customer: they
|
||||
# rewrite workflow files without anyone running zizmor locally.
|
||||
#
|
||||
# Like CI, this reports rather than blocks: main is deliberately unprotected, so a red
|
||||
# run is a signal to act on, not a merge barrier. Making it binding means adding
|
||||
@@ -22,10 +31,10 @@ name: Action Pin Gate
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**']
|
||||
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**']
|
||||
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -37,15 +46,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Every third-party action is pinned to a SHA
|
||||
run: |
|
||||
set -uo pipefail
|
||||
failed=0
|
||||
while IFS=: read -r file line ref; do
|
||||
# Reusable workflows from this repository move with the commit that calls
|
||||
# them, so there is nothing to pin.
|
||||
case "$ref" in ./*) continue ;; esac
|
||||
# Reusable workflows from this repository (workspace-relative ./ or the
|
||||
# self-repository form $/) move with the commit that calls them, so there is
|
||||
# nothing to pin.
|
||||
case "$ref" in ./*|\$/*) continue ;; esac
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then
|
||||
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
|
||||
@@ -68,3 +80,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "All action references are pinned to commit SHAs."
|
||||
|
||||
zizmor:
|
||||
name: zizmor audit
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write # SARIF upload: findings land in the Code scanning tab next to CodeQL
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# zizmor itself is version-pinned like every other tool here; bump it together with
|
||||
# the local install so the gate and the pre-commit run agree on the finding set.
|
||||
# Informational findings (template expansion of our own step outputs in pwsh) are
|
||||
# visible locally but do not fail the gate.
|
||||
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
|
||||
@@ -19,6 +19,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
|
||||
@@ -73,6 +73,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Every released build is signed; a missing variable stops the run before a build
|
||||
# number is burnt.
|
||||
@@ -91,7 +93,7 @@ jobs:
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -114,7 +116,7 @@ jobs:
|
||||
# well inside the token lifetime. Only the legacy mirror still needs a SAS: that account
|
||||
# lives in the pre-migration tenant, where this principal has no identity.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -41,6 +41,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
@@ -86,7 +88,7 @@ jobs:
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -105,7 +107,7 @@ jobs:
|
||||
|
||||
# The blob upload below is authorized by this federated identity, not by a SAS.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -24,6 +24,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -57,6 +58,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -97,6 +99,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
@@ -128,6 +132,8 @@ jobs:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -162,6 +168,8 @@ jobs:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -196,6 +204,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
@@ -217,6 +227,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
|
||||
@@ -17,6 +17,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -48,6 +49,7 @@ on:
|
||||
- 'infra/**'
|
||||
- '.claude/**'
|
||||
- '.gitmodules'
|
||||
- 'zizmor.yml'
|
||||
- '**/*.md'
|
||||
- 'LICENSE'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
@@ -103,6 +105,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
if: matrix.language == 'csharp'
|
||||
@@ -110,7 +114,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -121,6 +125,6 @@ jobs:
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
|
||||
@@ -30,11 +30,16 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Exact versions and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# main, so an install-time script from a hijacked package would run with a token that
|
||||
# can rewrite the repository. ajv is pure JS and needs no install hooks.
|
||||
- name: Install ajv-cli
|
||||
run: npm install -g --ignore-scripts ajv-cli@5.0.0 ajv-formats@3.0.1
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv-cli, ajv-formats
|
||||
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
|
||||
# needs no install hooks.
|
||||
- name: Install ajv-cli from the lockfile
|
||||
working-directory: rules/scripts
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Validate Gather Rules
|
||||
run: |
|
||||
|
||||
@@ -20,9 +20,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -27,6 +27,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The published customer documentation lives in its own PUBLIC repo and is
|
||||
# baked into the image as the search_docs corpus (see the Dockerfile). No
|
||||
@@ -38,6 +40,7 @@ jobs:
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: mcp-docs
|
||||
|
||||
@@ -46,7 +49,7 @@ jobs:
|
||||
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -77,7 +80,7 @@ jobs:
|
||||
"short_sha=$shortSha" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Login to ACR
|
||||
run: az acr login --name ${{ env.ACR_NAME }}
|
||||
run: az acr login --name "$ACR_NAME"
|
||||
|
||||
- name: Build and push container image
|
||||
run: |
|
||||
@@ -87,17 +90,17 @@ jobs:
|
||||
--build-arg BUILD_NUMBER=${{ steps.version.outputs.build_number }} \
|
||||
--build-arg BUILD_COMMIT=${{ steps.version.outputs.short_sha }} \
|
||||
--build-arg BUILD_UTC=${{ steps.version.outputs.build_utc }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
|
||||
-t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG" \
|
||||
-t "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:latest" \
|
||||
.
|
||||
docker push ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }} --all-tags
|
||||
docker push "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME" --all-tags
|
||||
|
||||
- name: Deploy to Container App
|
||||
run: |
|
||||
az containerapp update \
|
||||
--name ${{ env.CONTAINER_APP_NAME }} \
|
||||
--resource-group ${{ env.RESOURCE_GROUP }} \
|
||||
--image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }}
|
||||
--name "$CONTAINER_APP_NAME" \
|
||||
--resource-group "$RESOURCE_GROUP" \
|
||||
--image "$ACR_NAME.azurecr.io/$CONTAINER_APP_NAME:$IMAGE_TAG"
|
||||
|
||||
# `az containerapp update` returns once the revision is provisioned, not once
|
||||
# it serves traffic — and with minReplicas=0 the first request also pays a cold
|
||||
|
||||
@@ -37,13 +37,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
# Only the version manifest at the very end needs Azure; logging in here rather than
|
||||
# next to that step keeps a credential problem from surfacing after a live deploy.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -58,6 +59,7 @@ jobs:
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: whats-new-docs
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -3,6 +3,9 @@ name: IndexNow Notify (Bing/Yandex)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# The job only talks to public endpoints; it never needs a repository token.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Federated login + read check
|
||||
steps:
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
@@ -25,5 +25,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit.
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
@@ -26,7 +26,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
|
||||
# Shared with build-agent.yml: a script publish and an agent release both touch
|
||||
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
|
||||
@@ -46,15 +45,24 @@ env:
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit, never a
|
||||
# path some earlier step could have written to the workspace.
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
name: Publish to the download alias
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
|
||||
# OIDC token it does not need.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
@@ -70,7 +78,7 @@ jobs:
|
||||
# both federated credentials are bound to the ref, so this job must never carry a
|
||||
# GitHub `environment:`.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -91,7 +99,7 @@ jobs:
|
||||
# runs the same publisher with -DryRun and deliberately without a login -- a dry run
|
||||
# must never need Azure credentials.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
|
||||
+1
-1
Submodule internal updated: b54f43f711...d00a52a1fb
@@ -0,0 +1,3 @@
|
||||
# ajv-cli declares ts-node as a peer dependency it never needs for JSON schemas; do not
|
||||
# pull ts-node + typescript into a job that pushes to main.
|
||||
legacy-peer-deps=true
|
||||
Generated
+315
@@ -0,0 +1,315 @@
|
||||
{
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-cli": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz",
|
||||
"integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0",
|
||||
"fast-json-patch": "^2.0.0",
|
||||
"glob": "^7.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-schema-migrate": "^2.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ajv": "dist/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-patch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
|
||||
"integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-migrate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
|
||||
"integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"private": true,
|
||||
"description": "Validator tooling for rules/ - combine-rules.yml installs from the lockfile so nothing unpinned reaches a job that pushes to main.",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-json-patch": "3.1.1"
|
||||
}
|
||||
}
|
||||
+15
@@ -186,5 +186,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
DateTime resolved;
|
||||
Assert.False(sut.TryResolveUtc(Source, UtcNow, out resolved));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3610.39, true, 4)] // session 4377911b: +1 h on a 10 s script
|
||||
[InlineData(61203.0, true, 68)] // 17 h on a 3 s script
|
||||
[InlineData(7205.66, true, 8)] // session cbaed57b: +2 h
|
||||
[InlineData(-3596.0, true, 4)] // the same error on the start end
|
||||
[InlineData(26.4, false, 0)] // a run time below one grid step
|
||||
[InlineData(1030.0, false, 1)] // 17 min 10 s: residual past the tolerance
|
||||
[InlineData(900.0, true, 1)] // exactly one step — a genuine 15-minute run reads the same, the caller needs mixed provenance too
|
||||
public void A_grid_sized_span_is_the_signature_of_an_offset_error(double seconds, bool onGrid, int expectedSteps)
|
||||
{
|
||||
int steps;
|
||||
Assert.Equal(onGrid, CmTraceOffsetCalibrator.IsOnOffsetGrid(TimeSpan.FromSeconds(seconds), out steps));
|
||||
if (onGrid) Assert.Equal(expectedSteps, steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -304,5 +304,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
tracker.HandlePlatformScriptStarted("policyZ");
|
||||
Assert.Single(started);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emitted_marker_is_capped_at_the_clock_so_the_next_run_still_opens_a_slot()
|
||||
{
|
||||
// Session 4377911b: the result line was re-read after an overwrite rewind and resolved
|
||||
// in the reader's zone, one hour ahead of the clock. Stored uncapped, that stamp made
|
||||
// the genuine second run's start line read as a late line of the first run and its
|
||||
// result a duplicate.
|
||||
using var h = new Harness();
|
||||
h.Start("d94468af", T0.AddSeconds(-10));
|
||||
h.Result("d94468af", T0.AddHours(1));
|
||||
h.Exit("d94468af", T0.AddSeconds(-1));
|
||||
h.Flush();
|
||||
Assert.Single(h.Completed);
|
||||
|
||||
// The second run, two minutes later on the clock — still "before" the uncapped stamp.
|
||||
h.Now = T0.AddMinutes(2);
|
||||
h.Start("d94468af", h.Now.AddSeconds(-5));
|
||||
h.Result("d94468af", h.Now.AddSeconds(-1));
|
||||
h.Exit("d94468af", h.Now.AddSeconds(-2));
|
||||
h.Flush();
|
||||
|
||||
Assert.Equal(2, h.Completed.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -154,5 +154,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
Assert.False(tracker2.TryClaimScriptTimeoutSuspected("policyF")); // cross-restart dedup
|
||||
Assert.True(tracker2.TryClaimScriptTimeoutSuspected("policyG")); // other policies unaffected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Line_provenance_of_a_pending_script_survives_restart()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var observedAt = new DateTime(2026, 9, 17, 13, 20, 7, DateTimeKind.Utc);
|
||||
var start = new CmTraceLineProvenance
|
||||
{
|
||||
SourceLocalTs = new DateTime(2026, 9, 17, 15, 19, 57, DateTimeKind.Unspecified),
|
||||
Origin = CmTraceOffsetOrigin.LineAnchored,
|
||||
OffsetMinutes = 120,
|
||||
MeasuredWriterOffsetMinutes = 120,
|
||||
SourceFileName = "IntuneManagementExtension.log",
|
||||
};
|
||||
var exit = new CmTraceLineProvenance
|
||||
{
|
||||
SourceLocalTs = new DateTime(2026, 9, 17, 15, 20, 7, DateTimeKind.Unspecified),
|
||||
Origin = CmTraceOffsetOrigin.None,
|
||||
OffsetMinutes = 60,
|
||||
SourceFileName = "AgentExecutor.log",
|
||||
};
|
||||
|
||||
var tracker1 = BuildTracker(tmp, out _);
|
||||
tracker1.SeedPendingPlatformScriptForTesting("policyH", exitCode: 0, exitObservedAtUtc: observedAt,
|
||||
startedAtUtc: observedAt.AddSeconds(-10), startedAtProvenance: start, exitProvenance: exit);
|
||||
tracker1.SaveStateForTest();
|
||||
|
||||
var tracker2 = BuildTracker(tmp, out var emitted2);
|
||||
tracker2.LoadStateForTest();
|
||||
tracker2.FlushPendingPlatformScriptResults(observedAt.AddSeconds(20));
|
||||
|
||||
var script = Assert.Single(emitted2);
|
||||
Assert.Equal(CmTraceOffsetOrigin.LineAnchored, script.StartedAtProvenance.Origin);
|
||||
Assert.Equal(120, script.StartedAtProvenance.OffsetMinutes);
|
||||
Assert.Equal(start.SourceLocalTs, script.StartedAtProvenance.SourceLocalTs);
|
||||
Assert.Equal(DateTimeKind.Unspecified, script.StartedAtProvenance.SourceLocalTs!.Value.Kind); // no zone was ever attached
|
||||
Assert.Equal("AgentExecutor.log", script.ExitProvenance.SourceFileName);
|
||||
Assert.Equal(60, script.ExitProvenance.OffsetMinutes);
|
||||
Assert.Null(script.ResultProvenance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+137
-2
@@ -59,9 +59,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void ClassifyEnforcementState_bands(int state, string expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
|
||||
|
||||
// IME AppInstallStatus values (StatusServiceReports\..\Status), stable IME 1.50 .. 1.106.
|
||||
// 1001/1003 sit inside the 1000 band but are NOT terminal — IME maps every InProgress
|
||||
// enforcement state to 1001 (verified in the decompiled 1.106 StatusServiceHelpers).
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1999, "installed")]
|
||||
[InlineData(1002, "installed")]
|
||||
[InlineData(1004, "installed")]
|
||||
[InlineData(1001, "installing")]
|
||||
[InlineData(1003, "installing")]
|
||||
[InlineData(1005, null)]
|
||||
[InlineData(1999, null)]
|
||||
[InlineData(3000, "failed")]
|
||||
[InlineData(2000, "notApplicable")]
|
||||
[InlineData(2999, "notApplicable")]
|
||||
@@ -72,6 +80,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void ClassifyStatusServiceStatus_bands(int status, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status));
|
||||
|
||||
[Fact]
|
||||
public void TryParseRegistryDateTime_reads_both_ime_shapes_as_utc()
|
||||
{
|
||||
// ExecutionDeadlineTime: DateTime.ToString(InvariantCulture), UTC without a kind marker.
|
||||
var deadline = ImeRegistryAppStateObserver.TryParseRegistryDateTime("09/23/2026 18:00:00");
|
||||
Assert.Equal(new DateTime(2026, 9, 23, 18, 0, 0, DateTimeKind.Utc), deadline);
|
||||
Assert.Equal(DateTimeKind.Utc, deadline!.Value.Kind);
|
||||
|
||||
// DeferUntilTime: round-trip "o" with trailing Z.
|
||||
var until = ImeRegistryAppStateObserver.TryParseRegistryDateTime("2026-09-23T10:30:00.0000000Z");
|
||||
Assert.Equal(new DateTime(2026, 9, 23, 10, 30, 0, DateTimeKind.Utc), until);
|
||||
Assert.Equal(DateTimeKind.Utc, until!.Value.Kind);
|
||||
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(null));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(""));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime("not a date"));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryParseRegistryDateTime(42));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadBool_parses_registry_strings_only()
|
||||
{
|
||||
Assert.True(ImeRegistryAppStateObserver.TryReadBool("True"));
|
||||
Assert.False(ImeRegistryAppStateObserver.TryReadBool("false"));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryReadBool("yes"));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryReadBool(1));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TryReadBool(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TerminalOutcome_prefers_enforcement_state_then_status_service()
|
||||
{
|
||||
@@ -88,6 +125,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry()));
|
||||
// Installing (1001) / InstallingPendingReboot (1003) share the 1000 band but are not terminal.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1001)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009, statusServiceStatus: 1003)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_reports_wait_state_triggers_only()
|
||||
{
|
||||
var prev = Entry(enforcementState: 2009);
|
||||
var next = Entry(enforcementState: 2009,
|
||||
installDeadlineUtc: T0.AddHours(2),
|
||||
deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false);
|
||||
|
||||
Assert.Equal(new[] { "installDeadline", "deferralsUsed", "deferUntil" },
|
||||
ImeRegistryAppStateObserver.ChangedFields(prev, next));
|
||||
|
||||
// MaxDeferrals / AutoDeferred alone are data, not a transition.
|
||||
var sameCounters = Entry(enforcementState: 2009,
|
||||
installDeadlineUtc: T0.AddHours(2),
|
||||
deferralsUsed: 1, deferralMaxDeferrals: 5, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: true);
|
||||
Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, sameCounters));
|
||||
|
||||
// A cleared deadline is a transition too (IME clears it when the next download starts).
|
||||
Assert.Equal(new[] { "installDeadline" },
|
||||
ImeRegistryAppStateObserver.ChangedFields(
|
||||
Entry(installDeadlineUtc: T0.AddHours(2)), Entry()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescribeWaitState_texts()
|
||||
{
|
||||
Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(enforcementState: 2009)));
|
||||
Assert.Equal(", downloaded, install deadline 2026-08-18 10:00 UTC",
|
||||
ImeRegistryAppStateObserver.DescribeWaitState(Entry(installDeadlineUtc: T0.AddHours(2))));
|
||||
Assert.Equal(", deferred by user 1/3 until 2026-08-18 08:30 UTC",
|
||||
ImeRegistryAppStateObserver.DescribeWaitState(Entry(
|
||||
deferralsUsed: 1, deferralMaxDeferrals: 3, deferUntilUtc: T0.AddMinutes(30), deferralAutoDeferred: false)));
|
||||
Assert.Equal(", auto-deferred 2/3",
|
||||
ImeRegistryAppStateObserver.DescribeWaitState(Entry(
|
||||
deferralsUsed: 2, deferralMaxDeferrals: 3, deferralAutoDeferred: true)));
|
||||
// Zero deferrals used is not a deferral; a missing max falls back to the bare count.
|
||||
Assert.Equal(string.Empty, ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 0, deferralMaxDeferrals: 3)));
|
||||
Assert.Equal(", deferred by user 1", ImeRegistryAppStateObserver.DescribeWaitState(Entry(deferralsUsed: 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -184,6 +264,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("success", evt.Payload["enforcementClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wait_state_changes_emit_state_events_with_data_and_text()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 2009, null));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
|
||||
// Download finished, IME parks the app until its install deadline (1.106 ExecutionDeadlineTime).
|
||||
var withDeadline = Snapshot((App1, 2009, null));
|
||||
withDeadline.Entries.Values.Single().InstallDeadlineUtc = T0.AddHours(2);
|
||||
current = withDeadline;
|
||||
observer.Tick("registry_change");
|
||||
|
||||
// A required app hits a running process and the user defers (1.105 app-in-use deferral).
|
||||
var deferred = Snapshot((App1, 2014, null));
|
||||
var entry = deferred.Entries.Values.Single();
|
||||
entry.InstallDeadlineUtc = T0.AddHours(2);
|
||||
entry.DeferralsUsed = 1;
|
||||
entry.DeferralMaxDeferrals = 3;
|
||||
entry.DeferUntilUtc = T0.AddMinutes(30);
|
||||
entry.DeferralAutoDeferred = false;
|
||||
current = deferred;
|
||||
observer.Tick("registry_change");
|
||||
}
|
||||
|
||||
var events = Events(sink, SharedEventTypes.RegistryAppState);
|
||||
Assert.Equal(2, events.Count);
|
||||
|
||||
var deadlineEvent = events[0];
|
||||
Assert.Equal("installDeadline", deadlineEvent.Payload!["changedFields"]);
|
||||
Assert.Equal("2026-08-18T10:00:00.0000000Z", deadlineEvent.Payload["installDeadlineUtc"]);
|
||||
Assert.DoesNotContain("deferralsUsed", deadlineEvent.Payload.Keys);
|
||||
Assert.Contains("-> inProgress, downloaded, install deadline 2026-08-18 10:00 UTC",
|
||||
deadlineEvent.Payload[SignalPayloadKeys.Message]);
|
||||
|
||||
var deferralEvent = events[1];
|
||||
Assert.Equal("enforcementState,deferralsUsed,deferUntil", deferralEvent.Payload!["changedFields"]);
|
||||
Assert.Equal("2014", deferralEvent.Payload["enforcementState"]);
|
||||
Assert.Equal("1", deferralEvent.Payload["deferralsUsed"]);
|
||||
Assert.Equal("3", deferralEvent.Payload["deferralMaxDeferrals"]);
|
||||
Assert.Equal("2026-08-18T08:30:00.0000000Z", deferralEvent.Payload["deferUntilUtc"]);
|
||||
Assert.Equal("false", deferralEvent.Payload["deferralAutoDeferred"]);
|
||||
Assert.Contains("deferred by user 1/3 until 2026-08-18 08:30 UTC", deferralEvent.Payload[SignalPayloadKeys.Message]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonEmpty_baseline_emits_one_summary_with_counts()
|
||||
{
|
||||
@@ -331,7 +459,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
private static AppRegistryEntry Entry(
|
||||
int? enforcementState = null, long? errorCode = null, int? exitCode = null,
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null)
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null,
|
||||
DateTime? installDeadlineUtc = null, int? deferralsUsed = null, int? deferralMaxDeferrals = null,
|
||||
DateTime? deferUntilUtc = null, bool? deferralAutoDeferred = null)
|
||||
{
|
||||
var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1);
|
||||
entry.EnforcementState = enforcementState;
|
||||
@@ -340,6 +470,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
entry.StatusServiceStatus = statusServiceStatus;
|
||||
entry.EspTracked = espTracked;
|
||||
entry.EspPhase = espPhase;
|
||||
entry.InstallDeadlineUtc = installDeadlineUtc;
|
||||
entry.DeferralsUsed = deferralsUsed;
|
||||
entry.DeferralMaxDeferrals = deferralMaxDeferrals;
|
||||
entry.DeferUntilUtc = deferUntilUtc;
|
||||
entry.DeferralAutoDeferred = deferralAutoDeferred;
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
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 script run duration is the span between two CMTrace lines, each resolved on its own zone
|
||||
/// belief. When the beliefs differ the span is a whole number of offset-grid steps plus the run
|
||||
/// time, not the run time: session 4377911b read a 10 s script as 3610 s (start line-anchored
|
||||
/// +120, result re-read after an overwrite rewind in the reader's zone +60 and rejected as
|
||||
/// ahead of the clock), a fleet tenant read 2–9 s scripts as 17 h every day. The adapter judges
|
||||
/// the pair from the provenance bound to each line and omits the span rather than lie.
|
||||
/// </summary>
|
||||
public sealed class ImeLogTrackerAdapterDurationProvenanceTests
|
||||
{
|
||||
private const string ServiceLog = "IntuneManagementExtension.log";
|
||||
private const string ExecutorLog = "AgentExecutor.log";
|
||||
|
||||
private static CmTraceLineProvenance Line(
|
||||
DateTime resolvedUtc, int offsetMinutes, CmTraceOffsetOrigin origin,
|
||||
string file = ServiceLog, string? anchor = null, int? measured = null) =>
|
||||
new CmTraceLineProvenance
|
||||
{
|
||||
// The writer's local time is the resolved value with the applied offset put back.
|
||||
SourceLocalTs = DateTime.SpecifyKind(resolvedUtc.AddMinutes(offsetMinutes), DateTimeKind.Unspecified),
|
||||
Origin = origin,
|
||||
OffsetMinutes = offsetMinutes,
|
||||
EraAnchorKind = anchor,
|
||||
MeasuredWriterOffsetMinutes = measured,
|
||||
SourceFileName = file,
|
||||
};
|
||||
|
||||
private static ScriptExecutionState Platform(string policyId, DateTime startedAtUtc, CmTraceLineProvenance start,
|
||||
DateTime? resultObservedAtUtc = null, CmTraceLineProvenance? result = null, string outcome = "Success", int exitCode = 0) =>
|
||||
new ScriptExecutionState
|
||||
{
|
||||
PolicyId = policyId,
|
||||
ScriptType = "platform",
|
||||
ExitCode = exitCode,
|
||||
Result = outcome,
|
||||
ResultSource = "ime_policy_result",
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = start,
|
||||
ResultObservedAtUtc = resultObservedAtUtc,
|
||||
ResultProvenance = result,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Rejected_result_line_against_a_line_anchored_start_omits_the_span()
|
||||
{
|
||||
// Session 4377911b, script d94468af: start 15:19:57.005 local, self-anchored (+120);
|
||||
// result 15:20:07.391 local, re-read after a rewind and resolved in the reader's
|
||||
// zone (+60) → 14:20:07Z, one hour ahead of the clock. The raw pair reads 3610 s.
|
||||
var clockNow = new DateTime(2026, 9, 17, 13, 20, 7, 437, DateTimeKind.Utc);
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startUtc = new DateTime(2026, 9, 17, 13, 19, 57, 5, DateTimeKind.Utc);
|
||||
var resultRaw = new DateTime(2026, 9, 17, 14, 20, 7, 391, DateTimeKind.Utc);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("d94468af",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored, measured: 120),
|
||||
resultRaw, Line(resultRaw, 60, CmTraceOffsetOrigin.None, measured: 120)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal(clockNow, info.OccurredAtUtc); // stamp: clamped to the clock
|
||||
Assert.Equal("true", info.Payload!["derivedTimestamp"]);
|
||||
Assert.Equal(resultRaw.ToString("o"), info.Payload["rejectedSourceTimestamp"]);
|
||||
Assert.False(info.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.False(info.Payload.ContainsKey("durationBasis"));
|
||||
Assert.Equal("rejected-endpoint", info.Payload["durationSuppressedReason"]);
|
||||
Assert.Equal("unverified", info.Payload["durationProvenance"]);
|
||||
// The end line's own provenance and the start line's, side by side.
|
||||
Assert.Equal("reader-zone-fallback", info.Payload["sourceOffsetOrigin"]);
|
||||
Assert.Equal("60", info.Payload["sourceOffsetMinutes"]);
|
||||
Assert.Equal("120", info.Payload["measuredWriterOffsetMinutes"]);
|
||||
Assert.Equal("2026-09-17T15:20:07.3910000", info.Payload["sourceLocalTs"]);
|
||||
Assert.Equal("line-anchored", info.Payload["startSourceOffsetOrigin"]);
|
||||
Assert.Equal("120", info.Payload["startSourceOffsetMinutes"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejected_era_anchored_result_of_a_failed_script_omits_the_span_and_the_timeout_verdict()
|
||||
{
|
||||
// Fleet shape (script_failed, daily in one tenant): result era-anchored at −420 (the
|
||||
// OOBE default zone's era) on a line written under +600 → 17 h ahead of the clock;
|
||||
// the 61203 s "run" of a 3 s script fired script_timeout_suspected every day.
|
||||
var clockNow = new DateTime(2026, 9, 21, 1, 30, 13, DateTimeKind.Utc);
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startUtc = clockNow.AddSeconds(-8);
|
||||
var resultRaw = clockNow.AddSeconds(-5).AddHours(17);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("88d406eb",
|
||||
startUtc, Line(startUtc, 600, CmTraceOffsetOrigin.LineAnchored),
|
||||
resultRaw, Line(resultRaw, -420, CmTraceOffsetOrigin.EraAnchored, anchor: "bootstrap-policy-result", measured: 600),
|
||||
outcome: "Failed"));
|
||||
|
||||
var failed = f.InfoEvent(SharedEventTypes.ScriptFailed);
|
||||
Assert.Equal("rejected-endpoint", failed.Payload!["durationSuppressedReason"]);
|
||||
Assert.False(failed.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("era-anchored", failed.Payload["sourceOffsetOrigin"]);
|
||||
Assert.Equal("bootstrap-policy-result", failed.Payload["sourceOffsetAnchor"]);
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_pair_on_the_offset_grid_omits_the_span_even_when_nothing_was_rejected()
|
||||
{
|
||||
// Session cbaed57b: start from AgentExecutor.log in the reader's zone (+120, two
|
||||
// hours too early), result era-anchored at 0 in the service log → 7205 s.
|
||||
var clockNow = new DateTime(2026, 9, 11, 8, 48, 25, DateTimeKind.Utc);
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-7205);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("846cea22",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.None, file: ExecutorLog),
|
||||
resultUtc, Line(resultUtc, 0, CmTraceOffsetOrigin.EraAnchored, anchor: "bootstrap-policy-result")));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.False(info.Payload!.ContainsKey("derivedTimestamp"));
|
||||
Assert.False(info.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("offset-grid", info.Payload["durationSuppressedReason"]);
|
||||
Assert.Equal("unverified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mixed_pair_off_the_grid_keeps_the_span_as_unverified()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-26);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("f6f67f79",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored),
|
||||
resultUtc, Line(resultUtc, 120, CmTraceOffsetOrigin.None)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("26.00", info.Payload!["durationSeconds"]);
|
||||
Assert.Equal("script_runtime", info.Payload["durationBasis"]);
|
||||
Assert.Equal("unverified", info.Payload["durationProvenance"]);
|
||||
Assert.False(info.Payload.ContainsKey("durationSuppressedReason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_line_anchored_ends_on_the_grid_are_a_verified_fifteen_minute_run()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-900);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("11111111",
|
||||
startUtc, Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored),
|
||||
resultUtc, Line(resultUtc, 120, CmTraceOffsetOrigin.LineAnchored)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("900.00", info.Payload!["durationSeconds"]);
|
||||
Assert.Equal("verified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_fallback_ends_from_the_same_file_share_their_error_and_stay_measurable()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-900);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("22222222",
|
||||
startUtc, Line(startUtc, 60, CmTraceOffsetOrigin.None),
|
||||
resultUtc, Line(resultUtc, 60, CmTraceOffsetOrigin.None)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("900.00", info.Payload!["durationSeconds"]);
|
||||
Assert.Equal("verified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_fallback_ends_from_different_files_are_different_writers()
|
||||
{
|
||||
// Same reader zone applied to both, but the executor child process and the service
|
||||
// can hold different beliefs (cbaed57b) — a grid-sized span is not trusted.
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(-900);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("33333333",
|
||||
startUtc, Line(startUtc, 60, CmTraceOffsetOrigin.None, file: ExecutorLog),
|
||||
resultUtc, Line(resultUtc, 60, CmTraceOffsetOrigin.None)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.False(info.Payload!.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("offset-grid", info.Payload["durationSuppressedReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_provenance_the_pair_is_taken_as_before()
|
||||
{
|
||||
// States from before the field (persisted by an older agent) and synthetic events:
|
||||
// no snapshot on either end, the 30-minute Failed run still reads as a timeout.
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = clockNow.AddSeconds(-1);
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "44444444",
|
||||
ScriptType = "platform",
|
||||
ExitCode = 0,
|
||||
Result = "Failed",
|
||||
StartedAtUtc = clockNow.AddSeconds(-1801),
|
||||
});
|
||||
|
||||
var failed = f.InfoEvent(SharedEventTypes.ScriptFailed);
|
||||
Assert.Equal("1800.00", failed.Payload!["durationSeconds"]);
|
||||
Assert.False(failed.Payload.ContainsKey("durationProvenance"));
|
||||
Assert.False(failed.Payload.ContainsKey("durationSuppressedReason"));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ScriptTimeoutSuspected));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_grid_sized_span_from_a_future_shifted_start_is_named_not_dropped_silently()
|
||||
{
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var resultUtc = clockNow.AddSeconds(-1);
|
||||
var startUtc = resultUtc.AddSeconds(3595); // resolved one hour ahead of its real instant
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(Platform("55555555",
|
||||
startUtc, Line(startUtc, 60, CmTraceOffsetOrigin.None),
|
||||
resultUtc, Line(resultUtc, 120, CmTraceOffsetOrigin.LineAnchored)));
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.False(info.Payload!.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("offset-grid", info.Payload["durationSuppressedReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Health_script_cycle_is_judged_the_same_way()
|
||||
{
|
||||
// HS-SCRIPT-START self-anchored, HS-NEW-RESULT re-read in the reader's zone and
|
||||
// rejected: the cycle duration is omitted, the compliance verdict stays.
|
||||
var clockNow = ImeLogTrackerAdapterFixture.DefaultClockStart;
|
||||
using var f = new ImeLogTrackerAdapterFixture(clockNow);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
|
||||
var startUtc = clockNow.AddSeconds(-40);
|
||||
var resultRaw = clockNow.AddSeconds(-2).AddHours(1);
|
||||
|
||||
adapter.TriggerScriptCompletedFromTest(new ScriptExecutionState
|
||||
{
|
||||
PolicyId = "66666666",
|
||||
ScriptType = "remediation",
|
||||
ScriptPart = "detection",
|
||||
ExitCode = 0,
|
||||
ComplianceResult = "True",
|
||||
StartedAtUtc = startUtc,
|
||||
StartedAtProvenance = Line(startUtc, 120, CmTraceOffsetOrigin.LineAnchored),
|
||||
ResultObservedAtUtc = resultRaw,
|
||||
ResultProvenance = Line(resultRaw, 60, CmTraceOffsetOrigin.None),
|
||||
DurationBasis = "cycle_including_reporting_latency",
|
||||
});
|
||||
|
||||
var info = f.InfoEvent(SharedEventTypes.ScriptCompleted);
|
||||
Assert.Equal("True", info.Payload!["complianceResult"]);
|
||||
Assert.False(info.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal("rejected-endpoint", info.Payload["durationSuppressedReason"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
@@ -45,5 +46,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
Assert.Equal("ime_policy_result", info.Payload["resultSource"]);
|
||||
Assert.Equal("92.00", info.Payload["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Held_completion_carries_the_provenance_of_its_result_line()
|
||||
{
|
||||
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);
|
||||
|
||||
// Start and result lines both self-anchored (+120).
|
||||
f.Tracker.LastMatchedLogTimestamp = startTs;
|
||||
f.Tracker.LastMatchedSourceLocalTimestamp = DateTime.SpecifyKind(startTs.AddMinutes(120), DateTimeKind.Unspecified);
|
||||
f.Tracker.LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.LineAnchored;
|
||||
f.Tracker.LastMatchedSourceOffsetMinutes = 120;
|
||||
f.Tracker.HandlePlatformScriptStarted("376ee51a");
|
||||
|
||||
f.Tracker.LastMatchedLogTimestamp = resultTs;
|
||||
f.Tracker.LastMatchedSourceLocalTimestamp = DateTime.SpecifyKind(resultTs.AddMinutes(120), DateTimeKind.Unspecified);
|
||||
f.Tracker.LastMatchedPatternId = "PS-SCRIPT-RESULT";
|
||||
f.Tracker.CompletePlatformScriptFromImeResultForTesting("376ee51a", "Success", resultTs);
|
||||
|
||||
// By the time the held completion is emitted, the last matched line is an unrelated
|
||||
// one resolved in the reader's zone.
|
||||
f.Tracker.LastMatchedLogTimestamp = ClockNow.AddSeconds(-1);
|
||||
f.Tracker.LastMatchedSourceLocalTimestamp = DateTime.SpecifyKind(ClockNow.AddSeconds(-1).AddMinutes(60), DateTimeKind.Unspecified);
|
||||
f.Tracker.LastMatchedSourceOffsetOrigin = CmTraceOffsetOrigin.None;
|
||||
f.Tracker.LastMatchedSourceOffsetMinutes = 60;
|
||||
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("line-anchored", info.Payload!["sourceOffsetOrigin"]);
|
||||
Assert.Equal("120", info.Payload["sourceOffsetMinutes"]);
|
||||
Assert.Equal(resultTs.AddMinutes(120).ToString("yyyy-MM-ddTHH:mm:ss.fffffff"), info.Payload["sourceLocalTs"]);
|
||||
Assert.Equal("line-anchored", info.Payload["startSourceOffsetOrigin"]);
|
||||
Assert.Equal("120", info.Payload["startSourceOffsetMinutes"]);
|
||||
Assert.Equal("92.00", info.Payload["durationSeconds"]);
|
||||
Assert.Equal("verified", info.Payload["durationProvenance"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// How ONE CMTrace line's UTC value was arrived at, captured at the line itself.
|
||||
/// <para>
|
||||
/// The tracker exposes the provenance of the line it matched last. A consumer that acts later —
|
||||
/// a platform-script result held for its executor end block, a run duration spanning a start
|
||||
/// line and a result line — must describe ITS line, not whichever line happened to be matched
|
||||
/// last, so the snapshot travels on <see cref="ScriptExecutionState"/> (persisted as a plain
|
||||
/// DTO; null on state files written before it existed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CmTraceLineProvenance
|
||||
{
|
||||
/// <summary>
|
||||
/// The line's local time exactly as the writer wrote it (Kind Unspecified, no zone). Null
|
||||
/// when the value did not come from a CMTrace line at all (clock fallback).
|
||||
/// </summary>
|
||||
public DateTime? SourceLocalTs { get; set; }
|
||||
|
||||
/// <summary>How the offset applied to <see cref="SourceLocalTs"/> was obtained.</summary>
|
||||
public CmTraceOffsetOrigin Origin { get; set; }
|
||||
|
||||
/// <summary>The offset that was applied, in minutes (local = UTC + offset).</summary>
|
||||
public int? OffsetMinutes { get; set; }
|
||||
|
||||
/// <summary>For <see cref="CmTraceOffsetOrigin.EraAnchored"/>: which anchor established the era's offset.</summary>
|
||||
public string EraAnchorKind { get; set; }
|
||||
|
||||
/// <summary>What the calibrator measured for the source file — observational, never applied.</summary>
|
||||
public int? MeasuredWriterOffsetMinutes { get; set; }
|
||||
|
||||
/// <summary>Name of the log file the line was read from — the closest thing to a writer identity the tracker has.</summary>
|
||||
public string SourceFileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the applied offset was ASSUMED for this line rather than declared by the
|
||||
/// writer or measured against the agent clock: the reader's own zone (fallback), a writer
|
||||
/// era's anchor, or the retired per-file calibration. A clock-derived value (no source
|
||||
/// timestamp) sits on the true UTC axis and is not an assumption.
|
||||
/// </summary>
|
||||
public bool IsAssumed()
|
||||
{
|
||||
if (!SourceLocalTs.HasValue) return false;
|
||||
switch (Origin)
|
||||
{
|
||||
case CmTraceOffsetOrigin.Bias:
|
||||
case CmTraceOffsetOrigin.LineAnchored:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -159,6 +159,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a span between two resolved lines is the signature of a wrong offset on one
|
||||
/// of them rather than of elapsed time: a zone error is always a whole number of grid
|
||||
/// steps, so a span within <see cref="MaxGridResidual"/> of k × <see cref="OffsetGrid"/>
|
||||
/// (k ≥ 1) reads as an offset error plus a run time of seconds. Sign is ignored — a wrong
|
||||
/// offset on the START end produces the same magnitude with the opposite sign.
|
||||
/// </summary>
|
||||
internal static bool IsOnOffsetGrid(TimeSpan span, out int gridSteps)
|
||||
{
|
||||
var magnitude = span.Duration();
|
||||
gridSteps = (int)Math.Round(magnitude.TotalMinutes / OffsetGrid.TotalMinutes, MidpointRounding.AwayFromZero);
|
||||
if (gridSteps < 1) return false;
|
||||
|
||||
var nearest = TimeSpan.FromMinutes(gridSteps * OffsetGrid.TotalMinutes);
|
||||
return (magnitude - nearest).Duration() <= MaxGridResidual;
|
||||
}
|
||||
|
||||
public bool TryCalibrate(string sourceKey, DateTime localTimestamp, DateTime agentUtcNow)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceKey)) return false;
|
||||
|
||||
+59
-23
@@ -252,8 +252,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// duration when the consolidated [HS] new result line arrives (the slot above is
|
||||
// cleared by the early-signal HS-COMPLIANCE handler, so timing lives in its own
|
||||
// map). Prefer the source CMTrace timestamp; latest start wins on a policy re-run.
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow;
|
||||
_healthScriptStartTimes[id] = startedAtUtc;
|
||||
var startedAtUtc = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
_healthScriptStartTimes[id] = (startedAtUtc, CaptureLastMatchedProvenance());
|
||||
_logger.Info($"ImeLogTracker: health script started: {id}");
|
||||
|
||||
// A replayed line of a previous enrollment bypasses the recurrence gate: the
|
||||
@@ -281,6 +281,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
internal void HandlePlatformScriptStarted(string id, string source = null)
|
||||
{
|
||||
var currentStartTs = LastMatchedLogTimestamp ?? UtcNowProvider();
|
||||
var currentStartProvenance = CaptureLastMatchedProvenance();
|
||||
|
||||
// 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
|
||||
@@ -323,6 +324,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// The slot was opened by its result while the start block was still hidden;
|
||||
// the recovered start line dates the run.
|
||||
pendingSlot.StartedAtUtc = currentStartTs;
|
||||
pendingSlot.StartedAtProvenance = currentStartProvenance;
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
@@ -343,6 +345,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Set only at slot creation: the start line fires twice (agentexecutor + ime
|
||||
// source) but the earliest wins.
|
||||
StartedAtUtc = currentStartTs,
|
||||
StartedAtProvenance = currentStartProvenance,
|
||||
};
|
||||
// Live "running" indicator — same signal health scripts emit. Gated on slot
|
||||
// creation so the duplicate start line (agentexecutor + ime source) doesn't
|
||||
@@ -433,7 +436,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
var script = GetCurrentScriptForLineUpdate(parameters, out var ownerPolicyId);
|
||||
if (script != null)
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider());
|
||||
ApplyExitCode(script, exitCode, LastMatchedLogTimestamp ?? UtcNowProvider(), CaptureLastMatchedProvenance());
|
||||
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
|
||||
@@ -442,15 +445,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
RequestOverwriteCheck();
|
||||
}
|
||||
|
||||
private void ApplyExitCode(ScriptExecutionState script, int exitCode, DateTime observedAtUtc)
|
||||
private void ApplyExitCode(ScriptExecutionState script, int exitCode, DateTime observedAtUtc, CmTraceLineProvenance exitProvenance)
|
||||
{
|
||||
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.
|
||||
// UTC-normalized by the parser) so replayed log content is dated correctly. The
|
||||
// line's provenance rides along: the fallback emit is bound to this line.
|
||||
if (string.Equals(script.ScriptType, "platform", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
script.ExitObservedAtUtc = observedAtUtc;
|
||||
script.ExitProvenance = exitProvenance;
|
||||
}
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
|
||||
@@ -500,7 +507,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId);
|
||||
CompletePlatformScriptFromImeResult(id, result, LastMatchedLogTimestamp, LastMatchedPatternId, CaptureLastMatchedProvenance());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -514,7 +521,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// 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)
|
||||
internal void CompletePlatformScriptFromImeResult(string policyId, string result, DateTime? resultLineTimestampUtc, string patternId, CmTraceLineProvenance resultProvenance)
|
||||
{
|
||||
// Dedup: the deadline-based fallback (FlushPendingPlatformScriptResults) may already have
|
||||
// emitted this script from its AgentExecutor exit code because IME's authoritative
|
||||
@@ -559,6 +566,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
script.Result = result;
|
||||
script.ResultSource = "ime_policy_result";
|
||||
script.ResultObservedAtUtc = resultLineTimestampUtc ?? UtcNowProvider();
|
||||
script.ResultProvenance = resultProvenance;
|
||||
script.ResultPatternId = patternId;
|
||||
_stateDirty = true;
|
||||
|
||||
@@ -584,7 +592,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_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();
|
||||
// The run's source stamp, capped at the clock: a result line cannot be read before it
|
||||
// was written, so a stamp ahead of the clock is a wrong zone assumption on that line
|
||||
// (a rewound result resolved in the reader's zone, session 4377911b: +1 h). Left
|
||||
// uncapped, the marker made the genuine NEXT run's start line read as a late line of
|
||||
// this run and its result a duplicate.
|
||||
var runStamp = script.ResultObservedAtUtc ?? script.ExitObservedAtUtc ?? UtcNowProvider();
|
||||
var nowUtc = UtcNowProvider();
|
||||
_platformScriptResultEmitted[script.PolicyId] = runStamp > nowUtc ? nowUtc : runStamp;
|
||||
_pendingPlatformScripts.Remove(script.PolicyId);
|
||||
_stateDirty = true;
|
||||
}
|
||||
@@ -681,7 +696,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
/// <summary>Test seam: inject a pending platform script as if AgentExecutor.log had reported
|
||||
/// its start + exit code, without an IME PS-SCRIPT-RESULT line.</summary>
|
||||
internal void SeedPendingPlatformScriptForTesting(string policyId, int? exitCode, DateTime? exitObservedAtUtc, string stdout = null, DateTime? startedAtUtc = null)
|
||||
internal void SeedPendingPlatformScriptForTesting(string policyId, int? exitCode, DateTime? exitObservedAtUtc, string stdout = null, DateTime? startedAtUtc = null,
|
||||
CmTraceLineProvenance startedAtProvenance = null, CmTraceLineProvenance exitProvenance = null)
|
||||
{
|
||||
// Mirrors a fresh execution start: a new run clears any prior emitted-marker (see
|
||||
// HandleScriptStarted) so re-runs of the same policy within one lifetime emit again.
|
||||
@@ -694,6 +710,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
ExitObservedAtUtc = exitObservedAtUtc,
|
||||
Stdout = stdout,
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = startedAtProvenance,
|
||||
ExitProvenance = exitProvenance,
|
||||
};
|
||||
NextTestEntry();
|
||||
RecordInvocationMarker(policyId, isClose: false);
|
||||
@@ -703,18 +721,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// (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)
|
||||
internal void CompletePlatformScriptFromImeResultForTesting(string policyId, string result, DateTime? resultLineTimestampUtc = null, CmTraceLineProvenance resultProvenance = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
CompletePlatformScriptFromImeResult(policyId, result, resultLineTimestampUtc ?? LastMatchedLogTimestamp, "PS-SCRIPT-RESULT");
|
||||
CompletePlatformScriptFromImeResult(policyId, result, resultLineTimestampUtc ?? LastMatchedLogTimestamp, "PS-SCRIPT-RESULT", resultProvenance ?? CaptureLastMatchedProvenance());
|
||||
}
|
||||
|
||||
/// <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)
|
||||
internal void RecordPlatformScriptExitCodeForTesting(string policyId, int exitCode, DateTime observedAtUtc, CmTraceLineProvenance exitProvenance = null)
|
||||
{
|
||||
if (_pendingPlatformScripts.TryGetValue(policyId, out var script))
|
||||
ApplyExitCode(script, exitCode, observedAtUtc);
|
||||
ApplyExitCode(script, exitCode, observedAtUtc, exitProvenance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -790,8 +808,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// HS-NEW-RESULT path whose end stamp includes IME's batched reporting latency.
|
||||
// Peek only: HS-NEW-RESULT still consumes + removes the entry for cycle timing.
|
||||
StartedAtUtc = _healthScriptStartTimes.TryGetValue(id, out var earlyStart)
|
||||
? earlyStart
|
||||
? earlyStart.Utc
|
||||
: (DateTime?)null,
|
||||
StartedAtProvenance = _healthScriptStartTimes.TryGetValue(id, out var earlyStartProvenance)
|
||||
? earlyStartProvenance.Provenance
|
||||
: null,
|
||||
// The HS-COMPLIANCE line is the end of this phase; emitted synchronously, so the
|
||||
// last matched line is it.
|
||||
ResultProvenance = CaptureLastMatchedProvenance(),
|
||||
DurationBasis = "script_runtime",
|
||||
};
|
||||
|
||||
@@ -860,10 +884,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// HS-SCRIPT-START line had been observed, so a subsequent
|
||||
/// <see cref="HandleHealthScriptResultJson"/> surfaces a run duration without driving the
|
||||
/// full regex pipeline.</summary>
|
||||
internal void SeedHealthScriptStartForTesting(string policyId, DateTime startedAtUtc)
|
||||
internal void SeedHealthScriptStartForTesting(string policyId, DateTime startedAtUtc, CmTraceLineProvenance provenance = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(policyId)) return;
|
||||
_healthScriptStartTimes[policyId] = startedAtUtc;
|
||||
_healthScriptStartTimes[policyId] = (startedAtUtc, provenance);
|
||||
}
|
||||
|
||||
private void HandleHealthScriptResult(Match match, Dictionary<string, string> parameters)
|
||||
@@ -920,10 +944,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// emitted phase events — the remediation analog of platform-script timing. Consumed +
|
||||
// removed so a later re-run of the same policy re-times from its own start. Null when
|
||||
// the start line was never seen in the agent's window (e.g. a replayed result).
|
||||
DateTime? cycleStartedAtUtc = _healthScriptStartTimes.TryGetValue(policyId, out var hsStart)
|
||||
? hsStart
|
||||
: (DateTime?)null;
|
||||
var hasCycleStart = _healthScriptStartTimes.TryGetValue(policyId, out var hsStart);
|
||||
DateTime? cycleStartedAtUtc = hasCycleStart ? hsStart.Utc : (DateTime?)null;
|
||||
var cycleStartProvenance = hasCycleStart ? hsStart.Provenance : null;
|
||||
_healthScriptStartTimes.Remove(policyId);
|
||||
// Every phase ends on this HS-NEW-RESULT line.
|
||||
var resultProvenance = CaptureLastMatchedProvenance();
|
||||
|
||||
// Common metadata — applied to every emitted phase event.
|
||||
var remediationStatus = TryGetInt(root, "RemediationStatus");
|
||||
@@ -963,7 +989,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
targetType: targetType,
|
||||
errorCode: errorCode,
|
||||
errorDetails: errorDetails,
|
||||
startedAtUtc: cycleStartedAtUtc);
|
||||
startedAtUtc: cycleStartedAtUtc,
|
||||
startedAtProvenance: cycleStartProvenance,
|
||||
resultProvenance: resultProvenance);
|
||||
// complianceResult mirrors the legacy HS-COMPLIANCE event semantics: True when the
|
||||
// pre-detection script reported compliant (exit 0), False otherwise.
|
||||
detection.ComplianceResult = firstDetectExit == 0 ? "True" : (firstDetectExit.HasValue ? "False" : null);
|
||||
@@ -987,7 +1015,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
targetType: targetType,
|
||||
errorCode: errorCode,
|
||||
errorDetails: errorDetails,
|
||||
startedAtUtc: cycleStartedAtUtc);
|
||||
startedAtUtc: cycleStartedAtUtc,
|
||||
startedAtProvenance: cycleStartProvenance,
|
||||
resultProvenance: resultProvenance);
|
||||
// No complianceResult on the remediation phase — there is nothing to be compliant about.
|
||||
EmitScriptEvent(remediation);
|
||||
}
|
||||
@@ -1009,7 +1039,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
targetType: targetType,
|
||||
errorCode: errorCode,
|
||||
errorDetails: errorDetails,
|
||||
startedAtUtc: cycleStartedAtUtc);
|
||||
startedAtUtc: cycleStartedAtUtc,
|
||||
startedAtProvenance: cycleStartProvenance,
|
||||
resultProvenance: resultProvenance);
|
||||
postDetection.ComplianceResult = lastDetectExit == 0 ? "True" : (lastDetectExit.HasValue ? "False" : null);
|
||||
EmitScriptEvent(postDetection);
|
||||
}
|
||||
@@ -1032,7 +1064,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
int? targetType,
|
||||
int? errorCode,
|
||||
string errorDetails,
|
||||
DateTime? startedAtUtc) =>
|
||||
DateTime? startedAtUtc,
|
||||
CmTraceLineProvenance startedAtProvenance,
|
||||
CmTraceLineProvenance resultProvenance) =>
|
||||
new ScriptExecutionState
|
||||
{
|
||||
PolicyId = policyId,
|
||||
@@ -1049,6 +1083,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// Cycle start (HS-SCRIPT-START). Every phase carries the same cycle start so the
|
||||
// adapter computes the total run duration; the Web surfaces it once at card level.
|
||||
StartedAtUtc = startedAtUtc,
|
||||
StartedAtProvenance = startedAtProvenance,
|
||||
ResultProvenance = resultProvenance,
|
||||
// End stamp is the HS-NEW-RESULT line — written only after IME's batched report
|
||||
// to the service, so this duration overstates the actual script execution time.
|
||||
DurationBasis = "cycle_including_reporting_latency"
|
||||
|
||||
+15
@@ -505,6 +505,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
"imetokenfailure",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the last matched line's provenance, for a consumer that acts later than
|
||||
/// the match (a held platform-script result, a run duration across two lines) and must
|
||||
/// describe THAT line rather than whichever line is matched by then.
|
||||
/// </summary>
|
||||
internal CmTraceLineProvenance CaptureLastMatchedProvenance() => new CmTraceLineProvenance
|
||||
{
|
||||
SourceLocalTs = LastMatchedSourceLocalTimestamp,
|
||||
Origin = LastMatchedSourceOffsetOrigin,
|
||||
OffsetMinutes = LastMatchedSourceOffsetMinutes,
|
||||
EraAnchorKind = LastMatchedEraAnchorKind,
|
||||
MeasuredWriterOffsetMinutes = LastMatchedMeasuredWriterOffsetMinutes,
|
||||
SourceFileName = _currentSourceFileName,
|
||||
};
|
||||
|
||||
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
|
||||
{
|
||||
LastMatchedPatternId = pattern.PatternId;
|
||||
|
||||
@@ -205,8 +205,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// the remediation-script analog of the platform-script StartedAtUtc timing. Kept in a
|
||||
// dedicated map (not on _pendingHealthScript) because the early-signal HS-COMPLIANCE
|
||||
// handler clears that slot before HS-NEW-RESULT arrives. Consumed + removed on result emit.
|
||||
private readonly Dictionary<string, DateTime> _healthScriptStartTimes =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
// The start line's provenance rides along so the adapter can tell whether start and result
|
||||
// were resolved on the same zone assumption before it subtracts them.
|
||||
private readonly Dictionary<string, (DateTime Utc, CmTraceLineProvenance Provenance)> _healthScriptStartTimes =
|
||||
new Dictionary<string, (DateTime Utc, CmTraceLineProvenance Provenance)>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Restart-safe dedup for the adapter's one-shot script_timeout_suspected Warning.
|
||||
// Owned by the tracker (not the adapter) solely so it rides the persisted state file —
|
||||
|
||||
+20
@@ -29,6 +29,15 @@ public class ScriptExecutionState
|
||||
/// </summary>
|
||||
public System.DateTime? StartedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of <see cref="StartedAtUtc"/>: how the start line's offset was obtained. Paired
|
||||
/// with <see cref="ResultProvenance"/> / <see cref="ExitProvenance"/> by the adapter to decide
|
||||
/// whether a run duration is measurable — two lines resolved on different zone assumptions
|
||||
/// differ by whole offset-grid steps, not by run time (session 4377911b: a 10 s script read
|
||||
/// as 3610 s). Null when the start was never observed or on state files from before the field.
|
||||
/// </summary>
|
||||
public CmTraceLineProvenance StartedAtProvenance { get; set; }
|
||||
|
||||
/// <summary>"detection", "remediation", or "post-detection" (remediation scripts only).</summary>
|
||||
public string ScriptPart { get; set; }
|
||||
|
||||
@@ -66,6 +75,9 @@ public class ScriptExecutionState
|
||||
/// </summary>
|
||||
public System.DateTime? ExitObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Provenance of the exit-code line behind <see cref="ExitObservedAtUtc"/> (the fallback emit's end).</summary>
|
||||
public CmTraceLineProvenance ExitProvenance { 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
|
||||
@@ -76,6 +88,14 @@ public class ScriptExecutionState
|
||||
/// </summary>
|
||||
public System.DateTime? ResultObservedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance of the line that delivered <see cref="Result"/> — bound here for the same reason
|
||||
/// as <see cref="ResultObservedAtUtc"/>: a held completion is emitted passes later, when the
|
||||
/// tracker's "last matched" provenance describes an unrelated line. For health scripts the
|
||||
/// result line of the phase (HS-COMPLIANCE early signal or HS-NEW-RESULT).
|
||||
/// </summary>
|
||||
public CmTraceLineProvenance ResultProvenance { 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; }
|
||||
|
||||
|
||||
+126
-6
@@ -24,10 +24,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <item><c>EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev></c> — which apps IME
|
||||
/// registered for ESP tracking and in which phase.</item>
|
||||
/// <item><c>SideCarPolicies\StatusServiceReports\<userGuid>\<appId></c> — the exact
|
||||
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed).</item>
|
||||
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed, 1001=Installing,
|
||||
/// 1003=InstallingPendingReboot, 2000=NotApplicable, 3000=Failed).</item>
|
||||
/// <item><c>Win32Apps\OperationalState\<userGuid>\<appGuid></c> (bare GUIDs) — IME's
|
||||
/// per-app operational state: the install deadline a downloaded app is waiting for
|
||||
/// (<c>ExecutionDeadlineTime</c>, IME 1.106+) and the app-in-use deferral counters
|
||||
/// (<c>DeferralsUsed</c>, <c>DeferUntilTime</c>, ..., IME 1.105+).</item>
|
||||
/// </list>
|
||||
/// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only,
|
||||
/// coalesced — a per-write parse is impossible by design): every tick re-reads the three
|
||||
/// coalesced — a per-write parse is impossible by design): every tick re-reads the four
|
||||
/// surfaces and emits <c>registry_app_state</c> on real field changes. Pre-existing state at
|
||||
/// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying
|
||||
/// them as fresh events would be the registry twin of the historic-IME-replay bug.
|
||||
@@ -162,6 +167,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
ReadWin32Apps(root, snapshot);
|
||||
ReadEspTracking(root, snapshot);
|
||||
ReadStatusServiceReports(root, snapshot);
|
||||
ReadOperationalState(root, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -250,6 +256,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two wait states are read; the download/execution bookkeeping next to them
|
||||
// (DownloadStatus, DownloadStartTime, ExecutionStatus, ...) duplicates the log narrative
|
||||
// and would double the event volume without adding a state the timeline lacks.
|
||||
private static void ReadOperationalState(RegistryKey root, ImeRegistrySnapshot snapshot)
|
||||
{
|
||||
using var operational = root.OpenSubKey(@"Win32Apps\OperationalState");
|
||||
if (operational == null) return;
|
||||
|
||||
foreach (var userName in operational.GetSubKeyNames())
|
||||
{
|
||||
if (!LooksLikeGuid(userName)) continue;
|
||||
using var userKey = operational.OpenSubKey(userName);
|
||||
if (userKey == null) continue;
|
||||
|
||||
foreach (var appKeyName in userKey.GetSubKeyNames())
|
||||
{
|
||||
var appId = ExtractAppId(appKeyName);
|
||||
if (appId == null) continue;
|
||||
|
||||
using var appKey = userKey.OpenSubKey(appKeyName);
|
||||
if (appKey == null) continue;
|
||||
|
||||
var deadline = TryParseRegistryDateTime(appKey.GetValue("ExecutionDeadlineTime"));
|
||||
var deferralsUsed = TryReadInt(appKey.GetValue("DeferralsUsed"));
|
||||
if (deadline == null && deferralsUsed == null) continue; // no wait state — no entry of its own
|
||||
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.InstallDeadlineUtc = deadline;
|
||||
entry.DeferralsUsed = deferralsUsed;
|
||||
entry.DeferralMaxDeferrals = TryReadInt(appKey.GetValue("DeferralMaxDeferrals"));
|
||||
entry.DeferUntilUtc = TryParseRegistryDateTime(appKey.GetValue("DeferUntilTime"));
|
||||
entry.DeferralAutoDeferred = TryReadBool(appKey.GetValue("DeferralAutoDeferred"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── pure helpers (unit-tested directly) ─────────────────────────────────
|
||||
|
||||
internal static bool LooksLikeGuid(string name) =>
|
||||
@@ -301,6 +343,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool? TryReadBool(object? value) =>
|
||||
value is string s && bool.TryParse(s, out var parsed) ? parsed : (bool?)null;
|
||||
|
||||
/// <summary>
|
||||
/// OperationalState timestamps are UTC but stored in two InvariantCulture shapes:
|
||||
/// <c>ExecutionDeadlineTime</c> as <c>MM/dd/yyyy HH:mm:ss</c> without a kind marker,
|
||||
/// <c>DeferUntilTime</c> round-trip ("o", trailing Z). Both come back as UTC here.
|
||||
/// </summary>
|
||||
internal static DateTime? TryParseRegistryDateTime(object? value)
|
||||
{
|
||||
if (!(value is string s) || string.IsNullOrWhiteSpace(s)) return null;
|
||||
return DateTime.TryParse(s, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)
|
||||
? parsed
|
||||
: (DateTime?)null;
|
||||
}
|
||||
|
||||
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
|
||||
internal static string ClassifyEnforcementState(int state)
|
||||
{
|
||||
@@ -312,15 +371,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
|
||||
/// <summary>
|
||||
/// AppInstallStatus as IME writes it to StatusServiceReports (unchanged IME 1.50 .. 1.106):
|
||||
/// 1000 Installed, 1001 Installing, 1002 InstalledButDependenciesNotPresent,
|
||||
/// 1003 InstallingPendingReboot, 1004 InstalledPendingReboot, 2000 NotApplicable, 3000 Failed.
|
||||
/// The 1000 band is NOT uniformly terminal — IME maps every InProgress enforcement state to
|
||||
/// 1001 — so the mapping is explicit and an unknown value is not judged.
|
||||
/// </summary>
|
||||
internal static string? ClassifyStatusServiceStatus(int status)
|
||||
{
|
||||
if (status >= 1000 && status < 2000) return "installed";
|
||||
if (status == 3000) return "failed";
|
||||
switch (status)
|
||||
{
|
||||
case 1000:
|
||||
case 1002:
|
||||
case 1004:
|
||||
return "installed";
|
||||
case 1001:
|
||||
case 1003:
|
||||
return "installing";
|
||||
case 3000:
|
||||
return "failed";
|
||||
}
|
||||
if (status >= 2000 && status < 3000) return "notApplicable";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable suffix for the two IME wait states (install deadline, app-in-use
|
||||
/// deferral); empty when neither applies. Culture-invariant on purpose — the agent runs
|
||||
/// under whatever culture the SYSTEM account carries.
|
||||
/// </summary>
|
||||
internal static string DescribeWaitState(AppRegistryEntry entry)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
if (entry.InstallDeadlineUtc is DateTime deadline)
|
||||
parts.Add("downloaded, install deadline " + FormatUtcMinute(deadline));
|
||||
|
||||
if (entry.DeferralsUsed is int used && used > 0)
|
||||
{
|
||||
var kind = entry.DeferralAutoDeferred == true ? "auto-deferred" : "deferred by user";
|
||||
var count = entry.DeferralMaxDeferrals is int max
|
||||
? used.ToString(CultureInfo.InvariantCulture) + "/" + max.ToString(CultureInfo.InvariantCulture)
|
||||
: used.ToString(CultureInfo.InvariantCulture);
|
||||
parts.Add(entry.DeferUntilUtc is DateTime until
|
||||
? kind + " " + count + " until " + FormatUtcMinute(until)
|
||||
: kind + " " + count);
|
||||
}
|
||||
|
||||
return parts.Count == 0 ? string.Empty : ", " + string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string FormatUtcMinute(DateTime utc) =>
|
||||
utc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC";
|
||||
|
||||
internal static List<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
|
||||
{
|
||||
var changes = new List<AppRegistryChange>();
|
||||
@@ -343,6 +446,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus");
|
||||
if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked");
|
||||
if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase");
|
||||
// Wait states: the deadline and each recorded deferral are transitions worth an event;
|
||||
// MaxDeferrals / AutoDeferred only travel along as data.
|
||||
if (prev?.InstallDeadlineUtc != next.InstallDeadlineUtc) changed.Add("installDeadline");
|
||||
if (prev?.DeferralsUsed != next.DeferralsUsed) changed.Add("deferralsUsed");
|
||||
if (prev?.DeferUntilUtc != next.DeferUntilUtc) changed.Add("deferUntil");
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -445,6 +553,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
if (e.EspTracked) data["espTracked"] = "true";
|
||||
if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!;
|
||||
if (e.InstallDeadlineUtc is DateTime deadline) data["installDeadlineUtc"] = deadline.ToString("o", CultureInfo.InvariantCulture);
|
||||
if (e.DeferralsUsed is int used) data["deferralsUsed"] = used.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.DeferralMaxDeferrals is int max) data["deferralMaxDeferrals"] = max.ToString(CultureInfo.InvariantCulture);
|
||||
if (e.DeferUntilUtc is DateTime until) data["deferUntilUtc"] = until.ToString("o", CultureInfo.InvariantCulture);
|
||||
if (e.DeferralAutoDeferred is bool auto) data["deferralAutoDeferred"] = auto ? "true" : "false";
|
||||
|
||||
var summary = data.TryGetValue("enforcementClass", out var encls) ? encls
|
||||
: data.TryGetValue("statusServiceClass", out var sscls) ? sscls
|
||||
@@ -453,7 +566,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppState,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}",
|
||||
message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}{DescribeWaitState(e)}",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc);
|
||||
@@ -625,6 +738,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public int? StatusServiceStatus { get; set; }
|
||||
public bool EspTracked { get; set; }
|
||||
public string? EspPhase { get; set; }
|
||||
|
||||
// Win32Apps\OperationalState — the two IME wait states (all UTC).
|
||||
public DateTime? InstallDeadlineUtc { get; set; }
|
||||
public int? DeferralsUsed { get; set; }
|
||||
public int? DeferralMaxDeferrals { get; set; }
|
||||
public DateTime? DeferUntilUtc { get; set; }
|
||||
public bool? DeferralAutoDeferred { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class AppRegistryChange
|
||||
|
||||
@@ -461,31 +461,35 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// no second clock frame, so there is nothing to record.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void TagDerivedTimestamp(IDictionary<string, string> data, bool derivedFromClock, DateTime? rawSourceTs)
|
||||
private void TagDerivedTimestamp(IDictionary<string, string> data, bool derivedFromClock, DateTime? rawSourceTs, CmTraceLineProvenance? lineProvenance = null)
|
||||
{
|
||||
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||
|
||||
var sourceLocal = _tracker.LastMatchedSourceLocalTimestamp;
|
||||
// The line the event is bound to. Callers that emit later than the match (a held
|
||||
// platform-script result) pass the snapshot taken at their line; everyone else is
|
||||
// emitting while its line is still the last matched one.
|
||||
var line = lineProvenance ?? _tracker.CaptureLastMatchedProvenance();
|
||||
var sourceLocal = line.SourceLocalTs;
|
||||
if (sourceLocal.HasValue)
|
||||
{
|
||||
// No zone suffix: the value genuinely carries none, and formatting one in would
|
||||
// re-introduce the very assumption this whole change removes.
|
||||
data["sourceLocalTs"] = sourceLocal.Value.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", culture);
|
||||
data["sourceOffsetOrigin"] = OriginTag(_tracker.LastMatchedSourceOffsetOrigin);
|
||||
data["sourceOffsetOrigin"] = OriginTag(line.Origin);
|
||||
|
||||
var offsetMinutes = _tracker.LastMatchedSourceOffsetMinutes;
|
||||
var offsetMinutes = line.OffsetMinutes;
|
||||
if (offsetMinutes.HasValue)
|
||||
data["sourceOffsetMinutes"] = offsetMinutes.Value.ToString(culture);
|
||||
|
||||
// Which era anchor the offset came from — forensics for the era-anchored origin.
|
||||
var anchorKind = _tracker.LastMatchedEraAnchorKind;
|
||||
var anchorKind = line.EraAnchorKind;
|
||||
if (!string.IsNullOrEmpty(anchorKind))
|
||||
data["sourceOffsetAnchor"] = anchorKind;
|
||||
|
||||
// Observational only — what the calibrator measured, which since the 2026-08-20
|
||||
// revert is deliberately NOT what was applied. Kept separate so the two can never
|
||||
// be read as the same thing.
|
||||
var measured = _tracker.LastMatchedMeasuredWriterOffsetMinutes;
|
||||
var measured = line.MeasuredWriterOffsetMinutes;
|
||||
if (measured.HasValue)
|
||||
data["measuredWriterOffsetMinutes"] = measured.Value.ToString(culture);
|
||||
}
|
||||
@@ -1293,7 +1297,11 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
if (SuppressIfHistoricReplay(derivedFromClock, rawSourceTs, eventType, script.PolicyId))
|
||||
return;
|
||||
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
|
||||
// The line that delivered the result is bound on the state (D-247), so a completion
|
||||
// emitted passes later still describes ITS line. Null (states from before the field,
|
||||
// synthetic tests) falls back to the tracker's last matched line as before.
|
||||
var endProvenance = isFallback ? script.ExitProvenance : script.ResultProvenance;
|
||||
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs, endProvenance);
|
||||
|
||||
// Per-script run duration (start line → this completion). Surfaced on every
|
||||
// script completion so the UI can flag slow / inefficient scripts, not just timed-out
|
||||
@@ -1301,11 +1309,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
// against clock skew between the (possibly clock-derived) completion stamp and start.
|
||||
//
|
||||
// When the completion timestamp was clock-derived because the source line's timestamp
|
||||
// was rejected (future-skew CMTrace clock jump — the stale case never reaches here),
|
||||
// `now` lives on the clock's timeline while StartedAtUtc was captured RAW from the
|
||||
// source log. Mixing the two produced absurd durations (session eaf3d8c4: 170 h), so
|
||||
// the duration uses the raw source pair — both ends on the same timeline, the
|
||||
// difference is the true runtime. The event stamp stays `now` (timeline position).
|
||||
// was rejected (future-skew — the stale case never reaches here), `now` lives on the
|
||||
// clock's timeline while StartedAtUtc was captured RAW from the source log. Mixing the
|
||||
// two produced absurd durations (session eaf3d8c4: 170 h), so the span is taken from
|
||||
// the raw source pair. That pair is a run time only when both lines were resolved on
|
||||
// the same zone belief — JudgeDurationPair decides, from the provenance bound to each
|
||||
// line, whether the span is verified, unverified, or an offset error to omit (session
|
||||
// 4377911b: a 10 s script read as 3610 s). The event stamp stays `now`.
|
||||
//
|
||||
// L19 (delta review 2026-07-02): the same field name carries two different semantics —
|
||||
// start→own-end-signal (≈ script runtime) vs HS-SCRIPT-START→HS-NEW-RESULT (the whole
|
||||
@@ -1317,15 +1327,26 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
var completionForDuration = derivedFromClock && rawSourceTs.HasValue
|
||||
? NormalizeUtc(rawSourceTs.Value)
|
||||
: now;
|
||||
TimeSpan? measurableDuration = null;
|
||||
if (script.StartedAtUtc.HasValue)
|
||||
{
|
||||
var durationSeconds = (completionForDuration - script.StartedAtUtc.Value).TotalSeconds;
|
||||
var span = completionForDuration - script.StartedAtUtc.Value;
|
||||
var verdict = JudgeDurationPair(script.StartedAtProvenance, endProvenance,
|
||||
endStampRejected: derivedFromClock && rawSourceTs.HasValue, span, out var suppressedReason);
|
||||
if (verdict != DurationPairVerdict.Unknown)
|
||||
data["durationProvenance"] = verdict == DurationPairVerdict.Verified ? "verified" : "unverified";
|
||||
|
||||
if (verdict == DurationPairVerdict.Suppressed)
|
||||
{
|
||||
data["durationSuppressedReason"] = suppressedReason!;
|
||||
}
|
||||
// Plausibility backstop: IME's script execution timeout is ~30 min; anything above
|
||||
// 24 h is a cross-run artifact (e.g. a stale pending start slot from a previous
|
||||
// enrollment paired with a fresh completion) — omit rather than lie.
|
||||
if (durationSeconds >= 0 && durationSeconds <= StaleSourceThreshold.TotalSeconds)
|
||||
else if (span >= TimeSpan.Zero && span <= StaleSourceThreshold)
|
||||
{
|
||||
data["durationSeconds"] = durationSeconds.ToString("F2", culture);
|
||||
measurableDuration = span;
|
||||
data["durationSeconds"] = span.TotalSeconds.ToString("F2", culture);
|
||||
data["durationBasis"] = script.DurationBasis
|
||||
?? (IsRemediation(script.ScriptType)
|
||||
? "cycle_including_reporting_latency"
|
||||
@@ -1333,6 +1354,18 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
}
|
||||
}
|
||||
|
||||
// The start line's provenance, next to the end line's (sourceOffset*), so the pair can
|
||||
// be judged downstream as well.
|
||||
var startProvenance = script.StartedAtProvenance;
|
||||
if (startProvenance != null && startProvenance.SourceLocalTs.HasValue)
|
||||
{
|
||||
data["startSourceOffsetOrigin"] = OriginTag(startProvenance.Origin);
|
||||
if (startProvenance.OffsetMinutes.HasValue)
|
||||
data["startSourceOffsetMinutes"] = startProvenance.OffsetMinutes.Value.ToString(culture);
|
||||
if (!string.IsNullOrEmpty(startProvenance.EraAnchorKind))
|
||||
data["startSourceOffsetAnchor"] = startProvenance.EraAnchorKind;
|
||||
}
|
||||
|
||||
var label = IsRemediation(script.ScriptType) ? "Remediation script" : "Platform script";
|
||||
var shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
|
||||
string messageCore;
|
||||
@@ -1375,7 +1408,51 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
$"ImeAdapter: script completed policyId={shortId} type={script.ScriptType ?? "?"} result={script.Result ?? "?"} exit={(script.ExitCode.HasValue ? script.ExitCode.Value.ToString() : "n/a")}");
|
||||
|
||||
MaybeEmitBootstrapDetected(script, now);
|
||||
MaybeEmitScriptTimeoutSuspected(script, now, completionForDuration);
|
||||
MaybeEmitScriptTimeoutSuspected(script, now, measurableDuration);
|
||||
}
|
||||
|
||||
internal enum DurationPairVerdict { Unknown, Verified, Unverified, Suppressed }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the span between a start line and an end line measures elapsed time.
|
||||
/// <para>
|
||||
/// Each CMTrace line is resolved on its own zone belief: declared or measured (bias,
|
||||
/// line-anchored, or the clock itself) — or ASSUMED (the reader's zone, a writer era's
|
||||
/// anchor). Two measured ends sit on the true UTC axis; two assumed ends resolved with the
|
||||
/// same offset from the same file share their error, which cancels (D-191). Any other pair
|
||||
/// is unverified, and its span is omitted when the end stamp was rejected as ahead of the
|
||||
/// clock — its assumption is proven wrong and the raw pair inherits the error (session
|
||||
/// 4377911b: a 10 s script read as 3610 s) — or when the span sits on the 15-minute offset
|
||||
/// grid, the signature of a wrong assumption on either end (session cbaed57b: 7200 s). A
|
||||
/// missing snapshot (state files from before the field, synthetic tests) leaves the rule
|
||||
/// off and the pair treated as before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static DurationPairVerdict JudgeDurationPair(
|
||||
CmTraceLineProvenance? start, CmTraceLineProvenance? end, bool endStampRejected, TimeSpan span, out string? suppressedReason)
|
||||
{
|
||||
suppressedReason = null;
|
||||
if (start == null || end == null) return DurationPairVerdict.Unknown;
|
||||
|
||||
var startAssumed = start.IsAssumed();
|
||||
var endAssumed = end.IsAssumed();
|
||||
var sameTimeline = (!startAssumed && !endAssumed)
|
||||
|| (startAssumed && endAssumed
|
||||
&& start.OffsetMinutes == end.OffsetMinutes
|
||||
&& string.Equals(start.SourceFileName, end.SourceFileName, StringComparison.OrdinalIgnoreCase));
|
||||
if (sameTimeline) return DurationPairVerdict.Verified;
|
||||
|
||||
if (endStampRejected)
|
||||
{
|
||||
suppressedReason = "rejected-endpoint";
|
||||
return DurationPairVerdict.Suppressed;
|
||||
}
|
||||
if (CmTraceOffsetCalibrator.IsOnOffsetGrid(span, out _))
|
||||
{
|
||||
suppressedReason = "offset-grid";
|
||||
return DurationPairVerdict.Suppressed;
|
||||
}
|
||||
return DurationPairVerdict.Unverified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1387,19 +1464,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// late start / pre-failed ESP. Advisory only: no state mutation, the underlying
|
||||
/// <c>script_failed</c> is still emitted separately. Health (remediation) scripts are
|
||||
/// excluded — they re-run on their own cadence and have a different timeout regime.
|
||||
/// <paramref name="durationEndUtc"/> is the timeline-consistent completion for duration
|
||||
/// math (raw source ts on rejected-timestamp emissions); <paramref name="occurredAtUtc"/>
|
||||
/// stamps the emitted event.
|
||||
/// <paramref name="measurableDuration"/> is the run duration the completion event carries —
|
||||
/// null when it was omitted (no start line, implausible span, or a start/end pair resolved
|
||||
/// on different zone assumptions: the fleet's daily 17 h "timeouts" of 2–9 s scripts were
|
||||
/// offset errors); <paramref name="occurredAtUtc"/> stamps the emitted event.
|
||||
/// </summary>
|
||||
private void MaybeEmitScriptTimeoutSuspected(ScriptExecutionState script, DateTime occurredAtUtc, DateTime durationEndUtc)
|
||||
private void MaybeEmitScriptTimeoutSuspected(ScriptExecutionState script, DateTime occurredAtUtc, TimeSpan? measurableDuration)
|
||||
{
|
||||
if (IsRemediation(script.ScriptType)) return; // platform scripts only
|
||||
if (!script.StartedAtUtc.HasValue) return; // no start ⇒ no duration
|
||||
if (!measurableDuration.HasValue) return; // no measurable duration ⇒ no verdict
|
||||
if (!string.Equals(script.Result, "Failed", StringComparison.OrdinalIgnoreCase)) return;
|
||||
|
||||
var duration = durationEndUtc - script.StartedAtUtc.Value;
|
||||
var duration = measurableDuration.Value;
|
||||
if (duration < ScriptTimeoutSuspectedThreshold) return;
|
||||
if (duration > StaleSourceThreshold) return; // implausible — cross-run artifact, not a hung script
|
||||
|
||||
if (!_tracker.TryClaimScriptTimeoutSuspected(script.PolicyId)) return; // restart-safe dedup per policyId (persisted tracker state)
|
||||
|
||||
|
||||
@@ -526,7 +526,41 @@ public class DelegationSelfServiceTests
|
||||
Assert.Equal(Now.AddDays(-3), owned.SinceUtc);
|
||||
var direct = managers.Single(m => m.GroupId == null);
|
||||
Assert.Equal(DelegationSelfService.SourceOperator, direct.Source);
|
||||
Assert.Equal(DelegationSelfService.OperatorLabel, direct.Name);
|
||||
Assert.False(direct.Revocable);
|
||||
Assert.Single(direct.Assignees); // the revoked row confers nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListManagers_OperatorGroup_ShowsNeutralLabelNotItsName_ButItsAssignees()
|
||||
{
|
||||
const string operatorGroupId = "0f1e2d3c4b5a69788796a5b4c3d2e1f0";
|
||||
var h = Build();
|
||||
h.Repo.Setup(x => x.GetGroupIdsContainingTenantAsync(Customer)).ReturnsAsync(new List<string> { operatorGroupId });
|
||||
h.Repo.Setup(x => x.GetTenantGroupAsync(operatorGroupId)).ReturnsAsync(new TenantGroup
|
||||
{
|
||||
GroupId = operatorGroupId,
|
||||
Name = "Internal MSP customers north",
|
||||
OwnerTenantId = null,
|
||||
TenantIds = new List<string> { Customer, Other },
|
||||
Assignees = new List<TenantGroupAssignment>
|
||||
{
|
||||
new() { Upn = "ops@vendor.example", GroupId = operatorGroupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
new() { Upn = "ops2@vendor.example", GroupId = operatorGroupId, Role = Constants.DelegatedRoles.DelegatedReader, IsEnabled = true },
|
||||
},
|
||||
});
|
||||
|
||||
var managers = await h.Svc.ListManagersAsync(Customer);
|
||||
|
||||
var group = Assert.Single(managers);
|
||||
Assert.Equal(operatorGroupId, group.GroupId);
|
||||
Assert.Equal(DelegationSelfService.OperatorLabel, group.Name);
|
||||
Assert.DoesNotContain("north", group.Name, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Null(group.OwnerTenantId);
|
||||
Assert.Null(group.OwnerDomain);
|
||||
Assert.Null(group.SinceUtc);
|
||||
Assert.Equal(DelegationSelfService.SourceOperator, group.Source);
|
||||
Assert.False(group.Revocable);
|
||||
Assert.Equal(2, group.Assignees.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ public class DelegationWireParityTests
|
||||
managers = new object[]
|
||||
{
|
||||
new { groupId = (string?)("msp-" + Home), ownerTenantId = (string?)Home, ownerDomain = (string?)"partner.example", name = "Customers of partner.example", source = "self-service", assignees, sinceUtc = (DateTime?)since, revocable = true },
|
||||
new { groupId = nullGroup, ownerTenantId = nullOwner, ownerDomain = nullDomain, name = "Platform operators", source = "operator", assignees, sinceUtc = (DateTime?)null, revocable = false },
|
||||
new { groupId = nullGroup, ownerTenantId = nullOwner, ownerDomain = nullDomain, name = "Platform support", source = "operator", assignees, sinceUtc = (DateTime?)null, revocable = false },
|
||||
},
|
||||
},
|
||||
new TenantManagerListResponse
|
||||
@@ -204,7 +204,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
new() { GroupId = "msp-" + Home, OwnerTenantId = Home, OwnerDomain = "partner.example", Name = "Customers of partner.example", Source = "self-service",
|
||||
Assignees = new List<TenantManagerAssignee> { new() { Upn = "analyst@partner.example", Role = "DelegatedReader", IsEnabled = true } }, SinceUtc = since, Revocable = true },
|
||||
new() { Name = "Platform operators", Source = "operator",
|
||||
new() { Name = "Platform support", Source = "operator",
|
||||
Assignees = new List<TenantManagerAssignee> { new() { Upn = "analyst@partner.example", Role = "DelegatedReader", IsEnabled = true } }, Revocable = false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,24 +32,28 @@ public sealed class EmailServiceTests
|
||||
// ----- configuration gates -----
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_AndLogsMissingKeyDebugLine()
|
||||
public async Task SendAsync_EmptyApiKey_NoOps_ReturnsFalse_AndLogsMissingKeyWarning()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId, CancellationToken.None);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
// Warning, not Debug: same reasoning as the welcome mail — worker logs below Warning never
|
||||
// reach Application Insights, and the handler records FarewellEmailFailed on the false.
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_AndLogsMissingEmailDebugLine()
|
||||
public async Task SendAsync_EmptyRecipient_NoOps_ReturnsFalse_AndLogsMissingEmailDebugLine()
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "any-non-empty-key");
|
||||
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("No notification email captured"));
|
||||
@@ -65,7 +69,7 @@ public sealed class EmailServiceTests
|
||||
await sut.SendAsync("", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Debug && e.Message.Contains("Email:ApiKey not configured"));
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("Email:ApiKey not configured"));
|
||||
Assert.DoesNotContain(logger.Entries,
|
||||
e => e.Message.Contains("No notification email captured"));
|
||||
}
|
||||
@@ -123,8 +127,9 @@ public sealed class EmailServiceTests
|
||||
{
|
||||
var (sut, handler, logger) = Build(apiKey: "md-test-key");
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var message = doc.RootElement.GetProperty("message");
|
||||
Assert.Equal(EmailTemplates.OffboardingFarewellSubject, message.GetProperty("subject").GetString());
|
||||
@@ -161,8 +166,9 @@ public sealed class EmailServiceTests
|
||||
responder: _ => Json(HttpStatusCode.InternalServerError,
|
||||
"{\"status\":\"error\",\"code\":-1,\"name\":\"Invalid_Key\",\"message\":\"Invalid API key\"}"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("500") && e.Message.Contains("Invalid_Key"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
@@ -175,8 +181,9 @@ public sealed class EmailServiceTests
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
"[{\"email\":\"ops@contoso.invalid\",\"status\":\"rejected\",\"reject_reason\":\"hard-bounce\",\"_id\":\"abc\"}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Contains(logger.Entries,
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("rejected") && e.Message.Contains("hard-bounce"));
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
@@ -192,8 +199,9 @@ public sealed class EmailServiceTests
|
||||
responder: _ => Json(HttpStatusCode.OK,
|
||||
$"[{{\"email\":\"ops@contoso.invalid\",\"status\":\"{status}\",\"_id\":\"abc\"}}]"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.True(sent);
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
}
|
||||
@@ -204,8 +212,9 @@ public sealed class EmailServiceTests
|
||||
var (sut, _, logger) = Build(apiKey: "md-test-key",
|
||||
responder: _ => Json(HttpStatusCode.OK, "not json"));
|
||||
|
||||
await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
var sent = await sut.SendAsync("ops@contoso.invalid", "contoso.invalid", TenantId);
|
||||
|
||||
Assert.False(sent);
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning);
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Level == LogLevel.Information);
|
||||
}
|
||||
|
||||
+16
@@ -237,6 +237,22 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
string.Join("\n", missing.Select(m => $" - {m}")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AllowedDuringOffboarding punches a hole in the tenant suspension gate (offboarding tombstone only).
|
||||
/// It exists for exactly one route — the farewell feedback a departing admin submits after the offboard
|
||||
/// endpoint has already tombstoned their tenant. Any second route here needs the same justification.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AllowedDuringOffboarding_IsOnlyTheFarewellFeedbackRoute()
|
||||
{
|
||||
var routes = EndpointAccessPolicyCatalog.Entries
|
||||
.Where(e => e.AllowedDuringOffboarding)
|
||||
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(new[] { "POST tenants/{tenantId}/offboard/feedback" }, routes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every entry with TenantScoping.RouteParam must have {tenantId} in its route template.
|
||||
/// Prevents misattributed scoping declarations.
|
||||
|
||||
+6
-4
@@ -7,18 +7,20 @@ namespace AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IOffboardFarewellEmailSender"/>. Records every invocation so
|
||||
/// the handler-side tests can assert "did the farewell send fire after History → Completed?"
|
||||
/// without booting the real mail provider client. Default = silent noop; set <see cref="ThrowOnSend"/>
|
||||
/// to simulate downstream failures and prove the handler's fail-soft contract.
|
||||
/// without booting the real mail provider client. Default = accepted (<see cref="Result"/> true);
|
||||
/// set <see cref="Result"/> false to simulate a refused send, or <see cref="ThrowOnSend"/> to
|
||||
/// simulate downstream failures and prove the handler's fail-soft contract.
|
||||
/// </summary>
|
||||
internal sealed class FakeOffboardFarewellEmailSender : IOffboardFarewellEmailSender
|
||||
{
|
||||
public readonly List<(string ToEmail, string DomainName, string TenantId)> Calls = new();
|
||||
public bool Result { get; set; } = true;
|
||||
public System.Exception? ThrowOnSend { get; set; }
|
||||
|
||||
public Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
public Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
Calls.Add((toEmail, domainName, tenantId));
|
||||
if (ThrowOnSend is { } ex) throw ex;
|
||||
return Task.CompletedTask;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-4
@@ -383,6 +383,77 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
Times.Never(), "Drain-skip-gate must also bypass the enumerate+enqueue phase");
|
||||
}
|
||||
|
||||
// ── Farewell mail record (Side-effect 6) ────────────────────────────────────
|
||||
// The send fails soft and its success log never reaches Application Insights, so the ops
|
||||
// event is the only record of whether the departing customer got the farewell mail.
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_WithCapturedAddress_ProviderAccepts_RecordsFarewellEmailSent()
|
||||
{
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
|
||||
var call = Assert.Single(harness.Farewell.Calls);
|
||||
Assert.Equal(("it@contoso.invalid", "contoso.invalid", TenantId), call);
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailSent, evt.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, evt.Severity);
|
||||
Assert.Equal(TenantId, evt.TenantId);
|
||||
Assert.Contains("it@contoso.invalid", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_WithoutCapturedAddress_RecordsFarewellEmailSkipped_AndNeverCallsSender()
|
||||
{
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = null;
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
|
||||
Assert.Empty(harness.Farewell.Calls);
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailSkipped, evt.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
|
||||
Assert.Contains("no contact address", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_ProviderRefuses_RecordsFarewellEmailFailed()
|
||||
{
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
|
||||
harness.Farewell.Result = false;
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailFailed, evt.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Error, evt.Severity);
|
||||
Assert.Contains("did not accept", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Completed_SenderThrows_RecordsFarewellEmailFailed_AndStillCompletes()
|
||||
{
|
||||
// Fail-soft contract: the History row is already Completed when the mail goes out; a
|
||||
// throwing sender must neither propagate nor stop the TenantConfiguration delete after it.
|
||||
var harness = Harness.New();
|
||||
harness.Repo.History[HistoryRowKey].NotificationEmail = "it@contoso.invalid";
|
||||
harness.Farewell.ThrowOnSend = new HttpRequestException("simulated provider outage");
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
|
||||
|
||||
Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
|
||||
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
|
||||
var evt = Assert.Single(harness.OpsEvents, e => e.EventType.StartsWith("FarewellEmail", StringComparison.Ordinal));
|
||||
Assert.Equal(OpsEventTypes.FarewellEmailFailed, evt.EventType);
|
||||
Assert.Contains("HttpRequestException", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task History_AlreadyCompleted_IsNoOp()
|
||||
{
|
||||
@@ -436,6 +507,9 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
public CountingSafeWipeService SafeWipeProbe { get; } = new();
|
||||
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
|
||||
public Mock<IConfigRepository> ConfigRepo { get; } = new();
|
||||
public FakeOffboardFarewellEmailSender Farewell { get; } = new();
|
||||
/// <summary>Every ops event the handler recorded, in order (the repository is a capturing mock).</summary>
|
||||
public List<OpsEventEntry> OpsEvents { get; } = new();
|
||||
public List<string> EnumeratorYields { get; } = new();
|
||||
public Exception? EnumeratorThrow { get; set; }
|
||||
|
||||
@@ -475,7 +549,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var opsService = BuildRealOpsService();
|
||||
var opsService = BuildRealOpsService(h.OpsEvents);
|
||||
|
||||
var unusedTableStorage = new Mock<TableStorageService>(
|
||||
Mock.Of<TableServiceClient>(),
|
||||
@@ -494,7 +568,7 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
opsService,
|
||||
Mock.Of<ITenantCustomsArchiveRepository>(),
|
||||
h.ConfigRepo.Object,
|
||||
new FakeOffboardFarewellEmailSender(),
|
||||
h.Farewell,
|
||||
NullLogger<TenantOffboardingHandler>.Instance);
|
||||
|
||||
return h;
|
||||
@@ -578,10 +652,12 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
}, "\"0xFAKE_PTR_1\"");
|
||||
}
|
||||
|
||||
private static OpsEventService BuildRealOpsService()
|
||||
private static OpsEventService BuildRealOpsService(List<OpsEventEntry> captured)
|
||||
{
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Returns(Task.CompletedTask);
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(captured.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
var memCache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, memCache);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -1623,6 +1624,82 @@ public class PolicyEnforcementMiddlewareTests
|
||||
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Offboarding tombstone vs. the farewell feedback route (AllowedDuringOffboarding) ──────
|
||||
// The offboard endpoint tombstones the tenant (Disabled=true, offboarding reason) BEFORE it answers
|
||||
// 202, so the departing admin submits the farewell feedback from an already-suspended tenant. That
|
||||
// one route opts out of the tombstone gate; every other route and every other suspension stay closed.
|
||||
|
||||
private static void AsOffboardingTombstone(Harness h, string tenantId) =>
|
||||
AsSuspended(h, tenantId, reason: TenantOffboardFunction.OffboardingDisabledReason);
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardingTombstone_FeedbackRoute_IsAllowed_ForDepartingTenantAdmin()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsOffboardingTombstone(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(TenantA, result.Context!.TargetTenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardingTombstone_OtherRoutes_StayForbidden()
|
||||
{
|
||||
// The opt-out is per route: the same tombstone still closes the rest of the API.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsOffboardingTombstone(h, TenantA);
|
||||
|
||||
var read = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
var write = await h.Middleware.DecideAsync("DELETE", $"/api/tenants/{TenantA}/offboard", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(read.Allowed);
|
||||
Assert.Equal("TenantSuspended", read.ErrorCode);
|
||||
Assert.Equal(TenantOffboardFunction.OffboardingDisabledReason, read.ErrorMessage);
|
||||
Assert.False(write.Allowed);
|
||||
Assert.Equal("TenantSuspended", write.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OperatorSuspension_FeedbackRoute_StaysForbidden()
|
||||
{
|
||||
// The opt-out is for the offboarding tombstone only: a tenant an operator suspended (any other
|
||||
// reason) cannot reach the feedback route either — nothing in the flag grants access.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsSuspended(h, TenantA, reason: "Abuse investigation");
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardingTombstone_FeedbackRoute_NonMember_StillNeedsTenantAdmin()
|
||||
{
|
||||
// Policy tier is untouched by the flag: a plain member (no tenant admin role) is denied by the
|
||||
// TenantAdminOrGA tier before the suspension gate is even reached.
|
||||
const string upn = "viewer@contoso.com";
|
||||
var h = BuildHarness();
|
||||
AsOffboardingTombstone(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"POST", $"/api/tenants/{TenantA}/offboard/feedback", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.NotEqual("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
|
||||
// ── Tenant MCP switch (TenantConfiguration.McpDisabled) — the per-request half ────────────
|
||||
// The MCP front door (McpUserService) already denies a caller whose home tenant is closed; this gate
|
||||
// is what makes the switch hold per request (front-door verdicts are cached) and what keeps a closed
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -303,10 +304,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// and nobody without platform scope reaches a suspended target. Platform operators (GA/Reader) bypass —
|
||||
// they must still administer the suspended tenant. Anonymous/device routes bring their own gate
|
||||
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
|
||||
// An AllowedDuringOffboarding route ignores the offboarding tombstone alone (the departing admin's
|
||||
// farewell feedback arrives after their own tenant was tombstoned); any other suspension still holds.
|
||||
if (principal != null && !hasGlobalScope)
|
||||
{
|
||||
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
|
||||
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled());
|
||||
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled()
|
||||
&& !(catalogEntry.AllowedDuringOffboarding && TenantOffboardFunction.IsOffboardingTombstone(c)));
|
||||
if (suspended != null)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
|
||||
|
||||
@@ -134,11 +134,24 @@ public sealed class EndpointPolicyEntry
|
||||
/// </summary>
|
||||
public bool ApplicationAllowed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the tenant suspension gate lets the caller through while the ONLY reason the tenant
|
||||
/// is suspended is the offboarding tombstone (<c>Disabled=true</c> with the offboarding reason,
|
||||
/// <see cref="Functions.Admin.TenantOffboardFunction.IsOffboardingTombstone"/>). An operator
|
||||
/// suspension (any other reason, or a DisabledUntil window) still denies. Exists for the one route
|
||||
/// a departing admin must reach AFTER their tenant has been tombstoned: the farewell feedback
|
||||
/// (<c>POST tenants/{tenantId}/offboard/feedback</c>), which the offboard endpoint tombstones
|
||||
/// before it even answers 202. Never grants anything else — policy tier, cross-tenant check and
|
||||
/// the function's own active-marker check stay in force.
|
||||
/// </summary>
|
||||
public bool AllowedDuringOffboarding { get; }
|
||||
|
||||
// Pre-compiled regex for matching actual request paths against the route template
|
||||
internal Regex RouteRegex { get; }
|
||||
|
||||
public EndpointPolicyEntry(string httpMethod, string routeTemplate, EndpointPolicy policy,
|
||||
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false)
|
||||
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false,
|
||||
bool allowedDuringOffboarding = false)
|
||||
{
|
||||
HttpMethod = httpMethod.ToUpperInvariant();
|
||||
RouteTemplate = routeTemplate;
|
||||
@@ -146,6 +159,7 @@ public sealed class EndpointPolicyEntry
|
||||
TenantScoping = tenantScoping;
|
||||
ExcludeDelegated = excludeDelegated;
|
||||
ApplicationAllowed = applicationAllowed;
|
||||
AllowedDuringOffboarding = allowedDuringOffboarding;
|
||||
RouteRegex = BuildRouteRegex(routeTemplate);
|
||||
}
|
||||
|
||||
@@ -367,7 +381,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/enable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/permissions", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("DELETE", "tenants/{tenantId}/offboard", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
// Farewell feedback is submitted AFTER the offboard endpoint has tombstoned the tenant (Disabled=true
|
||||
// before the 202), so the suspension gate must let the departing admin through — for this route only.
|
||||
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, allowedDuringOffboarding: true),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
|
||||
@@ -47,6 +47,11 @@ public class DelegationSelfService
|
||||
{
|
||||
public const string SourceSelfService = "self-service";
|
||||
public const string SourceOperator = "operator";
|
||||
/// <summary>
|
||||
/// The one label a managed tenant sees for every operator-provisioned access (operator-created groups and
|
||||
/// direct grants alike). Operator groups are internal organisation — their names never reach a customer.
|
||||
/// </summary>
|
||||
public const string OperatorLabel = "Platform support";
|
||||
private const string AuditGroupAccess = "DelegatedGroupAccess";
|
||||
private const string AuditInvitation = "DelegationInvitation";
|
||||
private const string AuditManagedTenant = "DelegationManagedTenant";
|
||||
@@ -377,8 +382,11 @@ public class DelegationSelfService
|
||||
.Select(r => r.AcceptedAt)
|
||||
.Max();
|
||||
}
|
||||
result.Add(new TenantManagerView(group.GroupId, owner, owner != null ? await DomainAsync(owner) : null,
|
||||
group.Name, owner != null ? SourceSelfService : SourceOperator, group.Assignees, since, Revocable: owner != null));
|
||||
// An owned group carries its managing tenant's name; an operator group is internal and shows
|
||||
// only the neutral label (its assignees stay visible — the customer must know who can read it).
|
||||
result.Add(owner != null
|
||||
? new TenantManagerView(group.GroupId, owner, await DomainAsync(owner), group.Name, SourceSelfService, group.Assignees, since, Revocable: true)
|
||||
: new TenantManagerView(group.GroupId, null, null, OperatorLabel, SourceOperator, group.Assignees, null, Revocable: false));
|
||||
}
|
||||
|
||||
var direct = (await _adminRepo.GetDelegatedAssigneesAsync(target))
|
||||
@@ -386,7 +394,7 @@ public class DelegationSelfService
|
||||
.Select(r => new TenantGroupAssignment { Upn = r.Upn, GroupId = string.Empty, Role = r.Role, IsEnabled = r.IsEnabled, AssignedBy = r.GrantedBy, AssignedAt = r.GrantedAt })
|
||||
.ToList();
|
||||
if (direct.Count > 0)
|
||||
result.Add(new TenantManagerView(null, null, null, "Platform operators", SourceOperator, direct, direct.Min(d => d.AssignedAt), Revocable: false));
|
||||
result.Add(new TenantManagerView(null, null, null, OperatorLabel, SourceOperator, direct, direct.Min(d => d.AssignedAt), Revocable: false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -94,18 +94,21 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
|
||||
/// <summary>
|
||||
/// Sends the post-offboarding "sorry to see you go" farewell email.
|
||||
/// No-op if the API key or recipient email is not configured. Best-effort: failures
|
||||
/// are logged as warnings and never propagated (the offboarding correctness contract
|
||||
/// does not depend on email delivery).
|
||||
/// No-op (returns false) if the API key or recipient email is not configured. Best-effort:
|
||||
/// failures are logged as warnings and never propagated (the offboarding correctness
|
||||
/// contract does not depend on email delivery); the caller turns the result into the
|
||||
/// FarewellEmailSent / FarewellEmailFailed ops events.
|
||||
/// </summary>
|
||||
public async Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
public async Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_apiKey))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
// Warning, not Debug: an unconfigured provider is operator misconfiguration, and worker
|
||||
// logs below Warning never reach Application Insights.
|
||||
_logger.LogWarning(
|
||||
"{ConfigKey} not configured — skipping offboard farewell email for tenant {TenantId}",
|
||||
ApiKeyConfigKey, tenantId);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
@@ -113,7 +116,7 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
_logger.LogDebug(
|
||||
"No notification email captured — skipping offboard farewell email for tenant {TenantId} ({Domain})",
|
||||
tenantId, domainName);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sent = await SendViaMandrillAsync(
|
||||
@@ -129,6 +132,8 @@ public class EmailService : IEmailService, IOffboardFarewellEmailSender
|
||||
"Offboard farewell email sent to {ToEmail} for tenant {TenantId} ({Domain})",
|
||||
toEmail, tenantId, domainName);
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -7,9 +7,13 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// Invocation point: <c>TenantOffboardingHandler.RunPostDrainPhasesAsync</c> immediately
|
||||
/// after the History terminal write (Side-effect 6). Always fail-soft — implementations
|
||||
/// must NOT throw; the offboarding correctness contract does not depend on email delivery.
|
||||
/// The return value is what separates "sent" from "provider refused / not configured" for the
|
||||
/// <c>FarewellEmailSent</c> / <c>FarewellEmailFailed</c> ops events the handler records.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IOffboardFarewellEmailSender
|
||||
{
|
||||
Task SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default);
|
||||
/// <returns>True when the provider accepted the message; false when the send was skipped
|
||||
/// (provider not configured, empty recipient) or the provider refused it.</returns>
|
||||
Task<bool> SendAsync(string toEmail, string domainName, string tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
+47
-16
@@ -774,22 +774,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
// Side-effect 6: post-completion farewell email to the captured Preview-Notification-
|
||||
// Email. Fail-soft: any sender exception MUST NOT propagate, the offboarding
|
||||
// correctness contract is independent of email delivery and the History row is
|
||||
// already Completed. Skips silently when no email was captured at Phase 1
|
||||
// (tenant never set a preview notification address).
|
||||
if (!string.IsNullOrWhiteSpace(history.NotificationEmail))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _farewellEmail.SendAsync(
|
||||
history.NotificationEmail!, history.DomainName ?? string.Empty, tenantId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Offboard farewell email send threw for tenant={Tenant} — ignored (best-effort)",
|
||||
tenantId);
|
||||
}
|
||||
}
|
||||
// already Completed. Every outcome leaves an ops event (Sent / Skipped / Failed):
|
||||
// the send's own success log is Information and never reaches Application
|
||||
// Insights from the worker, which made a lost farewell mail invisible.
|
||||
await SendFarewellEmailAsync(history, tenantId, ct);
|
||||
|
||||
// ── 2.F-final — TenantConfiguration delete (PR3.B Codex Finding 2 reorder) ──
|
||||
//
|
||||
@@ -831,6 +819,49 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Side-effect 6 — the farewell mail plus its ops-event record. Never throws: the History
|
||||
/// row is already Completed and the TenantConfiguration delete that follows must run.
|
||||
/// Outcomes: no address captured at Phase 1 → <c>FarewellEmailSkipped</c> (Warning: the
|
||||
/// customer left in silence, nothing is broken); provider accepted → <c>FarewellEmailSent</c>
|
||||
/// (Info); provider refused, provider not configured or the send threw →
|
||||
/// <c>FarewellEmailFailed</c> (Error: an address was there and the mail did not go out).
|
||||
/// </summary>
|
||||
private async Task SendFarewellEmailAsync(OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
|
||||
{
|
||||
var domainName = history.DomainName ?? string.Empty;
|
||||
var toEmail = history.NotificationEmail;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(toEmail))
|
||||
{
|
||||
await _opsEvents.RecordFarewellEmailSkippedAsync(tenantId, domainName,
|
||||
"no contact address was set before the offboarding");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sent = await _farewellEmail.SendAsync(toEmail!, domainName, tenantId, ct);
|
||||
if (sent)
|
||||
{
|
||||
await _opsEvents.RecordFarewellEmailSentAsync(tenantId, domainName, toEmail!);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _opsEvents.RecordFarewellEmailFailedAsync(tenantId, domainName, toEmail!,
|
||||
"the email provider did not accept the message (or is not configured)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Offboard farewell email send threw for tenant={Tenant} — ignored (best-effort)",
|
||||
tenantId);
|
||||
await _opsEvents.RecordFarewellEmailFailedAsync(tenantId, domainName, toEmail!,
|
||||
$"{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR3.B §3 — Phase 2.D-archive. For each rules table, archive every row to
|
||||
/// <see cref="ITenantCustomsArchiveRepository"/> THEN delete that exact (PK, RK)
|
||||
|
||||
@@ -663,6 +663,47 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, "System.Activation", new { domainName, toEmail, reason });
|
||||
}
|
||||
|
||||
// ── Offboarding farewell mail ──
|
||||
// Same shape as the welcome mail above, for the same reason: the send fails soft and its
|
||||
// success log is Information, so without these events a lost farewell mail (2026-09-22:
|
||||
// a tenant WITH a captured address, no trace either way) leaves no record at all.
|
||||
// Dispatched by TenantOffboardingHandler after History → Completed. All three
|
||||
// dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES.
|
||||
|
||||
/// <summary>The provider accepted the farewell mail. Info-tier confirmation.</summary>
|
||||
public Task RecordFarewellEmailSentAsync(string tenantId, string? domainName, string toEmail)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailSent, OpsEventSeverity.Info,
|
||||
$"Farewell email sent to {toEmail} for offboarded tenant {tenantLabel}",
|
||||
tenantId, "System.Offboarding", new { domainName, toEmail });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The offboarding completed but no farewell mail went out because no contact address
|
||||
/// was captured before the wipe. Warning-tier: nothing is broken, but a customer left
|
||||
/// without the "sorry to see you go" and its feedback channels.
|
||||
/// </summary>
|
||||
public Task RecordFarewellEmailSkippedAsync(string tenantId, string? domainName, string reason)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailSkipped, OpsEventSeverity.Warning,
|
||||
$"No farewell email for offboarded tenant {tenantLabel} — {reason}",
|
||||
tenantId, "System.Offboarding", new { domainName, reason });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An address was captured and the mail did not go out: provider refused, provider not
|
||||
/// configured, or the send threw. Error-tier — this one IS a defect.
|
||||
/// </summary>
|
||||
public Task RecordFarewellEmailFailedAsync(string tenantId, string? domainName, string toEmail, string reason)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.FarewellEmailFailed, OpsEventSeverity.Error,
|
||||
$"Farewell email to {toEmail} failed for offboarded tenant {tenantLabel} — {reason}",
|
||||
tenantId, "System.Offboarding", new { domainName, toEmail, reason });
|
||||
}
|
||||
|
||||
// ── Tenant trial lifecycle (informational — enforcement is read-time) ──
|
||||
// Both types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
|
||||
// (memory feedback_ops_event_types_dual_register). Dispatched by TrialExpirySweepFunction.
|
||||
|
||||
+8
-8
@@ -16,7 +16,7 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
"zod": "^4.6.2"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"bin": {
|
||||
"autopilot-monitor-mcp": "dist/index.js"
|
||||
@@ -25,7 +25,7 @@
|
||||
"@modelcontextprotocol/client": "^2.0.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.2",
|
||||
"@types/node": "^22.20.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
@@ -1529,9 +1529,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz",
|
||||
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
|
||||
"version": "22.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz",
|
||||
"integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@@ -4067,9 +4067,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz",
|
||||
"integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==",
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
"zod": "^4.6.2"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "^2.0.0",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.2",
|
||||
"@types/node": "^22.20.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.11"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Catalog audit: dumps exactly what an MCP host receives per role (instructions, tools/list,
|
||||
* prompts/list, resources/list) and sizes every tool's name / description / inputSchema.
|
||||
*
|
||||
* No backend token needed: the catalog is built from the real server factory with stub search
|
||||
* providers (registration only checks `size`).
|
||||
*
|
||||
* npx tsx scripts/audit-catalog.ts [outDir]
|
||||
*/
|
||||
import express from 'express';
|
||||
import type { Server } from 'node:http';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpRequestHandler } from '../src/mcp-http.js';
|
||||
import { createServerForCaller, type ServerDeps } from '../src/mcp-server-factory.js';
|
||||
import { runWithCaller } from '../src/client.js';
|
||||
import type { SearchProvider } from '../src/search-provider.js';
|
||||
|
||||
const stub = (size = 3): SearchProvider => ({ name: 'stub', semanticCapable: true, size, index: async () => {}, search: async () => [] });
|
||||
const DEPS: ServerDeps = {
|
||||
serverVersion: '0.0.0-audit',
|
||||
knowledgeBase: stub(),
|
||||
eventTypeIndex: stub(),
|
||||
docs: { vector: stub(), sections: ['concepts', 'trust'] },
|
||||
};
|
||||
|
||||
type Role = { label: string; isGlobalAdmin: boolean; isGlobalReader: boolean; delegatedTenantIds?: string[] };
|
||||
const ROLES: Role[] = [
|
||||
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false },
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true },
|
||||
{ label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false },
|
||||
{ label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['00000000-0000-0000-0000-000000000001'] },
|
||||
];
|
||||
|
||||
// Rough token estimate: prose ≈ 4 chars/token, JSON schema ≈ 3.3 chars/token (punctuation-heavy).
|
||||
const estTokens = (s: string, perTok: number) => Math.round(s.length / perTok);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const outDir = process.argv[2] ?? join(process.cwd(), 'audit-out');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
let role: Role = ROLES[0];
|
||||
const app = express();
|
||||
app.use('/mcp', express.json({ limit: '256kb' }));
|
||||
const handler = createMcpRequestHandler(() => createServerForCaller(DEPS), { onerror: (e) => console.error(e.message) });
|
||||
app.post('/mcp', (req, res) =>
|
||||
runWithCaller(
|
||||
{ token: 't', isGlobalAdmin: role.isGlobalAdmin, isGlobalReader: role.isGlobalReader, upn: 'a@contoso.com', ...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}) } as never,
|
||||
() => handler(req, res),
|
||||
),
|
||||
);
|
||||
const httpServer: Server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
||||
const addr = httpServer.address() as { port: number };
|
||||
const url = new URL(`http://127.0.0.1:${addr.port}/mcp`);
|
||||
|
||||
const summary: Record<string, unknown>[] = [];
|
||||
for (role of ROLES) {
|
||||
const client = new Client({ name: 'audit', version: '0.0.0' }, { versionNegotiation: { mode: 'legacy' } });
|
||||
await client.connect(new StreamableHTTPClientTransport(url));
|
||||
const instructions = client.getInstructions() ?? '';
|
||||
const tools = (await client.listTools()).tools;
|
||||
const prompts = (await client.listPrompts()).prompts;
|
||||
const resources = (await client.listResources()).resources;
|
||||
await client.close();
|
||||
|
||||
const rows = tools.map((t) => {
|
||||
const desc = t.description ?? '';
|
||||
const schema = JSON.stringify(t.inputSchema);
|
||||
const props = (t.inputSchema as { properties?: Record<string, { description?: string }> }).properties ?? {};
|
||||
const argDescChars = Object.values(props).reduce((a, p) => a + (p.description?.length ?? 0), 0);
|
||||
return {
|
||||
name: t.name,
|
||||
descChars: desc.length,
|
||||
schemaChars: schema.length,
|
||||
argCount: Object.keys(props).length,
|
||||
argDescChars,
|
||||
totalChars: JSON.stringify(t).length,
|
||||
estTokens: estTokens(desc, 4) + estTokens(schema, 3.3),
|
||||
};
|
||||
});
|
||||
rows.sort((a, b) => b.estTokens - a.estTokens);
|
||||
const totalChars = JSON.stringify(tools).length;
|
||||
const totalTokens = rows.reduce((a, r) => a + r.estTokens, 0);
|
||||
const instrTokens = estTokens(instructions, 4);
|
||||
const promptsChars = JSON.stringify(prompts).length;
|
||||
const resourcesChars = JSON.stringify(resources).length;
|
||||
summary.push({ role: role.label, tools: tools.length, totalChars, estToolTokens: totalTokens, instrChars: instructions.length, estInstrTokens: instrTokens, prompts: prompts.length, promptsChars, resources: resources.length, resourcesChars });
|
||||
|
||||
writeFileSync(join(outDir, `${role.label}.tools.json`), JSON.stringify(tools, null, 2));
|
||||
writeFileSync(join(outDir, `${role.label}.instructions.txt`), instructions);
|
||||
writeFileSync(join(outDir, `${role.label}.prompts.json`), JSON.stringify(prompts, null, 2));
|
||||
writeFileSync(join(outDir, `${role.label}.resources.json`), JSON.stringify(resources, null, 2));
|
||||
writeFileSync(join(outDir, `${role.label}.sizes.json`), JSON.stringify(rows, null, 2));
|
||||
console.log(`\n=== ${role.label}: ${tools.length} tools, ${totalChars} chars ≈ ${totalTokens} tokens; instructions ${instructions.length} chars ≈ ${instrTokens} tokens; prompts ${prompts.length} (${promptsChars} chars); resources ${resources.length} (${resourcesChars} chars)`);
|
||||
console.log('name'.padEnd(34), 'desc'.padStart(6), 'schema'.padStart(7), 'args'.padStart(5), 'argDesc'.padStart(8), '~tok'.padStart(6));
|
||||
for (const r of rows) console.log(r.name.padEnd(34), String(r.descChars).padStart(6), String(r.schemaChars).padStart(7), String(r.argCount).padStart(5), String(r.argDescChars).padStart(8), String(r.estTokens).padStart(6));
|
||||
}
|
||||
writeFileSync(join(outDir, 'summary.json'), JSON.stringify(summary, null, 2));
|
||||
httpServer.close();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Catalog budget: what an MCP host receives per role (instructions + tools/list) is always-on
|
||||
* context for the model. Hosts without tool deferral (claude.ai connectors) inject the whole
|
||||
* catalog on every turn, so its size is a per-turn cost; Claude Code additionally truncates
|
||||
* every `instructions` string and every tool description silently at 2048 characters.
|
||||
*
|
||||
* These tests are a ratchet over the REAL wire catalog (served by the production factory over
|
||||
* loopback HTTP, exactly as mcp-http.test.ts does): lower a ceiling after a diet, never raise
|
||||
* one without a measured reason. `scripts/audit-catalog.ts` prints the per-tool breakdown.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import express from 'express';
|
||||
import type { Server } from 'node:http';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpRequestHandler } from '../mcp-http.js';
|
||||
import { buildInstructions, createServerForCaller, type ServerDeps } from '../mcp-server-factory.js';
|
||||
import { runWithCaller } from '../client.js';
|
||||
import type { SearchProvider } from '../search-provider.js';
|
||||
|
||||
/** Stand-in for the indexed corpora — registration only checks `size`, so every tool joins. */
|
||||
const stub = (size = 3): SearchProvider => ({ name: 'stub', semanticCapable: true, size, index: async () => {}, search: async () => [] });
|
||||
const DEPS: ServerDeps = {
|
||||
serverVersion: '0.0.0-test',
|
||||
knowledgeBase: stub(),
|
||||
eventTypeIndex: stub(),
|
||||
docs: { vector: stub(), sections: ['concepts', 'trust'] },
|
||||
};
|
||||
|
||||
/** Claude Code cuts each instructions string and each tool description here, silently. */
|
||||
const HOST_TEXT_CAP_CHARS = 2048;
|
||||
|
||||
interface Role {
|
||||
label: string;
|
||||
isGlobalAdmin: boolean;
|
||||
isGlobalReader: boolean;
|
||||
delegatedTenantIds?: string[];
|
||||
/** Ratchet: serialized `tools` array of tools/list, in characters (post-diet baseline 2026-09-22 + ~3 %). */
|
||||
maxCatalogChars: number;
|
||||
}
|
||||
const ROLES: Role[] = [
|
||||
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 92_000 },
|
||||
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 77_000 },
|
||||
{ label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false, maxCatalogChars: 53_000 },
|
||||
{ label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['11111111-1111-4111-8111-111111111111'], maxCatalogChars: 58_000 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared-mechanics arguments should read the same everywhere (shared.ts helpers); each distinct
|
||||
* wording is one more contract the model has to reconcile. Ratchet on the variant count in the
|
||||
* Global Admin catalog (2026-09-22 post-diet baseline; pre-diet: tenantId 28, pageSize 14, continuation 10).
|
||||
*/
|
||||
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 9 };
|
||||
|
||||
/**
|
||||
* Domains a description may name. Anything else that looks like a domain is treated as a
|
||||
* customer identifier (hard rule: no real customer domains, tenant names or tenant IDs in code).
|
||||
*/
|
||||
const ALLOWED_DOMAINS = new Set(['autopilotmonitor.com', 'docs.autopilotmonitor.com', 'contoso.com', 'json-schema.org']);
|
||||
const DOMAIN_RE = /\b(?:[a-z0-9-]+\.)+(?:com|de|net|org|io)\b/gi;
|
||||
const GUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
||||
|
||||
type Tool = { name: string; description?: string; inputSchema: { properties?: Record<string, { description?: string }> } };
|
||||
interface Snapshot { instructions: string; tools: Tool[] }
|
||||
|
||||
let httpServer: Server;
|
||||
let url: URL;
|
||||
let role: Role = ROLES[0];
|
||||
const snapshots = new Map<string, Snapshot>();
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use('/mcp', express.json({ limit: '256kb' }));
|
||||
const handler = createMcpRequestHandler(() => createServerForCaller(DEPS), { onerror: () => {} });
|
||||
app.post('/mcp', (req, res) =>
|
||||
runWithCaller(
|
||||
{
|
||||
token: 'test-token',
|
||||
isGlobalAdmin: role.isGlobalAdmin,
|
||||
isGlobalReader: role.isGlobalReader,
|
||||
upn: 'caller@contoso.com',
|
||||
...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}),
|
||||
},
|
||||
() => handler(req, res),
|
||||
),
|
||||
);
|
||||
await new Promise<void>((resolve) => { httpServer = app.listen(0, '127.0.0.1', () => resolve()); });
|
||||
const addr = httpServer.address();
|
||||
if (typeof addr !== 'object' || addr === null) throw new Error('no address');
|
||||
url = new URL(`http://127.0.0.1:${addr.port}/mcp`);
|
||||
|
||||
for (role of ROLES) {
|
||||
const client = new Client({ name: 'budget-test', version: '0.0.0' }, { versionNegotiation: { mode: 'legacy' } });
|
||||
await client.connect(new StreamableHTTPClientTransport(url));
|
||||
try {
|
||||
snapshots.set(role.label, { instructions: client.getInstructions() ?? '', tools: (await client.listTools()).tools as Tool[] });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
});
|
||||
|
||||
const snap = (label: string): Snapshot => {
|
||||
const s = snapshots.get(label);
|
||||
if (!s) throw new Error(`no snapshot for ${label}`);
|
||||
return s;
|
||||
};
|
||||
|
||||
describe('catalog size ratchet (per role)', () => {
|
||||
for (const r of ROLES) {
|
||||
it(`${r.label}: tools/list stays under ${r.maxCatalogChars} chars`, () => {
|
||||
const chars = JSON.stringify(snap(r.label).tools).length;
|
||||
expect(chars, `${r.label} catalog grew to ${chars} chars — shrink a description or lower nothing without a measured reason`)
|
||||
.toBeLessThanOrEqual(r.maxCatalogChars);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('schema overhead', () => {
|
||||
it('no inputSchema carries the $schema draft URL (stripped after registration)', () => {
|
||||
for (const r of ROLES) {
|
||||
const carrying = snap(r.label).tools.filter((t) => '$schema' in (t.inputSchema as Record<string, unknown>)).map((t) => t.name);
|
||||
expect(carrying, r.label).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared-mechanics arguments have few wording variants', () => {
|
||||
for (const [arg, max] of Object.entries(MAX_ARG_DESCRIPTION_VARIANTS)) {
|
||||
it(`${arg}: at most ${max} distinct descriptions in the Global Admin catalog`, () => {
|
||||
const variants = new Set<string>();
|
||||
for (const t of snap('global-admin').tools) {
|
||||
const d = t.inputSchema.properties?.[arg]?.description;
|
||||
if (d) variants.add(d);
|
||||
}
|
||||
expect([...variants].length, [...variants].join('\n---\n')).toBeLessThanOrEqual(max);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('no customer identifiers in any catalog text', () => {
|
||||
for (const r of ROLES) {
|
||||
it(`${r.label}: descriptions, argument descriptions and instructions name no foreign domain or GUID`, () => {
|
||||
const { instructions, tools } = snap(r.label);
|
||||
const texts: Array<[string, string]> = [['instructions', instructions]];
|
||||
for (const t of tools) {
|
||||
texts.push([t.name, t.description ?? '']);
|
||||
for (const [arg, p] of Object.entries(t.inputSchema.properties ?? {})) texts.push([`${t.name}.${arg}`, p.description ?? '']);
|
||||
}
|
||||
const offenders: string[] = [];
|
||||
for (const [where, text] of texts) {
|
||||
for (const m of text.match(DOMAIN_RE) ?? []) if (!ALLOWED_DOMAINS.has(m.toLowerCase())) offenders.push(`${where}: ${m}`);
|
||||
// The delegated instructions legitimately echo the caller's own managed tenant IDs.
|
||||
if (where !== 'instructions') for (const m of text.match(GUID_RE) ?? []) offenders.push(`${where}: ${m}`);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('host truncation cap (2048 chars, silent)', () => {
|
||||
// Texts known to exceed the cap. Empty since the 2026-09-22 diet; an entry here means a text
|
||||
// loses guidance in Claude Code today, so add one only with the shortening scheduled.
|
||||
const KNOWN_OVER_CAP = new Set<string>([]);
|
||||
|
||||
it('the delegated instructions keep room for a realistic managed-tenant list', () => {
|
||||
const five = Array.from({ length: 5 }, (_, i) => `${String(i).repeat(8)}-0000-4000-8000-000000000000`);
|
||||
const text = buildInstructions(DEPS, false, false, true, five, 'aaaaaaaa-0000-4000-8000-000000000000');
|
||||
expect(text.length).toBeLessThanOrEqual(HOST_TEXT_CAP_CHARS);
|
||||
// The scope line (the only role-specific guidance) sits before anything the cap could cut.
|
||||
expect(text.indexOf('Scope:')).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('every instructions string and tool description fits, except the known over-cap texts', () => {
|
||||
const over: string[] = [];
|
||||
for (const r of ROLES) {
|
||||
const { instructions, tools } = snap(r.label);
|
||||
if (instructions.length > HOST_TEXT_CAP_CHARS) over.push(`instructions:${r.label}`);
|
||||
for (const t of tools) if ((t.description ?? '').length > HOST_TEXT_CAP_CHARS) over.push(t.name);
|
||||
}
|
||||
const unexpected = [...new Set(over)].filter((n) => !KNOWN_OVER_CAP.has(n));
|
||||
expect(unexpected, 'new text above the host cap — it will be truncated silently in Claude Code').toEqual([]);
|
||||
});
|
||||
|
||||
it('the known over-cap list carries no stale entry', () => {
|
||||
const over = new Set<string>();
|
||||
for (const r of ROLES) {
|
||||
const { instructions, tools } = snap(r.label);
|
||||
if (instructions.length > HOST_TEXT_CAP_CHARS) over.add(`instructions:${r.label}`);
|
||||
for (const t of tools) if ((t.description ?? '').length > HOST_TEXT_CAP_CHARS) over.add(t.name);
|
||||
}
|
||||
const stale = [...KNOWN_OVER_CAP].filter((n) => !over.has(n));
|
||||
expect(stale, 'text is under the cap now — drop it from KNOWN_OVER_CAP').toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,10 @@ type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
|
||||
const GA = { token: 'ga', isGlobalAdmin: true };
|
||||
const extra = { signal: new AbortController().signal };
|
||||
|
||||
function getResourceTool(): { handler: ToolHandler; description: string } {
|
||||
function getResourceTool(): { handler: ToolHandler; description: string; inputSchema: { shape: { section: { description?: string } } } } {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, undefined, true, true, false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler; description: string }> })._registeredTools;
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler; description: string; inputSchema: { shape: { section: { description?: string } } } }> })._registeredTools;
|
||||
return registry.get_resource;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,14 @@ describe('get_resource section', () => {
|
||||
expect(text(res)).toContain('gatherRules');
|
||||
});
|
||||
|
||||
it('lists every object-shaped resource with its sections in the description', () => {
|
||||
const { description } = getResourceTool();
|
||||
it('lists every object-shaped resource with its sections in the `section` argument description', () => {
|
||||
// The tool description stays under the 2048-char host cap (D-269); the per-resource key list
|
||||
// lives on the argument it applies to, still derived from the live catalog.
|
||||
const { inputSchema } = getResourceTool();
|
||||
const sectionDescription = inputSchema.shape.section.description ?? '';
|
||||
for (const name of ['rule_authoring_guide', 'rule_schemas', 'rule_guardrails', 'diag_zip_layout'] as const) {
|
||||
const keys = Object.keys(getResourceContent(name) as Record<string, unknown>);
|
||||
expect(description).toContain(`${name} → ${keys.join(', ')}`);
|
||||
expect(sectionDescription).toContain(`${name} → ${keys.join(', ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -599,9 +599,9 @@ describe('list_tenants — extractTenantList keep-list projection', () => {
|
||||
|
||||
describe('list_tenants — filterTenantList (query / tenantId resolution)', () => {
|
||||
const tenants = [
|
||||
{ tenantId: '047b2e1f-a271-4bc1-97a4-703bf7adf135', domainName: 'rewe-group.com' },
|
||||
{ tenantId: '739614a3-9800-4868-ba08-7e2c16128a9f', domainName: 'HanseMerkur.onmicrosoft.com' },
|
||||
{ tenantId: '661d1080-0353-4416-a568-f85f2d4f2ff5', domainName: 'douglas.de' },
|
||||
{ tenantId: '11111111-1111-4111-8111-111111111111', domainName: 'fabrikam-group.com' },
|
||||
{ tenantId: '22222222-2222-4222-8222-222222222222', domainName: 'NorthwindTraders.onmicrosoft.com' },
|
||||
{ tenantId: '33333333-3333-4333-8333-333333333333', domainName: 'tailspin.de' },
|
||||
];
|
||||
|
||||
it('returns the list unchanged for a blank / whitespace filter', () => {
|
||||
@@ -611,28 +611,28 @@ describe('list_tenants — filterTenantList (query / tenantId resolution)', () =
|
||||
});
|
||||
|
||||
it('resolves a name to its tenant via case-insensitive domainName substring', () => {
|
||||
const [m] = filterTenantList(tenants, { query: 'REWE' });
|
||||
expect(m.domainName).toBe('rewe-group.com');
|
||||
expect(m.tenantId).toBe('047b2e1f-a271-4bc1-97a4-703bf7adf135');
|
||||
expect(filterTenantList(tenants, { query: 'merkur' })).toHaveLength(1);
|
||||
const [m] = filterTenantList(tenants, { query: 'FABRIKAM' });
|
||||
expect(m.domainName).toBe('fabrikam-group.com');
|
||||
expect(m.tenantId).toBe('11111111-1111-4111-8111-111111111111');
|
||||
expect(filterTenantList(tenants, { query: 'northwind' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('matches a substring anywhere in the domainName, not just a prefix', () => {
|
||||
expect(filterTenantList(tenants, { query: 'group' })[0].domainName).toBe('rewe-group.com');
|
||||
expect(filterTenantList(tenants, { query: 'group' })[0].domainName).toBe('fabrikam-group.com');
|
||||
});
|
||||
|
||||
it('also matches against a partial tenantId (forgiving GUID paste)', () => {
|
||||
expect(filterTenantList(tenants, { query: '739614a3' })[0].domainName)
|
||||
.toBe('HanseMerkur.onmicrosoft.com');
|
||||
expect(filterTenantList(tenants, { query: '22222222' })[0].domainName)
|
||||
.toBe('NorthwindTraders.onmicrosoft.com');
|
||||
});
|
||||
|
||||
it('tenantId filter is an exact case-insensitive match and wins over query', () => {
|
||||
const exact = filterTenantList(tenants, {
|
||||
tenantId: '661D1080-0353-4416-A568-F85F2D4F2FF5',
|
||||
query: 'rewe',
|
||||
tenantId: '33333333-3333-4333-8333-333333333333',
|
||||
query: 'fabrikam',
|
||||
});
|
||||
expect(exact).toHaveLength(1);
|
||||
expect(exact[0].domainName).toBe('douglas.de');
|
||||
expect(exact[0].domainName).toBe('tailspin.de');
|
||||
});
|
||||
|
||||
it('returns an empty list when nothing matches (no throw)', () => {
|
||||
@@ -641,7 +641,7 @@ describe('list_tenants — filterTenantList (query / tenantId resolution)', () =
|
||||
});
|
||||
|
||||
it('tolerates rows missing domainName / tenantId', () => {
|
||||
expect(filterTenantList([{ tenantId: 'x' }, {}], { query: 'rewe' })).toEqual([]);
|
||||
expect(filterTenantList([{ tenantId: 'x' }, {}], { query: 'fabrikam' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4972,6 +4972,7 @@ export interface TenantManagerItem {
|
||||
/** The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants. */
|
||||
ownerTenantId?: string;
|
||||
ownerDomain?: string;
|
||||
/** The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer. */
|
||||
name: string;
|
||||
/** self-service | operator */
|
||||
source: string;
|
||||
|
||||
@@ -111,6 +111,9 @@ export const OPS_EVENT_TYPES = [
|
||||
"WelcomeEmailSent",
|
||||
"WelcomeEmailSkipped",
|
||||
"WelcomeEmailFailed",
|
||||
"FarewellEmailSent",
|
||||
"FarewellEmailSkipped",
|
||||
"FarewellEmailFailed",
|
||||
"TenantTrialStarted",
|
||||
"TenantTrialExpiring",
|
||||
"TenantTrialExpired",
|
||||
|
||||
@@ -52,14 +52,11 @@ export function buildInstructions(deps: ServerDeps, ga: boolean, strictGa: boole
|
||||
const scopeLine = ga
|
||||
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
|
||||
: delegated
|
||||
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — the one ' +
|
||||
'exception is get_fleet_overview, a bounded aggregate across all your managed tenants. ' +
|
||||
`Your managed tenants: ${managedTenants.join(', ')}.` +
|
||||
(homeTenantId ? ` If you are a member of your own home tenant (${homeTenantId}), you may query it by naming it too.` : '') +
|
||||
' Call list_tenants to resolve these IDs to tenant display names (domainName). ' +
|
||||
'Quota: every request draws on your own organization\'s MCP budget (your home tenant\'s plan, grown by each ' +
|
||||
'delegation slot it has purchased); a read into a managed tenant never draws on that tenant\'s budget and never ' +
|
||||
'blocks it — a tenant-level 429 always means your own organization\'s window is exhausted.'
|
||||
? 'Scope: delegated (MSP) administrator — every query MUST name a tenant via tenantId (only ' +
|
||||
'get_fleet_overview aggregates over all your managed tenants). ' +
|
||||
`Managed tenants: ${managedTenants.join(', ')}.` +
|
||||
(homeTenantId ? ` Home tenant ${homeTenantId} may be named too if you are a member.` : '') +
|
||||
' list_tenants resolves IDs to domain names.'
|
||||
: 'Scope: all queries are automatically limited to your tenant.';
|
||||
// Role-aware headline: everyone below a real Global Admin keeps the exact READ-ONLY
|
||||
// contract (and wording) this server has always advertised. A strict GA additionally
|
||||
@@ -68,28 +65,46 @@ export function buildInstructions(deps: ServerDeps, ga: boolean, strictGa: boole
|
||||
const headline = strictGa
|
||||
? 'Autopilot-Monitor is a telemetry server for Windows Autopilot enrollment sessions. All investigation ' +
|
||||
'tools are read-only; as a Global Admin you additionally have tenant-configuration write tools ' +
|
||||
'(update_tenant_config, revert_tenant_config). Call get_tenant_config_schema before composing a patch — ' +
|
||||
'it lists every field with its exact name and JSON type. Every config write is snapshotted first and ' +
|
||||
'verified after — use list_tenant_config_backups + revert_tenant_config to roll back.'
|
||||
'(update_tenant_config, revert_tenant_config): call get_tenant_config_schema before composing a patch; ' +
|
||||
'every write is snapshotted first and verified after — list_tenant_config_backups + revert_tenant_config roll back.'
|
||||
: 'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.';
|
||||
// Hosts truncate this text (Claude Code: silently at 2048 characters), so the lines are ordered by
|
||||
// what the model must not lose: the role's scope first, then the workflow, then the mechanics.
|
||||
return [
|
||||
headline,
|
||||
'',
|
||||
'Investigating one session: call get_session_summary FIRST (status, observation coverage, filtered timeline, stats, rule analysis in one call), then drill in. Read coverage.gaps before treating a missing event as evidence — the agent watches only from its own start, and a degraded tracker or collector makes an absence unprovable.',
|
||||
...(deps.docs
|
||||
? ['Product questions ("how do I…", "what does X mean", "where is my data stored"): use search_docs — the ' +
|
||||
'published customer documentation. search_knowledge is a DIFFERENT corpus (analysis rules and IME log ' +
|
||||
'patterns) and answers why an enrollment failed, not how the product works.']
|
||||
: []),
|
||||
'Searching events: use search_events (hybrid keyword+semantic ranking; depth="fast" then "deep" for exhaustive recall) for ranked hits, or get_session_events / query_raw_events for the raw unranked stream.',
|
||||
'Counting / aggregating: pass a lean `fields=` projection and use `agentVersionPrefix=`/`imeAgentVersionPrefix=` sweeps to stay under the per-response size cap.',
|
||||
'Pagination: when a response carries `nextLink`, pass that whole string back as `continuation`; stop when it is absent. Results are never silently truncated: a page above a tool\'s response cap is refused with an overflow error that names the pageSize that fits — re-send the same call with it.',
|
||||
'Catalogs: call get_resource(name="event_types"|"device_properties") to discover valid eventType strings and deviceProperties keys before filtering.',
|
||||
'Error codes: lookup_error_code explains one HRESULT / MSI exit code / symbol / IME enforcement state from the shared catalog; search_knowledge finds the rules that name a code.',
|
||||
scopeLine,
|
||||
'',
|
||||
'One session: call get_session_summary FIRST (status, coverage, filtered timeline, stats, rule analysis), then drill in. Read coverage.gaps before treating a missing event as evidence — the agent watches only from its own start, and a degraded tracker or collector makes an absence unprovable.',
|
||||
...(deps.docs
|
||||
? ['Product questions ("how do I…", "what does X mean"): search_docs = the published customer documentation. ' +
|
||||
'search_knowledge is a DIFFERENT corpus (analysis rules, IME log patterns): why an enrollment failed, not how the product works.']
|
||||
: []),
|
||||
'Events: search_events for ranked hits (hybrid keyword+semantic; depth="fast", then "deep" for exhaustive recall); get_session_events / query_raw_events for the raw unranked stream.',
|
||||
'Counting / aggregating: pass a lean `fields=` projection; sweep versions with `agentVersionPrefix=` / `imeAgentVersionPrefix=`.',
|
||||
'Pagination: pass a response\'s whole `nextLink` back as `continuation`; stop when it is absent. Nothing is silently truncated: a page above a tool\'s response cap is refused with an overflow error naming the pageSize that fits — re-send with it. A follow-up call keeps the nextLink\'s pageSize and projection unless you pass new ones.',
|
||||
'Catalogs: get_resource(name="event_types"|"device_properties") lists valid eventType strings and deviceProperties keys.',
|
||||
'Error codes: lookup_error_code explains one HRESULT / MSI exit code / symbol / IME enforcement state; search_knowledge finds the rules naming it.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK converts every zod inputSchema with a `$schema` draft URL on top (56 tools = ~3 KB of
|
||||
* catalog that no host needs; the draft is fixed by the protocol). Wrap the `tools/list` handler
|
||||
* the SDK registered and drop the key. Like sortToolCatalog this touches one SDK-internal field
|
||||
* (`_requestHandlers`, the plain method → handler map) and nothing else.
|
||||
*/
|
||||
function stripSchemaUrls(s: McpServer): void {
|
||||
type ListHandler = (...args: unknown[]) => Promise<{ tools?: Array<{ inputSchema?: Record<string, unknown> }> }>;
|
||||
const internal = s.server as unknown as { _requestHandlers?: Map<string, ListHandler> };
|
||||
const prev = internal._requestHandlers?.get('tools/list');
|
||||
if (!prev) return;
|
||||
internal._requestHandlers!.set('tools/list', async (...args) => {
|
||||
const result = await prev(...args);
|
||||
for (const tool of result.tools ?? []) delete tool.inputSchema?.['$schema'];
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Cache lifetime advertised on role-dependent list/discover results (2026-07-28 `ttlMs`). */
|
||||
const LIST_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
/** Cache lifetime advertised on resource reads — static per deployment. */
|
||||
@@ -134,6 +149,7 @@ export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean
|
||||
},
|
||||
);
|
||||
registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated);
|
||||
stripSchemaUrls(s);
|
||||
registerResources(s);
|
||||
// A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) —
|
||||
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user. strictGa
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback, jsonBody } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { getResourceContent, assertKnownEventType, RESOURCE_NAMES } from '../resource-catalog.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js';
|
||||
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
import { collectDeploymentState } from '../deployment-state.js';
|
||||
@@ -417,17 +417,10 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'API Usage',
|
||||
description:
|
||||
'Get API/MCP usage statistics. Shows request counts per endpoint per day. ' +
|
||||
'Use to monitor platform usage, identify heavy users, or debug rate limiting. ' +
|
||||
'Use userId for a specific user, daily for aggregated summaries, or neither for global per-record breakdown. ' +
|
||||
'The daily and global breakdowns are Global Admin only; the per-user (userId) view also works for a ' +
|
||||
'Tenant Admin querying a user within their own tenant. ' +
|
||||
'The global per-record breakdown spans every user/endpoint and can run to thousands of rows, so results ' +
|
||||
'are paged: the default pageSize is 50 (these per-record rows are verbose — a bare no-arg call stays under the ' +
|
||||
'inline response budget) and the response carries a "nextLink" when more remain — pass that whole string back ' +
|
||||
'as "continuation" to get the next slice, and stop when nextLink is absent. Raise pageSize (up to 2000) to ' +
|
||||
'amortize round-trips on a full sweep. Narrow with dateFrom/dateTo (default is the full retention window) or ' +
|
||||
'daily=true for a compact summary.',
|
||||
'API/MCP usage statistics: request counts per endpoint per day. Use to monitor platform usage, identify ' +
|
||||
'heavy users, or debug rate limiting. userId gives one user\'s view, daily=true a compact daily summary, ' +
|
||||
'neither the global per-record breakdown (thousands of verbose rows — paged client-side over the full set, ' +
|
||||
'so a larger pageSize saves round-trips). dateFrom/dateTo default to the full retention window.',
|
||||
inputSchema: {
|
||||
userId: z.string().optional().describe('Specific user object ID to query usage for'),
|
||||
tenantId: z.string().optional().describe('Filter usage by tenant ID'),
|
||||
@@ -435,9 +428,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
||||
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
|
||||
pageSize: z.coerce.number().int().min(1).max(2000).optional().default(50)
|
||||
.describe('Rows to return per call (1-2000, default 50 — these per-record rows are verbose). Follow nextLink for more; raise it for full sweeps.'),
|
||||
.describe(pageSizeDescription(50, 2000)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Pass the whole nextLink string from the prior response to fetch the next slice.'),
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -465,18 +458,16 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Geographic Metrics',
|
||||
description:
|
||||
'Get geographic distribution of enrollments — where devices are enrolling from, with performance comparisons. ' +
|
||||
'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, outlier detection, and ' +
|
||||
'API latency (agent→backend HTTP round-trip): medianApiLatencyMs is the robust display metric for judging ' +
|
||||
'Geographic distribution of enrollments — where devices enroll from, with performance comparisons. ' +
|
||||
'Per location: session counts, success rates, avg/median/p95 duration, throughput, outlier detection, and ' +
|
||||
'API latency (agent→backend HTTP round-trip): medianApiLatencyMs is the robust metric for judging ' +
|
||||
'backend-region distance (apiLatencyVsGlobalPct compares against the global median); avgApiLatencyMs is the ' +
|
||||
'request-weighted mean, which a single corrupt session average can inflate by orders of magnitude. ' +
|
||||
(ga ? 'Omit tenantId for cross-tenant view (Global Admin). ' : '') +
|
||||
'Use get_geographic_sessions to drill into a specific location. ' +
|
||||
'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).',
|
||||
'Use get_geographic_sessions to drill into a location.',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant view (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time range in days (1-365). Defaults to 30.'),
|
||||
.describe(daysDescription(30, 365)),
|
||||
groupBy: z.enum(['country', 'region', 'city']).optional().default('city')
|
||||
.describe('Geographic grouping level (default: "city")'),
|
||||
},
|
||||
@@ -504,27 +495,24 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Geographic Sessions',
|
||||
description:
|
||||
'Drill into a specific geographic location and list its enrollment sessions (lean rows). ' +
|
||||
'Preferred: pass the structured country / region / city you saw in get_geographic_metrics ' +
|
||||
'Drill into one geographic location and list its enrollment sessions (lean rows). ' +
|
||||
'Preferred: pass the structured country / region / city seen in get_geographic_metrics ' +
|
||||
'(country alone lists the whole country; add region, then city, to narrow). ' +
|
||||
'Alternatively pass locationKey verbatim from a get_geographic_metrics row (e.g. "US", ' +
|
||||
'"Saxony, DE", or "Falkenstein, Saxony, DE") — any grouping level resolves. ' +
|
||||
'A busy location can hold thousands of sessions, so results are paged: the default pageSize is 50 ' +
|
||||
'and the response carries a "nextLink" when more remain — pass that whole string back as "continuation" ' +
|
||||
'to get the next slice, and stop when nextLink is absent. (Pagination is applied to the location result ' +
|
||||
'set, so raise pageSize for fewer round-trips when you need a full sweep.)',
|
||||
'Pages are cut client-side from the whole location result set, so a larger pageSize saves round-trips.',
|
||||
inputSchema: {
|
||||
locationKey: z.string().optional().describe('Location key copied verbatim from a get_geographic_metrics row (e.g. "US", "Saxony, DE", "Falkenstein, Saxony, DE"). Any grouping level works. If provided, country/region/city are ignored.'),
|
||||
country: z.string().optional().describe('Country filter, matched against the session GeoCountry exactly as shown by get_geographic_metrics (typically a 2-letter code, e.g. "DE", "US", "CH"). Used when locationKey is not provided; lists the whole country on its own.'),
|
||||
region: z.string().optional().describe('Region/state filter (e.g. "Saxony", "North Carolina"). Optional; used with country to narrow.'),
|
||||
city: z.string().optional().describe('City filter (e.g. "Falkenstein"). Optional; used with country to narrow.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant view (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time range in days (1-365). Defaults to 30.'),
|
||||
.describe(daysDescription(30, 365)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50)
|
||||
.describe('Sessions to return per call (1-1000, default 50). Follow nextLink for more; raise it for full sweeps.'),
|
||||
.describe(pageSizeDescription(50)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Pass the whole nextLink string from the prior response to fetch the next slice.'),
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -577,20 +565,17 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Platform Metrics',
|
||||
description:
|
||||
'Get aggregated platform-level agent performance metrics across recent sessions. ' +
|
||||
'Returns: avg/max/p95 CPU, memory (working set, private bytes), network (bytes up/down, latency, requests), ' +
|
||||
'spool queue stats, event delivery latency percentiles, agent crash rates (incl. top exceptions), ' +
|
||||
'top sessions by CPU/memory, and per-agent-version breakdown. Global Admin only. ' +
|
||||
'Only the newest maxSessions sessions inside the window are analyzed (default 100), so on a ' +
|
||||
'busy install widening days alone may not change the result — raise maxSessions to widen the ' +
|
||||
'sample. The response echoes sessionLimit, sessionsScanned, and a truncated flag based on the ' +
|
||||
'sessions actually scanned. days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90). ' +
|
||||
'For per-agent-version percentiles computed server-side, prefer get_agent_efficiency_metrics. ' +
|
||||
'Note totalBytesDown counts the agent\'s own HTTP traffic to the backend, NOT app downloads ' +
|
||||
'(app/Delivery Optimization bytes live in get_app_install_metrics / get_geographic_metrics).',
|
||||
'Aggregated platform-level agent performance metrics across recent sessions: avg/max/p95 CPU, memory ' +
|
||||
'(working set, private bytes), network (bytes up/down, latency, requests), spool queue stats, event delivery ' +
|
||||
'latency percentiles, agent crash rates (incl. top exceptions), top sessions by CPU/memory, and a ' +
|
||||
'per-agent-version breakdown. Only the newest maxSessions sessions inside the window are analyzed, so on a ' +
|
||||
'busy install widening days alone may not change the result — raise maxSessions. The response echoes ' +
|
||||
'sessionLimit, sessionsScanned and a truncated flag. For per-agent-version percentiles computed server-side ' +
|
||||
'prefer get_agent_efficiency_metrics. totalBytesDown counts the agent\'s own HTTP traffic to the backend, NOT ' +
|
||||
'app downloads (those live in get_app_install_metrics / get_geographic_metrics).',
|
||||
inputSchema: {
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time window in days (1-365). Defaults to 30.'),
|
||||
.describe(daysDescription(30, 365)),
|
||||
maxSessions: z.coerce.number().int().min(1).max(2000).optional().default(100)
|
||||
.describe('Newest N sessions in the window to analyze (1-2000, default 100). Raise for a wider sample on busy installs.'),
|
||||
},
|
||||
@@ -695,22 +680,20 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Agent Efficiency Metrics',
|
||||
description:
|
||||
'Get agent efficiency metrics aggregated by agent version, computed server-side: ' +
|
||||
'p50/p95/max/avg percentiles for CPU, working set, private bytes, thread count, handle count, ' +
|
||||
'spool depth/file size, API latency and request counts, plus crash rates (incl. top exceptions) ' +
|
||||
'and spool-pressure session counts per version. Includes an overall cross-version bucket and ' +
|
||||
'the top offender sessions per dimension (maxCpuPercent / maxWorkingSetMb / maxHandleCount) — ' +
|
||||
'drill into those with get_session_summary only when a threshold is violated, instead of ' +
|
||||
'pulling raw snapshot rows. Compare sessionsScanned against sessionLimit to detect truncation. ' +
|
||||
'Global Admin only. Omit tenantId for the cross-tenant aggregate; pass it to scope to one tenant. ' +
|
||||
'Prefer this over get_platform_metrics for version-comparison and efficiency reviews.',
|
||||
'Agent efficiency metrics aggregated by agent version, computed server-side: p50/p95/max/avg for CPU, ' +
|
||||
'working set, private bytes, thread count, handle count, spool depth/file size, API latency and request ' +
|
||||
'counts, plus crash rates (incl. top exceptions) and spool-pressure session counts per version. Includes an ' +
|
||||
'overall cross-version bucket and the top offender sessions per dimension (maxCpuPercent / maxWorkingSetMb / ' +
|
||||
'maxHandleCount) — drill into those with get_session_summary only when a threshold is violated. Compare ' +
|
||||
'sessionsScanned against sessionLimit to detect truncation. Prefer this over get_platform_metrics for ' +
|
||||
'version comparisons and efficiency reviews.',
|
||||
inputSchema: {
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time window in days (1-365). Defaults to 30.'),
|
||||
.describe(daysDescription(30, 365)),
|
||||
maxSessions: z.coerce.number().int().min(1).max(2000).optional().default(500)
|
||||
.describe('Newest N sessions in the window to analyze (1-2000, default 500).'),
|
||||
tenantId: z.string().optional()
|
||||
.describe('Optional tenant GUID to scope the aggregate to a single tenant. Omit for cross-tenant.'),
|
||||
.describe(tenantIdDescription(ga, delegated)),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -735,16 +718,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Usage Metrics',
|
||||
description:
|
||||
(ga
|
||||
? 'Get usage statistics. Omit tenantId for the cross-tenant platform overview (Global Admin), or pass tenantId to filter it to a single tenant. '
|
||||
: 'Get usage statistics for your tenant: session volumes, feature adoption, success rate, active users. ') +
|
||||
'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90). ' +
|
||||
'Usage statistics: session volumes, feature adoption, success rate, active users' +
|
||||
(ga ? ' — platform-wide or for one tenant. ' : ' for your tenant. ') +
|
||||
'Tenant-scoped responses also carry sessions.totalAllTime — the cumulative enrollment count since the ' +
|
||||
'tenant signed up (retention-independent, unaffected by days); all other session counts are window-bound.',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Filter the platform-wide view to a single tenant (Global Admin only). Omit for the whole platform.', 'Optional; ignored — usage is scoped to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, undefined, 'Ignored — usage is scoped to your tenant.')),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time window in days (1-365). Defaults to 30. Sessions.Total / Tenants.Total reflect this window.'),
|
||||
.describe(daysDescription(30, 365, 'Sessions.Total / Tenants.Total reflect this window.')),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -778,18 +759,18 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
(delegated
|
||||
? 'Bounded aggregate across ALL the tenants you manage as a delegated (MSP) administrator — the only tool that ' +
|
||||
'needs no tenantId. '
|
||||
: 'Platform-wide enrollment overview across every tenant (Global Admin / Reader). ') +
|
||||
: 'Platform-wide enrollment overview across every tenant. ') +
|
||||
'Returns enrollment stats (counts, success rate, durations, today) plus the most recent sessions, merged ' +
|
||||
'server-side newest-first' + (delegated ? ' and bounded to your managed tenants' : '') + '. ' +
|
||||
'For filtered searches or one tenant use search_sessions / get_session_summary with a tenantId. Pagination of the ' +
|
||||
'session list: pass the whole nextLink back as "continuation" (stats come with the first page only).',
|
||||
'For filtered searches or one tenant use search_sessions / get_session_summary with a tenantId. ' +
|
||||
'Stats come with the first page only.',
|
||||
inputSchema: {
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(7)
|
||||
.describe('Window in days for the stats and the session list (1-365, default 7).'),
|
||||
.describe(daysDescription(7, 365, 'Applies to the stats and the session list.')),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).optional()
|
||||
.describe('Sessions per page (1-200; default ' + FLEET_OVERVIEW_PAGE_SIZE + ' on the first page). On a follow-up call an explicit value overrides the pageSize embedded in the nextLink; omit it to keep that size.'),
|
||||
.describe(pageSizeDescription(FLEET_OVERVIEW_PAGE_SIZE, 200)),
|
||||
continuation: z.string().optional()
|
||||
.describe('The full nextLink from a prior response to fetch the next page of sessions (stats are not repeated).'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -833,34 +814,28 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'resolve a tenant\'s display name (domainName) to the tenantId that every other tool requires ' +
|
||||
'(e.g. "sessions of tenant contoso.com" → look up its tenantId here first). '
|
||||
: 'List onboarded tenants with their identity, plan tier, and lifecycle status (onboarded/disabled dates). ' +
|
||||
'Global Admin only. Use this to discover tenant IDs for the tenantId parameter of other tools when running ' +
|
||||
'cross-tenant investigations. ') +
|
||||
'Returns only non-sensitive fields — secrets (webhook URLs, SAS URLs) are stripped ' +
|
||||
'server-side. Tenants are sorted by tenantId and returned in pages (default 100). For lean ID discovery pass ' +
|
||||
'`fields=tenantId,domainName` — the projection is applied server-side and is echoed in nextLink, so it carries ' +
|
||||
'across every page automatically. ' +
|
||||
'RESOLVE A TENANT DIRECTLY (no manual paging): pass `query` to substring-match a name — e.g. query="rewe" ' +
|
||||
'returns rewe-group.com with its tenantId in one call — or pass `tenantId` to fetch that exact entry. In either ' +
|
||||
'filter mode the tool auto-paginates internally and returns only the matches (no "continuation" needed). ' +
|
||||
'Pagination (only when NEITHER filter is set): when "nextLink" is present, more tenants are available — call ' +
|
||||
'again and pass that whole string back as "continuation". Stop when nextLink is absent.',
|
||||
'Use it to discover tenant IDs for the tenantId parameter of other tools. ') +
|
||||
'Returns only non-sensitive fields — secrets (webhook URLs, SAS URLs) are stripped server-side. ' +
|
||||
'Tenants are sorted by tenantId. For lean ID discovery pass `fields=tenantId,domainName`. ' +
|
||||
'RESOLVE A TENANT DIRECTLY: pass `query` to substring-match a name (query="contoso" returns contoso.com with ' +
|
||||
'its tenantId) or `tenantId` to fetch that exact entry; in either filter mode the tool auto-paginates ' +
|
||||
'internally and returns only the matches, with no continuation. Pagination applies only when neither ' +
|
||||
'filter is set.',
|
||||
inputSchema: {
|
||||
query: z.string().optional()
|
||||
.describe('Resolve a tenant by name: case-insensitive substring match against domainName (the human-readable ' +
|
||||
'label — there is no separate displayName) and, forgivingly, against tenantId. e.g. "rewe" → rewe-group.com. ' +
|
||||
'Auto-paginates internally and returns every match in one call. Ignored if tenantId is also set.'),
|
||||
.describe('Resolve a tenant by name: case-insensitive substring match against domainName (there is no separate ' +
|
||||
'displayName) and, forgivingly, against tenantId. Auto-paginates internally and returns every match in one ' +
|
||||
'call. Ignored if tenantId is also set.'),
|
||||
tenantId: z.string().optional()
|
||||
.describe('Fetch exactly one tenant by its (case-insensitive) tenantId. Returns that single entry, or an empty ' +
|
||||
'list if no such tenant exists. Takes precedence over query.'),
|
||||
.describe('Fetch exactly one tenant by its (case-insensitive) tenantId; an empty list if none exists. Takes ' +
|
||||
'precedence over query.'),
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated subset of safe fields to return (e.g. "tenantId,domainName" for lean ID discovery). ' +
|
||||
'tenantId is always included. Default: all safe fields. Applied server-side; unknown/secret keys are ignored.'),
|
||||
.describe('Comma-separated subset of safe fields to return (e.g. "tenantId,domainName"). tenantId is always ' +
|
||||
'included; default: all safe fields. Applied server-side; unknown/secret keys are ignored.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default 100 on the first page) for the unfiltered listing. Ignored in filter mode (query/tenantId), ' +
|
||||
'which scans with a large internal page size. Tenants are sorted by tenantId; follow nextLink to fetch more. ' +
|
||||
'On a follow-up call an explicit value overrides the pageSize embedded in the nextLink; omit it to keep that size.'),
|
||||
.describe(pageSizeDescription(100, 1000, 'Ignored in filter mode (query/tenantId).')),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (pageSize, fields) round-trip correctly. Ignored in filter mode.'),
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Ignored in filter mode.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -949,18 +924,12 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Audit Logs',
|
||||
description:
|
||||
'Get audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' +
|
||||
(ga ? 'Omit tenantId for cross-tenant audit log (Global Admin). ' : '') +
|
||||
'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' +
|
||||
'Narrow further with exact-match field filters — action (e.g. "config_updated", "device_blocked"), performedBy (actor UPN), ' +
|
||||
'entityType (e.g. "TenantConfiguration", "Device"), entityId (the affected entity\'s id). All are applied server-side and ' +
|
||||
'are case-sensitive equality matches; combine them to answer questions like "every action alice@contoso.com took on this device". ' +
|
||||
'Pagination: when "nextLink" is present in the response, more entries are available — call this tool again and pass the ' +
|
||||
'whole nextLink string (e.g. "/api/global/audit/logs?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' +
|
||||
'"continuation". The tool follows it verbatim so the backend-defaulted date window AND any field filters round-trip ' +
|
||||
'correctly (otherwise a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.',
|
||||
'Audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' +
|
||||
'dateFrom / dateTo (ISO 8601 UTC) bound the window exactly; without either the backend defaults to the last 30 days. ' +
|
||||
'action, performedBy, entityType and entityId are server-side, case-sensitive equality filters; combine them ' +
|
||||
'(e.g. every action one actor took on one device).',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID for tenant-scoped audit log. Omit for cross-tenant view (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the audit window.'),
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the audit window.'),
|
||||
action: z.string().optional().describe('Exact-match filter on the action (e.g. "config_updated", "device_blocked", "deletion_started").'),
|
||||
@@ -968,9 +937,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
entityType: z.string().optional().describe('Exact-match filter on the affected entity type (e.g. "TenantConfiguration", "Device", "User").'),
|
||||
entityId: z.string().optional().describe('Exact-match filter on the affected entity id (e.g. a tenantId, deviceId, or report id).'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many entries per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo and field filters) round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1000,37 +969,33 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Operational Events',
|
||||
description:
|
||||
'Get operational events for platform monitoring. Shows consent flow results, maintenance runs, security blocks, ' +
|
||||
'tenant offboards, agent timeouts, and blob storage health. Global Admin only. ' +
|
||||
'FILTER FIRST — every filter below is applied by the STORAGE QUERY, so a narrow question costs a narrow response: ' +
|
||||
'Operational events for platform monitoring: consent flow results, maintenance runs, security blocks, ' +
|
||||
'tenant offboards, agent timeouts, blob storage health. ' +
|
||||
'FILTER FIRST — every filter is applied by the storage query, so a narrow question costs a narrow response: ' +
|
||||
'eventType for one exact type (e.g. "AgentEmergencyBreak"), severity / minSeverity for one level or a threshold, ' +
|
||||
`category to narrow to ${OPS_EVENT_CATEGORIES.join(', ')}, days / dateFrom / dateTo for the window. ` +
|
||||
'Do NOT pull a whole category and filter the events yourself — name the filter instead. ' +
|
||||
'Window: days=1 is the last 24h (shorthand, ignored when dateFrom or dateTo is given); dateFrom / dateTo (ISO 8601 UTC) ' +
|
||||
'bound it exactly; with none of the three, the backend defaults to the last 30 days. ' +
|
||||
'eventType is an EXACT, case-sensitive match against the stored type; call get_resource(name="ops_event_types") ' +
|
||||
'for the full vocabulary. An unknown type is not an error, it is an empty result. ' +
|
||||
'Pagination: when "nextLink" is present in the response, more events are available — call this tool again and pass the ' +
|
||||
'whole nextLink string (e.g. "/api/global/ops-events?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' +
|
||||
'"continuation". The tool follows it verbatim so the backend-defaulted date window and the filters round-trip correctly ' +
|
||||
'(otherwise a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.',
|
||||
`category (${OPS_EVENT_CATEGORIES.join(', ')}), days / dateFrom / dateTo for the window. ` +
|
||||
'Do NOT pull a whole category and filter the events yourself. ' +
|
||||
'Window: days=1 is the last 24h (ignored when dateFrom or dateTo is given); dateFrom / dateTo (ISO 8601 UTC) ' +
|
||||
'bound it exactly; with none of the three the backend defaults to the last 30 days. ' +
|
||||
'eventType is an EXACT, case-sensitive match against the stored type — get_resource(name="ops_event_types") lists ' +
|
||||
'the vocabulary; an unknown type is an empty result, not an error.',
|
||||
inputSchema: {
|
||||
category: z.enum(OPS_EVENT_CATEGORIES).optional().describe('Filter by category (OpsEvents partition key).'),
|
||||
eventType: z.string().optional()
|
||||
.describe('Exact event type, case-sensitive (e.g. "AgentEmergencyBreak", "MaintenanceFailed"). Applied server-side — prefer this over fetching a whole category.'),
|
||||
.describe('Exact event type, case-sensitive (e.g. "AgentEmergencyBreak", "MaintenanceFailed"). Prefer this over fetching a whole category.'),
|
||||
severity: z.enum(OPS_EVENT_SEVERITIES).optional()
|
||||
.describe('Exact severity. Use minSeverity instead when you want "this level and worse".'),
|
||||
.describe('Exact severity. Use minSeverity instead for "this level and worse".'),
|
||||
minSeverity: z.enum(OPS_EVENT_SEVERITIES).optional()
|
||||
.describe('Threshold: this severity and everything above it (Info < Warning < Error < Critical) — the same ladder the ops alert rules use.'),
|
||||
tenantId: z.string().optional().describe('Optional — filter events to a single tenant. Omit for cross-tenant view.'),
|
||||
.describe('Threshold: this severity and everything above it (Info < Warning < Error < Critical) — the ladder the ops alert rules use.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
days: z.coerce.number().int().min(1).max(90).optional()
|
||||
.describe('Shorthand window: only events from the last N days (1 = last 24h). Ignored when dateFrom or dateTo is given.'),
|
||||
.describe('Shorthand window: only events from the last N days (1-90; 1 = last 24h). Ignored when dateFrom or dateTo is given.'),
|
||||
dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the window.'),
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many events per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo and filters) round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1068,27 +1033,24 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Verdict Calibration (classifier thermometer)',
|
||||
description:
|
||||
'Answer "which code path produced our session verdicts, and does any of them look wrong?". Per verdict ' +
|
||||
'path (VerdictPaths vocabulary — agent:complete, agent:failed, sweep:r5_incomplete, maxlife:r6, ' +
|
||||
'late:r4, retro:r6, rule:<id>, manual:failed, register:superseded, legacy:* for pre-instrumentation ' +
|
||||
'rows derived read-side): session count and share in the window, derivedCount (rows attributed by ' +
|
||||
'derivation — weaker evidence), the 7-day re-enrollment proxy (eligible7d = terminal sessions old ' +
|
||||
'enough to judge, reEnrolled7d = the same device registered another terminal session within 7 days; ' +
|
||||
'reEnrollRatePct is null below 20 eligible), the correction stream attributed to the PRIOR path ' +
|
||||
'(overriddenByAdmin / overriddenByLateCompletion / overriddenOther — a path that is frequently ' +
|
||||
'overridden was wrong), and a 7d-vs-28d share trend (window7, baseline28, lift; lift null without ' +
|
||||
'baseline; window/baseline denominators are the single `trend.windowSessions` / `trend.baselineSessions`). ' +
|
||||
'reEnrollRatePct and lift are always present — null means withheld, not missing. Read it as calibration: ' +
|
||||
'a Succeeded path whose re-enrollment rate sits at Failed level reconciles too boldly; an Incomplete ' +
|
||||
'path whose rate matches the Succeeded background is too cautious; a rising sweep:*/maxlife:* share ' +
|
||||
'is an agent-liveness signal, not a classifier one. Omit tenantId for the cross-tenant aggregate, or ' +
|
||||
'pass tenantId to scope to one tenant. minSharePct / top trim the long tail of one-session paths ' +
|
||||
'(rows carrying overrides are never trimmed); what was dropped is reported in `omitted`, totals and ' +
|
||||
'trend always cover the full window. Rows come from the 2-hourly maintenance sweep (computedAt); ' +
|
||||
'empty before its first run.',
|
||||
'path (agent:complete, agent:failed, sweep:r5_incomplete, maxlife:r6, late:r4, retro:r6, rule:<id>, ' +
|
||||
'manual:failed, register:superseded, legacy:* for pre-instrumentation rows derived read-side): session ' +
|
||||
'count and share in the window, derivedCount (attributed by derivation — weaker evidence), the 7-day ' +
|
||||
're-enrollment proxy (eligible7d = terminal sessions old enough to judge, reEnrolled7d = the same device ' +
|
||||
'registered another terminal session within 7 days; reEnrollRatePct is null below 20 eligible), the ' +
|
||||
'correction stream attributed to the PRIOR path (overriddenByAdmin / overriddenByLateCompletion / ' +
|
||||
'overriddenOther — a frequently overridden path was wrong), and a 7d-vs-28d share trend (window7, ' +
|
||||
'baseline28, lift; lift null without baseline; denominators are `trend.windowSessions` / ' +
|
||||
'`trend.baselineSessions`). reEnrollRatePct and lift are always present — null means withheld, not ' +
|
||||
'missing. Read it as calibration: a Succeeded path with a Failed-level re-enrollment rate reconciles too ' +
|
||||
'boldly; an Incomplete path at the Succeeded background is too cautious; a rising sweep:*/maxlife:* share ' +
|
||||
'is an agent-liveness signal, not a classifier one. minSharePct / top trim the long tail (rows carrying ' +
|
||||
'overrides are never trimmed); `omitted` reports what was dropped, totals and trend always cover the full ' +
|
||||
'window. Rows come from the 2-hourly maintenance sweep (computedAt); empty before its first run.',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe('Optional — scope to one tenant. Omit for the cross-tenant aggregate.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
days: z.coerce.number().int().min(1).max(180).optional()
|
||||
.describe('Window in days (default 30, max 180 — aggregate retention). The 7d/28d trend is always today-anchored.'),
|
||||
.describe(daysDescription(30, 180, 'The 7d/28d trend is always today-anchored.')),
|
||||
minSharePct: z.coerce.number().min(0).max(100).optional()
|
||||
.describe('Drop paths whose share of the window is below this percentage (e.g. 1). Paths with overrides are always kept.'),
|
||||
top: z.coerce.number().int().min(1).max(100).optional()
|
||||
@@ -1113,17 +1075,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'List Session Reports',
|
||||
description:
|
||||
'List session reports submitted by tenant admins. Reports contain user comments, screenshots, and agent logs for troubleshooting. ' +
|
||||
'Global Admin only — returns reports across all tenants by default; pass tenantId to filter to one tenant. ' +
|
||||
'This endpoint is fully paginated — there is no truncation. The default pageSize=' + DEFAULT_FIRST_PAGE_SIZE + ' is tuned for typical ' +
|
||||
'interactive queries; raise it (up to 1000) for bulk pulls. Pass the whole nextLink string as "continuation" so ' +
|
||||
'all backend-echoed query params round-trip correctly.',
|
||||
'List session reports submitted by tenant admins: user comments, screenshots, and agent logs for ' +
|
||||
'troubleshooting. Fetch one with get_session_report_download (blobName).',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many reports per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1157,17 +1116,16 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
description:
|
||||
'List custom gather/analyze rules that tenant admins submitted for the community pool, newest first, with ' +
|
||||
'the effective status (pending | approved | declined | withdrawn | published — "published" is derived: the ' +
|
||||
'approved rule\'s published id exists in the global catalog). Platform scope; pass tenantId to filter to one ' +
|
||||
'tenant and status to one status. Each row carries the submission id — pass it to get_rule_submission for the ' +
|
||||
'frozen rule, findings, fire stats and the repo-ready file. Fully paginated; pass the whole nextLink as "continuation".',
|
||||
'approved rule\'s published id exists in the global catalog). Each row carries the submission id — pass it to ' +
|
||||
'get_rule_submission for the frozen rule, findings, fire stats and the repo-ready file.',
|
||||
inputSchema: {
|
||||
tenantId: TenantGuidSchema.optional().describe('Optional — filter to a single tenant. Omit for the cross-tenant view.'),
|
||||
tenantId: TenantGuidSchema.optional().describe(tenantIdDescription(ga, delegated)),
|
||||
status: z.enum(RULE_SUBMISSION_STATUSES).optional()
|
||||
.describe('Optional — one effective status. "published" lists the approved submissions whose rule is live in the global catalog.'),
|
||||
.describe('One effective status. "published" lists the approved submissions whose rule is live in the global catalog.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Follow nextLink for more.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('The "continuation" value or the full nextLink path of a prior response; the latter is preferred.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1230,7 +1188,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Review Rule Submission',
|
||||
description:
|
||||
'Record the decision on a submitted rule. Global Admin only. approve needs publishedRuleId — the reserved ' +
|
||||
'Record the decision on a submitted rule. approve needs publishedRuleId — the reserved ' +
|
||||
'built-in id the rule will ship under (ANALYZE-<CAT>-NNN / GATHER-<CAT>-NNN, three digits; take ' +
|
||||
'suggestedPublishedRuleId from get_rule_submission unless you have a reason not to) — and may set ' +
|
||||
'willBeAdapted when the published rule will differ from the submitted one. decline needs reviewComment. ' +
|
||||
@@ -1273,7 +1231,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Delete Rule Submission',
|
||||
description:
|
||||
'Hard-delete one rule submission so it disappears from the operator list and the submitting tenant\'s list ' +
|
||||
'alike. Global Admin only; not part of normal operations — for test and demo submissions, or when a tenant asks ' +
|
||||
'alike. Not part of normal operations — for test and demo submissions, or when a tenant asks ' +
|
||||
'for removal. A rule already published from the submission stays in the repository and the catalog.',
|
||||
inputSchema: {
|
||||
submissionId: z.string().trim().regex(/^[0-9a-f]{12}$/i).describe('The 12-character submission id.'),
|
||||
@@ -1301,7 +1259,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
description:
|
||||
'Pull the rule catalog of one kind from the public repository\'s main branch (rules/dist) into the global ' +
|
||||
'rule partition — the step that makes a committed and pushed community rule live for every tenant without a ' +
|
||||
'backend deploy. Global Admin only. Also sunsets GitHub-sourced rules the repository no longer ships, so run it ' +
|
||||
'backend deploy. Also sunsets GitHub-sourced rules the repository no longer ships, so run it ' +
|
||||
'only after the push landed. Returns deleted/written counts; the proof of a publish is get_rule_submission ' +
|
||||
'showing status "published", not this count.',
|
||||
inputSchema: {
|
||||
@@ -1360,8 +1318,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
description:
|
||||
'Returns a short-lived, ready-to-use download URL for one blob of the operator session-reports store: ' +
|
||||
'the report ZIP a tenant admin submitted (blobName from list_session_reports), the preserved copy of the ' +
|
||||
'session\'s diagnostics archive (diagnosticsBlobName, when the reporter attached it), or a diag-files report. ' +
|
||||
'Global Admin / Global Reader only.\n\n' +
|
||||
'session\'s diagnostics archive (diagnosticsBlobName, when the reporter attached it), or a diag-files report.\n\n' +
|
||||
'CLIENT REQUIREMENT: needs a client that can download files and run local file/shell tools (e.g. Claude ' +
|
||||
'Code); a pure chat client only gets a link a human can open.\n\n' +
|
||||
'HOW TO USE: download the ZIP from "downloadUrl" — NO auth header (short-lived signed ticket, ~10 min) — ' +
|
||||
@@ -1426,10 +1383,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'(root_cause_confirmed | analysis_wrong | different_problem | inconclusive), an optional free-text note, the ' +
|
||||
'author, and a snapshot of the rule ids that had fired for the session at write time (ruleIds). ' +
|
||||
'Evaluate rule quality by filtering: verdict + ruleId → confirmed vs false-positive rate per rule; ' +
|
||||
'lane=globaladmin → the platform team\'s own labels. Platform scope only. ' +
|
||||
'Pagination: when "nextLink" is present, pass the whole string back as "continuation"; stop when absent.',
|
||||
'lane=globaladmin → the platform team\'s own labels.',
|
||||
inputSchema: {
|
||||
tenantId: TenantGuidSchema.optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'),
|
||||
tenantId: TenantGuidSchema.optional().describe(tenantIdDescription(ga, delegated)),
|
||||
lane: z.enum(ANNOTATION_LANES).optional()
|
||||
.describe('Optional — filter to one annotation lane.'),
|
||||
verdict: z.enum(ANNOTATION_VERDICTS).optional()
|
||||
@@ -1442,9 +1398,9 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound on the last-updated time.'),
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many annotations per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so all filters round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1473,7 +1429,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
title: 'Annotate Session',
|
||||
description:
|
||||
'Write the platform team\'s annotation on an enrollment session — a structured verdict about the analysis ' +
|
||||
'plus an optional note. Global Admin only; always writes the platform-internal "globaladmin" lane (never a ' +
|
||||
'plus an optional note. Always writes the platform-internal "globaladmin" lane (never a ' +
|
||||
'tenant\'s own lanes), which tenant users cannot see. Use after debugging a session to label it: verdict ' +
|
||||
'root_cause_confirmed | analysis_wrong | different_problem | inconclusive. The backend stamps the author from ' +
|
||||
'your identity and snapshots the currently-fired rule ids onto the annotation for later rule-quality ' +
|
||||
@@ -1513,41 +1469,21 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Query Raw Events',
|
||||
description:
|
||||
'RAW CROSS-SESSION EVENT QUERY (fallback for broader scope). ' +
|
||||
'Query raw enrollment events with flexible filters across sessions. ' +
|
||||
(ga ? 'Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant. ' : '') +
|
||||
'Use this when search_events does not cover the time range or session scope you need, ' +
|
||||
'or when you need exact event-type filtering across many sessions. ' +
|
||||
'Returns the LITERAL stored Events rows — every column verbatim, PascalCase, incl. ' +
|
||||
'PartitionKey/RowKey/Timestamp. It is deliberately UNENRICHED: "DataJson" is the raw stored ' +
|
||||
'string (not parsed), Severity/Phase are the raw ints, and there are no decoded Win32/NTSTATUS ' +
|
||||
'error meanings. For the enriched/structured stream (parsed Data, decoded error text) use ' +
|
||||
'get_session_events or search_events instead. ' +
|
||||
'eventType is validated against the event_types catalog — a typo is rejected with a clear error, not a silent ' +
|
||||
'empty result. When you filter, the tool auto-scans forward past empty pages, so a returned "count": 0 with no ' +
|
||||
'"nextLink" means truly no matches, while "moreToScan": true means the per-call scan budget was hit (pass ' +
|
||||
'nextLink as "continuation" to keep scanning). ' +
|
||||
'This endpoint is fully paginated — there is no truncation. The default pageSize=' + DEFAULT_FIRST_PAGE_SIZE + ' is tuned for typical ' +
|
||||
'interactive queries; raise it (up to 1000) for forensics-grade exact recall. For broad analysis, use ' +
|
||||
'pageSize=1000 and follow nextLink repeatedly until absent. EXCEPTION: a page that includes DataJson (named in ' +
|
||||
'fields, or a filtered read without fields) is capped at pageSize=' + RAW_EVENTS_PAYLOAD_PAGE_MAX + ' (default ' +
|
||||
RAW_EVENTS_PAYLOAD_PAGE_DEFAULT + ') — the payload carries kilobytes per row, and a larger value is refused before ' +
|
||||
'any work is done. Pass the whole nextLink string as "continuation" ' +
|
||||
'so all backend-echoed query params round-trip correctly. Note: pageSize is the index-scan cadence — a single ' +
|
||||
'indexed session can contribute multiple events, so total events per page may exceed pageSize. ' +
|
||||
'The server bounds every page by a scan budget: a page that ends early carries "partial": true — nothing is ' +
|
||||
'missing up to its nextLink, simply keep following it. If a call still times out, the tool retries once with ' +
|
||||
'a halved pageSize on the same cursor and marks the page with "retriedWithPageSize". ' +
|
||||
'startedAfter/startedBefore filter on the EVENT time (the sanitized agent timestamp — OccurredUtc, or the ' +
|
||||
'RowKey prefix on older rows), not on the raw "Timestamp" column, which is the storage write time and is ' +
|
||||
'reset by storage migrations. ' +
|
||||
'For COUNTING / AGGREGATION pass a lean `fields=` projection (e.g. `fields=EventType,Severity,OccurredUtc`) — ' +
|
||||
'a pure pass-through over the real column names that drops the heavy `DataJson` payload (a single ' +
|
||||
'app_install_failed event can be tens of KB), so responses stay small; PartitionKey + RowKey are always ' +
|
||||
'kept. ' +
|
||||
(ga ? 'When querying by sessionId you may omit tenantId — it is auto-resolved from the session (Global Admin).' : ''),
|
||||
'Raw cross-session event query: the LITERAL stored Events rows — every column verbatim, PascalCase, incl. ' +
|
||||
'PartitionKey/RowKey/Timestamp. Deliberately unenriched: DataJson is the raw stored string (not parsed), ' +
|
||||
'Severity/Phase are raw ints, no decoded error meanings; for the enriched stream use get_session_events or ' +
|
||||
'search_events. Use it when search_events does not cover the time range or session scope, or for exact ' +
|
||||
'event-type filtering across many sessions. eventType is validated against the event_types catalog (a typo ' +
|
||||
'is rejected, not an empty result). With a filter the tool auto-scans past empty pages: "count": 0 without ' +
|
||||
'nextLink means no matches; "moreToScan": true means the per-call scan budget was hit — continue with nextLink. ' +
|
||||
'pageSize is the index-scan cadence (one indexed session can contribute several events, so a page may hold ' +
|
||||
'more than pageSize). A page that ends early carries "partial": true — nothing is missing up to its nextLink. ' +
|
||||
'On a timeout the tool retries once with a halved pageSize on the same cursor and marks the page ' +
|
||||
'"retriedWithPageSize". startedAfter/startedBefore filter on the EVENT time (OccurredUtc, or the RowKey ' +
|
||||
'prefix on older rows), not on the storage-write "Timestamp" column, which storage migrations reset. ' +
|
||||
(ga ? 'With sessionId, tenantId may be omitted (auto-resolved from the session).' : ''),
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant search, or to auto-resolve from a sessionId query (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Scope to one tenant; omit for cross-tenant or to auto-resolve from sessionId.')),
|
||||
sessionId: SessionIdSchema.optional().describe('Filter to a specific session'),
|
||||
eventType: z.string().optional().describe('Event type filter (e.g. "app_install_failed", "error_detected")'),
|
||||
severity: z.enum(EVENT_SEVERITIES).optional(),
|
||||
@@ -1555,11 +1491,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'),
|
||||
startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'),
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated pass-through projection over the literal stored column names (case-insensitive); narrows the row but never drops a real column. PartitionKey + RowKey are always kept. Stored columns: PartitionKey, RowKey, Timestamp (storage write time), OccurredUtc (event time), EventId, SessionId, TenantId, EventType, Severity (int), Source, Phase (int), Message, Sequence, DataJson (raw string), ReceivedAt, SentAt, OriginalTimestamp, TimestampClamped, CausedByTransitionStepIndex, CausedBySignalOrdinal. Omitted on an UNFILTERED read (no eventType/severity/source) = every column EXCEPT DataJson (the multi-KB payload), and the response says so (omittedFields); omitted on a FILTERED read = the full raw row including DataJson. List DataJson explicitly to force it either way.'),
|
||||
.describe('Keep-list of column names; PartitionKey + RowKey always kept. Columns: PartitionKey, RowKey, Timestamp (storage write time), OccurredUtc (event time), EventId, SessionId, TenantId, EventType, Severity (int), Source, Phase (int), Message, Sequence, DataJson (raw string), ReceivedAt, SentAt, OriginalTimestamp, TimestampClamped, CausedByTransitionStepIndex, CausedBySignalOrdinal. Default: all but DataJson on an unfiltered read; the full row on a filtered read (eventType/severity/source).'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Index rows walked per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid) — omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Index rows walked per call. When the projection includes DataJson the ceiling is ' + RAW_EVENTS_PAYLOAD_PAGE_MAX + ' (default ' + RAW_EVENTS_PAYLOAD_PAGE_DEFAULT + '); a larger value is refused.')),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1609,15 +1545,10 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'PartitionKey/RowKey/Timestamp (e.g. OsEdition, OsDisplayVersion, ImeAgentVersion, GeoRegion/City/Loc, ' +
|
||||
'FailureSource, CurrentPhaseDetail, LastEventAt, ResumedAt, StalledAt, PlatformScriptCount, DeletionState, ...). ' +
|
||||
'For the curated/typed view (camelCase summary, durationSeconds, deviceProperties filtering) use ' +
|
||||
'search_sessions or get_session instead. ' +
|
||||
(ga ? 'Specify tenantId for a specific tenant, or omit for cross-tenant access (Global Admin only). ' : '') +
|
||||
'For COUNTING / AGGREGATION pass a lean `fields=Status,AgentVersion,StartedAt` (or similar) — a pure pass-through ' +
|
||||
'over the real column names that avoids the response cap fat raw rows trip; PartitionKey + RowKey are always kept. ' +
|
||||
'For VERSION sweeps use `agentVersionPrefix=2.0.` instead of one call per build. ' +
|
||||
'This endpoint is fully paginated — there is no truncation. Default pageSize=' + DEFAULT_FIRST_PAGE_SIZE + '; raise it (up to 1000) for bulk pulls. ' +
|
||||
'Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip correctly.',
|
||||
'search_sessions or get_session instead. For counting pass a lean projection such as ' +
|
||||
'`fields=Status,AgentVersion,StartedAt` (PartitionKey + RowKey are always kept).',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID to query. Omit for cross-tenant access (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
status: z.enum(SESSION_STATUSES).optional(),
|
||||
startedAfter: z.string().optional().describe('ISO 8601 datetime'),
|
||||
startedBefore: z.string().optional().describe('ISO 8601 datetime'),
|
||||
@@ -1640,11 +1571,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
isCloudPc: z.boolean().optional().describe('Filter Windows 365 Cloud PC sessions (agent-detected W365 markers, sticky-true; independent of ValidatedBy="CloudPc")'),
|
||||
connectionType: z.enum(['WiFi', 'Ethernet']).optional()
|
||||
.describe('Active network connection type ("WiFi" or "Ethernet", exact match on the indexed column; last emission wins). Sessions predating the projection lack the column and are excluded.'),
|
||||
fields: z.string().optional().describe('Comma-separated pass-through projection over the literal stored column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); narrows the row but never drops a real column. PartitionKey + RowKey are always kept. Omit for the full raw row.'),
|
||||
fields: z.string().optional().describe('Keep-list of literal column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); PartitionKey + RowKey are always kept. Omit for the full raw row.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many sessions per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1684,7 +1615,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'list_tables',
|
||||
{
|
||||
title: 'List Tables',
|
||||
description: 'List all available Azure Table Storage tables that can be queried via query_table. Global Admin only.',
|
||||
description: 'List all available Azure Table Storage tables that can be queried via query_table.',
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1704,27 +1635,22 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Query Table',
|
||||
description:
|
||||
'Query any Azure Table Storage table directly with OData filters. Table names come from list_tables — a guessed name is a NotFound, not an empty result. Global Admin only. ' +
|
||||
'Use list_tables to see available tables. Useful for inspecting TenantConfiguration, RuleResults, or any raw data ' +
|
||||
'where no specialized tool exists. ' +
|
||||
'For COUNTING / AGGREGATION queries pass `fields=PartitionKey,RowKey,Status,AgentVersion` (or similar lean subset) ' +
|
||||
'to drop unneeded columns client-side — full TableEntity rows can be 1KB+ each and trip the response cap quickly. ' +
|
||||
'This endpoint is fully paginated — there is no truncation. Default pageSize=' + DEFAULT_FIRST_PAGE_SIZE + '; raise it (up to 1000) for ' +
|
||||
'full-table dumps. Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip ' +
|
||||
'correctly.',
|
||||
'Query any Azure Table Storage table directly with OData filters. Table names come from list_tables — a guessed ' +
|
||||
'name is a NotFound, not an empty result. Useful for TenantConfiguration, RuleResults, or any raw data where no ' +
|
||||
'specialized tool exists. Full TableEntity rows can be 1 KB+ each, so pass a lean `fields=` keep-list for ' +
|
||||
'counting (dropped client-side after fetch).',
|
||||
inputSchema: {
|
||||
tableName: z.string().describe('Table name (e.g. "Sessions", "Events", "RuleResults", "TenantConfiguration")'),
|
||||
partitionKey: z.string().optional().describe('Filter by exact partition key (usually TenantId)'),
|
||||
rowKeyPrefix: z.string().optional().describe('Filter by row key prefix'),
|
||||
filter: z.string().optional().describe('OData filter expression (e.g. "Status eq \'Failed\'")'),
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated column names to keep (e.g. "PartitionKey,RowKey,Status"). Other columns are dropped ' +
|
||||
'client-side after fetch. Useful for aggregation/counting on wide tables. Always includes PartitionKey ' +
|
||||
'and RowKey for cursor stability.'),
|
||||
.describe('Comma-separated column names to keep (e.g. "PartitionKey,RowKey,Status"); other columns are dropped ' +
|
||||
'client-side. PartitionKey and RowKey are always kept.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many rows per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1769,19 +1695,19 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Query Platform Logs (KQL)',
|
||||
description:
|
||||
'Run a KQL query against one of the platform telemetry stores. Global Admin only. ' +
|
||||
'Run a KQL query against one of the platform telemetry stores. ' +
|
||||
'The query is forwarded VERBATIM (same REST API as az monitor app-insights/log-analytics query); ' +
|
||||
'the result is the Kusto shape `tables[].columns/rows` plus `source`, `budgetSeconds`, `elapsedMs` and — ' +
|
||||
'when the store cut the result (size cap, shard timeout) — `partial: true` with `partialReason`. ' +
|
||||
'Sources: "backend" (default) = the API\'s Application Insights: requests (one canonical row per HTTP call, ' +
|
||||
'customDimensions.Source == \'WorkerMiddleware\'), traces, exceptions, customEvents, dependencies; ' +
|
||||
'"web" = the portal\'s Application Insights: pageViews, browser customEvents, browserTimings, client dependencies; ' +
|
||||
'"mcp" = the MCP Container App\'s Log Analytics workspace: ContainerAppConsoleLogs_CL (column Log_s is the ' +
|
||||
'raw JSON line — parse_json(Log_s)) and ContainerAppSystemLogs_CL. ' +
|
||||
'Budget: the backend runs the query for at most budgetSeconds (default 30, max 180) and answers 504 with a ' +
|
||||
'hint when exceeded — there is no continuation (Kusto has none): narrow the timespan, aggregate with ' +
|
||||
'summarize, cap rows with take. Both `timespan` and any ago() in the query bound the window; keep them ' +
|
||||
'consistent. Write string literals in single quotes (the call is JSON) and project only the columns you need.',
|
||||
'"mcp" = the MCP Container App\'s Log Analytics workspace: ContainerAppConsoleLogs_CL (Log_s is the raw JSON ' +
|
||||
'line — parse_json(Log_s)) and ContainerAppSystemLogs_CL. ' +
|
||||
'Budget: the backend runs the query for at most budgetSeconds and answers 504 with a hint when exceeded — ' +
|
||||
'there is no continuation (Kusto has none): narrow the timespan, aggregate with summarize, cap rows with take. ' +
|
||||
'Both `timespan` and any ago() in the query bound the window; keep them consistent. Write string literals in ' +
|
||||
'single quotes (the call is JSON).',
|
||||
inputSchema: {
|
||||
query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'),
|
||||
timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D, P7D'),
|
||||
@@ -1826,7 +1752,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Get Tenant Configuration',
|
||||
description:
|
||||
'Read a tenant\'s full configuration (all ~90 settings). Global Admin only. ' +
|
||||
'Read a tenant\'s full configuration (all ~90 settings). ' +
|
||||
'Secrets (webhook URLs, SAS URLs, custom headers) are ALWAYS redacted to "***REDACTED***" in this view — ' +
|
||||
'never copy a redacted placeholder into update_tenant_config; provide the real value or leave the field out. ' +
|
||||
'Use this before update_tenant_config to see current values and exact field names.',
|
||||
@@ -1877,9 +1803,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Update Tenant Configuration',
|
||||
description:
|
||||
'Change specific fields of a tenant\'s configuration — transactional and verified. Global Admin only. ' +
|
||||
'Unsure about a field\'s exact name, type, or writability? Call get_tenant_config_schema FIRST — it lists ' +
|
||||
'every field with its JSON type, so the patch is right on the first attempt. ' +
|
||||
'Change specific fields of a tenant\'s configuration — transactional and verified. ' +
|
||||
'Pass ONLY the fields to change (camelCase or PascalCase); omitted fields stay untouched; an explicit JSON ' +
|
||||
'null clears a nullable field. The backend snapshots the row first (fail-closed), writes conditionally, ' +
|
||||
're-reads, and verifies that exactly the intended fields changed — on any drift it rolls back automatically. ' +
|
||||
@@ -1919,7 +1843,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'List Tenant Config Backups',
|
||||
description:
|
||||
'List a tenant\'s pre-write configuration snapshots, newest first. Global Admin only. ' +
|
||||
'List a tenant\'s pre-write configuration snapshots, newest first. ' +
|
||||
'Every config write (portal, plan changes, MCP patches) snapshots the row beforehand; the newest 2 are kept. ' +
|
||||
'Returns metadata only — backupId, when, who, source, reason, and a masked field diff (never raw values). ' +
|
||||
'Use a backupId with revert_tenant_config to roll a tenant back.',
|
||||
@@ -1946,7 +1870,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Revert Tenant Configuration',
|
||||
description:
|
||||
'Restore a tenant\'s configuration from a named pre-write snapshot. Global Admin only. ' +
|
||||
'Restore a tenant\'s configuration from a named pre-write snapshot. ' +
|
||||
'The revert snapshots the CURRENT state first, so a revert is itself revertible — which is exactly why ' +
|
||||
'backupId is required and there is no "latest" default: a repeated revert without an id would restore the ' +
|
||||
'snapshot the previous revert just created and undo it. Reverting the same backupId twice is a no-op. ' +
|
||||
@@ -1989,19 +1913,17 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Rule Statistics',
|
||||
description:
|
||||
'Get rule firing statistics for analyze and gather rules. Shows which rules fire most often, ' +
|
||||
'their hit rates (fires/evaluations), and daily trends. Use to identify commonly triggered rules, ' +
|
||||
'optimize rule definitions, or understand tenant-specific failure patterns. ' +
|
||||
'Rule firing statistics for analyze and gather rules: which rules fire most often, hit rates ' +
|
||||
'(fires/evaluations), and daily trends. Use to identify commonly triggered rules, optimize rule definitions, ' +
|
||||
'or understand tenant-specific failure patterns. ' +
|
||||
'The tenant-scoped response also carries "regressions": active rule-frequency regression episodes ' +
|
||||
'(analyze rules whose 7-day hit rate rose >=2x over their 28-day baseline with disjoint Wilson ' +
|
||||
'intervals) incl. window/baseline counts and an optional dimension concentration — correlation ' +
|
||||
'only, never causal. ' +
|
||||
(ga ? 'Without tenantId returns global stats (cross-tenant; no regressions there). With tenantId returns tenant-specific stats. ' : '') +
|
||||
'By default per-rule daily trend rows are OMITTED (they dominate response size: one row per rule ' +
|
||||
'per day). Set includeTrends=true to get them — then also pass a tight `startDate`/`endDate` ' +
|
||||
'window (7 days is usually plenty) and/or `ruleType`, or the response can trip the response cap.',
|
||||
'only, never causal' + (ga ? '; the cross-tenant response has none' : '') + '. ' +
|
||||
'Per-rule daily trend rows are OMITTED by default (one row per rule per day); includeTrends=true returns ' +
|
||||
'them — then also pass a tight `startDate`/`endDate` window (7 days is usually plenty) and/or `ruleType`.',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Filter by tenant ID. Omit for global (cross-tenant) stats.', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
ruleType: z.enum(['analyze', 'gather']).optional().describe('Filter by rule type'),
|
||||
startDate: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to 30 days ago.'),
|
||||
endDate: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'),
|
||||
@@ -2051,15 +1973,15 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'probability of exploitation within 30 days, 0-1, null when unscored) and priority ("act" = in CISA KEV, ' +
|
||||
'"attend" = EPSS >= 0.1 or CVSS >= 9.0, "track" = everything else) — rank remediation by priority, then EPSS, ' +
|
||||
'not by CVSS alone. ' +
|
||||
(ga ? 'Omit tenantId for a cross-tenant overview (Global Admin; also returns affected tenant count); pass tenantId to scope to one tenant. ' : '') +
|
||||
(ga ? 'The cross-tenant overview also returns the affected tenant count. ' : '') +
|
||||
'Use this to answer "how exposed is the fleet / this ' +
|
||||
'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' +
|
||||
'If "truncated" is true, the underlying index scan hit its cap and counts are a lower bound (narrow with tenantId). ' +
|
||||
'Requires vulnerability scanning to be enabled (an empty summary means no findings, not necessarily "not affected").',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant overview (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time window in days (1-365, default 30). Filters CVEs by when they were detected.'),
|
||||
.describe(daysDescription(30, 365, 'Filters CVEs by detection time.')),
|
||||
topN: z.coerce.number().int().min(1).max(100).optional().default(20)
|
||||
.describe('How many top CVEs to return, ranked by affected device count (1-100, default 20).'),
|
||||
},
|
||||
@@ -2094,17 +2016,11 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'rollup — total bytes downloaded and how much came ' +
|
||||
'from peers / Microsoft Connected Cache (MCC) vs. the CDN, plus a peerOffloadPercent (bandwidth saved by ' +
|
||||
'not pulling from the internet). Use this to answer "which app breaks or slows down my enrollments?" and ' +
|
||||
'"how much install bandwidth is served locally?". ' +
|
||||
(ga
|
||||
? 'Omit tenantId for the cross-tenant fleet aggregate (Global Admin), or pass tenantId to scope to a single tenant. '
|
||||
: '') +
|
||||
'days accepts any value 1-365 (default 30).',
|
||||
'"how much install bandwidth is served locally?".',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated,
|
||||
'Filter to a single tenant (Global Admin only). Omit for the cross-tenant fleet aggregate.',
|
||||
'Optional; ignored — metrics are scoped to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, undefined, 'Ignored — metrics are scoped to your tenant.')),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time window in days (1-365). Defaults to 30. Filters apps by install StartedAt.'),
|
||||
.describe(daysDescription(30, 365, 'Filters apps by install StartedAt.')),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -2143,16 +2059,13 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'for the fleet rollup: rolling 30-day median/p75/p90 per segment per enrollment class (classes are ' +
|
||||
'never mixed) plus the top time-consuming ESP-blocking apps with "up to" what-if savings bounds. ' +
|
||||
'breakdown=null means the session has no computable attribution (pre-feature, non-terminal, or ' +
|
||||
'Incomplete) — that is "unknown", never zero. ' +
|
||||
(ga
|
||||
? 'Fleet mode: omit tenantId for the cross-tenant aggregate, or pass tenantId to scope to one tenant. '
|
||||
: ''),
|
||||
'Incomplete) — that is "unknown", never zero.',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.optional()
|
||||
.describe('Session (GUID) whose breakdown to fetch. Omit for the fleet rollup.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated,
|
||||
'Session mode: the session\'s tenant (needed cross-tenant). Fleet mode: filter to one tenant; omit for the cross-tenant aggregate.',
|
||||
'Optional; ignored — data is scoped to your tenant.')),
|
||||
'Session mode: the session\'s tenant. Fleet mode: scope to one tenant; omit for cross-tenant.',
|
||||
'Ignored — data is scoped to your tenant.')),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -2194,18 +2107,15 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'exclusion disclosure, and the repeat-devices list (devices whose current journey took ≥2 attempts). ' +
|
||||
'Open journeys (device not successfully enrolled yet, incl. WhiteGlove awaiting its user session) ' +
|
||||
'never count toward FTR. ' +
|
||||
(ga
|
||||
? 'Fleet mode: omit tenantId for the cross-tenant aggregate (no repeat-devices list there), or pass ' +
|
||||
'tenantId to scope to one tenant. Device mode: pass tenantId for cross-tenant serial lookups. '
|
||||
: ''),
|
||||
(ga ? 'The cross-tenant fleet rollup has no repeat-devices list.' : ''),
|
||||
inputSchema: {
|
||||
serialNumber: z.string().min(1).max(128).optional()
|
||||
.describe('Device serial number (trimmed, case-insensitive). Omit for the fleet FTR rollup.'),
|
||||
days: z.coerce.number().int().min(1).max(180).optional()
|
||||
.describe('Fleet mode window in days (default 30, max 180 — aggregate retention).'),
|
||||
.describe(daysDescription(30, 180, 'Fleet mode only.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated,
|
||||
'Device mode: the device\'s tenant (needed cross-tenant). Fleet mode: filter to one tenant; omit for the cross-tenant aggregate.',
|
||||
'Optional; ignored — data is scoped to your tenant.')),
|
||||
'Device mode: the device\'s tenant. Fleet mode: scope to one tenant; omit for cross-tenant.',
|
||||
'Ignored — data is scoped to your tenant.')),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -2241,7 +2151,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
const inventoryScopeShape: z.ZodRawShape = ga
|
||||
? {
|
||||
tenantId: z.string().optional()
|
||||
.describe('Tenant whose inventory to read (required for scope="inventory" as Global Admin; ignored for scope="unmatched").'),
|
||||
.describe('Tenant whose inventory to read (required for scope="inventory"; ignored for scope="unmatched").'),
|
||||
scope: z.enum(['inventory', 'unmatched']).optional().default('inventory')
|
||||
.describe('"inventory" = one tenant\'s full software catalog (needs tenantId). "unmatched" = cross-tenant software with no CPE mapping yet.'),
|
||||
}
|
||||
@@ -2250,7 +2160,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
// Delegated (MSP) callers get the per-tenant inventory only (no cross-tenant "unmatched" scope),
|
||||
// but DO need a tenantId selector to name one of their managed tenants (required; enforced below).
|
||||
tenantId: z.string().optional()
|
||||
.describe('Required: a tenantId from YOUR managed tenants (delegated/MSP). There is no cross-tenant view.'),
|
||||
.describe(tenantIdDescription(false, true)),
|
||||
}
|
||||
: {};
|
||||
server.registerTool(
|
||||
@@ -2262,23 +2172,20 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'version, publisher, registry source, CPE mapping for vulnerability correlation, session count, last seen). ' +
|
||||
'scope="inventory" (default) returns one tenant\'s full catalog — pass tenantId to choose the tenant. ' +
|
||||
'scope="unmatched" returns the cross-tenant list of software with no CPE mapping yet (the CPE-mapping gaps), ' +
|
||||
'ranked by how many sessions reference them. ' +
|
||||
'Results are paged: when "nextLink" is present, pass that whole string back as "continuation"; stop when it is absent.'
|
||||
'ranked by how many sessions reference them.'
|
||||
: delegated
|
||||
? 'List installed software discovered on enrolled devices, deduplicated per tenant (normalized vendor/name/' +
|
||||
'version, publisher, registry source, session count, last seen). As a delegated (MSP) user pass a tenantId ' +
|
||||
'from YOUR managed tenants (required) — there is no cross-tenant view. ' +
|
||||
'Results are paged: when "nextLink" is present, pass that whole string back as "continuation"; stop when it is absent.'
|
||||
'version, publisher, registry source, session count, last seen). Pass a tenantId from YOUR managed ' +
|
||||
'tenants — there is no cross-tenant view.'
|
||||
: 'List the installed software discovered on your tenant\'s enrolled devices, deduplicated (normalized ' +
|
||||
'vendor/name/version, publisher, registry source, how many sessions reference it, last seen). ' +
|
||||
'Use this to see your device software portfolio. ' +
|
||||
'Results are paged: when "nextLink" is present, pass that whole string back as "continuation"; stop when it is absent.',
|
||||
'Use this to see your device software portfolio.',
|
||||
inputSchema: {
|
||||
...inventoryScopeShape,
|
||||
pageSize: z.coerce.number().int().min(1).max(500).optional().default(100)
|
||||
.describe('Page size (1-500, default 100). Follow nextLink to fetch more.'),
|
||||
.describe(pageSizeDescription(100, 500)),
|
||||
continuation: z.string().optional()
|
||||
.describe('The "continuation"/"nextLink" value from a prior response to fetch the next page.'),
|
||||
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -2344,24 +2251,22 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
{
|
||||
title: 'Get Catalog Resource',
|
||||
description:
|
||||
'Returns the contents of a named static catalog resource. Use this when ' +
|
||||
'the host MCP client cannot list/read MCP-protocol resources (common with ' +
|
||||
'stateless HTTP MCP servers). Available names:\n' +
|
||||
' - "event_types": catalog of valid eventType strings for search_sessions_by_event\n' +
|
||||
' - "device_properties": catalog of dot-notation keys for the deviceProperties filter on search_sessions\n' +
|
||||
' - "diag_zip_layout": expected file layout of an agent diagnostics ZIP (what get_session_diagnostics returns you for local analysis)\n' +
|
||||
' - "rule_authoring_guide": complete guide for authoring gather/analyze rules (read FIRST when asked to create a rule; then validate_rule / test_analyze_rule)\n' +
|
||||
'Returns a named static catalog resource (for hosts that cannot read MCP resources). Names:\n' +
|
||||
' - "event_types": valid eventType strings (search_sessions_by_event, event filters)\n' +
|
||||
' - "device_properties": dot-notation keys for the deviceProperties filter of search_sessions\n' +
|
||||
' - "ops_event_types": categories, severities and types get_ops_events filters by\n' +
|
||||
' - "diag_zip_layout": file layout of an agent diagnostics ZIP (get_session_diagnostics)\n' +
|
||||
' - "rule_authoring_guide": how to author gather/analyze rules (read FIRST; then validate_rule / test_analyze_rule)\n' +
|
||||
' - "rule_schemas": the JSON Schemas for gather and analyze rules (the exact contract)\n' +
|
||||
' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy\n' +
|
||||
'The rule trio is large (tens of KB together). Pass "section" to read ONE top-level part — ' +
|
||||
'e.g. section="analyzeRules" of rule_authoring_guide when authoring an analyze rule, or ' +
|
||||
'section="gatherRuleSchema" of rule_schemas. Sections per resource: ' + describeResourceSections() + '.',
|
||||
'The rule trio is tens of KB: pass "section" to read ONE top-level key (e.g. section="analyzeRules" of ' +
|
||||
'rule_authoring_guide, section="gatherRuleSchema" of rule_schemas); an unknown section is refused with the list of valid ones.',
|
||||
inputSchema: {
|
||||
name: z
|
||||
.enum(RESOURCE_NAMES)
|
||||
.describe('Resource name'),
|
||||
section: z.string().trim().min(1).max(64).optional()
|
||||
.describe('Optional top-level key of the resource to return on its own (see the section list in the description). Omit for the whole resource.'),
|
||||
.describe('One top-level key of the resource (' + describeResourceSections() + '). Omit for the whole resource.'),
|
||||
},
|
||||
annotations: READ_ONLY_OPEN,
|
||||
},
|
||||
|
||||
@@ -28,14 +28,13 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
{
|
||||
title: 'Validate Rule Draft',
|
||||
description:
|
||||
'Validates a DRAFT gather or analyze rule without deploying anything: JSON-Schema check ' +
|
||||
'(the exact contract from get_resource(name="rule_schemas")), guardrail pre-flight for ' +
|
||||
'gather targets (registry/file/WMI/command/event-log allowlists with the agent\'s ' +
|
||||
'matching semantics), and semantic lint (unreachable confidence threshold, non-evaluable ' +
|
||||
'confidenceFactor conditions, unknown event types, unanchored allow-list regex, ' +
|
||||
'unresolvable {{token}} placeholders). Returns findings as error/warning/info. ' +
|
||||
'Fix all errors, then dry-run analyze rules with test_analyze_rule against a real session. ' +
|
||||
'Read get_resource(name="rule_authoring_guide") first when authoring from scratch.',
|
||||
'Validates a DRAFT gather or analyze rule locally, deploying nothing: JSON-Schema check (the contract from ' +
|
||||
'get_resource(name="rule_schemas")), guardrail pre-flight for gather targets (registry/file/WMI/command/' +
|
||||
'event-log allowlists with the agent\'s matching semantics) and semantic lint (unreachable confidence ' +
|
||||
'threshold, non-evaluable confidenceFactor conditions, unknown event types, unanchored allow-list regex, ' +
|
||||
'unresolvable {{token}} placeholders). Returns findings as error/warning/info. Fix all errors, then dry-run ' +
|
||||
'analyze rules with test_analyze_rule against a real session. Read get_resource(name="rule_authoring_guide") ' +
|
||||
'first when authoring from scratch.',
|
||||
inputSchema: {
|
||||
rule: z.record(z.string(), z.unknown()).describe('The draft rule JSON object (gather or analyze — detected automatically)'),
|
||||
},
|
||||
@@ -72,15 +71,13 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
{
|
||||
title: 'Test Logparser Pattern Against Sample Lines',
|
||||
description:
|
||||
'Tests a logparser gather-rule regex against pasted sample log lines using the AGENT\'s ' +
|
||||
'exact .NET matching semantics — the dry-run for logparser rules (which run on devices ' +
|
||||
'and cannot be tested against a session). Use this INSTEAD of testing the regex in ' +
|
||||
'JS/PHP/Python: .NET behaves subtly differently, and logparser matching is ' +
|
||||
'case-SENSITIVE (unlike analyze-rule regex conditions). Paste 10-50 representative raw ' +
|
||||
'lines from the customer\'s log file (include lines that must match AND lines that must ' +
|
||||
'not). format="cmtrace" (default) parses each line as CMTrace/IME format first and runs ' +
|
||||
'the regex against the parsed message; format="text" matches the raw line. Returns ' +
|
||||
'per-line outcomes with the exact capture groups that would land in the emitted ' +
|
||||
'Tests a logparser gather-rule regex against pasted sample log lines with the AGENT\'s exact .NET matching ' +
|
||||
'semantics — the dry-run for logparser rules, which run on devices and cannot be tested against a session. ' +
|
||||
'Use this instead of testing the regex in JS/PHP/Python: .NET differs subtly, and logparser matching is ' +
|
||||
'case-SENSITIVE (unlike analyze-rule regex conditions). Paste 10-50 representative raw lines from the ' +
|
||||
'customer\'s log file, including lines that must match AND lines that must not. format="cmtrace" (default) ' +
|
||||
'parses each line as CMTrace/IME format first and runs the regex against the parsed message; format="text" ' +
|
||||
'matches the raw line. Returns per-line outcomes with the exact capture groups that would land in the emitted ' +
|
||||
'timeline event\'s data. Nothing is stored.',
|
||||
inputSchema: {
|
||||
pattern: z.string().min(1).max(2000).describe('The regex (named groups become event data fields), .NET syntax'),
|
||||
@@ -110,19 +107,17 @@ export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
{
|
||||
title: 'Test Analyze Rule Against Session (Dry-Run)',
|
||||
description:
|
||||
'Dry-runs a DRAFT analyze rule against one real session\'s events and returns the full ' +
|
||||
'diagnostic trace: verdict (fired / required_condition_not_met / below_confidence_threshold ' +
|
||||
'/ skipped_by_precondition / no_conditions_matched / no_events), per-condition ' +
|
||||
'matched/evidence with matchingEventCount (how many session events even have that ' +
|
||||
'eventType — the first thing to check when a condition unexpectedly misses), the ' +
|
||||
'confidence breakdown per factor, and the interpolated explanation preview. ' +
|
||||
'NOTHING is persisted — safe to iterate. Use a recent session (older sessions predate ' +
|
||||
'newly deployed gather rules). Test both directions: a session where the rule should ' +
|
||||
'fire AND one where it should stay silent.' +
|
||||
(ga ? ' Platform-scope callers can target any tenant\'s session — the tenant is resolved from the session automatically.' : ''),
|
||||
'Dry-runs a DRAFT analyze rule against one real session\'s events and returns the full diagnostic trace: ' +
|
||||
'verdict (fired / required_condition_not_met / below_confidence_threshold / skipped_by_precondition / ' +
|
||||
'no_conditions_matched / no_events), per-condition matched/evidence with matchingEventCount (how many session ' +
|
||||
'events have that eventType at all — the first thing to check when a condition unexpectedly misses), the ' +
|
||||
'confidence breakdown per factor, and the interpolated explanation preview. NOTHING is persisted — safe to ' +
|
||||
'iterate. Use a recent session (older sessions predate newly deployed gather rules). Test both directions: a ' +
|
||||
'session where the rule should fire AND one where it should stay silent.' +
|
||||
(ga ? ' Any tenant\'s session can be targeted; the tenant is resolved from the session.' : ''),
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID to evaluate the draft against'),
|
||||
rule: z.record(z.string(), z.unknown()).describe('The draft analyze rule JSON object. Condition shapes — including the allowed condition `source` values — come from get_resource(name="rule_schemas"); run validate_rule first if the draft has not been validated yet'),
|
||||
rule: z.record(z.string(), z.unknown()).describe('The draft analyze rule JSON object. Condition shapes, including the allowed condition `source` values, come from get_resource(name="rule_schemas"); run validate_rule first if not yet validated'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
|
||||
@@ -1053,29 +1053,24 @@ export function registerSearchTools(
|
||||
{
|
||||
title: 'Search Events',
|
||||
description:
|
||||
'HYBRID EVENT SEARCH (try this first for ranked hits). ' +
|
||||
'Cross-session candidate event TYPES are selected semantically (vector embeddings) AND lexically; per-event ' +
|
||||
'ranking blends prefix-aware, synonym-aware keyword matching with the event type\'s semantic relevance — so ' +
|
||||
'intent-only queries surface even with no literal word overlap (e.g. "machine restarted unexpectedly" finds ' +
|
||||
'system_reboot_detected; "app stuck downloading" finds download_progress). Hits that matched purely on ' +
|
||||
'semantic type-relevance (no keyword) are flagged `semanticOnly`; `semanticOnlyCount` totals them. ' +
|
||||
'Problem queries (error/fail/stuck/timeout…) lift failure/Warning events and damp benign Info/Trace. ' +
|
||||
'`depth`: "fast" (default) is quick; "deep" scans more pages per type with a lower threshold for exhaustive ' +
|
||||
'recall when accuracy is critical. Returns the top `topK` (NOT every match) — compare resultCount vs ' +
|
||||
'eventsMatched. `matchedSessionIds` are the sessions behind the ranked hits (drill in next); ' +
|
||||
'`sessionsSearchedCount` is how many were scanned. ' +
|
||||
'IMPORTANT — cross-session recall is event-TYPE-driven: without a sessionId the scan maps the query to known ' +
|
||||
'event types (see event_types catalog) and fetches only those. A concept with no related event type still ' +
|
||||
'won\'t surface cross-session even when it sits inside another event\'s data — pass a sessionId (scans EVERY ' +
|
||||
'field of EVERY event, complete) for that. ' +
|
||||
'If `truncated` is true, recall is incomplete — narrow per `recallNote` or use depth="deep".' +
|
||||
(ga ? ' Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant.' : ''),
|
||||
'Hybrid keyword + semantic event search, ranked (try this first). Candidate event TYPES are ' +
|
||||
'selected semantically and lexically; each event is then scored by prefix- and synonym-aware keyword matching ' +
|
||||
'blended with its type\'s semantic relevance, so intent-only queries hit without literal word overlap ' +
|
||||
'("machine restarted unexpectedly" finds system_reboot_detected; "app stuck downloading" finds download_progress). ' +
|
||||
'Hits with no keyword match are flagged `semanticOnly` (`semanticOnlyCount` totals them). Problem wording ' +
|
||||
'(error/fail/stuck/timeout) lifts failure and Warning events and damps benign Info/Trace. ' +
|
||||
'Returns only the top `topK` — compare resultCount with eventsMatched; `matchedSessionIds` are the sessions to ' +
|
||||
'drill into next, `sessionsSearchedCount` how many were scanned. ' +
|
||||
'Cross-session recall is event-TYPE-driven: without a sessionId the query is mapped to known event types ' +
|
||||
'(event_types catalog) and only those are fetched, so a concept with no related event type does not surface ' +
|
||||
'even when it sits inside another event\'s data — pass a sessionId for a complete scan of every event field. ' +
|
||||
'`truncated: true` means incomplete recall: narrow per `recallNote` or use depth="deep".',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language description of what to find (e.g. "machine restarted unexpectedly", "app download stuck", "certificate error")'),
|
||||
sessionId: SessionIdSchema.optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
query: z.string().describe('Natural-language description of what to find (e.g. "machine restarted unexpectedly", "app download stuck", "certificate error")'),
|
||||
sessionId: SessionIdSchema.optional().describe('Search within one session (complete scan). Omitted: cross-session search by event type.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
depth: z.enum(['fast', 'deep']).optional().default('fast')
|
||||
.describe('"fast" (default): a few pages per type, quick. "deep": broad multi-page scan per type with a lower threshold, for exhaustive recall when accuracy is critical.'),
|
||||
.describe('"fast" (default): a few pages per type. "deep": more pages per type and a lower threshold, for exhaustive recall.'),
|
||||
topK: z.coerce.number().min(1).max(50).optional()
|
||||
.describe('Number of matching events to return (1-50). Defaults: 10 (fast) / 20 (deep).'),
|
||||
minScore: z.coerce.number().min(0).max(1).optional()
|
||||
@@ -1083,7 +1078,7 @@ export function registerSearchTools(
|
||||
keywords: z.array(z.string()).optional()
|
||||
.describe('Additional exact keywords, merged (case-insensitively) with those auto-extracted from the query.'),
|
||||
guaranteedTopRanked: z.coerce.number().min(0).max(50).optional().default(3)
|
||||
.describe('How many top results (by pure relevance score) are locked to the head in rank order, exempt from cross-session diversification. Default 3 keeps the strongest hits on top while spreading the rest across sessions. Set =topK to trust the ranking and disable diversification entirely; set 0 for maximum session diversity. Ignored for single-session (sessionId) searches.'),
|
||||
.describe('How many top hits (by pure score) stay at the head in rank order, exempt from cross-session diversification (default 3). Set =topK to disable diversification, 0 for maximum session spread. Ignored with sessionId.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1179,27 +1174,25 @@ export function registerSearchTools(
|
||||
{
|
||||
title: 'Search Knowledge Base',
|
||||
description:
|
||||
'Semantic/fuzzy search over the Autopilot Monitor knowledge base: analysis rules, gather rules, IME log patterns, ' +
|
||||
'and (literal matches only) the error-code catalog. ' +
|
||||
'Use natural language queries like "app install timeout", "BitLocker issues", "detection script failure". ' +
|
||||
'Returns the most relevant rules and patterns ranked by similarity. ' +
|
||||
'Great for finding remediation steps, understanding error patterns, or discovering relevant diagnostic rules. ' +
|
||||
'Semantic search over the knowledge base: analysis rules, gather rules, IME log patterns and (literal matches ' +
|
||||
'only) the error-code catalog — why an enrollment failed and how to remediate, not how the product works ' +
|
||||
'(that is search_docs). Natural-language queries such as "app install timeout", "BitLocker issues", ' +
|
||||
'"detection script failure". ' +
|
||||
'QUERY IN ENGLISH: the rules and the embedding model are English-only, so a query in another language ' +
|
||||
'matches nothing at all. Translate the user\'s question first and answer them in their own language. ' +
|
||||
'ERROR CODES: a query containing an HRESULT/Win32 hex code (e.g. "0x87D1041C", "0x80070002") also triggers a ' +
|
||||
'literal substring fallback — any rule that names the code verbatim AND the catalog entry for the code itself ' +
|
||||
'(type "error-code": symbol, meaning, family) are returned regardless of minScore, since such opaque codes embed ' +
|
||||
'poorly and the semantic score alone would miss them. Those hits are flagged `matchType: "error-code"`. ' +
|
||||
'For one known code, lookup_error_code is the direct answer (decimal, symbol and enforcement-state input too).',
|
||||
'matches nothing; translate the user\'s question first and answer them in their own language. ' +
|
||||
'ERROR CODES: a query containing an HRESULT/Win32 hex code ("0x87D1041C", "0x80070002") also runs a literal ' +
|
||||
'substring fallback — rules naming the code verbatim and the catalog entry for the code itself (type ' +
|
||||
'"error-code": symbol, meaning, family) are returned regardless of minScore, flagged `matchType: "error-code"`, ' +
|
||||
'because opaque codes embed poorly. For one known code, lookup_error_code is the direct answer (accepts ' +
|
||||
'decimal, symbol and enforcement-state input too).',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'),
|
||||
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
|
||||
type: z.enum(['all', 'analyze-rule', 'gather-rule', 'ime-log-pattern', 'error-code']).optional().default('all')
|
||||
.describe('Filter by document type. Default: search all types.'),
|
||||
minScore: z.coerce.number().min(0).max(1).optional().default(0.25)
|
||||
.describe('Minimum similarity score threshold (0-1, default 0.25). Lower = more results, higher = stricter matching. ' +
|
||||
'Short keyword queries on all-MiniLM embeddings score low (a relevant-but-marginal hit lands ~0.25-0.35), so the ' +
|
||||
'default is tuned to keep that band; error codes bypass this entirely via the literal fallback.'),
|
||||
.describe('Minimum similarity (0-1, default 0.25). Short keyword queries score low on all-MiniLM embeddings ' +
|
||||
'(a relevant-but-marginal hit lands ~0.25-0.35), so the default keeps that band; error-code hits bypass it.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -1286,14 +1279,14 @@ export function registerSearchTools(
|
||||
title: 'Look Up Error Code',
|
||||
description:
|
||||
'Explain ONE Windows / MSI / Windows Update / AppX / Intune error code from the shared error-code catalog ' +
|
||||
'(the same catalog the backend uses to enrich events and the portal shows in tooltips). ' +
|
||||
'(the catalog the backend enriches events with). ' +
|
||||
'Input: hex ("0x87D30067", "87d30067"), signed or unsigned decimal as the IME logs print it ("-2016214937"), ' +
|
||||
'an MSI exit code ("1603"), a symbol ("ERROR_INSTALL_FAILURE", "WU_E_ALL_UPDATES_FAILED", "UnzipError") or an ' +
|
||||
'IME app enforcement state by number or name ("6001", "NotAttemptedDependencyWithFailure"). ' +
|
||||
'Returns the normalised hex, the signed decimal, symbol, category (family), the catalog meaning, confidence ' +
|
||||
'and source kind; a 0x8007xxxx value that resolves through its low word to an MSI exit code reports ' +
|
||||
'`derivedFromWin32`. `imeRetriesDuringEsp` marks the MSI return codes the IME retries automatically during ' +
|
||||
'the ESP. An unknown code answers `found: false` with the normalised hex — say so rather than guessing a meaning. ' +
|
||||
'Returns normalised hex, signed decimal, symbol, category (family), meaning, confidence and source kind; ' +
|
||||
'a 0x8007xxxx value that resolves through its low word to an MSI exit code reports `derivedFromWin32`; ' +
|
||||
'`imeRetriesDuringEsp` marks the MSI return codes the IME retries automatically during the ESP. ' +
|
||||
'An unknown code answers `found: false` with the normalised hex — say so rather than guessing a meaning. ' +
|
||||
'For "which rule covers this code" use search_knowledge with the code in the query.',
|
||||
inputSchema: {
|
||||
code: z.string().min(1).max(80).describe('The code, symbol or enforcement state to explain (one value).'),
|
||||
@@ -1325,27 +1318,20 @@ export function registerSearchTools(
|
||||
{
|
||||
title: 'Search Product Documentation',
|
||||
description:
|
||||
'PRODUCT DOCUMENTATION SEARCH — the published Autopilot Monitor customer documentation ' +
|
||||
'The published Autopilot Monitor customer documentation ' +
|
||||
`(${DOCS_BASE_URL}), chunked by section and ranked semantically. ` +
|
||||
'Use this for questions about how the PRODUCT works rather than what a specific enrollment did: ' +
|
||||
'setup and onboarding, agent deployment, portal features, roles and permissions, settings, ' +
|
||||
'notifications, network endpoints, security/privacy/data-residency, plans, and troubleshooting guidance. ' +
|
||||
'PREFER THIS OVER FETCHING OR WEB-SEARCHING THE DOCS SITE: it returns the matching SECTIONS ' +
|
||||
'(~1k tokens for topK=3) instead of whole pages (~9k tokens raw, ~600k rendered), it is the exact ' +
|
||||
'version this server ships rather than a cached or third-party copy, and it matches on meaning — ' +
|
||||
'"where is my data stored" finds the page that says "Germany West Central" with no shared words, ' +
|
||||
'which keyword or web search cannot do. ' +
|
||||
'Each result carries the page `title`, its `heading` breadcrumb and a citable `url` — quote the url when ' +
|
||||
'answering so the user can verify. ' +
|
||||
'If semantic matching returns too few hits, exact keyword matches are APPENDED (flagged ' +
|
||||
'`matchType: "keyword"`, and their scores are on a different scale — rank order across the two is ' +
|
||||
'not comparable). ' +
|
||||
'For how the PRODUCT works, not what one enrollment did: onboarding, agent deployment, portal features, ' +
|
||||
'roles, settings, notifications, network endpoints, security/privacy/data residency, plans, troubleshooting. ' +
|
||||
'Prefer this over fetching the docs site: exact shipped version, semantic match. ' +
|
||||
'Each result carries the page `title`, `heading` breadcrumb and a citable `url` — quote the url in the answer. ' +
|
||||
'When semantic matching finds too little, exact keyword matches are added (appended, or in front when no ' +
|
||||
'semantic hit is convincing), flagged `matchType: "keyword"`; their scores use a different scale, so rank ' +
|
||||
'order across the two is not comparable. ' +
|
||||
'QUERY IN ENGLISH: the documentation and the embedding model are English-only, so a query in another ' +
|
||||
'language matches nothing at all (it returns zero results rather than poor ones). Translate the ' +
|
||||
'user\'s question first and answer them in their own language. ' +
|
||||
'NOT the same corpus as search_knowledge: that one holds analysis/gather rules and IME log patterns and ' +
|
||||
'explains why an enrollment failed. Rule of thumb — "how does X work?" → search_docs; ' +
|
||||
'"why did this session fail?" → search_knowledge or get_session_summary.',
|
||||
'language returns zero results; translate the user\'s question first and answer in their language. ' +
|
||||
'NOT the same corpus as search_knowledge (rules and IME log patterns: why an enrollment failed). ' +
|
||||
'Rule of thumb: "how does X work?" → search_docs; "why did this session fail?" → search_knowledge or ' +
|
||||
'get_session_summary.',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
|
||||
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
|
||||
@@ -1353,8 +1339,8 @@ export function registerSearchTools(
|
||||
.describe('Restrict to one documentation area, matching the bundle\'s top-level folder ' +
|
||||
`(one of: ${docs.sections.join(', ')}). Omit to search everything.`),
|
||||
minScore: z.coerce.number().min(0).max(1).optional().default(0.25)
|
||||
.describe('Minimum similarity score (0-1, default 0.25). Short keyword queries score low on ' +
|
||||
'all-MiniLM embeddings, so the default is tuned to keep the relevant-but-marginal band.'),
|
||||
.describe('Minimum similarity (0-1, default 0.25). Short keyword queries score low on all-MiniLM ' +
|
||||
'embeddings, so the default keeps the relevant-but-marginal band.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { z } from 'zod';
|
||||
import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanUntilMatch, scanWithTimeoutFallback } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription } from './shared.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { lookupErrorCode } from '../error-code-catalog.js';
|
||||
import { assertKnownEventType, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
|
||||
@@ -106,34 +106,28 @@ const phaseName = (phase: unknown, enrollmentType: unknown): string => {
|
||||
// ── Registration ────────────────────────────────────────────────────────
|
||||
|
||||
export function registerSessionTools(server: McpServer, ga: boolean, delegated: boolean = false): void {
|
||||
// Session-bound reads (get_session, events, summary, diagnostics) resolve the tenant from the
|
||||
// session itself; the standard scope / default-tenant wording would be wrong for them.
|
||||
const SESSION_TENANT_TEXT = 'Optional; resolved from the session when omitted.';
|
||||
const sessionTenantIdDescription = tenantIdDescription(ga, delegated, SESSION_TENANT_TEXT, SESSION_TENANT_TEXT);
|
||||
|
||||
// Tool 1: search_sessions
|
||||
server.registerTool(
|
||||
'search_sessions',
|
||||
{
|
||||
title: 'Search Sessions',
|
||||
description:
|
||||
'Search enrollment sessions' +
|
||||
(ga ? '. Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant' : ' in your tenant') + '. ' +
|
||||
'Basic properties (status, serial number, manufacturer, model, etc.) filter on the session index. ' +
|
||||
'Use deviceProperties for any device hardware/config filter — keys use "eventType.propertyName" notation. ' +
|
||||
'Consult the device_properties catalog (call get_resource(name="device_properties")) for available keys. ' +
|
||||
'Examples: {"tpm_status.specVersion": "2.0"}, {"hardware_spec.ramTotalGB": ">=8"}, {"secureboot_status.uefiSecureBootEnabled": "True"}. ' +
|
||||
'Array values are searched as substring match (e.g. disks containing "NVMe"). ' +
|
||||
'For COUNTING / AGGREGATION queries (e.g. "how many V2 enrollments?", "how many failed in last 7 days?") pass ' +
|
||||
'`fields=sessionId,status,agentVersion,startedAt` (or a similar lean subset): full SessionSummary objects are ~1.5KB ' +
|
||||
'each and can trip the response cap before pagination would normally deliver the answer. With projection a 100-session ' +
|
||||
'aggregate fits in <10KB. ' +
|
||||
'For VERSION sweeps use `agentVersionPrefix=2.0.` or `imeAgentVersionPrefix=1.23.` instead of one call per build — ' +
|
||||
'matches every patch in the line in a single response. ' +
|
||||
'deviceProperties key prefixes are validated against the event_types catalog — a typo is rejected with a clear ' +
|
||||
'error, not a silent empty result. For deviceProperties / serial / geo / time filters the tool auto-scans ' +
|
||||
'forward past empty pages, so a returned "count": 0 with no "nextLink" means truly no matches, while ' +
|
||||
'"moreToScan": true means the per-call scan budget was hit (pass nextLink as "continuation" to keep scanning). ' +
|
||||
'This endpoint is fully paginated — there is no truncation. Default pageSize=' + DEFAULT_FIRST_PAGE_SIZE + ' is tuned for interactive queries; ' +
|
||||
'raise it (up to 1000) for full sweeps. Pass the whole nextLink string as "continuation" so all backend-echoed ' +
|
||||
'query params round-trip correctly.',
|
||||
'Search enrollment sessions. Basic properties (status, serial number, manufacturer, model, versions, dates, ...) ' +
|
||||
'filter on the session index; deviceProperties filters on device hardware/config with "eventType.propertyName" keys ' +
|
||||
'(syntax and catalog on that argument). ' +
|
||||
'Counting / aggregation: a full SessionSummary is ~1.5 KB, so pass a lean fields= projection ' +
|
||||
'(e.g. "sessionId,status,agentVersion,startedAt"). Version sweeps: agentVersionPrefix / imeAgentVersionPrefix ' +
|
||||
'match a whole build line in one call. ' +
|
||||
'deviceProperties / serial / geo / time filters are applied after the index read, so the tool scans forward past ' +
|
||||
'empty pages: "count": 0 without nextLink means no matches; "moreToScan": true means the per-call scan budget ' +
|
||||
'was hit — pass the nextLink as continuation to keep scanning.',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant search (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
status: z.enum(SESSION_STATUSES).optional()
|
||||
.describe('Enrollment status filter. Pending = White Glove pre-provisioning done, awaiting user enrollment; ' +
|
||||
'Stalled = no progress for a while (non-terminal, can heal back to InProgress).'),
|
||||
@@ -148,8 +142,8 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
isSelfDeployingProfile: z.boolean().optional().describe(
|
||||
'Filter by self-deploying/kiosk Autopilot profile (CloudAssignedOobeConfig bits 0x20|0x40, agent-detected at registration)'),
|
||||
isCloudPc: z.boolean().optional().describe(
|
||||
'Filter by Windows 365 Cloud PC (agent-detected marker AND: Windows365 registry key + CloudManagedDesktopExtension service; sticky-true). ' +
|
||||
'Independent of validatedBy="CloudPc" (server-side Graph verification). Sessions from agents predating the field read as false.'),
|
||||
'Windows 365 Cloud PC as detected by the agent (Windows365 registry key AND CloudManagedDesktopExtension service; sticky-true). ' +
|
||||
'Independent of validatedBy="CloudPc" (server-side Graph check). False on sessions from agents predating the field.'),
|
||||
geoCountry: z.string().optional().describe('Country of enrollment (2-letter ISO code, e.g. "DE", "US")'),
|
||||
startedAfter: IsoDateString.optional().describe('ISO 8601 datetime — only sessions started after this'),
|
||||
startedBefore: IsoDateString.optional().describe('ISO 8601 datetime — only sessions started before this'),
|
||||
@@ -165,29 +159,22 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
rebootCountMax: z.coerce.number().int().min(0).optional()
|
||||
.describe('Maximum number of reboots observed during enrollment (<=).'),
|
||||
connectionType: z.enum(['WiFi', 'Ethernet']).optional()
|
||||
.describe('Active network connection type during enrollment ("WiFi" or "Ethernet"), indexed for cheap exact-match ' +
|
||||
'filtering (e.g. "how many machines enrolled over WiFi?"). Last emission wins — a device that switches media ' +
|
||||
'mid-enrollment reports the most recent state. Sessions that predate the projection lack the column and are excluded.'),
|
||||
.describe('Active network media during enrollment, indexed for exact match. Last emission wins (a device that ' +
|
||||
'switches media reports the most recent state). Sessions predating the column are excluded.'),
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated lean projection (e.g. "sessionId,status,agentVersion,startedAt"). ' +
|
||||
'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' +
|
||||
'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' +
|
||||
'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' +
|
||||
'isUserDriven, isHybridJoin, isSelfDeployingProfile, isCloudPc, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' +
|
||||
'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' +
|
||||
'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field), ' +
|
||||
'connectionType ("WiFi"/"Ethernet"; null on sessions predating the projection).'),
|
||||
.describe('Comma-separated projection keys: sessionId, tenantId, status, serialNumber, manufacturer, model, ' +
|
||||
'deviceName, osBuild, osName, startedAt, completedAt, durationSeconds, currentPhase, failureReason, ' +
|
||||
'eventCount, enrollmentType, isPreProvisioned, isUserDriven, isHybridJoin, isSelfDeployingProfile, ' +
|
||||
'isCloudPc, agentVersion, imeAgentVersion, geoCountry, rebootCount, connectionType, avgApiLatencyMs, ' +
|
||||
'apiRequestCount (weight avgApiLatencyMs by it). A key newer than the recording agent is null.'),
|
||||
deviceProperties: z.record(z.string(), z.string()).optional().describe(
|
||||
'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' +
|
||||
'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' +
|
||||
'Values: exact match by default. Prefix with >=, <=, >, < for numeric ranges (e.g. ">=8"). ' +
|
||||
'Trailing "*" is a prefix wildcard (e.g. {"hardware_spec.cpuArchitecture": "ARM*"} matches ARM + ARM64). ' +
|
||||
'Booleans: use "True" or "False". Arrays: substring match in any element.'
|
||||
'Keys "eventType.propertyName" from get_resource(name="device_properties"); unknown prefix rejected. ' +
|
||||
'Exact match; numeric ">=8" (>=, <=, >, <); "ARM*" = prefix; "True"/"False"; arrays: substring of any element. ' +
|
||||
'E.g. {"tpm_status.specVersion": "2.0"}.'
|
||||
),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many sessions per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -232,21 +219,15 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'Search Sessions by Event',
|
||||
description:
|
||||
'Find sessions that contain a specific event type (e.g. app install failure, phase transitions, errors). ' +
|
||||
(ga ? 'Omit tenantId for cross-tenant search (Global Admin). ' : '') +
|
||||
'Check the event_types catalog (call get_resource(name="event_types")) for valid eventType values. ' +
|
||||
'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase. ' +
|
||||
'This endpoint is fully paginated — there is no truncation. The default pageSize=' + DEFAULT_FIRST_PAGE_SIZE + ' is tuned for typical ' +
|
||||
'interactive queries; raise it (up to 1000) for full sweeps. For broad analysis, use pageSize=1000 and follow ' +
|
||||
'nextLink repeatedly until absent. Pass the whole nextLink string as "continuation" so all backend-echoed query ' +
|
||||
'params round-trip correctly.',
|
||||
'Find sessions that contain a given event type, read from the event-type index (e.g. every session with ' +
|
||||
'app_install_failed or enrollment_failed). eventType is validated against the event_types catalog; an unknown ' +
|
||||
'type is rejected, not an empty result.',
|
||||
inputSchema: {
|
||||
eventType: z.string().describe('Event type string — see event_types catalog (call get_resource(name="event_types")) for valid values (e.g. "app_install_failed", "enrollment_failed")'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant search (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
eventType: z.string().describe('Event type string from get_resource(name="event_types") (e.g. "app_install_failed", "enrollment_failed")'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many sessions per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -279,10 +260,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
'get_session',
|
||||
{
|
||||
title: 'Get Session',
|
||||
description: 'Get full details of a single enrollment session including all device metadata. Set includeAnalysis=true to also get AI rule analysis results explaining why the session failed and remediation suggestions.',
|
||||
description: 'Full record of one enrollment session including all device metadata. includeAnalysis=true adds the rule analysis (why the session failed, remediation).',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).', 'Tenant ID. If omitted, auto-resolved from the session.')),
|
||||
tenantId: z.string().optional().describe(sessionTenantIdDescription),
|
||||
includeAnalysis: z.boolean().optional().default(false).describe('Include rule analysis results (failure explanations and remediation steps)'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
@@ -322,28 +303,20 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'Get Session Diagnostics (Agent Log ZIP)',
|
||||
description:
|
||||
'Returns a short-lived, ready-to-use download URL for the agent DIAGNOSTICS ZIP of a session ' +
|
||||
'(agent logs, DecisionCore journal/signals, IME logs, final-status.json). The archive layout and ' +
|
||||
'the file priority order are the static "diag_zip_layout" resource — read it ONCE via ' +
|
||||
'get_resource(name="diag_zip_layout"); it is not repeated in this response. This is the ' +
|
||||
'highest-value source for root-causing why an enrollment went wrong: correlate the on-device ' +
|
||||
'agent log against the backend Events table.\n\n' +
|
||||
'CLIENT REQUIREMENT: this needs a client that can download files and run local file/shell tools ' +
|
||||
'(e.g. Claude Code or another agentic client). A pure chat client (Claude Desktop, claude.ai web) ' +
|
||||
'has no local filesystem and CANNOT unzip the binary archive — there this tool only yields a ' +
|
||||
'download link a human could open manually, not an automated analysis.\n\n' +
|
||||
'HOW TO USE: download the ZIP from "downloadUrl" — NO auth header needed, it is a short-lived ' +
|
||||
'signed ticket (~10 min) — then unzip and analyze it LOCALLY. The backend never unzips or parses ' +
|
||||
'it; you process it on your side and enrich with get_session_events / query_raw_events / ' +
|
||||
'search_knowledge. Read files in the diag_zip_layout priority order; AppWorkload*.log can be ' +
|
||||
'hundreds of MB → grep, never read whole.\n\n' +
|
||||
'If "available" is false there is no uploaded diagnostics package (upload mode may be Off or ' +
|
||||
'OnFailure on a successful session) — proceed with backend telemetry only. ' +
|
||||
'Tenant admins get their own tenant\'s diagnostics; ' +
|
||||
(ga ? 'Global Admins can pass tenantId to target any tenant.' : 'tenantId is optional and defaults to your tenant.'),
|
||||
'Returns a short-lived signed download URL (~10 min, no auth header) for the agent DIAGNOSTICS ZIP of a ' +
|
||||
'session: agent logs, DecisionCore journal/signals, IME logs, final-status.json. The archive layout and file ' +
|
||||
'priority order are the static "diag_zip_layout" resource — read it once via get_resource(name="diag_zip_layout"); ' +
|
||||
'the response does not repeat it. Correlating the on-device agent log against the backend Events table is the ' +
|
||||
'highest-value way to root-cause an enrollment.\n\n' +
|
||||
'CLIENT REQUIREMENT: the client must download and unzip the archive locally (e.g. Claude Code); the backend ' +
|
||||
'never unzips or parses it. A pure chat client without a filesystem only gets a link a human can open.\n\n' +
|
||||
'Read files in the diag_zip_layout priority order; AppWorkload*.log can be hundreds of MB — grep, never read ' +
|
||||
'whole. Enrich with get_session_events / query_raw_events / search_knowledge. ' +
|
||||
'"available": false means no diagnostics package was uploaded (upload mode Off, or OnFailure on a successful ' +
|
||||
'session) — proceed with backend telemetry only.',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).', 'Tenant ID. If omitted, auto-resolved from the session.')),
|
||||
tenantId: z.string().optional().describe(sessionTenantIdDescription),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -419,32 +392,24 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'Get Session Events',
|
||||
description:
|
||||
'RAW EVENT RETRIEVAL (fallback when ranked search misses). ' +
|
||||
'Returns up to pageSize events from a single session. Filter by eventType, severity, or source (app name). ' +
|
||||
'Use this when search_events returns incomplete results and you need the full unfiltered event stream, ' +
|
||||
'or for root cause analysis when you need every event in chronological sequence. ' +
|
||||
'If you omit tenantId, the backend auto-resolves it from the session' + (ga ? ' (Global Admin can access any tenant)' : '') + '. ' +
|
||||
'eventType is validated against the event_types catalog — a typo is rejected with a clear error, not a silent ' +
|
||||
'empty result. When you filter, the tool auto-scans forward past empty pages, so a returned "count": 0 is ' +
|
||||
'meaningful: with no "nextLink" it means truly no matching events; with "moreToScan": true it means the ' +
|
||||
'per-call scan budget was hit before a match (pass nextLink as "continuation" to keep scanning). ' +
|
||||
'Pagination: if the response includes "nextLink", more events are available — call this tool again and pass the ' +
|
||||
'whole nextLink string (e.g. "/api/sessions/{id}/events?pageSize=...&continuation=...&tenantId=...") as ' +
|
||||
'"continuation". The tool follows it verbatim so query params the backend echoes (tenantId, ' +
|
||||
'filters, etc.) round-trip correctly. Stop when the response no longer contains a nextLink. Sessions with ' +
|
||||
'thousands of events are fully reachable across multiple calls.',
|
||||
'RAW EVENT RETRIEVAL (fallback when ranked search_events misses, or when every event of one session is needed ' +
|
||||
'in chronological sequence). Filter by eventType (validated against the event_types catalog; an unknown type is ' +
|
||||
'rejected, not an empty result), severity or source (app name). An unfiltered read omits the per-event "data" ' +
|
||||
'payload by default and says so in omittedFields; a filtered read includes it (see fields). ' +
|
||||
'Filters are applied after the partition read, so the tool scans forward past empty pages: "count": 0 without ' +
|
||||
'nextLink means no matching events; "moreToScan": true means the per-call scan budget was hit — pass the ' +
|
||||
'nextLink as continuation to keep scanning.',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. If omitted, auto-resolved from the session.', 'Tenant ID. If omitted, auto-resolved from the session.')),
|
||||
eventType: z.string().optional().describe('Filter to only events of this type — valid values come from the event_types catalog (call get_resource(name="event_types")); an invented type is rejected, not an empty result'),
|
||||
tenantId: z.string().optional().describe(sessionTenantIdDescription),
|
||||
eventType: z.string().optional().describe('Only events of this type; values from get_resource(name="event_types")'),
|
||||
severity: z.enum(EVENT_SEVERITIES).optional(),
|
||||
source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'),
|
||||
fields: z.string().optional()
|
||||
.describe('Comma-separated projection. Omitted on an UNFILTERED read (no eventType/severity/source) = lean timeline default "' + LEAN_EVENT_FIELDS + '" — the multi-KB "data" payload is NOT included and the response says so (omittedFields). Omitted on a FILTERED read = full events including "data". List "data" for the whole payload, or "data.<key>" entries (e.g. "data.errorCode,data.scriptType") for just those payload keys. Valid keys: eventId, sessionId, tenantId, eventType, severity, source, phase, phaseName, timestamp, receivedAt, sentAt, message, sequence, rowKey, originalTimestamp, timestampClamped, causedByTransitionStepIndex, causedBySignalOrdinal, data, data.<key>.'),
|
||||
.describe('Projection. Omitted on an UNFILTERED read = lean default "' + LEAN_EVENT_FIELDS + '" without the multi-KB "data" payload (omittedFields says so); omitted on a FILTERED read = full events incl. "data". List "data" for the whole payload or "data.<key>" (e.g. "data.errorCode") for single keys. Further keys: eventId, sessionId, tenantId, receivedAt, sentAt, rowKey, originalTimestamp, timestampClamped, causedByTransitionStepIndex, causedBySignalOrdinal.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many events per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so query params the backend echoes round-trip correctly.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -482,21 +447,17 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'Get Session Summary',
|
||||
description:
|
||||
'Get a concise, structured summary of an enrollment session optimized for analysis. ' +
|
||||
'Returns: session overview (status, duration, device, enrollment config), ' +
|
||||
'observation coverage (coverage: from when the agent actually watched, IME log tracker / collector / ' +
|
||||
'upload / diagnostics-package health, and coverage.gaps — one line per known blind spot; read it before ' +
|
||||
'treating a missing event as proof that something did not happen), ' +
|
||||
'key events timeline (errors, warnings, phase transitions, app installs — noise filtered out, ' +
|
||||
'capped at 50 most-relevant entries; stats.keyEventsTruncated indicates if more were dropped), ' +
|
||||
'rule analysis results (probable cause, remediation), and aggregate stats. A key event that carries an ' +
|
||||
'error code shows it as errorCode plus errorText (symbol and catalog meaning); heavy event payloads ' +
|
||||
'(data JSON) are otherwise NOT included — pull them via get_session_events for the same sessionId when needed. ' +
|
||||
'Use this as the first tool when investigating a session. ' +
|
||||
'For raw unfiltered events use get_session_events. For full metadata use get_session.',
|
||||
'First tool when investigating a session. Returns: overview (status, duration, device, enrollment config); ' +
|
||||
'observation coverage (from when the agent actually watched, IME log tracker / collector / upload / ' +
|
||||
'diagnostics-package health, and coverage.gaps — one line per known blind spot; read it before treating a ' +
|
||||
'missing event as proof that something did not happen); a key-event timeline (errors, warnings, phase ' +
|
||||
'transitions, app installs; noise filtered; capped at the 50 most relevant, stats.keyEventsTruncated says if ' +
|
||||
'more were dropped); rule analysis (probable cause, remediation); aggregate stats. A key event with an error ' +
|
||||
'code shows errorCode plus errorText (symbol and catalog meaning); event payloads (data) are otherwise not ' +
|
||||
'included — get_session_events returns them. get_session returns the full metadata.',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).', 'Tenant ID. If omitted, auto-resolved from the session.')),
|
||||
tenantId: z.string().optional().describe(sessionTenantIdDescription),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -711,13 +672,12 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'Enrollment Metrics',
|
||||
description:
|
||||
'Get aggregated enrollment metrics: failure rates, slowest/most-failing apps, session counts. ' +
|
||||
(ga ? 'Omit tenantId for cross-tenant platform overview (Global Admin). Specify tenantId for single-tenant metrics. ' : '') +
|
||||
'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).',
|
||||
'Aggregated enrollment metrics over a trailing window: failure rates, slowest and most-failing apps, session counts ' +
|
||||
'(a summary block plus an app-metrics block; one failing block is reported in partialErrors, not as an error).',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant overview (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
days: z.coerce.number().int().min(1).max(365).optional().default(30)
|
||||
.describe('Time window in days (1-365). Defaults to 30. Applied to both summary and app metrics.'),
|
||||
.describe(daysDescription(30, 365, 'Applies to both the summary and the app metrics.')),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -764,27 +724,20 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'Search Sessions by CVE',
|
||||
description:
|
||||
"Find enrollment sessions where a specific CVE was detected in the device's software inventory. " +
|
||||
(ga ? "Omit tenantId for cross-tenant search (Global Admin). " : "") +
|
||||
"Requires vulnerability scanning to be enabled. " +
|
||||
"Use this to answer: which devices are affected by CVE-2024-XXXX, show all critical vulnerability sessions. " +
|
||||
"Find enrollment sessions whose software inventory reported a given CVE (requires vulnerability scanning to be " +
|
||||
"enabled): which devices are affected by CVE-YYYY-NNNN, narrowed by minCvssScore / overallRisk. " +
|
||||
"The per-session vulnerability report (get_session_summary / vulnerability_report event) lists each CVE with " +
|
||||
"cvssScore, cvssVector, isKev, epssScore (FIRST EPSS, 0-1), epssPercentile and priority (act/attend/track). " +
|
||||
"This endpoint is fully paginated — there is no truncation. The default pageSize=" + DEFAULT_FIRST_PAGE_SIZE + " is tuned for typical " +
|
||||
"interactive queries; raise it (up to 1000) for full exposure audits. For \"how many of my devices have CVE-X\" " +
|
||||
"use pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as " +
|
||||
"\"continuation\" so all backend-echoed query params (cveId, minCvssScore, overallRisk) round-trip correctly.",
|
||||
"cvssScore, cvssVector, isKev, epssScore (FIRST EPSS, 0-1), epssPercentile and priority (act/attend/track).",
|
||||
inputSchema: {
|
||||
cveId: z.string()
|
||||
.regex(/^CVE-\d{4}-\d{4,}$/i, 'Must be a CVE identifier like "CVE-2024-21447" (CVE-YYYY-NNNN+).')
|
||||
.describe('CVE identifier (e.g. "CVE-2024-21447"). Validated — a non-CVE string is rejected, not silently empty.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated, 'Tenant ID. Omit for cross-tenant search (Global Admin only).', 'Optional tenant ID. Defaults to your tenant.')),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
minCvssScore: z.coerce.number().min(0).max(10).optional().describe('Minimum CVSS score filter (e.g. 7.0 for high+critical)'),
|
||||
overallRisk: z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional()
|
||||
.describe('Page size (1-1000; default ' + DEFAULT_FIRST_PAGE_SIZE + ' on the first page). Returns this many affected sessions per call; follow nextLink for more. On a follow-up call an explicit value overrides the pageSize embedded in the nextLink (the cursor stays valid); omit it to keep the size the nextLink carries.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'),
|
||||
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for a full exposure audit.')),
|
||||
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -818,11 +771,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'List Blocked Devices',
|
||||
description:
|
||||
'List devices currently blocked from enrolling. Blocked devices have their enrollment sessions rejected by the backend. ' +
|
||||
'Global Admin only — both the tenant-scoped (?tenantId=) and cross-tenant variants of this endpoint require Global Admin. ' +
|
||||
'Tenant Admins and Operators receive 403 (the backend manages the device block list as a platform-wide concern).',
|
||||
'List devices currently blocked from enrolling; the backend rejects their enrollment sessions. The block list is ' +
|
||||
'platform-wide.',
|
||||
inputSchema: {
|
||||
tenantId: z.string().optional().describe('Tenant ID to scope results. Optional — both forms require Global Admin.'),
|
||||
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -850,15 +802,15 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'IME Pattern Health',
|
||||
description:
|
||||
'Operator view of IME log-pattern drift: for every IME agent version, how many sessions reported the ' +
|
||||
'session-end pattern histogram and in what share of them each shipped pattern matched (cells), which ' +
|
||||
'version is the fleet baseline, which patterns are EXPECTED (>= expectedHitRate on the baseline), and the ' +
|
||||
'open drift alerts (an expected pattern that matched in none of >= minCandidateSessions sessions on a newer ' +
|
||||
'version — Microsoft probably changed the log wording). Workflow on an alert: search_sessions with ' +
|
||||
'imeAgentVersion=<version> -> get_session_diagnostics on a session with a package -> validate the pattern ' +
|
||||
'against the real IME log -> compare with the IME decompile -> fix the pattern in rules/ime-log-patterns. ' +
|
||||
'Only sessions that reached a terminal run report a histogram (crashes/kills are excluded from the denominator). ' +
|
||||
'catalog says where the shipped-pattern list comes from (the last GitHub reseed or the deployed backend build) and when it was written.',
|
||||
'IME log-pattern drift: per IME agent version, how many sessions reported the session-end pattern histogram ' +
|
||||
'and in what share of them each shipped pattern matched (cells); the fleet baseline version; which patterns ' +
|
||||
'are EXPECTED (>= expectedHitRate on the baseline); and the open drift alerts (an expected pattern that ' +
|
||||
'matched in none of >= minCandidateSessions sessions on a newer version — Microsoft probably changed the log ' +
|
||||
'wording). On an alert: search_sessions with imeAgentVersion=<version> -> get_session_diagnostics on a session ' +
|
||||
'with a package -> validate the pattern against the real IME log -> compare with the IME decompile -> fix it ' +
|
||||
'in rules/ime-log-patterns. Only sessions that reached a terminal run report a histogram (crashes/kills are ' +
|
||||
'excluded from the denominator). catalog says where the shipped-pattern list comes from (the last GitHub ' +
|
||||
'reseed or the deployed backend build) and when it was written.',
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
@@ -881,10 +833,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
{
|
||||
title: 'IME Version History',
|
||||
description:
|
||||
'Get the history of all IME (Intune Management Extension) agent versions seen across enrollments. ' +
|
||||
'Shows when each version was first and last seen, and how many sessions reported it. ' +
|
||||
'This is a permanent archive that survives data retention — useful for tracking Microsoft IME release rollouts over time. ' +
|
||||
'Available to all tenant members (no tenantId needed, data is global).',
|
||||
'History of every IME (Intune Management Extension) agent version seen across enrollments: first and last seen, ' +
|
||||
'session count. A permanent platform-wide archive that survives data retention (no tenantId; data is global) — ' +
|
||||
'tracks Microsoft IME rollouts over time.',
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
|
||||
@@ -133,16 +133,19 @@ export function isBenignHealthDetectionReport(
|
||||
* existing GA / tenant-user texts; the delegated text is shared so the contract reads identically
|
||||
* everywhere.
|
||||
*/
|
||||
export function tenantIdDescription(ga: boolean, delegated: boolean, gaText: string, tenantText: string): string {
|
||||
export function tenantIdDescription(
|
||||
ga: boolean,
|
||||
delegated: boolean,
|
||||
gaText = 'Scope to one tenant; omit for cross-tenant.',
|
||||
tenantText = 'Optional; defaults to your tenant.',
|
||||
): string {
|
||||
if (delegated) {
|
||||
// Deliberately says nothing about pagination: this string is shared across tools whose follow-up
|
||||
// pages behave differently — backend-nextLink pagers re-send tenantId inside the continuation, but
|
||||
// offset-based client-side pagers (geo-offset:/inv-offset:) still need it re-passed every page. The
|
||||
// per-tool `continuation` arg description owns those mechanics; here we only state the invariant.
|
||||
return 'REQUIRED: name the tenant to query — one of YOUR managed tenants (delegated/MSP), or your own ' +
|
||||
'home tenant if you are a member of it. There is no cross-tenant aggregate here and no implicit default — ' +
|
||||
'every query must name a specific tenant. Call list_tenants to see your tenants with display names; ' +
|
||||
'for a bounded overview across ALL your managed tenants call get_fleet_overview instead.';
|
||||
// Short on purpose: the instructions carry the managed-tenant list, list_tenants and get_fleet_overview.
|
||||
return 'REQUIRED: one of your managed tenants (or your own home tenant if you are a member); there is no default.';
|
||||
}
|
||||
return ga ? gaText : tenantText;
|
||||
}
|
||||
@@ -319,3 +322,23 @@ function overflowResult(data: unknown, responseChars: number, maxResultSizeChars
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared argument descriptions. The catalog is always-on model context (D-269): mechanics that
|
||||
// every paginated tool shares are said once here (and once in the server instructions), so a
|
||||
// tool's own text carries only what is specific to it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** pageSize: the follow-up-call rule lives in the instructions; only the bounds are per tool. */
|
||||
export function pageSizeDescription(defaultSize: number, max = 1000, note?: string): string {
|
||||
return `Rows per page (1-${max}; default ${defaultSize} on the first page).${note ? ` ${note}` : ''}`;
|
||||
}
|
||||
|
||||
/** continuation: the nextLink is preferred because it echoes the backend-resolved filters. */
|
||||
export const CONTINUATION_DESCRIPTION =
|
||||
'The whole nextLink string from the previous response (preferred — it carries the resolved filters) or its opaque continuation value.';
|
||||
|
||||
/** days: a trailing window ending now. */
|
||||
export function daysDescription(defaultDays: number, max: number, note?: string): string {
|
||||
return `Trailing window in days (1-${max}; default ${defaultDays}).${note ? ` ${note}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,16 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public const string WelcomeEmailSent = "WelcomeEmailSent";
|
||||
public const string WelcomeEmailSkipped = "WelcomeEmailSkipped";
|
||||
public const string WelcomeEmailFailed = "WelcomeEmailFailed";
|
||||
/// <summary>
|
||||
/// Post-offboarding farewell mail, recorded by the offboarding worker after the History
|
||||
/// row went Completed. Sent (Info) = the provider accepted it; Skipped (Warning) = no
|
||||
/// contact address was captured before the wipe, the customer left in silence; Failed
|
||||
/// (Error) = an address was there and the mail did not go out. The only record of the
|
||||
/// send: its success log is Information and never reaches Application Insights.
|
||||
/// </summary>
|
||||
public const string FarewellEmailSent = "FarewellEmailSent";
|
||||
public const string FarewellEmailSkipped = "FarewellEmailSkipped";
|
||||
public const string FarewellEmailFailed = "FarewellEmailFailed";
|
||||
public const string TenantTrialStarted = "TenantTrialStarted";
|
||||
public const string TenantTrialExpiring = "TenantTrialExpiring";
|
||||
public const string TenantTrialExpired = "TenantTrialExpired";
|
||||
@@ -139,7 +149,7 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
|
||||
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, MaintenanceStarted, MaintenanceSkippedLocked, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
|
||||
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied, McpServicePrincipalFirstSeen,
|
||||
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
|
||||
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, FarewellEmailSent, FarewellEmailSkipped, FarewellEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
|
||||
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable, TelemetryItemsRejected,
|
||||
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
|
||||
AzureMonitorAlert,
|
||||
|
||||
@@ -163,6 +163,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>The managing tenant that owns the group (self-service); absent for operator-created groups and direct grants.</summary>
|
||||
public string? OwnerTenantId { get; set; }
|
||||
public string? OwnerDomain { get; set; }
|
||||
/// <summary>The owned group's name for a self-service delegation; the neutral operator label ("Platform support") for every operator-provisioned entry — internal group names never reach a customer.</summary>
|
||||
public string Name { get; set; } = default!;
|
||||
/// <summary>self-service | operator</summary>
|
||||
public string Source { get; set; } = default!;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useRef, useState } from "react"
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import { fromWireAdminConfiguration, toWireAdminConfiguration, type AdminConfiguration, type OpsAlertRule } from "@/types/adminConfig";
|
||||
import type {
|
||||
AdminConfiguration as WireAdminConfiguration,
|
||||
@@ -370,6 +371,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
body: jsonBody<WireAdminConfiguration>(toWireAdminConfiguration(updatedConfig)),
|
||||
});
|
||||
setAdminConfig(fromWireAdminConfiguration(result.config));
|
||||
// selfServiceAppHomingEnabled gates every tenant's appHomingFunnelActive feature flag.
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
setSuccessMessage("Admin configuration saved successfully!");
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ApiError, apiErrorText, fetchJson, fetchOk } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import { parseOpsDetails, useOpsRunStatus, type OpsRunLifecycle } from "@/hooks/useOpsRunStatus";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import type { GetLatestVersionsResponse } from "@/utils/wire-types.generated";
|
||||
@@ -51,6 +52,7 @@ export function MaintenanceTriggerSection({
|
||||
const data = await fetchJson<GetLatestVersionsResponse>(api.config.latestVersions({ refresh: true }), getAccessToken, {
|
||||
method: "GET",
|
||||
});
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
const agentVer = data.latestAgentVersion ?? "unknown";
|
||||
const bootstrapVer = data.latestBootstrapScriptVersion ?? "unknown";
|
||||
const fetchedAt = data.fetchedAtUtc ? new Date(data.fetchedAtUtc).toISOString().replace("T", " ").substring(0, 19) + " UTC" : "now";
|
||||
|
||||
@@ -186,6 +186,13 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
"WelcomeEmailSent",
|
||||
"WelcomeEmailSkipped",
|
||||
"WelcomeEmailFailed",
|
||||
// Offboarding farewell mail, recorded by the offboarding worker after the wipe completed.
|
||||
// Same three-way record as the welcome mail: Skipped (Warning) = no contact address was
|
||||
// captured before the wipe, Failed (Error) = the provider refused an address we had,
|
||||
// Sent (Info) = confirmation. Dual-register per memory feedback_ops_event_types_dual_register.
|
||||
"FarewellEmailSent",
|
||||
"FarewellEmailSkipped",
|
||||
"FarewellEmailFailed",
|
||||
// A tenant admin turned on-demand diagnostics upload (the Collect Logs capability) on or
|
||||
// off — portal Settings, the Collect Logs quick-config dialog, MCP update_tenant_config,
|
||||
// or a config revert. Fires on the flip only. Backend
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import { classifyClientId, legacyConfigured } from "@/lib/authApp";
|
||||
import { appHomingErrorMessage } from "@/lib/appHoming";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
@@ -294,6 +295,7 @@ function TenantManagementSectionInner({
|
||||
method: "PUT",
|
||||
body: jsonBody<TenantConfiguration>(tenant),
|
||||
});
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
|
||||
// Update tenant in list
|
||||
setTenants(prev => prev.map(t => t.tenantId === tenant.tenantId ? result.config : t));
|
||||
@@ -331,6 +333,7 @@ function TenantManagementSectionInner({
|
||||
payingCustomer: tenant.payingCustomer === true,
|
||||
}),
|
||||
});
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
const apply = (t: TenantConfiguration): TenantConfiguration => ({
|
||||
...t,
|
||||
planTier: result.planTier,
|
||||
@@ -376,6 +379,7 @@ function TenantManagementSectionInner({
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
trackEvent("app_homing_manual_flip", {
|
||||
tenantId: tenant.tenantId,
|
||||
target,
|
||||
@@ -425,6 +429,7 @@ function TenantManagementSectionInner({
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
trackEvent("admin_tenant_offboarded", {
|
||||
tenantId: tenant.tenantId,
|
||||
status: data.status ?? "unknown",
|
||||
@@ -456,6 +461,7 @@ function TenantManagementSectionInner({
|
||||
setSuccessMessage(null);
|
||||
const data = await fetchJson<OffboardResponse>(
|
||||
api.tenants.offboardingRetry(tenant.tenantId), getAccessToken, { method: "POST" });
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
trackEvent("admin_tenant_offboard_retried", {
|
||||
tenantId: tenant.tenantId,
|
||||
failedPhase: editingRecord?.failedPhase ?? "unknown",
|
||||
|
||||
@@ -284,6 +284,13 @@ export function SessionTable({
|
||||
[sessions],
|
||||
);
|
||||
|
||||
// Status pill counts in one pass, keyed on the list — the pills used to filter it once per status.
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const s of sessions) counts[s.status] = (counts[s.status] ?? 0) + 1;
|
||||
return counts;
|
||||
}, [sessions]);
|
||||
|
||||
const activeFilterCount = Object.values(columnFilters).reduce(
|
||||
(sum, s) => sum + (s.size > 0 ? 1 : 0), 0
|
||||
);
|
||||
@@ -720,7 +727,7 @@ export function SessionTable({
|
||||
Incomplete: { bg: "bg-slate-50 text-slate-700 border-slate-200 hover:bg-slate-100", bgActive: "bg-slate-600 text-white border-slate-600", text: "text-slate-600", label: "Incomplete" },
|
||||
};
|
||||
const c = config[status];
|
||||
const count = sessions.filter(s => s.status === status).length;
|
||||
const count = statusCounts[status] ?? 0;
|
||||
const isActive = statusFilter === status;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* First-page size of the dashboard session list and the stats window — shared by the
|
||||
* dashboard hooks and the auth bootstrap's seed of the dashboard's first fetch
|
||||
* (lib/dashboardSeed.ts). Both sides must build the identical URL, so the rule lives once.
|
||||
*/
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 10;
|
||||
export const MAX_PAGE_SIZE = 1000;
|
||||
export const DASHBOARD_STATS_DEFAULT_DAYS = 7;
|
||||
|
||||
/** localStorage key an earlier build introduced for the per-page choice; still honoured. */
|
||||
const SESSIONS_PER_PAGE_KEY = "sessionsPerPage";
|
||||
|
||||
export function getInitialSessionsPageSize(): number {
|
||||
// Pattern B2 default first-paint pageSize is 10; localStorage may override. Cap to the
|
||||
// backend's MAX_PAGE_SIZE.
|
||||
if (typeof window === "undefined") return DEFAULT_PAGE_SIZE;
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(SESSIONS_PER_PAGE_KEY);
|
||||
} catch {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
const parsed = stored ? parseInt(stored, 10) : NaN;
|
||||
const value = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PAGE_SIZE;
|
||||
return Math.min(value, MAX_PAGE_SIZE);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import type { Session } from "../types";
|
||||
import { buildSessionSearchMatcher, parseSessionSearchQuery } from "../utils/sessionSearchQuery";
|
||||
@@ -114,9 +114,19 @@ export function useDashboardFilters({
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// The input and the page-reset key below stay on the immediate query; the filter pass follows
|
||||
// the deferred value, so a keystroke never waits for it.
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
|
||||
// Search grammar (terms AND-ed, `-` excludes, quotes, `field=value`) — see
|
||||
// utils/sessionSearchQuery. Null while the query carries no filter.
|
||||
const searchMatcher = useMemo(() => buildSessionSearchMatcher(searchQuery), [searchQuery]);
|
||||
const searchMatcher = useMemo(() => buildSessionSearchMatcher(deferredSearchQuery), [deferredSearchQuery]);
|
||||
// The client-derived `extra` text is read by free terms only (a qualified term reads its one
|
||||
// field), so it is built only while the query has one.
|
||||
const searchNeedsExtra = useMemo(() => {
|
||||
const { include, exclude } = parseSessionSearchQuery(deferredSearchQuery);
|
||||
return include.some((t) => !t.field) || exclude.some((t) => !t.field);
|
||||
}, [deferredSearchQuery]);
|
||||
|
||||
// Reset to page 1 whenever the displayed set or its ordering changes —
|
||||
// includes external scope changes (tenant switch, global-admin toggle, tenant filter).
|
||||
@@ -161,6 +171,8 @@ export function useDashboardFilters({
|
||||
}, [sessions, globalAdminMode, tenantIdFilter, tenantId]);
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
const query = deferredSearchQuery.toLowerCase().trim();
|
||||
const durationMatch = query.match(/^([><]=?)\s*(\d+)$/);
|
||||
return effectiveSessions.filter((session) => {
|
||||
// Fleet-context rule filter: only while the hit set is loaded (null = still
|
||||
// loading → unfiltered, so the table never flashes empty before the fetch).
|
||||
@@ -174,11 +186,8 @@ export function useDashboardFilters({
|
||||
if (!allowedValues.has(value)) return false;
|
||||
}
|
||||
|
||||
if (!searchQuery.trim()) return true;
|
||||
if (!query) return true;
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
const durationMatch = query.match(/^([><]=?)\s*(\d+)$/);
|
||||
if (durationMatch) {
|
||||
const operator = durationMatch[1];
|
||||
const value = parseInt(durationMatch[2]);
|
||||
@@ -193,20 +202,24 @@ export function useDashboardFilters({
|
||||
if (!searchMatcher) return true;
|
||||
|
||||
// Client-derived tokens only a free term can reach; the session fields themselves
|
||||
// are read by the matcher. Newline-joined so no term spans two tokens.
|
||||
const extra = [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
`${Math.round((session.durationSeconds ?? 0) / 60)} min`,
|
||||
blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) ? "blocked" : "",
|
||||
// Tenant column is cross-tenant only; keep single-tenant search free of a value
|
||||
// every row shares.
|
||||
globalAdminMode ? session.tenantId : "",
|
||||
globalAdminMode ? tenantDomainById?.get(session.tenantId) ?? "" : "",
|
||||
].join("\n");
|
||||
// are read by the matcher. Newline-joined so no term spans two tokens. Formatted
|
||||
// per pass on purpose (no per-session cache, D-274); the deferred query keeps
|
||||
// typing responsive.
|
||||
const extra = searchNeedsExtra
|
||||
? [
|
||||
new Date(session.startedAt).toLocaleString(),
|
||||
`${Math.round((session.durationSeconds ?? 0) / 60)} min`,
|
||||
blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) ? "blocked" : "",
|
||||
// Tenant column is cross-tenant only; keep single-tenant search free of a value
|
||||
// every row shares.
|
||||
globalAdminMode ? session.tenantId : "",
|
||||
globalAdminMode ? tenantDomainById?.get(session.tenantId) ?? "" : "",
|
||||
].join("\n")
|
||||
: undefined;
|
||||
|
||||
return searchMatcher(session, extra);
|
||||
});
|
||||
}, [effectiveSessions, ruleSessionIds, statusFilter, columnFilters, searchQuery, searchMatcher, blockedDevicesSet, globalAdminMode, tenantDomainById]);
|
||||
}, [effectiveSessions, ruleSessionIds, statusFilter, columnFilters, deferredSearchQuery, searchMatcher, searchNeedsExtra, blockedDevicesSet, globalAdminMode, tenantDomainById]);
|
||||
|
||||
const sortedSessions = useMemo(() => {
|
||||
if (!sortColumn) return filteredSessions;
|
||||
|
||||
@@ -14,9 +14,7 @@ import type { Session } from "../types";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
import type { BlockedDeviceListResponse, SearchSessionsResponse, SessionListResponse } from "@/utils/wire-types.generated";
|
||||
import { ApiError, fetchJson, nullOnApiError } from "@/lib/apiClient";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const MAX_PAGE_SIZE = 1000;
|
||||
import { getInitialSessionsPageSize } from "./sessionsPageSize";
|
||||
|
||||
// Server-side search sweep bounds: the backend scans up to 10 Azure pages per request
|
||||
// (free-text backfill) and returns only matches; the client follows nextLink until it has
|
||||
@@ -223,19 +221,10 @@ export function useDashboardSessions({
|
||||
}
|
||||
}, [getAccessToken, setBlockedDevicesSet, adminModeRef, globalAdminModeRef, tenantIdRef]);
|
||||
|
||||
const getInitialPageSize = (): number => {
|
||||
// Pattern B2 default first-paint pageSize is 10; localStorage may override
|
||||
// (legacy "sessionsPerPage" key). Cap to backend MAX_PAGE_SIZE.
|
||||
if (typeof window === "undefined") return DEFAULT_PAGE_SIZE;
|
||||
const stored = window.localStorage.getItem("sessionsPerPage");
|
||||
const parsed = stored ? parseInt(stored, 10) : NaN;
|
||||
const value = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PAGE_SIZE;
|
||||
return Math.min(value, MAX_PAGE_SIZE);
|
||||
};
|
||||
|
||||
// Internal batch fetcher — returns data without touching state so callers can
|
||||
// decide how to apply it (single append vs. progressive loop). For first-page
|
||||
// calls we use getInitialPageSize() (Pattern B2: typically 10). For load-more
|
||||
// calls we use getInitialSessionsPageSize() (Pattern B2: typically 10; the auth
|
||||
// bootstrap seeds the same URL, see lib/dashboardSeed.ts). For load-more
|
||||
// we keep the same pageSize so the user-visible cadence is uniform.
|
||||
const fetchSessionsBatch = useCallback(async (
|
||||
loadMoreContinuation?: string,
|
||||
@@ -251,7 +240,7 @@ export function useDashboardSessions({
|
||||
// bounds this too; this just keeps the client from ever asking for an unmanaged tenant.
|
||||
const effectiveTenantFilter = boundTenantToDelegatedScope(
|
||||
asGuidOrUndefined(rawFilter), isDelegatedScopeRef.current, delegatedTenantIdsRef.current);
|
||||
const pageSize = getInitialPageSize();
|
||||
const pageSize = getInitialSessionsPageSize();
|
||||
const opts = loadMoreContinuation
|
||||
? { pageSize, continuation: loadMoreContinuation }
|
||||
: { pageSize };
|
||||
@@ -262,7 +251,11 @@ export function useDashboardSessions({
|
||||
isHomeTenantTarget(asGuidOrUndefined(rawFilter), tenantIdRef.current ?? undefined);
|
||||
const endpoint = globalAdminModeRef.current && !homeSelected
|
||||
? api.globalSessions.list(effectiveTenantFilter, undefined, opts)
|
||||
: api.sessions.list(tenantIdRef.current ?? undefined, undefined, opts);
|
||||
// Own-tenant list: the backend takes the tenant from the JWT and ignores a tenantId query
|
||||
// (GetSessionsFunction, ParseQuery acceptFilterTenantId:false), so none is sent — and the
|
||||
// URL is the one the auth bootstrap seeds (lib/dashboardSeed.ts), whether or not the tenant
|
||||
// id is known yet.
|
||||
: api.sessions.list(undefined, undefined, opts);
|
||||
|
||||
let data: SessionListResponse;
|
||||
try {
|
||||
@@ -512,28 +505,23 @@ export function useDashboardSessions({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConnected, tenantId]);
|
||||
|
||||
// Join/leave the cross-tenant global-admins broadcast group. Real GA only (joinGlobalAdmins) — a
|
||||
// Join the cross-tenant global-admins broadcast group while in GA mode. Real GA only (joinGlobalAdmins) — a
|
||||
// delegated ("MSP") caller has no platform scope and would be rejected (403); the dashboard still reads
|
||||
// the bounded aggregate and recovers live state via the reconnect refetch above.
|
||||
// the bounded aggregate and recovers live state via the reconnect refetch above. The global notification
|
||||
// bell holds the same group for the whole session: group membership is reference-counted per name in the
|
||||
// SignalR layer, so the cleanup's leave drops only the dashboard's own reference (an unconditional leave
|
||||
// whenever GA mode was off used to drop the bell's membership as well).
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
if (!isConnected || !joinGlobalAdmins) return;
|
||||
|
||||
if (joinGlobalAdmins) {
|
||||
console.log("[Dashboard] joining global-admins group");
|
||||
joinGroup("global-admins");
|
||||
} else {
|
||||
console.log("[Dashboard] leaving global-admins group");
|
||||
leaveGroup("global-admins");
|
||||
}
|
||||
console.log("[Dashboard] joining global-admins group");
|
||||
joinGroup("global-admins");
|
||||
|
||||
return () => {
|
||||
if (joinGlobalAdmins) {
|
||||
console.log("[Dashboard] Component unmounting: leaving global-admins group");
|
||||
leaveGroup("global-admins");
|
||||
}
|
||||
console.log("[Dashboard] leaving global-admins group");
|
||||
leaveGroup("global-admins");
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConnected, joinGlobalAdmins]);
|
||||
}, [isConnected, joinGlobalAdmins, joinGroup, leaveGroup]);
|
||||
|
||||
// SignalR listeners — re-register when connection cycles
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isHomeTenantTarget } from "@/utils/homeTenantScope";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
import type { SessionStatsResponse } from "@/utils/wire-types.generated";
|
||||
import { ApiError, fetchJson } from "@/lib/apiClient";
|
||||
import { DASHBOARD_STATS_DEFAULT_DAYS } from "./sessionsPageSize";
|
||||
|
||||
export interface DashboardStats {
|
||||
days: number;
|
||||
@@ -64,7 +65,8 @@ export interface UseDashboardStatsReturn {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 7;
|
||||
// Shared with the auth bootstrap's seed of the first stats request (lib/dashboardSeed.ts).
|
||||
const DEFAULT_DAYS = DASHBOARD_STATS_DEFAULT_DAYS;
|
||||
const SIGNALR_DEBOUNCE_MS = 3000;
|
||||
/**
|
||||
* Debounce for the Global-Admin AGGREGATE scope (no tenant filter). In that scope every
|
||||
|
||||
@@ -49,11 +49,13 @@ export function useDeleteSession(
|
||||
});
|
||||
const tenantId = pendingTenantsRef.current.get(sessionId);
|
||||
pendingTenantsRef.current.delete(sessionId);
|
||||
if (tenantId && isConnected) {
|
||||
// Fire-and-forget leave; the SignalR layer no-ops if we're not in the group.
|
||||
if (tenantId) {
|
||||
// Fire-and-forget leave, also while disconnected: it only drops this hook's reference in
|
||||
// the SignalR layer, which no-ops if the group was never joined and otherwise leaves it
|
||||
// once the last holder is gone.
|
||||
leaveGroup(`session-${tenantId}-${sessionId}`).catch(() => { /* best-effort */ });
|
||||
}
|
||||
}, [isConnected, leaveGroup]);
|
||||
}, [leaveGroup]);
|
||||
|
||||
// Single SignalR subscription that dispatches by sessionId — one listener handles N
|
||||
// concurrent pending deletions without registering / unregistering per-session handlers.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { fetchJson } from "@/lib/apiClient";
|
||||
import { cachedAuthFetchJson, TENANT_LIST_TTL_MS } from "@/lib/cachedAuthFetch";
|
||||
|
||||
export interface TenantListItem {
|
||||
tenantId: string;
|
||||
@@ -29,7 +29,7 @@ export function useTenantList(
|
||||
}
|
||||
try {
|
||||
// config/all is a bare array of tenant configurations (deliberately untyped, D-043).
|
||||
const configs = await fetchJson<{ tenantId?: string; domainName?: string }[]>(api.config.all(), getAccessToken);
|
||||
const configs = await cachedAuthFetchJson<{ tenantId?: string; domainName?: string }[]>(api.config.all(), getAccessToken, { ttlMs: TENANT_LIST_TTL_MS });
|
||||
if (cancelled) return;
|
||||
setTenantList(
|
||||
configs
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
|
||||
import { cachedAuthFetchJson, FEATURE_FLAGS_TTL_MS } from "@/lib/cachedAuthFetch";
|
||||
import { missingContactProfileParts } from "@/lib/edition";
|
||||
|
||||
/** NotificationContext.notifyError, injected so the hook stays free of the provider. */
|
||||
@@ -89,7 +89,7 @@ export function useTenantSecurityConfig(
|
||||
if (!user.isTenantAdmin && !user.isGlobalAdmin && user.role == null) return;
|
||||
|
||||
try {
|
||||
const data = await dedupedFetchJson<TenantConfigurationSummary>(api.config.featureFlags(tenantId), getAccessToken);
|
||||
const data = await cachedAuthFetchJson<TenantConfigurationSummary>(api.config.featureFlags(tenantId), getAccessToken, { ttlMs: FEATURE_FLAGS_TTL_MS });
|
||||
setSummary(summarizeTenantSecurityConfig(data));
|
||||
} catch (error) {
|
||||
// Fail-soft on a backend refusal (the banners just stay quiet); a token expiry is the
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from "@/lib/api";
|
||||
import { describeDelegationError, invitationStatusLabel } from "@/lib/delegations";
|
||||
import type { AcceptDelegationInvitationRequest, AcceptDelegationInvitationResponse, DelegationAcceptPreviewResponse } from "@/utils/wire-types.generated";
|
||||
import { ApiError, apiErrorText, fetchJson, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
|
||||
export default function AcceptDelegationPage() {
|
||||
// useSearchParams needs a Suspense boundary for the static prerender (query-string route).
|
||||
@@ -72,6 +73,8 @@ function AcceptDelegationInner() {
|
||||
method: "POST",
|
||||
body: jsonBody<AcceptDelegationInvitationRequest>({ token }),
|
||||
}));
|
||||
// The caller's own edition may be Pro (MSP) now — the cached feature flags are stale.
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
} catch (err) {
|
||||
setError(explain(err, "Could not accept the invitation."));
|
||||
} finally {
|
||||
|
||||
@@ -48,7 +48,6 @@ function DiagnosisContent() {
|
||||
|
||||
const hasInitialFetch = useRef(false);
|
||||
const lastFetchedSessionId = useRef<string | null>(null);
|
||||
const hasJoinedGroups = useRef(false);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
// Eager tenant hydration lets the session + analysis fetches run in parallel,
|
||||
// so the loading gate must wait for BOTH — otherwise a fast analysis response
|
||||
@@ -178,20 +177,22 @@ function DiagnosisContent() {
|
||||
useEffect(() => {
|
||||
const effectiveTenantId = sessionTenantId || tenantId;
|
||||
if (!sessionId || !isConnected || !effectiveTenantId) return;
|
||||
if (!hasJoinedGroups.current) {
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = true;
|
||||
// Re-fetch after group join to catch any missed during join
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
joinAndCatchUp();
|
||||
}
|
||||
const group = `session-${effectiveTenantId}-${sessionId}`;
|
||||
// Cleared by the cleanup: a run superseded while its join was in flight (typically because
|
||||
// sessionTenantId resolved) must not fetch on behalf of the run that replaced it.
|
||||
let current = true;
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(group);
|
||||
if (!current) return;
|
||||
// Re-fetch after group join to catch any missed during join
|
||||
void Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
void joinAndCatchUp();
|
||||
// Every run that joined leaves in its own cleanup: group membership is reference-counted in
|
||||
// the SignalR layer, and a leave that arrives before the join resolved is settled there.
|
||||
return () => {
|
||||
if (hasJoinedGroups.current && effectiveTenantId) {
|
||||
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = false;
|
||||
}
|
||||
current = false;
|
||||
leaveGroup(group);
|
||||
};
|
||||
}, [sessionId, isConnected, sessionTenantId, tenantId, joinGroup, leaveGroup, fetchEvents, fetchAnalysisResults]);
|
||||
|
||||
|
||||
@@ -670,90 +670,6 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
color: #14b8a6 !important; /* teal-500 */
|
||||
}
|
||||
|
||||
/* ===== LANDING PAGE DARK MODE =====
|
||||
Scoped to .landing-page to avoid affecting the authenticated app.
|
||||
The landing page uses many opacity-variant Tailwind classes (bg-white/80,
|
||||
bg-gray-50/60, etc.) that are not covered by the global solid-color overrides
|
||||
above — those need explicit handling here. */
|
||||
|
||||
/* Page background gradient (from-blue-50 via-indigo-50 to-purple-50) */
|
||||
.dark .landing-page {
|
||||
background: linear-gradient(135deg, #0c1020 0%, #1a1b3b 50%, #0c1020 100%) !important;
|
||||
}
|
||||
|
||||
/* Navigation sticky bar (bg-white/80 backdrop-blur) */
|
||||
.dark .landing-page nav {
|
||||
background-color: rgba(15, 23, 42, 0.9) !important;
|
||||
border-bottom-color: #334155 !important;
|
||||
}
|
||||
|
||||
/* Nav anchor hover (hover:bg-blue-50/50 — not caught by global hover overrides) */
|
||||
.dark .landing-page nav a:hover {
|
||||
background-color: rgba(30, 58, 95, 0.3) !important;
|
||||
}
|
||||
|
||||
/* -- White opacity variants (nav, quick-start cards, step cards, sections, footer) -- */
|
||||
.dark .landing-page [class*="bg-white/"] {
|
||||
background-color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* -- Gray opacity variants -- */
|
||||
.dark .landing-page [class*="bg-gray-50/"] {
|
||||
background-color: #0f172a !important;
|
||||
}
|
||||
.dark .landing-page [class*="bg-gray-200/"] {
|
||||
background-color: #334155 !important;
|
||||
}
|
||||
|
||||
/* -- Colored bg opacity variants (KPI cards, comparison monitor column) -- */
|
||||
.dark .landing-page [class*="bg-blue-50/"] {
|
||||
background-color: rgba(30, 58, 95, 0.4) !important;
|
||||
}
|
||||
.dark .landing-page [class*="bg-green-50/"] {
|
||||
background-color: rgba(20, 51, 42, 0.5) !important;
|
||||
}
|
||||
.dark .landing-page [class*="bg-red-50/"] {
|
||||
background-color: rgba(59, 28, 28, 0.5) !important;
|
||||
}
|
||||
.dark .landing-page [class*="bg-indigo-50/"] {
|
||||
background-color: rgba(30, 27, 75, 0.5) !important;
|
||||
}
|
||||
|
||||
/* -- Gradient overrides for child elements -- */
|
||||
|
||||
/* Pipeline "One pipeline" container: from-white via-blue-50/60 to-indigo-50/60 */
|
||||
.dark .landing-page [class*="from-white"] {
|
||||
--tw-gradient-from: #1e293b !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(30, 41, 59, 0)) !important;
|
||||
}
|
||||
.dark .landing-page [class*="via-blue-50"] {
|
||||
--tw-gradient-to: rgba(30, 58, 95, 0) var(--tw-gradient-to-position) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), #1e3a5f var(--tw-gradient-via-position), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
/* Hero community banner: from-blue-50 via-indigo-50 to-blue-50 */
|
||||
.dark .landing-page [class*="from-blue-50"] {
|
||||
--tw-gradient-from: #1e3a5f !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(30, 58, 95, 0)) !important;
|
||||
}
|
||||
.dark .landing-page [class*="via-indigo-50"] {
|
||||
--tw-gradient-to: rgba(30, 27, 75, 0) var(--tw-gradient-to-position) !important;
|
||||
--tw-gradient-stops: var(--tw-gradient-from), #1e1b4b var(--tw-gradient-via-position), var(--tw-gradient-to) !important;
|
||||
}
|
||||
|
||||
/* -- Border opacity variants -- */
|
||||
.dark .landing-page [class*="border-gray-200/"] {
|
||||
border-color: #334155 !important;
|
||||
}
|
||||
.dark .landing-page [class*="border-blue-300/"] {
|
||||
border-color: rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
/* -- Dot-pulse timeline rings (ring-white → match dark card surface) -- */
|
||||
.dark .landing-page .ring-white {
|
||||
--tw-ring-color: #1e293b !important;
|
||||
}
|
||||
|
||||
/* -- Scrollbar (dark mode) -- */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
@@ -774,48 +690,53 @@ html:hover ::-webkit-scrollbar-thumb {
|
||||
background: #94a3b8 !important;
|
||||
}
|
||||
|
||||
/* ===== LANDING PAGE ANIMATIONS ===== */
|
||||
@keyframes stepReveal {
|
||||
0% {
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
/* ===== AUTH-PENDING OVERLAY (public page opened by a signed-in browser) =====
|
||||
lib/authHint.ts: the inline <head> script adds `auth-pending` to <html> before first paint
|
||||
when the browser carries an auth hint (MSAL account keys, #code=/#state= of a sign-in
|
||||
return); components/landing/AuthGate.tsx drops the class once auth settles or the page
|
||||
navigates away. The content stays mounted but invisible under a backdrop with a spinner,
|
||||
so a returning user sees no landing flash — and the prerendered HTML carries no overlay,
|
||||
so an anonymous visitor sees the page at first paint. */
|
||||
/* Safety net: if nothing lifts the class (a script error, a stalled sign-in), the page shows
|
||||
after AUTH_PENDING_MAX seconds instead of a spinner forever; a normal load lifts it within
|
||||
a second. Keep the value in sync with lib/authHint.ts's contract comment. */
|
||||
html.auth-pending .landing-v2 > * {
|
||||
visibility: hidden;
|
||||
animation: auth-pending-reveal 0s 8s forwards;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.04);
|
||||
transform: scale(1);
|
||||
}
|
||||
2% {
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.16);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
8% {
|
||||
box-shadow: 0 0 0 5px rgba(59, 130, 246, 0.30);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
14.5% {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.18);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
15.87% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.04);
|
||||
transform: scale(1);
|
||||
}
|
||||
html.auth-pending .landing-v2::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: var(--lp-bg);
|
||||
animation: auth-pending-expire 0s 8s forwards;
|
||||
}
|
||||
|
||||
.landing-page .step-card {
|
||||
animation: stepReveal 0.45s ease-out both;
|
||||
html.auth-pending .landing-v2::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 61;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: -1.5rem 0 0 -1.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
border-bottom-color: var(--lp-accent-ink);
|
||||
animation: auth-pending-spin 0.8s linear infinite, auth-pending-expire 0s 8s forwards;
|
||||
}
|
||||
|
||||
.landing-page .dot-pulse {
|
||||
animation: dotPulse 12.6s linear infinite;
|
||||
@keyframes auth-pending-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes auth-pending-reveal {
|
||||
to { visibility: visible; }
|
||||
}
|
||||
@keyframes auth-pending-expire {
|
||||
to { visibility: hidden; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.auth-pending .landing-v2::after { animation: auth-pending-expire 0s 8s forwards; }
|
||||
}
|
||||
|
||||
/* ===== LANDING V2 (scroll reveals, event stream, terminal) ===== */
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSignalR } from '@/contexts/SignalRContext';
|
||||
import { useTenant } from '@/contexts/TenantContext';
|
||||
import { useGlobalAdminUi } from '@/hooks/useGlobalAdminUi';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { HubConnectionState } from '@microsoft/signalr';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
isUrlDetail,
|
||||
@@ -218,20 +218,20 @@ export default function HealthCheckPage() {
|
||||
}
|
||||
}, [tenantId, isConnected, joinGroup]);
|
||||
|
||||
const getConnectionStateLabel = (state: signalR.HubConnectionState) => {
|
||||
const getConnectionStateLabel = (state: HubConnectionState) => {
|
||||
switch (state) {
|
||||
case signalR.HubConnectionState.Connected: return 'Connected';
|
||||
case signalR.HubConnectionState.Connecting: return 'Connecting';
|
||||
case signalR.HubConnectionState.Reconnecting: return 'Reconnecting';
|
||||
case signalR.HubConnectionState.Disconnecting: return 'Disconnecting';
|
||||
case signalR.HubConnectionState.Disconnected: return 'Disconnected';
|
||||
case HubConnectionState.Connected: return 'Connected';
|
||||
case HubConnectionState.Connecting: return 'Connecting';
|
||||
case HubConnectionState.Reconnecting: return 'Reconnecting';
|
||||
case HubConnectionState.Disconnecting: return 'Disconnecting';
|
||||
case HubConnectionState.Disconnected: return 'Disconnected';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectionStatus = (): 'healthy' | 'unhealthy' | 'warning' => {
|
||||
if (connectionState === signalR.HubConnectionState.Connected) return 'healthy';
|
||||
if (connectionState === signalR.HubConnectionState.Reconnecting || connectionState === signalR.HubConnectionState.Connecting) return 'warning';
|
||||
if (connectionState === HubConnectionState.Connected) return 'healthy';
|
||||
if (connectionState === HubConnectionState.Reconnecting || connectionState === HubConnectionState.Connecting) return 'warning';
|
||||
return 'unhealthy';
|
||||
};
|
||||
|
||||
|
||||
@@ -18,10 +18,13 @@ import AppInsightsInit from "../components/AppInsightsInit";
|
||||
import ChunkReloadRecovery from "../components/ChunkReloadRecovery";
|
||||
import { HostRoutingGuard } from "../components/HostRoutingGuard";
|
||||
import { LegacyPathRedirect } from "../components/LegacyPathRedirect";
|
||||
import { DOCS_URL, SITE_URL } from "@/utils/config";
|
||||
import { API_BASE_URL, DOCS_URL, SITE_URL } from "@/utils/config";
|
||||
import { AUTH_HINT_INLINE_SCRIPT } from "@/lib/authHint";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
const apiOrigin = new URL(API_BASE_URL).origin;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
@@ -174,8 +177,17 @@ export default function RootLayout({
|
||||
// data-scroll-behavior: Next 16 no longer auto-suppresses CSS smooth-scroll
|
||||
// during route navigation; without the opt-in, globals.css's
|
||||
// `scroll-behavior: smooth` would animate every route change to top.
|
||||
// suppressHydrationWarning on <html> only: lib/authHint.ts's inline <head> script may add the
|
||||
// `auth-pending` class before React hydrates; the class is meant to survive hydration (React
|
||||
// leaves attribute mismatches in place) and must not be reported as one.
|
||||
return (
|
||||
<html lang="en" data-scroll-behavior="smooth">
|
||||
<html lang="en" data-scroll-behavior="smooth" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Runs before first paint: hides the landing for browsers that will leave it (lib/authHint.ts). */}
|
||||
<script dangerouslySetInnerHTML={{ __html: AUTH_HINT_INLINE_SCRIPT }} />
|
||||
{/* The first API call (auth/me) otherwise pays DNS+TCP+TLS only once the JS runs. */}
|
||||
<link rel="preconnect" href={apiOrigin} crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { EnrollmentEvent } from "@/types";
|
||||
import { api } from "@/lib/api";
|
||||
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import { NotificationType } from "@/contexts/NotificationContext";
|
||||
import type { QueueSessionActionRequest, TenantConfiguration } from "@/utils/wire-types.generated";
|
||||
import {
|
||||
@@ -145,6 +146,7 @@ export default function CollectLogsButton({
|
||||
method: "PUT",
|
||||
body: jsonBody<TenantConfiguration>(updated),
|
||||
});
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
|
||||
onDiagnosticsConfigured();
|
||||
setShowQuickConfig(false);
|
||||
|
||||
@@ -32,14 +32,21 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
|
||||
const [showIpv6, setShowIpv6] = useState<Record<number, boolean>>({});
|
||||
const [showOobeModal, setShowOobeModal] = useState(false);
|
||||
|
||||
const getEventData = <T extends Record<string, unknown> = Record<string, unknown>>(eventType: string): T | null => {
|
||||
const matchingEvents = events.filter(e => e.eventType === eventType);
|
||||
if (matchingEvents.length === 0) return null;
|
||||
// Highest sequence, not "last element": the merged event list is not guaranteed to be
|
||||
// sequence-sorted when handed to this card, and periodic snapshots must show the latest.
|
||||
const latestEvent = matchingEvents.reduce((best, e) => (e.sequence > best.sequence ? e : best), matchingEvents[0]);
|
||||
return (latestEvent?.data as T | undefined) ?? null;
|
||||
};
|
||||
// Latest event per type in one pass over the list (the card reads ~24 types per render).
|
||||
// Highest sequence, not "last element": the merged event list is not guaranteed to be
|
||||
// sequence-sorted when handed to this card, and periodic snapshots must show the latest;
|
||||
// a tie keeps the first one seen.
|
||||
const latestEventByType = useMemo(() => {
|
||||
const byType = new Map<string, EnrollmentEvent>();
|
||||
for (const e of events) {
|
||||
const best = byType.get(e.eventType);
|
||||
if (!best || e.sequence > best.sequence) byType.set(e.eventType, e);
|
||||
}
|
||||
return byType;
|
||||
}, [events]);
|
||||
|
||||
const getEventData = <T extends Record<string, unknown> = Record<string, unknown>>(eventType: string): T | null =>
|
||||
(latestEventByType.get(eventType)?.data as T | undefined) ?? null;
|
||||
|
||||
const isIpv6 = (ip: string): boolean => {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useDeferredValue } from "react";
|
||||
import { EnrollmentEvent, Session } from "@/types";
|
||||
import { normalizeEventDataForDisplay, shortenBuildHashInMessage } from "../utils/eventHelpers";
|
||||
import { buildEventSearchMatcher, formatEventSearchTerm, parseEventSearchQuery } from "../utils/eventSearchQuery";
|
||||
@@ -67,12 +67,15 @@ export default function EventTimeline({
|
||||
}: EventTimelineProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [rawMode, setRawMode] = useState(false);
|
||||
// The input stays on the immediate value; everything derived from the query (matcher, counter,
|
||||
// visible rows) follows the deferred one, so a keystroke never waits for the per-phase filter pass.
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
|
||||
// Terms are AND-ed, a leading minus excludes, key=value restricts — see utils/eventSearchQuery.ts.
|
||||
const matchesSearch = useMemo(() => buildEventSearchMatcher(searchQuery), [searchQuery]);
|
||||
const matchesSearch = useMemo(() => buildEventSearchMatcher(deferredSearchQuery), [deferredSearchQuery]);
|
||||
const excludedTerms = useMemo(
|
||||
() => parseEventSearchQuery(searchQuery).exclude.map(formatEventSearchTerm),
|
||||
[searchQuery],
|
||||
() => parseEventSearchQuery(deferredSearchQuery).exclude.map(formatEventSearchTerm),
|
||||
[deferredSearchQuery],
|
||||
);
|
||||
|
||||
// The counter has to follow the search, otherwise excluding event types leaves it
|
||||
@@ -82,11 +85,13 @@ export default function EventTimeline({
|
||||
[filteredEvents, matchesSearch],
|
||||
);
|
||||
|
||||
// Only the raw view reads this flat list; the phase view must not pay for the sort.
|
||||
const sortedBySequence = useMemo(() => {
|
||||
if (!rawMode) return [];
|
||||
let filtered = events.filter(e => severityFilters.has(e.severity));
|
||||
if (matchesSearch) filtered = filtered.filter(matchesSearch);
|
||||
return filtered.sort((a, b) => a.sequence - b.sequence);
|
||||
}, [events, severityFilters, matchesSearch]);
|
||||
}, [events, severityFilters, matchesSearch, rawMode]);
|
||||
|
||||
// Ground-truth clock steps recorded by the agent's system timeline watcher — lets the
|
||||
// TimeJumpBadge name a backwards display step as an actual OS clock set instead of an
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { EnrollmentEvent } from "@/types";
|
||||
import { resolvePhaseLayout } from "../utils/phaseConstants";
|
||||
import { summarizeEventsByPhase } from "./phaseEventSummary";
|
||||
|
||||
interface PhaseTimelineProps {
|
||||
currentPhase: number;
|
||||
@@ -18,96 +19,22 @@ interface PhaseTimelineProps {
|
||||
export default function PhaseTimeline({ currentPhase, completedPhases, events = [], sessionStatus, enrollmentType, isPreProvisioned, isSkipUserStatusPage, onPhaseClick }: PhaseTimelineProps) {
|
||||
const { phases, useSkipLayout, skippedPhaseIds } = resolvePhaseLayout({ enrollmentType, isSkipUserStatusPage, isPreProvisioned });
|
||||
|
||||
// One pass over the list per change of `events`: first/last timestamps, live activity and the
|
||||
// WhiteGlove signals per phase. Every per-phase helper below is a lookup into this.
|
||||
const phaseSummary = useMemo(() => summarizeEventsByPhase(events), [events]);
|
||||
|
||||
// Derive current activity for the active phase from events
|
||||
const getCurrentActivity = (phaseId: number): string | null => {
|
||||
if (sessionStatus === 'Succeeded' || sessionStatus === 'Failed') return null;
|
||||
if (phaseId !== effectiveCurrentPhase) return null;
|
||||
|
||||
// Event types that are not useful as activity status display
|
||||
const ignoredEventTypes = new Set([
|
||||
"performance_snapshot",
|
||||
"system_info",
|
||||
"network_info",
|
||||
]);
|
||||
|
||||
// Get events for this phase, sorted by sequence desc, excluding noise
|
||||
const phaseEvents = events
|
||||
.filter(e => e.phase === phaseId && !ignoredEventTypes.has(e.eventType))
|
||||
.sort((a, b) => b.sequence - a.sequence);
|
||||
|
||||
if (phaseEvents.length === 0) return null;
|
||||
|
||||
// Check for app_tracking_summary events (new strategic events)
|
||||
const trackingSummary = phaseEvents.find(e => e.eventType === "app_tracking_summary");
|
||||
if (trackingSummary?.data) {
|
||||
// Counters are serialized as strings on the wire.
|
||||
const d = trackingSummary.data as Record<string, string | undefined>;
|
||||
const completed = parseInt(d.completedApps ?? d.appsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.totalApps ?? "0", 10);
|
||||
if (total > 0) {
|
||||
return `Installing apps (${completed}/${total})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for esp_ui_state events (legacy) to show app install progress
|
||||
const espState = phaseEvents.find(e => e.eventType === "esp_ui_state");
|
||||
if (espState?.data) {
|
||||
const d = espState.data as Record<string, string | undefined>;
|
||||
const completed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
|
||||
const currentItem = d.current_item ?? d.currentItem ?? d.status_text ?? d.statusText;
|
||||
if (total > 0 && currentItem) {
|
||||
return `${currentItem} (${completed}/${total})`;
|
||||
}
|
||||
if (total > 0) {
|
||||
return `Installing apps (${completed}/${total})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for app install events (new strategic events)
|
||||
const appInstallEvt = phaseEvents.find(e =>
|
||||
e.eventType === "app_download_started" || e.eventType === "app_install_started"
|
||||
);
|
||||
if (appInstallEvt?.data) {
|
||||
const appName = appInstallEvt.data.appName ?? appInstallEvt.data.appId ?? "app";
|
||||
if (appInstallEvt.eventType === "app_download_started") return `Downloading ${appName}`;
|
||||
return `Installing ${appName}`;
|
||||
}
|
||||
|
||||
// Check for download_progress to show active download (legacy)
|
||||
const downloadEvt = phaseEvents.find(e => e.eventType === "download_progress");
|
||||
if (downloadEvt?.data) {
|
||||
const d = downloadEvt.data as Record<string, string | undefined>;
|
||||
const appName = d.app_name ?? d.appName ?? "content";
|
||||
const pct = d.bytes_total && d.bytes_downloaded
|
||||
? Math.round((parseInt(d.bytes_downloaded) / parseInt(d.bytes_total)) * 100)
|
||||
: null;
|
||||
if (pct !== null) return `Downloading ${appName} - ${pct}%`;
|
||||
return `Downloading ${appName}`;
|
||||
}
|
||||
|
||||
// Fall back to latest event message
|
||||
const latest = phaseEvents[0];
|
||||
if (latest && latest.message && latest.message.length < 80) {
|
||||
return latest.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
return phaseSummary.byPhase.get(phaseId)?.currentActivity ?? null;
|
||||
};
|
||||
|
||||
// Helper: get first/last event timestamp for a phase
|
||||
const getFirstEventTime = (phaseId: number): number | null => {
|
||||
const ts = events
|
||||
.filter(e => e.phase === phaseId)
|
||||
.map(e => new Date(e.timestamp).getTime());
|
||||
return ts.length > 0 ? Math.min(...ts) : null;
|
||||
};
|
||||
const getLastEventTime = (phaseId: number): number | null => {
|
||||
const ts = events
|
||||
.filter(e => e.phase === phaseId)
|
||||
.map(e => new Date(e.timestamp).getTime());
|
||||
return ts.length > 0 ? Math.max(...ts) : null;
|
||||
};
|
||||
const getFirstEventTime = (phaseId: number): number | null =>
|
||||
phaseSummary.byPhase.get(phaseId)?.firstMs ?? null;
|
||||
const getLastEventTime = (phaseId: number): number | null =>
|
||||
phaseSummary.byPhase.get(phaseId)?.lastMs ?? null;
|
||||
|
||||
const formatDuration = (ms: number): string | null => {
|
||||
const durationSec = Math.round(ms / 1000);
|
||||
@@ -209,8 +136,8 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
|
||||
};
|
||||
|
||||
// WhiteGlove signal detection: check if pre-provisioning completed and/or user enrollment resumed
|
||||
const hasWhiteGloveComplete = isPreProvisioned && events.some(e => e.eventType === 'whiteglove_complete');
|
||||
const hasWhiteGloveResumed = isPreProvisioned && events.some(e => e.eventType === 'whiteglove_resumed');
|
||||
const hasWhiteGloveComplete = isPreProvisioned && phaseSummary.hasWhiteGloveComplete;
|
||||
const hasWhiteGloveResumed = isPreProvisioned && phaseSummary.hasWhiteGloveResumed;
|
||||
|
||||
// Helper: get display-order index of a phase ID in the phases array.
|
||||
// Handles non-sequential phase IDs (e.g. SkipUser: 0,1,2,3,6,5,7).
|
||||
@@ -224,13 +151,10 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
|
||||
// background events with their phase tag (e.g. esp_phase_changed → AccountSetup) must
|
||||
// not pull the active marker into a phase the policy says is skipped.
|
||||
const maxEventPhase = (() => {
|
||||
const realPhases = events
|
||||
.filter(e => e.phase >= 0 && e.phase <= 7 && !skippedPhaseIds.has(e.phase))
|
||||
.map(e => e.phase);
|
||||
|
||||
let maxPhase = -1;
|
||||
let maxIdx = -1;
|
||||
for (const p of realPhases) {
|
||||
for (const p of phaseSummary.byPhase.keys()) {
|
||||
if (p < 0 || p > 7 || skippedPhaseIds.has(p)) continue;
|
||||
const idx = phaseIndex(p);
|
||||
if (idx > maxIdx) {
|
||||
maxIdx = idx;
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { summarizeEventsByPhase } from "../phaseEventSummary";
|
||||
import type { EnrollmentEvent } from "@/types";
|
||||
|
||||
// Only the fields the summary reads matter; the rest satisfy the wire type.
|
||||
function ev(phase: number, sequence: number, eventType: string, extra: Partial<EnrollmentEvent> = {}): EnrollmentEvent {
|
||||
return {
|
||||
eventId: `e-${sequence}`,
|
||||
timestamp: "2026-09-23T10:00:00Z",
|
||||
eventType,
|
||||
severity: "Info",
|
||||
source: "Test",
|
||||
phase,
|
||||
phaseName: "",
|
||||
message: "",
|
||||
data: {},
|
||||
sequence,
|
||||
timestampClamped: false,
|
||||
rowKey: "",
|
||||
...extra,
|
||||
} as EnrollmentEvent;
|
||||
}
|
||||
|
||||
describe("summarizeEventsByPhase", () => {
|
||||
it("returns an empty summary for no events", () => {
|
||||
const s = summarizeEventsByPhase([]);
|
||||
expect(s.byPhase.size).toBe(0);
|
||||
expect(s.hasWhiteGloveComplete).toBe(false);
|
||||
expect(s.hasWhiteGloveResumed).toBe(false);
|
||||
});
|
||||
|
||||
it("takes first/last per phase from the timestamps regardless of list order", () => {
|
||||
const s = summarizeEventsByPhase([
|
||||
ev(3, 2, "x", { timestamp: "2026-09-23T10:05:00Z" }),
|
||||
ev(3, 1, "x", { timestamp: "2026-09-23T10:01:00Z" }),
|
||||
ev(3, 3, "x", { timestamp: "2026-09-23T10:03:00Z" }),
|
||||
ev(6, 4, "x", { timestamp: "2026-09-23T10:09:00Z" }),
|
||||
]);
|
||||
expect(s.byPhase.get(3)).toMatchObject({
|
||||
firstMs: Date.parse("2026-09-23T10:01:00Z"),
|
||||
lastMs: Date.parse("2026-09-23T10:05:00Z"),
|
||||
count: 3,
|
||||
});
|
||||
expect(s.byPhase.get(6)).toMatchObject({
|
||||
firstMs: Date.parse("2026-09-23T10:09:00Z"),
|
||||
lastMs: Date.parse("2026-09-23T10:09:00Z"),
|
||||
count: 1,
|
||||
});
|
||||
expect(s.byPhase.has(4)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives the activity from the highest-sequence event of a kind, not from list order", () => {
|
||||
const s = summarizeEventsByPhase([
|
||||
ev(3, 9, "app_install_started", { data: { appName: "Later" } }),
|
||||
ev(3, 5, "app_download_started", { data: { appName: "Earlier" } }),
|
||||
]);
|
||||
expect(s.byPhase.get(3)?.currentActivity).toBe("Installing Later");
|
||||
});
|
||||
|
||||
it("keeps the first event seen on a sequence tie (stable sort + find semantics)", () => {
|
||||
const s = summarizeEventsByPhase([
|
||||
ev(3, 5, "download_progress", { data: { app_name: "First" } }),
|
||||
ev(3, 5, "download_progress", { data: { app_name: "Second" } }),
|
||||
]);
|
||||
expect(s.byPhase.get(3)?.currentActivity).toBe("Downloading First");
|
||||
});
|
||||
|
||||
it("ranks the activity sources by kind: tracking summary, then ESP state, then app events", () => {
|
||||
const s = summarizeEventsByPhase([
|
||||
ev(3, 1, "app_tracking_summary", { data: { completedApps: "2", totalApps: "5" } }),
|
||||
ev(3, 2, "esp_ui_state", { data: { blocking_apps_completed: "1", blocking_apps_total: "4", current_item: "Installing App" } }),
|
||||
ev(3, 3, "app_install_started", { data: { appName: "App" } }),
|
||||
]);
|
||||
expect(s.byPhase.get(3)?.currentActivity).toBe("Installing apps (2/5)");
|
||||
|
||||
const esp = summarizeEventsByPhase([
|
||||
ev(3, 1, "esp_ui_state", { data: { blockingAppsCompleted: "1", blockingAppsTotal: "4", currentItem: "Installing App" } }),
|
||||
ev(3, 2, "app_install_started", { data: { appName: "App" } }),
|
||||
]);
|
||||
expect(esp.byPhase.get(3)?.currentActivity).toBe("Installing App (1/4)");
|
||||
});
|
||||
|
||||
it("falls back to the latest short message and ignores snapshot noise", () => {
|
||||
const s = summarizeEventsByPhase([
|
||||
ev(6, 1, "esp_phase_changed", { message: "Finalizing" }),
|
||||
ev(6, 2, "performance_snapshot", { message: "cpu 12%" }),
|
||||
]);
|
||||
expect(s.byPhase.get(6)).toMatchObject({ count: 2, currentActivity: "Finalizing" });
|
||||
|
||||
const noiseOnly = summarizeEventsByPhase([ev(6, 1, "performance_snapshot", { message: "cpu 12%" })]);
|
||||
expect(noiseOnly.byPhase.get(6)?.currentActivity).toBeNull();
|
||||
|
||||
const longMessage = summarizeEventsByPhase([ev(6, 1, "x", { message: "m".repeat(80) })]);
|
||||
expect(longMessage.byPhase.get(6)?.currentActivity).toBeNull();
|
||||
});
|
||||
|
||||
it("reports download progress with a percentage when both byte counters are present", () => {
|
||||
const s = summarizeEventsByPhase([
|
||||
ev(3, 1, "download_progress", { data: { app_name: "Pkg", bytes_downloaded: "50", bytes_total: "200" } }),
|
||||
]);
|
||||
expect(s.byPhase.get(3)?.currentActivity).toBe("Downloading Pkg - 25%");
|
||||
});
|
||||
|
||||
it("flags the WhiteGlove signals wherever they appear", () => {
|
||||
const s = summarizeEventsByPhase([ev(3, 1, "whiteglove_complete"), ev(7, 2, "agent_shutdown")]);
|
||||
expect(s.hasWhiteGloveComplete).toBe(true);
|
||||
expect(s.hasWhiteGloveResumed).toBe(false);
|
||||
expect(summarizeEventsByPhase([ev(4, 1, "whiteglove_resumed")]).hasWhiteGloveResumed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// One pass over a session's events for the phase timeline: per phase the first and last event
|
||||
// time (phase durations) and the live-activity text, plus the WhiteGlove signals. The timeline
|
||||
// renders eight phase nodes, and each one used to re-scan the whole list several times per render.
|
||||
|
||||
import type { EnrollmentEvent } from "@/types";
|
||||
|
||||
// Event types that are not useful as activity status display
|
||||
const ACTIVITY_IGNORED_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
"performance_snapshot",
|
||||
"system_info",
|
||||
"network_info",
|
||||
]);
|
||||
|
||||
export interface PhaseEventSummary {
|
||||
/** Earliest event timestamp of the phase, ms since epoch. */
|
||||
firstMs: number;
|
||||
/** Latest event timestamp of the phase, ms since epoch. */
|
||||
lastMs: number;
|
||||
count: number;
|
||||
/** What the phase is doing, read from its latest events; null when nothing describes it. */
|
||||
currentActivity: string | null;
|
||||
}
|
||||
|
||||
export interface EventPhaseSummary {
|
||||
byPhase: ReadonlyMap<number, PhaseEventSummary>;
|
||||
hasWhiteGloveComplete: boolean;
|
||||
hasWhiteGloveResumed: boolean;
|
||||
}
|
||||
|
||||
// "Latest" is the highest sequence; on a tie the first one in list order wins — the same event
|
||||
// the former sort-by-sequence-desc (stable) + find() picked.
|
||||
interface PhaseAccumulator {
|
||||
firstMs: number;
|
||||
lastMs: number;
|
||||
count: number;
|
||||
/** Latest event that is not activity noise — the fallback message. */
|
||||
latest: EnrollmentEvent | null;
|
||||
trackingSummary: EnrollmentEvent | null;
|
||||
espState: EnrollmentEvent | null;
|
||||
/** app_download_started or app_install_started, whichever is later. */
|
||||
appInstall: EnrollmentEvent | null;
|
||||
download: EnrollmentEvent | null;
|
||||
}
|
||||
|
||||
function later(current: EnrollmentEvent | null, candidate: EnrollmentEvent): EnrollmentEvent {
|
||||
return current === null || candidate.sequence > current.sequence ? candidate : current;
|
||||
}
|
||||
|
||||
// Precedence is by kind, not by sequence: tracking summary, ESP UI state, app download/install
|
||||
// start, download progress, then the latest short message.
|
||||
function deriveActivity(p: PhaseAccumulator): string | null {
|
||||
if (p.latest === null) return null;
|
||||
|
||||
// Check for app_tracking_summary events (new strategic events)
|
||||
if (p.trackingSummary?.data) {
|
||||
// Counters are serialized as strings on the wire.
|
||||
const d = p.trackingSummary.data as Record<string, string | undefined>;
|
||||
const completed = parseInt(d.completedApps ?? d.appsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.totalApps ?? "0", 10);
|
||||
if (total > 0) {
|
||||
return `Installing apps (${completed}/${total})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for esp_ui_state events (legacy) to show app install progress
|
||||
if (p.espState?.data) {
|
||||
const d = p.espState.data as Record<string, string | undefined>;
|
||||
const completed = parseInt(d.blocking_apps_completed ?? d.blockingAppsCompleted ?? "0", 10);
|
||||
const total = parseInt(d.blocking_apps_total ?? d.blockingAppsTotal ?? "0", 10);
|
||||
const currentItem = d.current_item ?? d.currentItem ?? d.status_text ?? d.statusText;
|
||||
if (total > 0 && currentItem) {
|
||||
return `${currentItem} (${completed}/${total})`;
|
||||
}
|
||||
if (total > 0) {
|
||||
return `Installing apps (${completed}/${total})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for app install events (new strategic events)
|
||||
if (p.appInstall?.data) {
|
||||
const appName = p.appInstall.data.appName ?? p.appInstall.data.appId ?? "app";
|
||||
if (p.appInstall.eventType === "app_download_started") return `Downloading ${appName}`;
|
||||
return `Installing ${appName}`;
|
||||
}
|
||||
|
||||
// Check for download_progress to show active download (legacy)
|
||||
if (p.download?.data) {
|
||||
const d = p.download.data as Record<string, string | undefined>;
|
||||
const appName = d.app_name ?? d.appName ?? "content";
|
||||
const pct = d.bytes_total && d.bytes_downloaded
|
||||
? Math.round((parseInt(d.bytes_downloaded) / parseInt(d.bytes_total)) * 100)
|
||||
: null;
|
||||
if (pct !== null) return `Downloading ${appName} - ${pct}%`;
|
||||
return `Downloading ${appName}`;
|
||||
}
|
||||
|
||||
// Fall back to latest event message
|
||||
if (p.latest.message && p.latest.message.length < 80) {
|
||||
return p.latest.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function summarizeEventsByPhase(events: readonly EnrollmentEvent[]): EventPhaseSummary {
|
||||
const accumulators = new Map<number, PhaseAccumulator>();
|
||||
let hasWhiteGloveComplete = false;
|
||||
let hasWhiteGloveResumed = false;
|
||||
|
||||
for (const e of events) {
|
||||
if (e.eventType === "whiteglove_complete") hasWhiteGloveComplete = true;
|
||||
else if (e.eventType === "whiteglove_resumed") hasWhiteGloveResumed = true;
|
||||
|
||||
const t = new Date(e.timestamp).getTime();
|
||||
let p = accumulators.get(e.phase);
|
||||
if (p === undefined) {
|
||||
p = { firstMs: t, lastMs: t, count: 0, latest: null, trackingSummary: null, espState: null, appInstall: null, download: null };
|
||||
accumulators.set(e.phase, p);
|
||||
} else {
|
||||
if (t < p.firstMs) p.firstMs = t;
|
||||
if (t > p.lastMs) p.lastMs = t;
|
||||
}
|
||||
p.count++;
|
||||
|
||||
if (ACTIVITY_IGNORED_EVENT_TYPES.has(e.eventType)) continue;
|
||||
p.latest = later(p.latest, e);
|
||||
switch (e.eventType) {
|
||||
case "app_tracking_summary": p.trackingSummary = later(p.trackingSummary, e); break;
|
||||
case "esp_ui_state": p.espState = later(p.espState, e); break;
|
||||
case "app_download_started":
|
||||
case "app_install_started": p.appInstall = later(p.appInstall, e); break;
|
||||
case "download_progress": p.download = later(p.download, e); break;
|
||||
}
|
||||
}
|
||||
|
||||
const byPhase = new Map<number, PhaseEventSummary>();
|
||||
for (const [phase, p] of accumulators) {
|
||||
byPhase.set(phase, { firstMs: p.firstMs, lastMs: p.lastMs, count: p.count, currentActivity: deriveActivity(p) });
|
||||
}
|
||||
return { byPhase, hasWhiteGloveComplete, hasWhiteGloveResumed };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applySessionUpdate } from "../useSessionSignalR";
|
||||
import { makeSession } from "@/test/factories";
|
||||
|
||||
/**
|
||||
* applySessionUpdate gates the session-detail re-render on "newevents" pushes: a delta that
|
||||
* repeats what the page already shows must return the SAME session object so React bails out,
|
||||
* while any real change must produce a new object (live views stay live).
|
||||
*/
|
||||
describe("applySessionUpdate", () => {
|
||||
it("returns the same object when every updated field already holds that value", () => {
|
||||
const prev = makeSession({ status: "InProgress", currentPhase: 2, eventCount: 40 });
|
||||
expect(applySessionUpdate(prev, { status: "InProgress", currentPhase: 2, eventCount: 40 })).toBe(prev);
|
||||
});
|
||||
|
||||
it("returns the same object for an empty delta", () => {
|
||||
const prev = makeSession();
|
||||
expect(applySessionUpdate(prev, {})).toBe(prev);
|
||||
});
|
||||
|
||||
it("returns a new object with the delta applied when one field changed", () => {
|
||||
const prev = makeSession({ status: "InProgress", currentPhase: 2, eventCount: 40 });
|
||||
const next = applySessionUpdate(prev, { status: "InProgress", currentPhase: 3, eventCount: 40 });
|
||||
expect(next).not.toBe(prev);
|
||||
expect(next.currentPhase).toBe(3);
|
||||
expect(next.status).toBe("InProgress");
|
||||
expect(prev.currentPhase).toBe(2); // never mutated
|
||||
});
|
||||
|
||||
it("treats a field cleared to undefined as unchanged only when it was already unset", () => {
|
||||
const prev = makeSession({ failureReason: "" });
|
||||
expect(applySessionUpdate(prev, { failureReason: "" })).toBe(prev);
|
||||
const cleared = applySessionUpdate(prev, { failureReason: undefined });
|
||||
expect(cleared).not.toBe(prev);
|
||||
expect(cleared.failureReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps fields the delta does not mention", () => {
|
||||
const prev = makeSession({ deviceName: "DEVICE-A", status: "InProgress" });
|
||||
const next = applySessionUpdate(prev, { status: "Succeeded" });
|
||||
expect(next.deviceName).toBe("DEVICE-A");
|
||||
expect(next.status).toBe("Succeeded");
|
||||
});
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldScheduleFetch } from "../useSessionEvents";
|
||||
|
||||
/**
|
||||
* The policy behind the session page's "subscribe-then-fetch": the catch-up after the SignalR
|
||||
* group join must not queue a second full page walk behind one that is still running, while a
|
||||
* live signal (a burst after the walk finished, or during it) is always scheduled.
|
||||
*/
|
||||
describe("shouldScheduleFetch", () => {
|
||||
it("always schedules a live signal, walk in flight or not", () => {
|
||||
expect(shouldScheduleFetch("signal", false)).toBe(true);
|
||||
expect(shouldScheduleFetch("signal", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs the join catch-up only when no walk is in flight", () => {
|
||||
expect(shouldScheduleFetch("join-catch-up", false)).toBe(true);
|
||||
expect(shouldScheduleFetch("join-catch-up", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,22 @@ const EVENT_REFRESH_MAX_WAIT_MS = 1_000;
|
||||
// drop them until the user manually refreshes. One nachfass-fetch covers the window.
|
||||
const TERMINAL_TRAILING_REFETCH_DELAY_MS = 12_000;
|
||||
|
||||
/** Why a timeline refetch is requested; decides whether a walk already in flight makes it redundant. */
|
||||
export type FetchEventsReason = "signal" | "join-catch-up";
|
||||
|
||||
/**
|
||||
* A live SignalR signal always schedules a refetch: a walk already in flight queues one
|
||||
* follow-up (see fetchEvents), because the signal may concern events past the page the walk
|
||||
* is reading. The one-shot catch-up after the SignalR group join only exists for events whose
|
||||
* signal was sent before the join completed; a walk still in flight reads those on its
|
||||
* remaining pages anyway, so the catch-up is dropped instead of queueing a second full walk
|
||||
* behind the first. A burst that arrives after the walk finished comes in as signals and is
|
||||
* fetched regardless.
|
||||
*/
|
||||
export function shouldScheduleFetch(reason: FetchEventsReason, fetchInFlight: boolean): boolean {
|
||||
return reason === "signal" || !fetchInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable event key — mirrors the React `key` used by the timeline rows so that
|
||||
* merge identity and React reconciliation identity agree.
|
||||
@@ -85,8 +101,11 @@ export interface UseSessionEventsReturn {
|
||||
events: EnrollmentEvent[];
|
||||
setEvents: React.Dispatch<React.SetStateAction<EnrollmentEvent[]>>;
|
||||
fetchEvents: () => Promise<void>;
|
||||
/** Coalesced live refetch — call on every SignalR signal, the scheduler decides when to fetch. */
|
||||
scheduleFetchEvents: () => void;
|
||||
/**
|
||||
* Coalesced live refetch — call on every SignalR signal, the scheduler decides when to fetch.
|
||||
* The "join-catch-up" reason is dropped while a walk is in flight (see shouldScheduleFetch).
|
||||
*/
|
||||
scheduleFetchEvents: (reason?: FetchEventsReason) => void;
|
||||
/**
|
||||
* True while a Pattern-A eager-fetch is still streaming pages after the first
|
||||
* batch has rendered. Surfaces a "loading more events…" indicator on the
|
||||
@@ -255,7 +274,8 @@ export function useSessionEvents({
|
||||
fetchEventsRef.current = fetchEvents;
|
||||
}, [fetchEvents]);
|
||||
|
||||
const scheduleFetchEvents = useCallback(() => {
|
||||
const scheduleFetchEvents = useCallback((reason: FetchEventsReason = "signal") => {
|
||||
if (!shouldScheduleFetch(reason, fetchEventsInFlight.current)) return;
|
||||
eventRefreshScheduler.current ??= createBurstScheduler(
|
||||
() => { void fetchEventsRef.current(); },
|
||||
{ trailingMs: EVENT_REFRESH_TRAILING_MS, maxWaitMs: EVENT_REFRESH_MAX_WAIT_MS },
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Session, RuleResult } from "@/types";
|
||||
import { isTerminalStatus } from "@/utils/sessionStatus";
|
||||
import type { SignalRMessageName } from "@/lib/signalrMessages";
|
||||
import type { FetchEventsReason } from "./useSessionEvents";
|
||||
|
||||
interface SignalRApi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -25,13 +26,26 @@ interface UseSessionSignalRParams {
|
||||
sessionRef: React.RefObject<Session | null>;
|
||||
resolveEffectiveTenantId: () => string | null;
|
||||
signalR: SignalRApi;
|
||||
scheduleFetchEvents: () => void;
|
||||
scheduleFetchEvents: (reason?: FetchEventsReason) => void;
|
||||
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
|
||||
setSessionTenantId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
fetchAnalysisResults: (reanalyze?: boolean) => Promise<void>;
|
||||
fetchVulnerabilityReport: (rescan?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a "newevents" session delta. Returns `prev` itself when every field of the update
|
||||
* already holds the same value, so React bails out of the re-render: a live session pushes a
|
||||
* delta with every agent batch, and most of them repeat the status and phase the page already
|
||||
* shows. Shallow comparison only — a nested object with a fresh identity counts as a change
|
||||
* (a pure derivation, recomputed on every push; nothing is cached across pushes).
|
||||
*/
|
||||
export function applySessionUpdate(prev: Session, update: Partial<Session>): Session {
|
||||
const keys = Object.keys(update) as (keyof Session)[];
|
||||
if (keys.every((key) => Object.is(prev[key], update[key]))) return prev;
|
||||
return { ...prev, ...update };
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the session detail page's SignalR integration:
|
||||
* - joins tenant + session groups using subscribe-then-fetch pattern
|
||||
@@ -55,7 +69,6 @@ export function useSessionSignalR({
|
||||
fetchVulnerabilityReport,
|
||||
}: UseSessionSignalRParams): void {
|
||||
const { on, off, isConnected, joinGroup, leaveGroup } = signalR;
|
||||
const hasJoinedGroups = useRef(false);
|
||||
|
||||
// Join SignalR groups when connected (for multi-tenancy and cost optimization)
|
||||
// Uses "subscribe-then-fetch" pattern: join groups first, then re-fetch events
|
||||
@@ -64,31 +77,32 @@ export function useSessionSignalR({
|
||||
const effectiveTenantId = resolveEffectiveTenantId();
|
||||
if (!sessionId || !isConnected || !effectiveTenantId) return;
|
||||
|
||||
if (!hasJoinedGroups.current) {
|
||||
const tenantGroupName = `tenant-${effectiveTenantId}`;
|
||||
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
|
||||
const tenantGroupName = `tenant-${effectiveTenantId}`;
|
||||
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
|
||||
// Cleared by the cleanup: a run superseded while its joins were in flight (typically because
|
||||
// sessionTenantId resolved) must not schedule a catch-up of its own.
|
||||
let current = true;
|
||||
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(tenantGroupName);
|
||||
await joinGroup(sessionGroupName);
|
||||
hasJoinedGroups.current = true;
|
||||
const joinAndCatchUp = async () => {
|
||||
await Promise.all([joinGroup(tenantGroupName), joinGroup(sessionGroupName)]);
|
||||
if (!current) return;
|
||||
|
||||
// Re-fetch events after group join to catch any SignalR messages
|
||||
// that were sent before the client joined the session group.
|
||||
// The frontend deduplicates by eventId, so no duplicates.
|
||||
scheduleFetchEvents();
|
||||
};
|
||||
joinAndCatchUp();
|
||||
}
|
||||
// Re-fetch events after group join to catch any SignalR messages that were sent
|
||||
// before the client joined the session group — unless a fetch is still walking the
|
||||
// pages, which reads them anyway (see shouldScheduleFetch in useSessionEvents).
|
||||
// The frontend deduplicates by eventId, so no duplicates.
|
||||
scheduleFetchEvents("join-catch-up");
|
||||
};
|
||||
void joinAndCatchUp();
|
||||
|
||||
// Every run that joined leaves in its own cleanup, whether or not its joins have resolved:
|
||||
// the SignalR layer counts references per group and settles a leave that arrives before the
|
||||
// join resolved. (A "joined" flag set after the await used to skip this leave when the effect
|
||||
// re-ran mid-join, and the re-run then took a second reference nobody released.)
|
||||
return () => {
|
||||
if (hasJoinedGroups.current && effectiveTenantId) {
|
||||
const tenantGroupName = `tenant-${effectiveTenantId}`;
|
||||
const sessionGroupName = `session-${effectiveTenantId}-${sessionId}`;
|
||||
leaveGroup(tenantGroupName);
|
||||
leaveGroup(sessionGroupName);
|
||||
hasJoinedGroups.current = false;
|
||||
}
|
||||
current = false;
|
||||
leaveGroup(tenantGroupName);
|
||||
leaveGroup(sessionGroupName);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, isConnected, sessionTenantId, tenantId, sessionTenantIdFromSession, globalAdminMode]);
|
||||
@@ -130,8 +144,9 @@ export function useSessionSignalR({
|
||||
const handleNewEvents = (data: { sessionId: string; tenantId: string; sessionUpdate?: Partial<Session> }) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
|
||||
if (data.sessionUpdate) {
|
||||
setSession(prev => prev ? { ...prev, ...data.sessionUpdate } : prev);
|
||||
const update = data.sessionUpdate;
|
||||
if (update) {
|
||||
setSession(prev => prev ? applySessionUpdate(prev, update) : prev);
|
||||
}
|
||||
if (data.tenantId) {
|
||||
setSessionTenantId(prev => prev || data.tenantId);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { dedupedFetchJson } from "@/lib/dedupedAuthFetch";
|
||||
import { cachedAuthFetchJson, FEATURE_FLAGS_TTL_MS } from "@/lib/cachedAuthFetch";
|
||||
import type { TenantFeatureFlagsResponse } from "@/utils/wire-types.generated";
|
||||
|
||||
interface UseSessionTenantConfigReturn {
|
||||
@@ -37,7 +37,7 @@ export function useSessionTenantConfig(
|
||||
// These flags are exposed via the member-readable feature-flags endpoint so that
|
||||
// Operators and Viewers can load session details without 403'ing on the admin-only
|
||||
// full /api/config/{tenantId} response.
|
||||
const cfg = await dedupedFetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(sessionTenantId), getAccessToken);
|
||||
const cfg = await cachedAuthFetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(sessionTenantId), getAccessToken, { ttlMs: FEATURE_FLAGS_TTL_MS });
|
||||
if (cancelled) return;
|
||||
setShowScriptOutput(cfg.showScriptOutput ?? true);
|
||||
setEnableSoftwareInventoryAnalyzer(cfg.enableSoftwareInventoryAnalyzer ?? false);
|
||||
|
||||
@@ -217,7 +217,40 @@ function SessionDetailContent() {
|
||||
// bounded by the agent's last report. Live sessions pass null and keep ticking.
|
||||
const observedUntilMs = isTerminalStatus(displayStatus) ? lastObservedAtMs : null;
|
||||
// One boundary for the Download / Install / Script panels, so all three split at the same moment.
|
||||
const userPhaseBoundary = findUserPhaseBoundary(events, session?.enrollmentType);
|
||||
const userPhaseBoundary = useMemo(
|
||||
() => findUserPhaseBoundary(events, session?.enrollmentType),
|
||||
[events, session?.enrollmentType]
|
||||
);
|
||||
|
||||
// Per-panel event slices keyed on the events array. Inlined filters would hand each panel a
|
||||
// fresh array identity on every render and defeat its own useMemo([events]); any change to
|
||||
// `events` still recomputes all four (D-274: no caching across data changes).
|
||||
const performanceEvents = useMemo(
|
||||
() => events.filter(e => e.eventType === "performance_snapshot"),
|
||||
[events]
|
||||
);
|
||||
const scriptEvents = useMemo(
|
||||
() => events.filter(
|
||||
e => e.eventType === "script_started"
|
||||
|| e.eventType === "script_completed"
|
||||
|| e.eventType === "script_failed"
|
||||
),
|
||||
[events]
|
||||
);
|
||||
const downloadEvents = useMemo(
|
||||
() => events.filter(
|
||||
e => e.eventType === "download_progress" || e.eventType === "app_download_started" || e.eventType === "app_install_skipped"
|
||||
),
|
||||
[events]
|
||||
);
|
||||
const installEvents = useMemo(
|
||||
() => events.filter(
|
||||
e => e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_postponed" || e.eventType === "app_install_skipped"
|
||||
|| e.eventType === "office_install_started" || e.eventType === "office_install_completed" || e.eventType === "office_install_failed" || e.eventType === "office_preinstalled_detected"
|
||||
|| e.eventType === "realmjoin_package_started" || e.eventType === "realmjoin_package_completed"
|
||||
),
|
||||
[events]
|
||||
);
|
||||
|
||||
// All phase keys currently present. For WhiteGlove sessions we use prefixed keys
|
||||
// (pre-X, user-X) to avoid collisions.
|
||||
@@ -658,7 +691,7 @@ function SessionDetailContent() {
|
||||
{!isGatherRulesSession && (
|
||||
<div id="section-performance">
|
||||
<PerformanceChart
|
||||
events={events.filter(e => e.eventType === "performance_snapshot")}
|
||||
events={performanceEvents}
|
||||
expanded={perfExpanded}
|
||||
setExpanded={setPerfExpanded}
|
||||
/>
|
||||
@@ -669,11 +702,7 @@ function SessionDetailContent() {
|
||||
{!isGatherRulesSession && (
|
||||
<div id="section-scripts">
|
||||
<ScriptExecutions
|
||||
events={events.filter(
|
||||
e => e.eventType === "script_started"
|
||||
|| e.eventType === "script_completed"
|
||||
|| e.eventType === "script_failed"
|
||||
)}
|
||||
events={scriptEvents}
|
||||
showScriptOutput={showScriptOutput}
|
||||
latestBootstrapVersion={latestBootstrapVersion}
|
||||
displayNamesByRefKey={scriptDisplayNamesByRefKey}
|
||||
@@ -687,9 +716,7 @@ function SessionDetailContent() {
|
||||
{!isGatherRulesSession && (
|
||||
<div id="section-downloads">
|
||||
<DownloadProgress
|
||||
events={events.filter(
|
||||
e => e.eventType === "download_progress" || e.eventType === "app_download_started" || e.eventType === "app_install_skipped"
|
||||
)}
|
||||
events={downloadEvents}
|
||||
summaryStats={appSummaryStats}
|
||||
observedUntilMs={observedUntilMs}
|
||||
userPhaseBoundary={userPhaseBoundary}
|
||||
@@ -705,11 +732,7 @@ function SessionDetailContent() {
|
||||
{!isGatherRulesSession && (
|
||||
<div id="section-install-progress">
|
||||
<InstallProgress
|
||||
events={events.filter(
|
||||
e => e.eventType === "app_install_started" || e.eventType === "app_install_completed" || e.eventType === "app_install_failed" || e.eventType === "app_install_postponed" || e.eventType === "app_install_skipped"
|
||||
|| e.eventType === "office_install_started" || e.eventType === "office_install_completed" || e.eventType === "office_install_failed" || e.eventType === "office_preinstalled_detected"
|
||||
|| e.eventType === "realmjoin_package_started" || e.eventType === "realmjoin_package_completed"
|
||||
)}
|
||||
events={installEvents}
|
||||
summaryStats={appSummaryStats}
|
||||
observedUntilMs={observedUntilMs}
|
||||
userPhaseBoundary={userPhaseBoundary}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNotifications } from "../../contexts/NotificationContext";
|
||||
import { api } from "@/lib/api";
|
||||
import { TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import { trackEvent } from "@/lib/appInsights";
|
||||
import { classifyAccessCheck, type AccessCheckOutcome, type AccessCheckPayload } from "@/lib/accessCheck";
|
||||
import { primaryClientId } from "@/lib/authApp";
|
||||
@@ -509,6 +510,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
trackEvent("app_homing_flipped", { source });
|
||||
setHomingFlipped(true);
|
||||
setAppHomingFunnelActive(false);
|
||||
// The flip changed appHomingFunnelActive in the feature flags the dashboard banner reads.
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
setHomingMissingRoles(null);
|
||||
homingPendingRef.current = false;
|
||||
setConfig(prev => (prev ? { ...prev, homedAppClientId: primaryClientId() ?? prev.homedAppClientId } : prev));
|
||||
@@ -843,6 +846,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
method: "PATCH",
|
||||
body: jsonBody<PatchTenantConfigurationFieldsRequest>({ fields: patchFields, reason: `settings:${sectionName}` }),
|
||||
});
|
||||
// The feature flags derive from these fields: the cached copy is stale from here on.
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
setConfig({ ...config, ...patchFields } as TenantConfiguration);
|
||||
}
|
||||
|
||||
@@ -1508,6 +1513,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setOffboardError(null);
|
||||
|
||||
const body = await fetchJson<OffboardResponse>(api.tenants.offboard(tenantId), getAccessToken, { method: 'DELETE' });
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
|
||||
// Backend returns 202 (or 200 for idempotent re-clicks) with the History row pointer
|
||||
// and EarliestProcessingAt (cache-drain barrier deadline). Switch the UI into the
|
||||
@@ -1551,6 +1557,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
await fetchOk(api.config.trial(tenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
});
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
|
||||
// Refetch the authoritative edition surface (server-resolved).
|
||||
const flags = await fetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(tenantId), getAccessToken).catch(() => null);
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { TokenExpiredError } from "@/lib/authenticatedFetch";
|
||||
import { apiErrorText, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type { SubmitOffboardingFeedbackRequest } from "@/utils/wire-types.generated";
|
||||
import { ModalPortal } from "@/components/ModalPortal";
|
||||
import OffboardingFeedbackForm from "@/components/OffboardingFeedbackForm";
|
||||
|
||||
interface OffboardingInProgressInfo {
|
||||
status: string; // "Queued" | "Initiated" | "InProgress" | "Completed" | "Failed"
|
||||
@@ -39,13 +36,6 @@ interface OffboardingSectionProps {
|
||||
getAccessToken: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
/** localStorage key prefix for "this user already submitted feedback for historyRowKey X".
|
||||
* Tied to historyRowKey rather than tenantId so a tenant that offboards twice
|
||||
* (re-onboard → offboard) gets a fresh prompt each time. */
|
||||
const FEEDBACK_SUBMITTED_KEY_PREFIX = "offboard-feedback-submitted:";
|
||||
|
||||
const FEEDBACK_MAX_CHARS = 4096;
|
||||
|
||||
function formatRemaining(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return "0 min";
|
||||
return `~${Math.ceil(totalSeconds / 60)} min`;
|
||||
@@ -251,50 +241,6 @@ function OffboardingInProgressBanner({
|
||||
const secondsLeft = useCountdown(info.earliestProcessingAt ?? null, onDrainBarrierElapsed);
|
||||
const inDrainBarrier = (info.earliestProcessingAt && secondsLeft > 0) === true;
|
||||
|
||||
// Farewell-feedback widget state. Each historyRowKey gets its own localStorage flag so a
|
||||
// refresh during the countdown remembers the submit. Re-offboarding later (new historyRowKey)
|
||||
// shows a fresh prompt — exactly what we want for repeat-offboard scenarios.
|
||||
const localStorageKey = `${FEEDBACK_SUBMITTED_KEY_PREFIX}${info.historyRowKey}`;
|
||||
const [feedbackText, setFeedbackText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [alreadySubmitted, setAlreadySubmitted] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(localStorageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmitFeedback = async () => {
|
||||
const trimmed = feedbackText.trim();
|
||||
if (trimmed.length === 0 || !tenantId) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await fetchOk(api.tenants.offboardFeedback(tenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: jsonBody<SubmitOffboardingFeedbackRequest>({ comment: trimmed.slice(0, FEEDBACK_MAX_CHARS) }),
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem(localStorageKey, "1");
|
||||
} catch {
|
||||
// localStorage disabled — UI still flips via state below; the round trip already succeeded.
|
||||
}
|
||||
setAlreadySubmitted(true);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
setSubmitError("Your session has expired. The offboarding will continue in the background.");
|
||||
} else {
|
||||
setSubmitError(apiErrorText(err, "Failed to submit feedback"));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Single 4-state visual; status text drives the badge color + label.
|
||||
// "Preparing" is derived from earliestProcessingAt + countdown rather than from status,
|
||||
// because the backend status stays "Queued" during the cache-drain window.
|
||||
@@ -364,50 +310,14 @@ function OffboardingInProgressBanner({
|
||||
{/* Farewell-feedback widget — only meaningful while the drain barrier is running.
|
||||
Hidden once we transition to actual deletion / completion / failure: at that point
|
||||
the user can no longer interact, and an admin re-clicking later would see the
|
||||
banner in a non-feedback state. */}
|
||||
banner in a non-feedback state. A reload during the barrier lands on the suspended
|
||||
page (ProtectedRoute), which renders the same form. */}
|
||||
{inDrainBarrier && tenantId && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">We're sorry to see you go</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
What could we have done better? Anything specific that didn't fit your needs?
|
||||
(optional, max {FEEDBACK_MAX_CHARS} characters)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alreadySubmitted ? (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" />
|
||||
</svg>
|
||||
<span>Thanks for the feedback — it really helps shape what we build next.</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value.slice(0, FEEDBACK_MAX_CHARS))}
|
||||
disabled={submitting}
|
||||
rows={4}
|
||||
placeholder="Pricing, missing features, bugs that bit us, alternatives we picked, …"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 text-sm disabled:bg-gray-100"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{feedbackText.length}/{FEEDBACK_MAX_CHARS}</span>
|
||||
<button
|
||||
onClick={handleSubmitFeedback}
|
||||
disabled={submitting || feedbackText.trim().length === 0}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||
>
|
||||
{submitting ? "Submitting…" : "Submit feedback"}
|
||||
</button>
|
||||
</div>
|
||||
{submitError && (
|
||||
<p className="text-xs text-red-600">{submitError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<OffboardingFeedbackForm
|
||||
tenantId={tenantId}
|
||||
offboardingKey={info.historyRowKey || tenantId}
|
||||
getAccessToken={getAccessToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
|
||||
+4
-1
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTenantConfig } from "../../TenantConfigContext";
|
||||
import { ApiError, apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import { api } from "@/lib/api";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
@@ -115,6 +116,8 @@ export function SectionDelegatedAccess() {
|
||||
if (err instanceof ApiError) throw new Error(describeDelegationError(err.code, err.message));
|
||||
throw err;
|
||||
}
|
||||
// A delegation confers Pro (MSP): the edition in the cached feature flags may have changed.
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
notify(ok);
|
||||
setConfirm(null);
|
||||
await load();
|
||||
@@ -209,7 +212,7 @@ export function SectionDelegatedAccess() {
|
||||
<li key={key} className="py-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<span className="font-medium text-gray-900">{m.ownerDomain || m.name}</span>
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${m.source === "self-service" ? "bg-sky-100 text-sky-800" : "bg-gray-100 text-gray-700"}`}>
|
||||
{m.source === "self-service" ? "Delegated by you" : "Platform operators"}
|
||||
{m.source === "self-service" ? "Delegated by you" : "Provisioned by operators"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{m.assignees.length} reader{m.assignees.length === 1 ? "" : "s"}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB |
@@ -3,6 +3,7 @@
|
||||
import { api } from "@/lib/api";
|
||||
import { nextSlotLimit, slotTenantLabel, type SlotLimitError } from "@/lib/delegatedSlots";
|
||||
import { ApiError, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { CONFIG_PATH_PREFIX, invalidateCachedAuthFetch } from "@/lib/cachedAuthFetch";
|
||||
import type { PatchTenantPlanRequest } from "@/utils/wire-types.generated";
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export async function raiseDelegatedSlotLimit(
|
||||
method: "PATCH",
|
||||
body: jsonBody<PatchTenantPlanRequest>({ maxDelegatedTenants: newLimit }),
|
||||
});
|
||||
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError)) throw err;
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function FeedbackBubble() {
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Check eligibility on mount
|
||||
// Check eligibility shortly after mount
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !user) return;
|
||||
|
||||
@@ -48,8 +48,10 @@ export default function FeedbackBubble() {
|
||||
}
|
||||
};
|
||||
|
||||
checkEligibility();
|
||||
return () => { cancelled = true; };
|
||||
// Deferred off the mount wave: the bubble is not first-paint content, and its status call
|
||||
// used to compete with the page's own data fetches.
|
||||
const timer = setTimeout(() => { void checkEligibility(); }, 1500);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
}, [isAuthenticated, user, getAccessToken]);
|
||||
|
||||
const handleDismiss = useCallback(async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user