@swarmclawai/swarmclaw 1.8.11 → 1.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,151 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { buildOpenClawGatewayTopology, getOpenClawGatewayFleetTopology } from './gateway-topology'
5
+ import type { GatewayProfile, OpenClawGatewayStats } from '@/types'
6
+
7
+ const now = 1_800_000_000_000
8
+
9
+ function profile(overrides: Partial<GatewayProfile> = {}): GatewayProfile {
10
+ return {
11
+ id: overrides.id || 'gateway_1',
12
+ name: overrides.name || 'Primary Gateway',
13
+ provider: 'openclaw',
14
+ endpoint: overrides.endpoint || 'http://127.0.0.1:18789/v1',
15
+ wsUrl: overrides.wsUrl ?? null,
16
+ credentialId: overrides.credentialId ?? null,
17
+ status: overrides.status || 'healthy',
18
+ notes: overrides.notes ?? null,
19
+ tags: overrides.tags || [],
20
+ lastError: overrides.lastError ?? null,
21
+ lastCheckedAt: overrides.lastCheckedAt ?? null,
22
+ lastModelCount: overrides.lastModelCount ?? null,
23
+ discoveredHost: overrides.discoveredHost ?? null,
24
+ discoveredPort: overrides.discoveredPort ?? null,
25
+ deployment: overrides.deployment ?? null,
26
+ stats: overrides.stats ?? null,
27
+ isDefault: overrides.isDefault ?? false,
28
+ createdAt: overrides.createdAt ?? now - 1000,
29
+ updatedAt: overrides.updatedAt ?? now - 500,
30
+ }
31
+ }
32
+
33
+ function gateway(responses: Record<string, unknown>) {
34
+ return {
35
+ connected: true,
36
+ rpc: async (method: string) => {
37
+ const response = responses[method]
38
+ if (response instanceof Error) throw response
39
+ return response
40
+ },
41
+ }
42
+ }
43
+
44
+ describe('OpenClaw gateway topology', () => {
45
+ it('normalizes nodes, pairings, sessions, presence, and persists aggregate stats', async () => {
46
+ let savedStats: OpenClawGatewayStats | null = null
47
+ const topology = await buildOpenClawGatewayTopology(profile(), {
48
+ now: () => now,
49
+ ensureGatewayConnected: async () => gateway({
50
+ 'node.list': {
51
+ nodes: [
52
+ { nodeId: 'node_1', displayName: 'Mac Studio', connected: true, commands: ['shell.exec'] },
53
+ { id: 'node_2', name: 'Builder', connected: false },
54
+ ],
55
+ },
56
+ 'node.pair.list': { pending: [{ requestId: 'node_pair_1', nodeId: 'node_3' }] },
57
+ 'device.pair.list': {
58
+ pending: [{ requestId: 'device_pair_1', deviceId: 'phone_1', role: 'operator' }],
59
+ paired: [{ deviceId: 'tablet_1', displayName: 'Ops Tablet' }],
60
+ },
61
+ 'sessions.list': { sessions: [{ sessionId: 'session_1', title: 'Release room' }] },
62
+ 'system-presence': { presence: [{ deviceId: 'phone_1', mode: 'active' }] },
63
+ }) as never,
64
+ persistStats: (id, input) => {
65
+ assert.equal(id, 'gateway_1')
66
+ savedStats = input.stats as OpenClawGatewayStats
67
+ return { ...profile(), stats: savedStats }
68
+ },
69
+ })
70
+
71
+ assert.equal(topology.connected, true)
72
+ assert.equal(topology.nodes.length, 2)
73
+ assert.equal(topology.stats.nodeCount, 2)
74
+ assert.equal(topology.stats.connectedNodeCount, 1)
75
+ assert.equal(topology.stats.pendingNodePairings, 1)
76
+ assert.equal(topology.stats.pendingDevicePairings, 1)
77
+ assert.equal(topology.stats.pairedDeviceCount, 1)
78
+ assert.equal(topology.stats.sessionCount, 1)
79
+ assert.equal(topology.stats.presenceCount, 1)
80
+ assert.equal(topology.stats.pendingPairingCount, 2)
81
+ assert.equal(topology.stats.hasErrors, false)
82
+ assert.equal(topology.stats.lastTopologyCheckedAt, now)
83
+ assert.deepEqual(savedStats, topology.stats)
84
+ })
85
+
86
+ it('returns partial topology when optional gateway RPC methods fail', async () => {
87
+ const topology = await buildOpenClawGatewayTopology(profile(), {
88
+ now: () => now,
89
+ ensureGatewayConnected: async () => gateway({
90
+ 'node.list': { nodes: [{ nodeId: 'node_1', connected: true }] },
91
+ 'node.pair.list': { pending: [] },
92
+ 'device.pair.list': { pending: [], paired: [] },
93
+ 'sessions.list': new Error('sessions unavailable'),
94
+ 'system-presence': new Error('presence unavailable'),
95
+ }) as never,
96
+ persistStats: (id, input) => ({ ...profile({ id }), stats: input.stats as OpenClawGatewayStats }),
97
+ })
98
+
99
+ assert.equal(topology.nodes.length, 1)
100
+ assert.equal(topology.sessions.length, 0)
101
+ assert.deepEqual(topology.errors.map((error) => error.method), ['sessions.list', 'system-presence'])
102
+ assert.equal(topology.stats.hasErrors, true)
103
+ assert.equal(topology.stats.lastTopologyErrorCount, 2)
104
+ assert.equal(topology.stats.lastTopologyError, 'sessions unavailable')
105
+ })
106
+
107
+ it('marks a profile disconnected when no gateway can be reached', async () => {
108
+ const topology = await buildOpenClawGatewayTopology(profile(), {
109
+ now: () => now,
110
+ ensureGatewayConnected: async () => null,
111
+ })
112
+
113
+ assert.equal(topology.connected, false)
114
+ assert.equal(topology.stats.hasErrors, true)
115
+ assert.equal(topology.stats.nodeCount, 0)
116
+ assert.equal(topology.errors[0]?.method, 'gateway.connect')
117
+ })
118
+
119
+ it('aggregates fleet totals from every gateway topology', async () => {
120
+ const first = profile({ id: 'gateway_a' })
121
+ const second = profile({ id: 'gateway_b' })
122
+ const fleet = await getOpenClawGatewayFleetTopology({
123
+ now: () => now,
124
+ listGatewayProfiles: () => [first, second],
125
+ ensureGatewayConnected: async (target?: { profileId?: string | null }) => gateway({
126
+ 'node.list': {
127
+ nodes: target?.profileId === 'gateway_a'
128
+ ? [{ nodeId: 'node_a', connected: true }]
129
+ : [{ nodeId: 'node_b', connected: false }],
130
+ },
131
+ 'node.pair.list': { pending: target?.profileId === 'gateway_b' ? [{ requestId: 'pair_b' }] : [] },
132
+ 'device.pair.list': { pending: [], paired: [] },
133
+ 'sessions.list': { sessions: target?.profileId === 'gateway_a' ? [{ id: 'session_a' }] : [] },
134
+ 'system-presence': { presence: [] },
135
+ }) as never,
136
+ persistStats: (id, input) => ({
137
+ ...(id === 'gateway_a' ? first : second),
138
+ stats: input.stats as OpenClawGatewayStats,
139
+ }),
140
+ })
141
+
142
+ assert.equal(fleet.generatedAt, now)
143
+ assert.equal(fleet.totals.gatewayCount, 2)
144
+ assert.equal(fleet.totals.connectedGatewayCount, 2)
145
+ assert.equal(fleet.totals.nodeCount, 2)
146
+ assert.equal(fleet.totals.connectedNodeCount, 1)
147
+ assert.equal(fleet.totals.pendingNodePairings, 1)
148
+ assert.equal(fleet.totals.sessionCount, 1)
149
+ assert.equal(fleet.gateways.length, 2)
150
+ })
151
+ })
@@ -0,0 +1,366 @@
1
+ import { errorMessage } from '@/lib/shared-utils'
2
+ import {
3
+ getGatewayProfileById,
4
+ listOpenClawGatewayProfiles,
5
+ updateGatewayProfile,
6
+ } from './gateway-profile-service'
7
+ import { ensureGatewayConnected } from '@/lib/server/openclaw/gateway'
8
+ import type {
9
+ GatewayProfile,
10
+ OpenClawDevicePairRequest,
11
+ OpenClawGatewayFleetTopology,
12
+ OpenClawGatewayPresenceEntry,
13
+ OpenClawGatewayRpcError,
14
+ OpenClawGatewaySession,
15
+ OpenClawGatewayTopology,
16
+ OpenClawGatewayTopologyStats,
17
+ OpenClawNode,
18
+ OpenClawNodePairRequest,
19
+ OpenClawPairedDevice,
20
+ } from '@/types'
21
+
22
+ type GatewayRpcClient = {
23
+ connected: boolean
24
+ rpc: (method: string, params?: Record<string, unknown>) => Promise<unknown>
25
+ }
26
+
27
+ interface GatewayTopologyDeps {
28
+ ensureGatewayConnected?: typeof ensureGatewayConnected
29
+ listGatewayProfiles?: typeof listOpenClawGatewayProfiles
30
+ now?: () => number
31
+ persistStats?: typeof updateGatewayProfile
32
+ }
33
+
34
+ function asObject(value: unknown): Record<string, unknown> | null {
35
+ return value && typeof value === 'object' && !Array.isArray(value)
36
+ ? value as Record<string, unknown>
37
+ : null
38
+ }
39
+
40
+ function asString(value: unknown): string | null {
41
+ return typeof value === 'string' && value.trim() ? value.trim() : null
42
+ }
43
+
44
+ function asNumber(value: unknown): number | null {
45
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
46
+ }
47
+
48
+ function extractArray(value: unknown, keys: string[] = []): unknown[] {
49
+ if (Array.isArray(value)) return value
50
+ const record = asObject(value)
51
+ if (!record) return []
52
+ for (const key of keys) {
53
+ const nested = record[key]
54
+ if (Array.isArray(nested)) return nested
55
+ }
56
+ return []
57
+ }
58
+
59
+ function normalizeNode(value: unknown): OpenClawNode | null {
60
+ const record = asObject(value)
61
+ const nodeId = asString(record?.nodeId) || asString(record?.id)
62
+ if (!record || !nodeId) return null
63
+ const stringArray = (key: string): string[] | undefined => {
64
+ const raw = record[key]
65
+ return Array.isArray(raw) ? (raw.map(asString).filter(Boolean) as string[]) : undefined
66
+ }
67
+ return {
68
+ nodeId,
69
+ displayName: asString(record.displayName) || asString(record.name) || undefined,
70
+ platform: asString(record.platform) || undefined,
71
+ version: asString(record.version) || undefined,
72
+ coreVersion: asString(record.coreVersion) || undefined,
73
+ uiVersion: asString(record.uiVersion) || undefined,
74
+ deviceFamily: asString(record.deviceFamily) || undefined,
75
+ modelIdentifier: asString(record.modelIdentifier) || undefined,
76
+ remoteIp: asString(record.remoteIp) || undefined,
77
+ caps: stringArray('caps'),
78
+ commands: stringArray('commands'),
79
+ pathEnv: stringArray('pathEnv'),
80
+ permissions: stringArray('permissions'),
81
+ connectedAtMs: asNumber(record.connectedAtMs) || undefined,
82
+ paired: typeof record.paired === 'boolean' ? record.paired : undefined,
83
+ connected: typeof record.connected === 'boolean' ? record.connected : undefined,
84
+ }
85
+ }
86
+
87
+ function normalizeNodePairing(value: unknown): OpenClawNodePairRequest | null {
88
+ const record = asObject(value)
89
+ const requestId = asString(record?.requestId) || asString(record?.id)
90
+ if (!record || !requestId) return null
91
+ return {
92
+ requestId,
93
+ nodeId: asString(record.nodeId) || undefined,
94
+ displayName: asString(record.displayName) || asString(record.name) || undefined,
95
+ platform: asString(record.platform) || undefined,
96
+ remoteIp: asString(record.remoteIp) || undefined,
97
+ createdAtMs: asNumber(record.createdAtMs) || undefined,
98
+ }
99
+ }
100
+
101
+ function normalizeDevicePairing(value: unknown): OpenClawDevicePairRequest | null {
102
+ const record = asObject(value)
103
+ const requestId = asString(record?.requestId) || asString(record?.id)
104
+ if (!record || !requestId) return null
105
+ return {
106
+ requestId,
107
+ deviceId: asString(record.deviceId) || undefined,
108
+ displayName: asString(record.displayName) || asString(record.name) || undefined,
109
+ role: asString(record.role) || undefined,
110
+ platform: asString(record.platform) || undefined,
111
+ remoteIp: asString(record.remoteIp) || undefined,
112
+ createdAtMs: asNumber(record.createdAtMs) || undefined,
113
+ }
114
+ }
115
+
116
+ function normalizePairedDevice(value: unknown): OpenClawPairedDevice | null {
117
+ const record = asObject(value)
118
+ const deviceId = asString(record?.deviceId) || asString(record?.id)
119
+ if (!record || !deviceId) return null
120
+ return {
121
+ deviceId,
122
+ displayName: asString(record.displayName) || asString(record.name) || undefined,
123
+ role: asString(record.role) || undefined,
124
+ remoteIp: asString(record.remoteIp) || undefined,
125
+ platform: asString(record.platform) || undefined,
126
+ tokens: Array.isArray(record.tokens)
127
+ ? record.tokens.map((token) => {
128
+ const tokenRecord = asObject(token) || {}
129
+ return {
130
+ role: asString(tokenRecord.role) || undefined,
131
+ scopes: Array.isArray(tokenRecord.scopes)
132
+ ? tokenRecord.scopes.map(asString).filter(Boolean) as string[]
133
+ : undefined,
134
+ createdAtMs: asNumber(tokenRecord.createdAtMs) || undefined,
135
+ rotatedAtMs: asNumber(tokenRecord.rotatedAtMs) || undefined,
136
+ revokedAtMs: asNumber(tokenRecord.revokedAtMs) || undefined,
137
+ }
138
+ })
139
+ : undefined,
140
+ }
141
+ }
142
+
143
+ function normalizeSession(value: unknown): OpenClawGatewaySession | null {
144
+ const record = asObject(value)
145
+ const id = asString(record?.id)
146
+ || asString(record?.sessionId)
147
+ || asString(record?.sessionKey)
148
+ || asString(record?.key)
149
+ if (!record || !id) return null
150
+ return {
151
+ id,
152
+ key: asString(record.key) || asString(record.sessionKey),
153
+ title: asString(record.title) || asString(record.name),
154
+ channel: asString(record.channel) || asString(record.platform) || asString(record.provider),
155
+ sender: asString(record.sender) || asString(record.senderId) || asString(record.from),
156
+ updatedAt: asNumber(record.updatedAt) || asNumber(record.lastMessageAt) || asNumber(record.createdAt),
157
+ status: asString(record.status) || asString(record.state),
158
+ }
159
+ }
160
+
161
+ function normalizePresence(value: unknown): OpenClawGatewayPresenceEntry | null {
162
+ const record = asObject(value)
163
+ const id = asString(record?.id)
164
+ || asString(record?.key)
165
+ || asString(record?.deviceId)
166
+ || asString(record?.instanceId)
167
+ if (!record || !id) return null
168
+ return {
169
+ id,
170
+ label: asString(record.label) || asString(record.name) || asString(record.text),
171
+ mode: asString(record.mode),
172
+ deviceId: asString(record.deviceId),
173
+ host: asString(record.host) || asString(record.hostname),
174
+ status: asString(record.status) || asString(record.state),
175
+ updatedAt: asNumber(record.updatedAt) || asNumber(record.seenAt) || asNumber(record.createdAt),
176
+ }
177
+ }
178
+
179
+ async function safeRpc<T>(
180
+ gateway: GatewayRpcClient,
181
+ method: string,
182
+ errors: OpenClawGatewayRpcError[],
183
+ normalize: (value: unknown) => T,
184
+ ): Promise<T> {
185
+ try {
186
+ return normalize(await gateway.rpc(method, {}))
187
+ } catch (err: unknown) {
188
+ errors.push({ method, message: errorMessage(err) })
189
+ return normalize(null)
190
+ }
191
+ }
192
+
193
+ function topologyStats(params: {
194
+ nodes: OpenClawNode[]
195
+ nodePairings: OpenClawNodePairRequest[]
196
+ devicePairings: OpenClawDevicePairRequest[]
197
+ pairedDevices: OpenClawPairedDevice[]
198
+ sessions: OpenClawGatewaySession[]
199
+ presence: OpenClawGatewayPresenceEntry[]
200
+ errors: OpenClawGatewayRpcError[]
201
+ refreshedAt: number
202
+ }): OpenClawGatewayTopologyStats {
203
+ return {
204
+ nodeCount: params.nodes.length,
205
+ connectedNodeCount: params.nodes.filter((node) => node.connected === true).length,
206
+ pendingNodePairings: params.nodePairings.length,
207
+ pairedDeviceCount: params.pairedDevices.length,
208
+ pendingDevicePairings: params.devicePairings.length,
209
+ sessionCount: params.sessions.length,
210
+ presenceCount: params.presence.length,
211
+ pendingPairingCount: params.nodePairings.length + params.devicePairings.length,
212
+ hasErrors: params.errors.length > 0,
213
+ lastTopologyCheckedAt: params.refreshedAt,
214
+ lastTopologyErrorCount: params.errors.length,
215
+ lastTopologyError: params.errors[0]?.message || null,
216
+ }
217
+ }
218
+
219
+ export async function buildOpenClawGatewayTopology(
220
+ profile: GatewayProfile,
221
+ deps: GatewayTopologyDeps = {},
222
+ ): Promise<OpenClawGatewayTopology> {
223
+ const now = deps.now ?? (() => Date.now())
224
+ const refreshedAt = now()
225
+ const ensureConnected = deps.ensureGatewayConnected ?? ensureGatewayConnected
226
+ const errors: OpenClawGatewayRpcError[] = []
227
+ const gateway = await ensureConnected({ profileId: profile.id }) as GatewayRpcClient | null
228
+
229
+ if (!gateway) {
230
+ errors.push({ method: 'gateway.connect', message: 'OpenClaw gateway not connected' })
231
+ const stats = topologyStats({
232
+ nodes: [],
233
+ nodePairings: [],
234
+ devicePairings: [],
235
+ pairedDevices: [],
236
+ sessions: [],
237
+ presence: [],
238
+ errors,
239
+ refreshedAt,
240
+ })
241
+ return {
242
+ profile,
243
+ connected: false,
244
+ refreshedAt,
245
+ stats,
246
+ nodes: [],
247
+ nodePairings: [],
248
+ devicePairings: [],
249
+ pairedDevices: [],
250
+ sessions: [],
251
+ presence: [],
252
+ errors,
253
+ }
254
+ }
255
+
256
+ const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence] = await Promise.all([
257
+ safeRpc(gateway, 'node.list', errors, (value) =>
258
+ extractArray(value, ['nodes']).map(normalizeNode).filter(Boolean) as OpenClawNode[],
259
+ ),
260
+ safeRpc(gateway, 'node.pair.list', errors, (value) => extractArray(asObject(value)?.pending ?? value, ['pending'])),
261
+ safeRpc(gateway, 'device.pair.list', errors, (value) => asObject(value) || {}),
262
+ safeRpc(gateway, 'sessions.list', errors, (value) =>
263
+ extractArray(value, ['sessions', 'items', 'data']).map(normalizeSession).filter(Boolean) as OpenClawGatewaySession[],
264
+ ),
265
+ safeRpc(gateway, 'system-presence', errors, (value) =>
266
+ extractArray(value, ['presence']).map(normalizePresence).filter(Boolean) as OpenClawGatewayPresenceEntry[],
267
+ ),
268
+ ])
269
+
270
+ const devicePairingsRecord = asObject(devicePairingsRaw) || {}
271
+ const nodePairings = extractArray(nodePairingsRaw, ['pending'])
272
+ .map(normalizeNodePairing)
273
+ .filter(Boolean) as OpenClawNodePairRequest[]
274
+ const devicePairings = extractArray(devicePairingsRecord.pending)
275
+ .map(normalizeDevicePairing)
276
+ .filter(Boolean) as OpenClawDevicePairRequest[]
277
+ const pairedDevices = extractArray(devicePairingsRecord.paired)
278
+ .map(normalizePairedDevice)
279
+ .filter(Boolean) as OpenClawPairedDevice[]
280
+ const stats = topologyStats({
281
+ nodes,
282
+ nodePairings,
283
+ devicePairings,
284
+ pairedDevices,
285
+ sessions,
286
+ presence,
287
+ errors,
288
+ refreshedAt,
289
+ })
290
+
291
+ const persisted = (deps.persistStats ?? updateGatewayProfile)(profile.id, { stats })
292
+
293
+ return {
294
+ profile: persisted || profile,
295
+ connected: gateway.connected,
296
+ refreshedAt,
297
+ stats,
298
+ nodes,
299
+ nodePairings,
300
+ devicePairings,
301
+ pairedDevices,
302
+ sessions,
303
+ presence,
304
+ errors,
305
+ }
306
+ }
307
+
308
+ function emptyTotals(generatedAt: number): OpenClawGatewayFleetTopology['totals'] {
309
+ return {
310
+ gatewayCount: 0,
311
+ connectedGatewayCount: 0,
312
+ nodeCount: 0,
313
+ connectedNodeCount: 0,
314
+ pendingNodePairings: 0,
315
+ pairedDeviceCount: 0,
316
+ pendingDevicePairings: 0,
317
+ sessionCount: 0,
318
+ presenceCount: 0,
319
+ pendingPairingCount: 0,
320
+ hasErrors: false,
321
+ lastTopologyCheckedAt: generatedAt,
322
+ lastTopologyErrorCount: 0,
323
+ lastTopologyError: null,
324
+ }
325
+ }
326
+
327
+ export async function getOpenClawGatewayTopology(
328
+ id: string,
329
+ deps: GatewayTopologyDeps = {},
330
+ ): Promise<OpenClawGatewayTopology | null> {
331
+ const profile = getGatewayProfileById(id)
332
+ if (!profile) return null
333
+ return buildOpenClawGatewayTopology(profile, deps)
334
+ }
335
+
336
+ export async function getOpenClawGatewayFleetTopology(
337
+ deps: GatewayTopologyDeps = {},
338
+ ): Promise<OpenClawGatewayFleetTopology> {
339
+ const now = deps.now ?? (() => Date.now())
340
+ const generatedAt = now()
341
+ const listProfiles = deps.listGatewayProfiles ?? listOpenClawGatewayProfiles
342
+ const gateways = await Promise.all(
343
+ listProfiles().map((profile) => buildOpenClawGatewayTopology(profile, {
344
+ ...deps,
345
+ now: () => generatedAt,
346
+ })),
347
+ )
348
+ const totals = gateways.reduce((acc, topology) => {
349
+ acc.gatewayCount += 1
350
+ if (topology.connected) acc.connectedGatewayCount += 1
351
+ acc.nodeCount = (acc.nodeCount || 0) + (topology.stats.nodeCount || 0)
352
+ acc.connectedNodeCount = (acc.connectedNodeCount || 0) + (topology.stats.connectedNodeCount || 0)
353
+ acc.pendingNodePairings = (acc.pendingNodePairings || 0) + (topology.stats.pendingNodePairings || 0)
354
+ acc.pairedDeviceCount = (acc.pairedDeviceCount || 0) + (topology.stats.pairedDeviceCount || 0)
355
+ acc.pendingDevicePairings = (acc.pendingDevicePairings || 0) + (topology.stats.pendingDevicePairings || 0)
356
+ acc.sessionCount = (acc.sessionCount || 0) + (topology.stats.sessionCount || 0)
357
+ acc.presenceCount = (acc.presenceCount || 0) + (topology.stats.presenceCount || 0)
358
+ acc.pendingPairingCount += topology.stats.pendingPairingCount
359
+ acc.hasErrors = acc.hasErrors || topology.stats.hasErrors
360
+ acc.lastTopologyErrorCount = (acc.lastTopologyErrorCount || 0) + (topology.stats.lastTopologyErrorCount || 0)
361
+ acc.lastTopologyError = acc.lastTopologyError || topology.stats.lastTopologyError || null
362
+ return acc
363
+ }, emptyTotals(generatedAt))
364
+
365
+ return { generatedAt, gateways, totals }
366
+ }
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import { describe, it } from 'node:test'
3
3
 
