Add key-alarm push: reuse URL/token, dial netx hub, sticky DSH session (0.1.15).

Plugins card gains an alarm-push toggle; when enabled, netxops subscribes outbound and followups a Netx Ops session without requiring im/WhatsApp.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 20:50:51 +08:00
parent 027f83dd00
commit 12f7018103
16 changed files with 966 additions and 31 deletions

View file

@ -147,6 +147,38 @@ export function NetxopsCard(props: NetxopsCardProps) {
onEdit={(text) => { props.edit('lang', text) }}
onReset={() => { props.resetField('lang') }}
/>
<div className="dsh-nx-field">
<div className="dsh-nx-fieldHead">
<label className="dsh-nx-label" htmlFor="netxops-alarm-push">{t('alarmPushEnabled')}</label>
{state.alarmPushEnabled.overridden
? (
<span className="dsh-nx-badges">
<span className="dsh-nx-badge">{t('overridden')}</span>
<button
type="button"
className="dsh-nx-reset"
disabled={disabled}
onClick={() => { props.resetField('alarmPushEnabled') }}
>
{t('reset')}
</button>
</span>
)
: null}
</div>
<label className="dsh-nx-checkRow" htmlFor="netxops-alarm-push">
<input
id="netxops-alarm-push"
type="checkbox"
checked={state.alarmPushEnabled.text === 'true'}
disabled={disabled}
onChange={(event) => {
props.edit('alarmPushEnabled', event.target.checked ? 'true' : 'false')
}}
/>
<span>{t('alarmPushEnabledHint')}</span>
</label>
</div>
<div className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
<button

View file

@ -62,6 +62,24 @@ export function textField(field: string): CardFieldSpec {
}
}
/** Boolean settings field rendered as a checkbox (stores true / clears when false). */
export function booleanField(field: string): CardFieldSpec {
return {
field,
format: value => (value === true ? 'true' : 'false'),
parse: (text) => {
const normalized = text.trim().toLowerCase()
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return { kind: 'set', value: true }
}
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === '') {
return { kind: 'clear' }
}
return undefined
},
}
}
export class CardForm<T> {
private readonly specs: Map<string, CardFieldSpec>
private readonly secretSpecs: Map<string, CardSecretSpec>

View file

@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { SnapshotStore } from './snapshot-store.ts'
import {
CardForm, textField,
CardForm, textField, booleanField,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
@ -19,6 +19,7 @@ export interface NetxopsSettings {
apiUrl?: string
lang?: string
tokenCredentialRef?: string
alarmPushEnabled?: boolean
}
interface CredentialState {
@ -31,6 +32,7 @@ interface CredentialState {
export interface NetxopsCardState extends CardShell {
apiUrl: CardFieldState
lang: CardFieldState
alarmPushEnabled: CardFieldState
apiToken: CardFieldState
apiTokenConfigured: boolean
apiTokenWritable: boolean
@ -64,7 +66,7 @@ export class NetxopsCardController {
) {
this.form = new CardForm(
scope,
[textField('apiUrl'), textField('lang')],
[textField('apiUrl'), textField('lang'), booleanField('alarmPushEnabled')],
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
)
this.store = this.form.bind(() => this.projection())
@ -89,6 +91,7 @@ export class NetxopsCardController {
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
alarmPushEnabled: this.form.field('alarmPushEnabled'),
apiToken: this.form.field(API_TOKEN_FIELD),
apiTokenConfigured: this.credential.configured,
apiTokenWritable: this.credential.remoteReady && this.credential.writable,

View file

@ -7,6 +7,8 @@ export type NetxopsLocaleKey =
| 'apiUrlHint'
| 'lang'
| 'langHint'
| 'alarmPushEnabled'
| 'alarmPushEnabledHint'
| 'apiToken'
| 'apiTokenHint'
| 'apiTokenUnavailable'
@ -28,9 +30,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API endpoint and bearer token for netx__* tools.',
apiUrl: 'API URL',
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890',
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890 (also used for alarm subscribe)',
lang: 'Language',
langHint: 'Response language hint (zh / en).',
alarmPushEnabled: 'Key-alarm push',
alarmPushEnabledHint: 'Dial out to netx and open/follow a DSH session when a matched key alarm arrives. WhatsApp/im is optional and not required.',
apiToken: 'API token',
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.',
@ -53,9 +57,11 @@ export const zh: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API 地址与 Bearer Token,供 netx__* 工具使用。',
apiUrl: 'API 地址',
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890',
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890(告警订阅复用同一地址)',
lang: '语言',
langHint: '响应语言提示(zh / en)。',
alarmPushEnabled: '关键告警推送',
alarmPushEnabledHint: '主动连接 netx;匹配到关键告警后在本机打开/续写一条 DSH 会话。不必安装 WhatsApp / im。',
apiToken: 'API Token',
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',

View file

@ -29,6 +29,8 @@ const CSS = `
.dsh-nx-input:focus-visible{outline:none;border-color:var(--dsw-alias-brand-primary)}
.dsh-nx-inputInvalid{border-color:var(--dsw-alias-label-error)}
.dsh-nx-hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
.dsh-nx-checkRow{display:flex;align-items:flex-start;gap:10px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer}
.dsh-nx-checkRow input{margin-top:2px;flex:none}
.dsh-nx-invalid{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}
.dsh-nx-footer{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:12px 0 4px;border-top:.5px solid var(--dsw-alias-border-l2)}
.dsh-nx-failed{flex:1;min-width:0;margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}

View file

@ -1,11 +1,10 @@
/**
* Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN)
* publish a connection snapshot; the Ops preset mounts `netx__*` into its own
* tool scope (`dsh-netxops/tools`) so other agents do not see them.
* Host-plane Netx Ops: settings (apiUrl / lang / alarm push) + credentials
* (NETX_API_TOKEN) publish a connection snapshot; the Ops preset mounts
* `netx__*` into its own tool scope (`dsh-netxops/tools`).
*
* On activate, the agent preset + skills are copied into
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
* (Windows junctions are invisible to DSH's `Dirent.isDirectory()` scan).
* When「关键告警推送」is on, this host dials out to netx's fixed-IP alarm hub and
* opens/follows a sticky DSH session (im / WhatsApp is optional and separate).
*
* @module dsh-netxops
*/
@ -20,6 +19,8 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-credentials'
import * as DshSettings from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/dsh-settings'
import { startAlarmPushClient } from './netx/alarm-push.ts'
import { deliverAlarmToSession, resetAlarmSession } from './netx/alarm-session.ts'
import { publishNetxConnection } from './netx/runtime.ts'
/** Cordis plugin name. */
@ -52,6 +53,11 @@ export interface Config {
* on every activate (required for Settings → Agent presets).
*/
installAgentPreset: boolean
/**
* Dial out to netx `/v1/integrations/dsh-alarm/ws` and deliver matched key
* alarms into a sticky DSH session.
*/
alarmPushEnabled: boolean
}
export const Config: z<Config> = z.object({
@ -60,6 +66,7 @@ export const Config: z<Config> = z.object({
tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF),
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
installAgentPreset: z.boolean().default(true),
alarmPushEnabled: z.boolean().default(false),
})
/** Package root (parent of `lib/` or `src/` depending on launch). */
@ -144,17 +151,35 @@ function installNetxopsSettings(
}
/**
* Apply the Netx Ops host bridge (settings + connection publish; tools live on the preset).
* Apply the Netx Ops host bridge (settings + connection publish + optional alarm push).
*/
export function apply(ctx: Context, config: Config = Config({})): void {
let source: () => Config = () => config
let publishing: Promise<void> = Promise.resolve()
let generation = 0
let stopAlarmPush: (() => void) | undefined
if (config.installAgentPreset) {
ensureAgentPresetInstalled(ctx.logger)
}
const restartAlarmPush = (apiUrl: string, token: string, enabled: boolean, lang: string): void => {
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmSession()
if (!enabled) return
if (!token.trim()) {
ctx.logger.warn('netxops alarm-push: enabled but token is empty — not connecting')
return
}
stopAlarmPush = startAlarmPushClient({
apiUrl,
token,
logger: ctx.logger,
onAlarm: (payload) => deliverAlarmToSession(ctx, payload, lang),
})
}
const publish = (): void => {
publishing = publishing.then(async () => {
const gen = ++generation
@ -170,6 +195,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs,
})
restartAlarmPush(apiUrl, token, current.alarmPushEnabled === true, current.lang)
if (!tokenConfigured) {
ctx.logger.warn(
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
@ -177,7 +203,11 @@ export function apply(ctx: Context, config: Config = Config({})): void {
current.tokenCredentialRef,
)
} else {
ctx.logger.info('netxops: published connection → %s tokenConfigured=true', apiUrl)
ctx.logger.info(
'netxops: published connection → %s tokenConfigured=true alarmPush=%s',
apiUrl,
current.alarmPushEnabled === true,
)
}
}).catch((error) => {
ctx.logger.error('netxops: connection publish error: %s', error)
@ -201,5 +231,8 @@ export function apply(ctx: Context, config: Config = Config({})): void {
ctx.effect(() => () => {
generation += 1
stopAlarmPush?.()
stopAlarmPush = undefined
resetAlarmSession()
}, 'netxops: dispose host bridge')
}

220
src/netx/alarm-push.ts Normal file
View file

@ -0,0 +1,220 @@
/**
* Convert a netx REST apiUrl into the DSH alarm-subscribe WebSocket URL.
* @param apiUrl - e.g. http://192.168.1.10:8890
*/
export function alarmSubscribeUrl(apiUrl: string): string {
const trimmed = apiUrl.trim().replace(/\/$/, '')
if (!trimmed) return ''
let url: URL
try {
url = new URL(trimmed)
} catch {
return ''
}
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = '/v1/integrations/dsh-alarm/ws'
url.search = ''
url.hash = ''
return url.toString()
}
export interface KeyAlarmPayload {
action?: string
alarm_key?: string
notification_id?: string
rule_label?: string
rule_key?: string
native_probable_cause?: string
perceived_severity?: string
time_created?: string
object_name?: string
ne_id?: string
ne?: Record<string, string>
[key: string]: unknown
}
/** Human-readable prompt for a sticky Netx Ops DSH session. */
export function formatAlarmPrompt(payload: KeyAlarmPayload, lang = 'zh'): string {
const action = String(payload.action ?? '').trim().toLowerCase()
const actionZh: Record<string, string> = {
inserted: '告警产生',
updated: '告警更新',
deleted: '告警清除',
}
const actionEn: Record<string, string> = {
inserted: 'Alarm Raised',
updated: 'Alarm Updated',
deleted: 'Alarm Cleared',
}
const ne = payload.ne && typeof payload.ne === 'object' ? payload.ne : {}
const host = String(ne.host_name ?? '').trim()
const ip = String(ne.ip_address ?? '').trim()
const neName = String(ne.ne_name ?? ne.user_label ?? '').trim()
let device = host || neName || String(payload.ne_id ?? '').trim() || '-'
if (ip) device = device === '-' ? ip : `${device} (${ip})`
const label = String(payload.rule_label ?? payload.native_probable_cause ?? '关键告警').trim()
if (lang.startsWith('en')) {
return [
`[UME ${actionEn[action] ?? (action || 'Alarm')}] ${label}`,
`Device: ${device}`,
`Object: ${String(payload.object_name ?? '-').trim()}`,
`Severity: ${String(payload.perceived_severity ?? '-').trim()}`,
`Cause: ${String(payload.native_probable_cause ?? '-').trim()}`,
`Time: ${String(payload.time_created ?? '-').trim()}`,
`notificationId: ${String(payload.notification_id ?? '-').trim()}`,
`alarm_key: ${String(payload.alarm_key ?? '-').trim()}`,
'',
'Please analyze this key alarm and suggest next ops steps.',
].join('\n')
}
return [
`[UME ${actionZh[action] ?? (action || '告警')}] ${label}`,
`设备: ${device}`,
`对象: ${String(payload.object_name ?? '-').trim()}`,
`级别: ${String(payload.perceived_severity ?? '-').trim()}`,
`原因: ${String(payload.native_probable_cause ?? '-').trim()}`,
`时间: ${String(payload.time_created ?? '-').trim()}`,
`notificationId: ${String(payload.notification_id ?? '-').trim()}`,
`alarm_key: ${String(payload.alarm_key ?? '-').trim()}`,
'',
'请分析这条关键告警并给出下一步运维建议。',
].join('\n')
}
export type AlarmHandler = (payload: KeyAlarmPayload) => void | Promise<void>
export interface AlarmPushClientOptions {
apiUrl: string
token: string
logger?: {
info?: (...args: unknown[]) => void
warn?: (...args: unknown[]) => void
error?: (...args: unknown[]) => void
}
onAlarm: AlarmHandler
/** Reconnect delay base in ms (default 2000). */
reconnectMs?: number
/** WebSocket constructor override (tests). */
WebSocketImpl?: typeof WebSocket
}
/**
* Dial out to netx's fixed-IP alarm hub and forward `netx.alarm` events.
* @returns disposer that closes the socket and cancels reconnect.
*/
export function startAlarmPushClient(options: AlarmPushClientOptions): () => void {
const log = options.logger ?? console
const wsUrl = alarmSubscribeUrl(options.apiUrl)
const token = options.token.trim()
if (!wsUrl || !token) {
log.warn?.('netxops alarm-push: missing apiUrl or token — not connecting')
return () => {}
}
const WS = options.WebSocketImpl ?? globalThis.WebSocket
if (typeof WS !== 'function') {
log.error?.('netxops alarm-push: WebSocket is unavailable in this runtime')
return () => {}
}
let closed = false
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
let attempt = 0
const baseDelay = Math.max(500, options.reconnectMs ?? 2_000)
const clearReconnect = (): void => {
if (reconnectTimer !== undefined) {
clearTimeout(reconnectTimer)
reconnectTimer = undefined
}
}
const scheduleReconnect = (): void => {
if (closed) return
clearReconnect()
const delay = Math.min(60_000, baseDelay * (2 ** Math.min(attempt, 5)))
attempt += 1
reconnectTimer = setTimeout(() => { connect() }, delay)
}
const connect = (): void => {
if (closed) return
clearReconnect()
try {
socket = new WS(wsUrl)
} catch (error) {
log.warn?.('netxops alarm-push: connect failed:', error)
scheduleReconnect()
return
}
socket.addEventListener('open', () => {
attempt = 0
socket?.send(JSON.stringify({ type: 'auth', token }))
})
socket.addEventListener('message', (event) => {
let msg: Record<string, unknown>
try {
msg = JSON.parse(String(event.data)) as Record<string, unknown>
} catch {
return
}
const type = String(msg.type ?? '').toLowerCase()
if (type === 'auth-ok') {
log.info?.('netxops alarm-push: subscribed to %s', wsUrl)
return
}
if (type === 'auth-fail') {
log.error?.('netxops alarm-push: auth failed (%s)', msg.error)
socket?.close()
return
}
if (type === 'pong') return
if (type === 'event' && String(msg.event ?? '') === 'netx.alarm') {
const payload = msg.payload && typeof msg.payload === 'object'
? msg.payload as KeyAlarmPayload
: {}
void Promise.resolve(options.onAlarm(payload)).catch((error) => {
log.warn?.('netxops alarm-push: handler failed:', error)
})
}
})
socket.addEventListener('close', () => {
socket = null
if (!closed) scheduleReconnect()
})
socket.addEventListener('error', () => {
// close handler schedules reconnect
})
}
// Keepalive ping while connected.
const pingTimer = setInterval(() => {
if (socket && socket.readyState === WS.OPEN) {
try {
socket.send(JSON.stringify({ type: 'ping', ts: new Date().toISOString() }))
} catch {
// ignore
}
}
}, 25_000)
connect()
return () => {
closed = true
clearReconnect()
clearInterval(pingTimer)
try {
socket?.close()
} catch {
// ignore
}
socket = null
}
}

180
src/netx/alarm-session.ts Normal file
View file

@ -0,0 +1,180 @@
/**
* Deliver a key-alarm into a sticky Netx Ops DSH session (create once, then followup).
*
* Soft-depends on host services that the web profile usually provides
* (agents / agentPresets / workspaceRegistry / permissionPresets / sessionTitle /
* agentDefaultModel). When any are missing, delivery is skipped with a warning.
*/
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import { formatAlarmPrompt, type KeyAlarmPayload } from './alarm-push.ts'
const PRESET_ID = 'netxops'
const PERMISSION_PRESET = 'default'
const TITLE = 'Netx 关键告警'
interface StickyHandle {
sessionId: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Agent type varies by DSH version
agent: any
}
let sticky: StickyHandle | null = null
function resolveWorkspacePath(): string {
const fromEnv = process.env.DSH_HOME?.trim()
const home = fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh')
return join(home, 'workspaces', 'netxops-alarms')
}
/**
* Open or reuse a Netx Ops session and append the alarm as a user followup.
* @param ctx - host cordis context (may lack session services on minimal profiles).
* @param payload - matched key-alert payload from netx.
* @param lang - zh / en prompt flavour.
*/
export async function deliverAlarmToSession(
ctx: Context,
payload: KeyAlarmPayload,
lang = 'zh',
): Promise<void> {
const prompt = formatAlarmPrompt(payload, lang)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const agents = (ctx as any).agents
if (!agents || typeof agents.create !== 'function') {
ctx.logger.warn(
'netxops alarm-push: ctx.agents unavailable — enable a profile that mounts agents to receive alarms in a DSH session',
)
return
}
if (sticky?.agent && typeof sticky.agent.followup === 'function') {
try {
await followup(ctx, sticky.agent, prompt)
return
} catch (error) {
ctx.logger.warn('netxops alarm-push: sticky followup failed, recreating session: %s', error)
sticky = null
}
}
await createStickySession(ctx, prompt)
}
async function followup(ctx: Context, agent: { followup: (msg: unknown) => unknown }, prompt: string): Promise<void> {
let createUserMessage: ((input: {
content: Array<{ type: 'text'; text: string }>
source?: Record<string, unknown>
}) => unknown) | undefined
try {
const mod = await import('@deepseek-ai/dsh-llm') as {
createUserMessage?: typeof createUserMessage
boundContextSummary?: (text: string) => unknown
}
createUserMessage = mod.createUserMessage
if (typeof createUserMessage !== 'function') throw new Error('createUserMessage missing')
const summary = typeof mod.boundContextSummary === 'function'
? mod.boundContextSummary('netx key alarm')
: 'netx key alarm'
agent.followup(createUserMessage({
content: [{ type: 'text', text: prompt }],
source: {
kind: 'webhook',
provider: 'netx',
source: 'dsh-alarm-hub',
form: 'notice',
summary,
},
}))
} catch (error) {
// Fallback: some hosts accept a plain text followup helper on agents.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyAgent = agent as any
if (typeof anyAgent.prompt === 'function') {
await anyAgent.prompt(prompt)
return
}
ctx.logger.warn('netxops alarm-push: cannot build user message (%s)', error)
throw error
}
}
async function createStickySession(ctx: Context, prompt: string): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const c = ctx as any
const agents = c.agents
const agentPresets = c.agentPresets
const workspaceRegistry = c.workspaceRegistry
const permissionPresets = c.permissionPresets
const sessionTitle = c.sessionTitle
const agentDefaultModel = c.agentDefaultModel
if (!agentPresets || typeof agentPresets.resolve !== 'function'
|| !workspaceRegistry || typeof workspaceRegistry.create !== 'function') {
ctx.logger.warn(
'netxops alarm-push: agentPresets/workspaceRegistry unavailable — cannot create a DSH session',
)
return
}
if (permissionPresets && typeof permissionPresets.resolve === 'function') {
try {
permissionPresets.resolve(PERMISSION_PRESET)
} catch {
ctx.logger.warn('netxops alarm-push: permission preset %s missing', PERMISSION_PRESET)
}
}
const preset = await agentPresets.resolve(PRESET_ID)
if (typeof agentPresets.standingKeyFor === 'function') {
await agentPresets.standingKeyFor(preset.id)
}
const selected = typeof agentDefaultModel?.currentSelection === 'function'
? agentDefaultModel.currentSelection()
: { provider: 'deepseek', model: 'deepseek-chat' }
const workspacePath = resolveWorkspacePath()
const workspace = await workspaceRegistry.create(workspacePath)
const sessionId = `netxops-alarm-${Date.now().toString(36)}`
const handle = await agents.create({
sessionId,
meta: { cwd: workspace.path, agentPreset: preset.id },
agentOptions: { provider: selected.provider, model: selected.model },
setup: async (agentCtx: Context) => {
if (typeof agentPresets.mount === 'function') {
await agentPresets.mount(agentCtx, preset.id)
}
},
})
try {
if (typeof workspace.attachSession === 'function') {
await workspace.attachSession(sessionId)
}
if (permissionPresets && typeof permissionPresets.set === 'function') {
permissionPresets.set(handle.agent.session, PERMISSION_PRESET)
}
if (sessionTitle && typeof sessionTitle.rename === 'function') {
sessionTitle.rename(handle.agent.session, TITLE)
}
await followup(ctx, handle.agent, prompt)
sticky = { sessionId, agent: handle.agent }
ctx.logger.info('netxops alarm-push: opened sticky session %s', sessionId)
} catch (error) {
try {
await handle.dispose?.()
} catch {
// ignore
}
throw error
}
}
/** Drop the sticky handle (tests / dispose). */
export function resetAlarmSession(): void {
sticky = null
}