@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.
- package/README.md +18 -2
- package/package.json +3 -3
- package/src/app/api/gateways/[id]/topology/route.ts +13 -0
- package/src/app/api/gateways/fleet/route.ts +9 -0
- package/src/app/api/gateways/topology-route.test.ts +37 -0
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/components/gateways/gateway-sheet.tsx +43 -0
- package/src/components/operations/operations-pulse-panel.tsx +6 -3
- package/src/components/providers/provider-list.tsx +99 -2
- package/src/features/gateways/queries.ts +34 -47
- package/src/lib/server/gateways/gateway-profile-service.ts +5 -0
- package/src/lib/server/gateways/gateway-topology.test.ts +151 -0
- package/src/lib/server/gateways/gateway-topology.ts +366 -0
- package/src/lib/server/operations/operation-pulse.test.ts +57 -1
- package/src/lib/server/operations/operation-pulse.ts +83 -0
- package/src/lib/server/runtime/queue/core.ts +11 -2
- package/src/lib/server/runtime/queue-retry-policy.test.ts +98 -0
- package/src/lib/server/session-tools/context.ts +2 -0
- package/src/lib/server/session-tools/execute.test.ts +93 -1
- package/src/lib/server/session-tools/execute.ts +4 -3
- package/src/lib/server/session-tools/index.ts +2 -1
- package/src/lib/server/tasks/task-validation.test.ts +33 -0
- package/src/lib/server/tasks/task-validation.ts +2 -1
- package/src/types/misc.ts +58 -0
- package/src/types/operations.ts +2 -0
|
@@ -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
|
})
|