From ad4fbff6be447b9c7e93fa845c70c88dff7cc1dc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 07:21:34 +0100 Subject: [PATCH] Clarify external-probe outage and buffering limits Separate remote check execution from server-side alert delivery, explain indeterminate and bounded-buffer behaviour, and link independent watchdog coverage without a permanent Mobile or Relay promise. Preserve the existing probe and notification contracts and verify both shipped guides and their keyboard help paths. Contract-Neutral: Existing help, its shipped mirrors and verification only; no runtime, entitlement, alert policy or API changes. Change-source: pulse-maintainer --- docs/CONFIGURATION.md | 64 +++++---- docs/UNIFIED_AGENT.md | 55 ++++---- .../browser-tests/probe-guidance.cjs | 124 ++++++++++++++++++ frontend-modern/public/docs/CONFIGURATION.md | 64 +++++---- frontend-modern/public/docs/UNIFIED_AGENT.md | 55 ++++---- .../src/utils/__tests__/docsLinks.test.ts | 56 ++++++++ 6 files changed, 326 insertions(+), 92 deletions(-) create mode 100644 frontend-modern/browser-tests/probe-guidance.cjs diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 409cbc8bf6..ef90f2a3b3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -577,30 +577,46 @@ alerts, and resource projections keep one canonical protocol value. ### External probes (Pro) -By default every availability check runs from the Pulse server itself, which -means a check cannot report the one failure that matters most: the site -running Pulse losing its connectivity. With the Pro `external_probe` -entitlement, a check can instead be assigned to any connected Pulse agent -(`probeAgentId` in the API, "Run from" in the UI) — for example an agent on a -cloud VM or at another site. The assigned agent receives the check through -its signed agent configuration, runs it on the configured interval, and -reports results back with its reports; results are only accepted from the -currently assigned agent. An assigned check is not also run locally. If no -report arrives for several intervals the check shows as indeterminate -("no recent report from probe agent"). Freshness uses the Pulse server's receipt -time, so clock drift on the probe cannot create or hide a disconnect. After at -least five minutes without a report, Pulse raises one warning per disconnected -probe through the normal notification routes, even when that probe owns several -checks. The incident is keyed to the probe agent rather than an arbitrary check, -so changing assignments does not reopen it. If the agent heartbeat is also -offline, the existing -host-offline alert owns the incident instead of producing a duplicate probe -warning. A paired Pulse Mobile client receives a privacy-safe -`external_probe_offline` Relay push linked to that canonical alert. If the whole -Pulse instance becomes unreachable, Relay's independent five-minute -instance-offline push covers the dark-site case. If the entitlement lapses, the -check automatically resumes running from the Pulse server. Checks without an -assignment are unaffected and remain available in every edition. +External probes change **where a check runs**, not where alerts are sent. +By default, checks run on the Pulse server. With the Pro `external_probe` +entitlement, assign a connected Pulse agent using **Run from** in the UI +(`probeAgentId` in the API). An agent on a cloud VM or at another site can +observe a service from outside its local network. + +The assigned agent receives signed configuration, runs the check and reports +results back to Pulse. Only the currently assigned agent's results are accepted; +an assigned check is not also run locally. **The agent does not send +notifications directly.** The Pulse server must be running and able to reach +the notification destination to evaluate results and send alerts. + +| Situation | What the check can tell you | +| --- | --- | +| Pulse is running and receives probe results | Target observations are evaluated through the normal alert and notification policies. | +| Pulse is running but probe results stop arriving | The check becomes indeterminate: "no recent report from probe agent". This is missing evidence, not proof the target is down. | +| Pulse is stopped or cannot reach its notification destination | An external agent cannot deliver Pulse alerts in its place. Use an independent external watchdog for this failure. | + +The missing-report window is the longer of **five minutes or three check +intervals**, measured from server receipt time, not the agent's clock. During +normal server evaluation, eligible stale probes raise one warning per agent, +not per assigned check. Alert and connectivity policies still apply. If the +agent heartbeat is also offline, the host-offline alert owns the incident +instead of a duplicate probe warning. See the +[agent probe guide](UNIFIED_AGENT.md#external-probes-pro) for buffering limits. + +For an outage of Pulse itself, configure an +[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down) +outside the power and network failure you need to detect, with an independently +reachable notification destination. Verify actual receipt in an authorised test +environment; a successful destination test does not prove outage coverage. + +Existing paired Pulse Mobile/Relay users retain their current push path until +**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not +evaluate individual targets while Pulse is offline, and is not a permanent +substitute for the watchdog. See [Mobile retirement](RELAY.md). + +If the entitlement lapses, the check resumes running from the Pulse server: +this changes its observation location and may change its result. Checks without +an agent assignment remain available in every edition. ### ICMP probe privileges diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index 405e50271d..dc9b567fae 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -305,40 +305,51 @@ storage nodes to avoid duplicate or ambiguously correlated readings. With the Pro `external_probe` entitlement, availability checks configured in Pulse can be assigned to run from a specific agent instead of the Pulse server (Settings -> Monitoring -> Availability checks -> "Run from"). This is -how you monitor a site from the outside: deploy the agent on a machine -elsewhere — a cloud VM, a Docker host at another location — and assign checks -to it. Target failures are evaluated on the Pulse server through your normal -alert routes. +how you observe a service from outside its local network: deploy the agent on +a machine elsewhere — a cloud VM, a Docker host at another location — and +assign checks to it. **The agent does not send notifications directly.** The +Pulse server must be running and able to reach the notification destination; +target failures are evaluated there through your normal alert routes. There is nothing to configure on the agent itself. Assignments arrive through the agent's signed remote configuration, the agent runs each check on its configured interval, and results are delivered with its regular reports. -Results survive temporary connectivity loss to the Pulse server in a bounded -in-memory queue; if the agent cannot deliver for several check intervals the -check shows as indeterminate in Pulse until reports resume. After the -five-minute minimum grace window, Pulse raises one +Results waiting for delivery use a bounded **in-memory** queue of up to 200 +observations across the agent's checks. Oldest pending observations are dropped +when it fills, and an agent restart loses the queue; it is not a complete outage +record. If reports stop arriving, the check becomes indeterminate ("no recent +report from probe agent"). This is missing evidence, not proof the target is +down. The missing-report window is the +longer of **five minutes or three check intervals**. During normal server +evaluation, eligible stale probes raise one `availability_probe_unavailable` warning per disconnected probe, regardless of how many checks it owns. Pulse measures that reporting window from server receipt time rather than the agent's clock, so clock skew cannot create or conceal the disconnect. That warning uses the normal email, webhook, Apprise, and -recovery-notification pipeline. When Pulse Mobile is paired through Relay, Pulse -also sends a privacy-safe `external_probe_offline` push linked to the canonical -mobile attention item without exposing target names or addresses. The alert -identity belongs to the probe agent, so adding or removing an assigned check -does not resolve and reopen it. +recovery-notification pipeline, subject to alert and connectivity policies. +The alert identity belongs to the probe agent, so adding or removing an assigned +check does not resolve and reopen it. When the host heartbeat itself is offline, Pulse keeps the existing host-offline alert as the single canonical incident and suppresses the -probe-results warning. Assigned probe hosts still receive the external-probe -mobile push, but operators do not get two normal alerts for the same agent -failure. +probe-results warning, rather than producing two normal alerts for the same +agent failure. -This has a complementary dark-site path: if the entire Pulse instance or its -site goes offline, Pulse Relay independently sends its existing instance -offline push after five minutes. Together, probe-loss alerts while Pulse is -online and Relay's instance-loss alert while Pulse is dark ensure the -outside-monitoring path cannot disappear silently. Relay does not evaluate -individual target results while the Pulse server is offline. +If Pulse itself stops or loses outbound connectivity, the external agent cannot +deliver Pulse alerts in its place. Configure an +[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down) +outside the shared power/network failure, with an independently reachable +notification destination. Verify recipient delivery in an authorised test +environment, not by interrupting a production site. + +Existing paired Pulse Mobile/Relay users retain their current push path until +**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not +evaluate individual targets while Pulse is offline, and is not a permanent +substitute for the watchdog. See [Mobile retirement](RELAY.md). + +If the external-probe entitlement lapses, checks resume on the Pulse server, +not the agent. That changes the observation location and may change the result; +see the [configuration guide](CONFIGURATION.md#external-probes-pro). The module appears as `availability` in the agent's module status when at least one check is assigned. diff --git a/frontend-modern/browser-tests/probe-guidance.cjs b/frontend-modern/browser-tests/probe-guidance.cjs new file mode 100644 index 0000000000..a13e496443 --- /dev/null +++ b/frontend-modern/browser-tests/probe-guidance.cjs @@ -0,0 +1,124 @@ +// Render the existing probe help and its outage/retirement links with the real +// Docs page. No agent, destination, watchdog or Mobile operation is performed. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'node_modules', 'probe-guidance-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-probe-guidance'), + server: { host: '127.0.0.1', port: 5253, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html?scenario=configuration', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('heading', { name: 'External probes (Pro)', exact: true }).waitFor(); + if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark')); + const table = page.getByRole('table').filter({ has: page.getByRole('columnheader', + { name: 'Situation', exact: true }) }); + assert.equal(await table.getByRole('row').count(), 4); + assert.ok(await table.evaluate((element) => { + const parent = element.parentElement; + return parent.dataset.docTableScroll !== undefined && + getComputedStyle(parent).overflowX === 'auto' && + [...element.querySelectorAll('thead th')].every((header) => header.scope === 'col'); + })); + const assertBoundaries = async () => { + const body = (await page.locator('article').innerText()).replace(/\s+/g, ' '); + for (const text of ['The agent does not send notifications directly.', + 'Pulse server must be running and able to reach the notification destination', + 'not proof the target is down', 'five minutes or three check intervals', + '31 March 2027', 'Relay is no longer sold', 'not a permanent substitute']) { + assert.ok(body.includes(text), text); + } + assert.ok(!body.includes('cannot disappear silently')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + }; + const screenshot = async (name, heading) => { + await page.getByRole('heading', { name: heading, exact: true }) + .evaluate((element) => element.scrollIntoView({ block: 'start' })); + await page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); + await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) }); + }; + const follow = async (name, href, heading, id) => { + const link = page.getByRole('link', { name, exact: true }); + assert.equal(await link.getAttribute('href'), href); + await link.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL(`**${href}`); + await page.getByRole('heading', { name: heading, exact: true }).waitFor(); + if (id) await page.waitForFunction((id) => document.activeElement?.id === id, id); + }; + await assertBoundaries(); + await screenshot('configuration', 'External probes (Pro)'); + await follow('agent probe guide', '/docs/UNIFIED_AGENT#external-probes-pro', + 'External Probes (Pro)', 'external-probes-pro'); + await assertBoundaries(); + const agentBody = (await page.locator('article').innerText()).replace(/\s+/g, ' '); + for (const text of ['up to 200 observations', 'Oldest pending observations are dropped', + 'agent restart loses the queue', 'not a complete outage record']) assert.ok(agentBody.includes(text), text); + await screenshot('agent', 'External Probes (Pro)'); + await follow('external watchdog', '/docs/TROUBLESHOOTING#no-alert-when-pulse-power-or-internet-goes-down', + 'No alert when Pulse, power or internet goes down', 'no-alert-when-pulse-power-or-internet-goes-down'); + assert.ok((await page.locator('article').innerText()).includes('period plus grace')); + await screenshot('watchdog', 'No alert when Pulse, power or internet goes down'); + await page.goBack(); + await page.getByRole('heading', { name: 'External Probes (Pro)', exact: true }).waitFor(); + await follow('Mobile retirement', '/docs/RELAY', 'Relay / Pulse Mobile', ''); + assert.ok((await page.locator('article').innerText()).includes('31 March 2027')); + await page.goBack(); + await page.getByRole('heading', { name: 'External Probes (Pro)', exact: true }).waitFor(); + await follow('configuration guide', '/docs/CONFIGURATION#external-probes-pro', + 'External probes (Pro)', 'external-probes-pro'); + await follow('external watchdog', '/docs/TROUBLESHOOTING#no-alert-when-pulse-power-or-internet-goes-down', + 'No alert when Pulse, power or internet goes down', 'no-alert-when-pulse-power-or-internet-goes-down'); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, + scope: engine === 'webkit' ? 'phone-emulated, not a native device' : 'desktop', + serverDependencyVisible: true, semanticContainedCoverageTable: true, + noDocumentOverflow: true, queueAndRetirementLimitsVisible: true, + keyboardAgentGuide: true, keyboardConfigurationGuide: true, + keyboardWatchdogLinks: 2, keyboardRetirementLink: true, errors }); + await browser.close(); + browser = undefined; + } + const files = ['public/docs/CONFIGURATION.md', 'public/docs/UNIFIED_AGENT.md', + 'browser-tests/probe-guidance.cjs', 'browser-tests/docs-fragment-navigation.tsx', + 'src/features/docs/docMarkdown.ts', 'src/pages/Docs.tsx'].map((file) => ({ file, + sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') })); + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'existing shipped Docs rendering and keyboard links only; no native outage or delivery acceptance', + files, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index 409cbc8bf6..ef90f2a3b3 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -577,30 +577,46 @@ alerts, and resource projections keep one canonical protocol value. ### External probes (Pro) -By default every availability check runs from the Pulse server itself, which -means a check cannot report the one failure that matters most: the site -running Pulse losing its connectivity. With the Pro `external_probe` -entitlement, a check can instead be assigned to any connected Pulse agent -(`probeAgentId` in the API, "Run from" in the UI) — for example an agent on a -cloud VM or at another site. The assigned agent receives the check through -its signed agent configuration, runs it on the configured interval, and -reports results back with its reports; results are only accepted from the -currently assigned agent. An assigned check is not also run locally. If no -report arrives for several intervals the check shows as indeterminate -("no recent report from probe agent"). Freshness uses the Pulse server's receipt -time, so clock drift on the probe cannot create or hide a disconnect. After at -least five minutes without a report, Pulse raises one warning per disconnected -probe through the normal notification routes, even when that probe owns several -checks. The incident is keyed to the probe agent rather than an arbitrary check, -so changing assignments does not reopen it. If the agent heartbeat is also -offline, the existing -host-offline alert owns the incident instead of producing a duplicate probe -warning. A paired Pulse Mobile client receives a privacy-safe -`external_probe_offline` Relay push linked to that canonical alert. If the whole -Pulse instance becomes unreachable, Relay's independent five-minute -instance-offline push covers the dark-site case. If the entitlement lapses, the -check automatically resumes running from the Pulse server. Checks without an -assignment are unaffected and remain available in every edition. +External probes change **where a check runs**, not where alerts are sent. +By default, checks run on the Pulse server. With the Pro `external_probe` +entitlement, assign a connected Pulse agent using **Run from** in the UI +(`probeAgentId` in the API). An agent on a cloud VM or at another site can +observe a service from outside its local network. + +The assigned agent receives signed configuration, runs the check and reports +results back to Pulse. Only the currently assigned agent's results are accepted; +an assigned check is not also run locally. **The agent does not send +notifications directly.** The Pulse server must be running and able to reach +the notification destination to evaluate results and send alerts. + +| Situation | What the check can tell you | +| --- | --- | +| Pulse is running and receives probe results | Target observations are evaluated through the normal alert and notification policies. | +| Pulse is running but probe results stop arriving | The check becomes indeterminate: "no recent report from probe agent". This is missing evidence, not proof the target is down. | +| Pulse is stopped or cannot reach its notification destination | An external agent cannot deliver Pulse alerts in its place. Use an independent external watchdog for this failure. | + +The missing-report window is the longer of **five minutes or three check +intervals**, measured from server receipt time, not the agent's clock. During +normal server evaluation, eligible stale probes raise one warning per agent, +not per assigned check. Alert and connectivity policies still apply. If the +agent heartbeat is also offline, the host-offline alert owns the incident +instead of a duplicate probe warning. See the +[agent probe guide](UNIFIED_AGENT.md#external-probes-pro) for buffering limits. + +For an outage of Pulse itself, configure an +[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down) +outside the power and network failure you need to detect, with an independently +reachable notification destination. Verify actual receipt in an authorised test +environment; a successful destination test does not prove outage coverage. + +Existing paired Pulse Mobile/Relay users retain their current push path until +**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not +evaluate individual targets while Pulse is offline, and is not a permanent +substitute for the watchdog. See [Mobile retirement](RELAY.md). + +If the entitlement lapses, the check resumes running from the Pulse server: +this changes its observation location and may change its result. Checks without +an agent assignment remain available in every edition. ### ICMP probe privileges diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index 405e50271d..dc9b567fae 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -305,40 +305,51 @@ storage nodes to avoid duplicate or ambiguously correlated readings. With the Pro `external_probe` entitlement, availability checks configured in Pulse can be assigned to run from a specific agent instead of the Pulse server (Settings -> Monitoring -> Availability checks -> "Run from"). This is -how you monitor a site from the outside: deploy the agent on a machine -elsewhere — a cloud VM, a Docker host at another location — and assign checks -to it. Target failures are evaluated on the Pulse server through your normal -alert routes. +how you observe a service from outside its local network: deploy the agent on +a machine elsewhere — a cloud VM, a Docker host at another location — and +assign checks to it. **The agent does not send notifications directly.** The +Pulse server must be running and able to reach the notification destination; +target failures are evaluated there through your normal alert routes. There is nothing to configure on the agent itself. Assignments arrive through the agent's signed remote configuration, the agent runs each check on its configured interval, and results are delivered with its regular reports. -Results survive temporary connectivity loss to the Pulse server in a bounded -in-memory queue; if the agent cannot deliver for several check intervals the -check shows as indeterminate in Pulse until reports resume. After the -five-minute minimum grace window, Pulse raises one +Results waiting for delivery use a bounded **in-memory** queue of up to 200 +observations across the agent's checks. Oldest pending observations are dropped +when it fills, and an agent restart loses the queue; it is not a complete outage +record. If reports stop arriving, the check becomes indeterminate ("no recent +report from probe agent"). This is missing evidence, not proof the target is +down. The missing-report window is the +longer of **five minutes or three check intervals**. During normal server +evaluation, eligible stale probes raise one `availability_probe_unavailable` warning per disconnected probe, regardless of how many checks it owns. Pulse measures that reporting window from server receipt time rather than the agent's clock, so clock skew cannot create or conceal the disconnect. That warning uses the normal email, webhook, Apprise, and -recovery-notification pipeline. When Pulse Mobile is paired through Relay, Pulse -also sends a privacy-safe `external_probe_offline` push linked to the canonical -mobile attention item without exposing target names or addresses. The alert -identity belongs to the probe agent, so adding or removing an assigned check -does not resolve and reopen it. +recovery-notification pipeline, subject to alert and connectivity policies. +The alert identity belongs to the probe agent, so adding or removing an assigned +check does not resolve and reopen it. When the host heartbeat itself is offline, Pulse keeps the existing host-offline alert as the single canonical incident and suppresses the -probe-results warning. Assigned probe hosts still receive the external-probe -mobile push, but operators do not get two normal alerts for the same agent -failure. +probe-results warning, rather than producing two normal alerts for the same +agent failure. -This has a complementary dark-site path: if the entire Pulse instance or its -site goes offline, Pulse Relay independently sends its existing instance -offline push after five minutes. Together, probe-loss alerts while Pulse is -online and Relay's instance-loss alert while Pulse is dark ensure the -outside-monitoring path cannot disappear silently. Relay does not evaluate -individual target results while the Pulse server is offline. +If Pulse itself stops or loses outbound connectivity, the external agent cannot +deliver Pulse alerts in its place. Configure an +[external watchdog](TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down) +outside the shared power/network failure, with an independently reachable +notification destination. Verify recipient delivery in an authorised test +environment, not by interrupting a production site. + +Existing paired Pulse Mobile/Relay users retain their current push path until +**31 March 2027**. Relay is no longer sold. Its instance-disconnect push does not +evaluate individual targets while Pulse is offline, and is not a permanent +substitute for the watchdog. See [Mobile retirement](RELAY.md). + +If the external-probe entitlement lapses, checks resume on the Pulse server, +not the agent. That changes the observation location and may change the result; +see the [configuration guide](CONFIGURATION.md#external-probes-pro). The module appears as `availability` in the agent's module status when at least one check is assigned. diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index a08c2189a0..a2a9f683e3 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -61,6 +61,62 @@ function getRuntimeSourceFiles(dir: string): string[] { } describe('docsLinks', () => { + const probeGuides = () => [ + readFileSync(path.join(repoRoot, 'docs', 'CONFIGURATION.md'), 'utf8') + .split('### External probes (Pro)')[1].split('### ICMP probe privileges')[0], + readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8') + .split('## External Probes (Pro)')[1].split('## Custom metrics')[0], + ].map((section) => section.replace(/\s+/g, ' ')); + + it('keeps external probes dependent on server-side alert delivery', () => { + for (const guide of probeGuides()) { + expect(guide).toContain('The agent does not send notifications directly.'); + expect(guide).toContain('Pulse server must be running and able to reach the notification destination'); + expect(guide).toContain('agent cannot deliver Pulse alerts in its place'); + expect(guide).toContain('not proof'); + expect(guide).not.toContain('cannot disappear silently'); + } + }); + + it('explains the existing missing-report and bounded-buffer limits', () => { + for (const guide of probeGuides()) { + expect(guide).toContain('five minutes or three check intervals'); + expect(guide).toContain('server receipt time'); + expect(guide).toContain('host-offline alert'); + expect(guide).toContain('policies'); + expect(guide).toMatch(/changes (?:its|the) observation location/); + } + const agentGuide = probeGuides()[1]; + expect(agentGuide).toContain('up to 200 observations'); + expect(agentGuide).toContain('Oldest pending observations are dropped'); + expect(agentGuide).toContain('agent restart loses the queue'); + expect(agentGuide).toContain('not a complete outage record'); + // These limits come from the current implementation, not a new policy. + const monitor = readFileSync( + path.join(repoRoot, 'internal', 'monitoring', 'availability_probe_agent.go'), 'utf8', + ); + expect(monitor).toContain('availabilityProbeStaleFloor = 5 * time.Minute'); + expect(monitor).toContain('target.EffectivePollIntervalSecs()) * 3 * time.Second'); + const agent = readFileSync( + path.join(repoRoot, 'internal', 'hostagent', 'availability.go'), 'utf8', + ); + expect(agent).toContain('availabilityPendingCapacity = 200'); + }); + + it('links independent outage coverage without a permanent Mobile guarantee', () => { + for (const guide of probeGuides()) { + expect(guide).toContain('TROUBLESHOOTING.md#no-alert-when-pulse-power-or-internet-goes-down'); + expect(guide).toContain('independently reachable notification destination'); + expect(guide).toContain('authorised test environment'); + expect(guide).toContain('Existing paired Pulse Mobile/Relay users'); + expect(guide).toContain('31 March 2027'); + expect(guide).toContain('Relay is no longer sold'); + expect(guide).toContain('not a permanent substitute'); + expect(guide).toContain('[Mobile retirement](RELAY.md)'); + expect(guide).not.toContain('push covers the dark-site case'); + } + }); + it('separates server removal from persistent-data erasure', () => { const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8'); const removal = installation.split('## 🗑️ Uninstall')[1];