4
4
  import { buildOperationPulse, normalizeOperationPulseRange } from './operation-pulse'
5
- import type { ApprovalRequest, Connector, Mission, SessionRunRecord } from '@/types'
5
+ import type { ApprovalRequest, Connector, GatewayProfile, Mission, SessionRunRecord } from '@/types'
6
6
 
7
7
  const now = 10_000_000
8
8
 
@@ -79,6 +79,30 @@ function connector(overrides: Partial<Connector>): Connector {
79
79
  }
80
80
  }
81
81
 
82
+ function gateway(overrides: Partial<GatewayProfile>): GatewayProfile {
83
+ return {
84
+ id: overrides.id || 'gateway_1',
85
+ name: overrides.name || 'OpenClaw Gateway',
86
+ provider: 'openclaw',
87
+ endpoint: overrides.endpoint || 'http://127.0.0.1:18789/v1',
88
+ wsUrl: overrides.wsUrl ?? null,
89
+ credentialId: overrides.credentialId ?? null,
90
+ status: overrides.status || 'healthy',
91
+ notes: overrides.notes ?? null,
92
+ tags: overrides.tags || [],
93
+ lastError: overrides.lastError ?? null,
94
+ lastCheckedAt: overrides.lastCheckedAt ?? null,
95
+ lastModelCount: overrides.lastModelCount ?? null,
96
+ discoveredHost: overrides.discoveredHost ?? null,
97
+ discoveredPort: overrides.discoveredPort ?? null,
98
+ deployment: overrides.deployment ?? null,
99
+ stats: overrides.stats ?? null,
100
+ isDefault: overrides.isDefault ?? false,
101
+ createdAt: overrides.createdAt ?? now - 4000,
102
+ updatedAt: overrides.updatedAt ?? now - 2000,
103
+ }
104
+ }
105
+
82
106
  describe('operation pulse', () => {
83
107
  it('normalizes unsupported ranges to the 24-hour default', () => {
84
108
  assert.equal(normalizeOperationPulseRange('7d'), '7d')
@@ -94,6 +118,7 @@ describe('operation pulse', () => {
94
118
  runs: [run({ id: 'failed', status: 'failed', error: 'bad', endedAt: now - 100 }), run({ id: 'running', status: 'running' })],
95
119
  approvals: [approval({ category: 'budget_change' })],
96
120
  connectors: [connector({ lastError: 'token rejected' })],
121
+ gateways: [],
97
122
  })
98
123
 
99
124
  assert.equal(pulse.kpis.activeMissions, 1)
@@ -101,8 +126,39 @@ describe('operation pulse', () => {
101
126
  assert.equal(pulse.kpis.failedRuns, 1)
102
127
  assert.equal(pulse.kpis.pendingApprovals, 1)
103
128
  assert.equal(pulse.kpis.connectorAttention, 1)
129
+ assert.equal(pulse.kpis.gatewayAttention, 0)
104
130
  assert.equal(pulse.kpis.budgetWarnings, 1)
105
131
  assert.deepEqual(pulse.actions.slice(0, 3).map((action) => action.severity), ['high', 'high', 'high'])
106
132
  assert.ok(pulse.actions.some((action) => action.kind === 'budget' && action.summary.includes('90%')))
107
133
  })
134
+
135
+ it('surfaces OpenClaw gateway topology attention', () => {
136
+ const pulse = buildOperationPulse({
137
+ range: '24h',
138
+ now,
139
+ missions: [],
140
+ runs: [],
141
+ approvals: [],
142
+ connectors: [],
143
+ gateways: [
144
+ gateway({
145
+ status: 'healthy',
146
+ stats: {
147
+ nodeCount: 2,
148
+ connectedNodeCount: 1,
149
+ pendingNodePairings: 1,
150
+ pendingDevicePairings: 1,
151
+ lastTopologyCheckedAt: now - 1000,
152
+ },
153
+ }),
154
+ ],
155
+ })
156
+
157
+ assert.equal(pulse.kpis.gatewayAttention, 1)
158
+ assert.equal(pulse.actions.length, 1)
159
+ assert.equal(pulse.actions[0]?.kind, 'gateway')
160
+ assert.equal(pulse.actions[0]?.severity, 'medium')
161
+ assert.ok((pulse.actions[0]?.summary || '').includes('pending OpenClaw pairing'))
162
+ assert.equal(pulse.actions[0]?.href, '/providers')
163
+ })
108
164
  })