@swarmclawai/swarmclaw 1.9.2 → 1.9.4
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 +23 -3
- package/electron-dist/main.js +218 -0
- package/package.json +2 -2
- package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
- package/src/app/api/extensions/managed-resources/route.ts +116 -0
- package/src/app/api/gateways/[id]/environments/[environmentId]/route.ts +16 -0
- package/src/app/api/gateways/[id]/environments/route.ts +13 -0
- package/src/app/api/gateways/topology-route.test.ts +30 -0
- package/src/app/api/tasks/task-workspace-route.test.ts +4 -0
- package/src/cli/index.js +4 -0
- package/src/cli/spec.js +4 -0
- package/src/components/providers/provider-list.tsx +34 -1
- package/src/components/tasks/task-sheet.tsx +50 -0
- package/src/features/gateways/queries.ts +3 -0
- package/src/lib/server/extension-managed-resources.test.ts +159 -0
- package/src/lib/server/extension-managed-resources.ts +905 -0
- package/src/lib/server/extensions.ts +113 -2
- package/src/lib/server/gateways/gateway-profile-service.ts +2 -0
- package/src/lib/server/gateways/gateway-topology.test.ts +59 -3
- package/src/lib/server/gateways/gateway-topology.ts +129 -3
- package/src/lib/server/operations/operation-pulse.test.ts +29 -0
- package/src/lib/server/operations/operation-pulse.ts +9 -0
- package/src/lib/server/session-tools/extension-creator.ts +50 -0
- package/src/lib/server/tasks/task-execution-workspace.test.ts +14 -0
- package/src/lib/server/tasks/task-execution-workspace.ts +133 -6
- package/src/types/agent.ts +2 -0
- package/src/types/app-settings.ts +8 -0
- package/src/types/extension.ts +132 -0
- package/src/types/misc.ts +31 -0
- package/src/types/schedule.ts +3 -0
- package/src/types/task.ts +30 -0
- package/src/views/settings/extension-manager.tsx +157 -1
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ExtensionUIDefinition,
|
|
12
12
|
ExtensionProviderDefinition,
|
|
13
13
|
ExtensionConnectorDefinition,
|
|
14
|
+
ExtensionManagedResources,
|
|
14
15
|
Session,
|
|
15
16
|
ExtensionPackageManager,
|
|
16
17
|
ExtensionDependencyInstallStatus,
|
|
@@ -467,11 +468,60 @@ function coerceTools(rawTools: unknown): ExtensionToolDef[] {
|
|
|
467
468
|
return []
|
|
468
469
|
}
|
|
469
470
|
|
|
471
|
+
function coerceManagedResources(raw: Record<string, unknown>): ExtensionManagedResources | undefined {
|
|
472
|
+
const explicit = isRecord(raw.managedResources)
|
|
473
|
+
? raw.managedResources as Record<string, unknown>
|
|
474
|
+
: {}
|
|
475
|
+
const agents = Array.isArray(explicit.agents)
|
|
476
|
+
? explicit.agents
|
|
477
|
+
: Array.isArray(raw.agents)
|
|
478
|
+
? raw.agents
|
|
479
|
+
: undefined
|
|
480
|
+
const schedules = Array.isArray(explicit.schedules)
|
|
481
|
+
? explicit.schedules
|
|
482
|
+
: Array.isArray(raw.schedules)
|
|
483
|
+
? raw.schedules
|
|
484
|
+
: undefined
|
|
485
|
+
const routines = Array.isArray(explicit.routines)
|
|
486
|
+
? explicit.routines
|
|
487
|
+
: Array.isArray(raw.routines)
|
|
488
|
+
? raw.routines
|
|
489
|
+
: undefined
|
|
490
|
+
const localFolders = Array.isArray(explicit.localFolders)
|
|
491
|
+
? explicit.localFolders
|
|
492
|
+
: Array.isArray(raw.localFolders)
|
|
493
|
+
? raw.localFolders
|
|
494
|
+
: undefined
|
|
495
|
+
const gatewayPlatforms = Array.isArray(explicit.gatewayPlatforms)
|
|
496
|
+
? explicit.gatewayPlatforms
|
|
497
|
+
: Array.isArray(raw.gatewayPlatforms)
|
|
498
|
+
? raw.gatewayPlatforms
|
|
499
|
+
: undefined
|
|
500
|
+
const setupChecks = Array.isArray(explicit.setupChecks)
|
|
501
|
+
? explicit.setupChecks
|
|
502
|
+
: Array.isArray(raw.setupChecks)
|
|
503
|
+
? raw.setupChecks
|
|
504
|
+
: undefined
|
|
505
|
+
|
|
506
|
+
const managedResources: ExtensionManagedResources = {
|
|
507
|
+
agents: agents as ExtensionManagedResources['agents'],
|
|
508
|
+
schedules: schedules as ExtensionManagedResources['schedules'],
|
|
509
|
+
routines: routines as ExtensionManagedResources['routines'],
|
|
510
|
+
localFolders: localFolders as ExtensionManagedResources['localFolders'],
|
|
511
|
+
gatewayPlatforms: gatewayPlatforms as ExtensionManagedResources['gatewayPlatforms'],
|
|
512
|
+
setupChecks: setupChecks as ExtensionManagedResources['setupChecks'],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)
|
|
516
|
+
? managedResources
|
|
517
|
+
: undefined
|
|
518
|
+
}
|
|
519
|
+
|
|
470
520
|
function normalizeExtension(mod: unknown): Extension | null {
|
|
471
521
|
const modObj = mod as Record<string, unknown>
|
|
472
522
|
const raw: Record<string, unknown> = (modObj?.default as Record<string, unknown>) || modObj
|
|
473
523
|
|
|
474
|
-
if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors)) {
|
|
524
|
+
if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors || raw.managedResources || raw.agents || raw.schedules || raw.routines || raw.localFolders || raw.gatewayPlatforms || raw.setupChecks)) {
|
|
475
525
|
const hooks = isRecord(raw.hooks) ? (raw.hooks as ExtensionHooks) : {}
|
|
476
526
|
return {
|
|
477
527
|
name: raw.name as string,
|
|
@@ -484,6 +534,7 @@ function normalizeExtension(mod: unknown): Extension | null {
|
|
|
484
534
|
ui: isRecord(raw.ui) ? (raw.ui as ExtensionUIDefinition) : undefined,
|
|
485
535
|
providers: Array.isArray(raw.providers) ? (raw.providers as ExtensionProviderDefinition[]) : undefined,
|
|
486
536
|
connectors: Array.isArray(raw.connectors) ? (raw.connectors as ExtensionConnectorDefinition[]) : undefined,
|
|
537
|
+
managedResources: coerceManagedResources(raw),
|
|
487
538
|
} as Extension
|
|
488
539
|
}
|
|
489
540
|
|
|
@@ -639,6 +690,7 @@ interface LoadedExtension {
|
|
|
639
690
|
ui?: ExtensionUIDefinition
|
|
640
691
|
providers?: ExtensionProviderDefinition[]
|
|
641
692
|
connectors?: ExtensionConnectorDefinition[]
|
|
693
|
+
managedResources?: ExtensionManagedResources
|
|
642
694
|
isBuiltin?: boolean
|
|
643
695
|
}
|
|
644
696
|
|
|
@@ -1017,6 +1069,7 @@ class ExtensionManager {
|
|
|
1017
1069
|
ui: p.ui,
|
|
1018
1070
|
providers: p.providers,
|
|
1019
1071
|
connectors: p.connectors,
|
|
1072
|
+
managedResources: p.managedResources || coerceManagedResources(p as unknown as Record<string, unknown>),
|
|
1020
1073
|
isBuiltin: true
|
|
1021
1074
|
})
|
|
1022
1075
|
this.markExtensionSuccess(id)
|
|
@@ -1064,6 +1117,7 @@ class ExtensionManager {
|
|
|
1064
1117
|
ui: ext.ui,
|
|
1065
1118
|
providers: ext.providers,
|
|
1066
1119
|
connectors: ext.connectors,
|
|
1120
|
+
managedResources: ext.managedResources,
|
|
1067
1121
|
})
|
|
1068
1122
|
this.markExtensionSuccess(file)
|
|
1069
1123
|
} catch (err: unknown) {
|
|
@@ -1145,6 +1199,55 @@ class ExtensionManager {
|
|
|
1145
1199
|
return allUI
|
|
1146
1200
|
}
|
|
1147
1201
|
|
|
1202
|
+
getManagedResourceExtensions(): Array<{
|
|
1203
|
+
extensionId: string
|
|
1204
|
+
extensionName: string
|
|
1205
|
+
enabled: boolean
|
|
1206
|
+
isBuiltin: boolean
|
|
1207
|
+
source?: ExtensionMeta['source']
|
|
1208
|
+
managedResources: ExtensionManagedResources
|
|
1209
|
+
}> {
|
|
1210
|
+
this.load()
|
|
1211
|
+
const result: Array<{
|
|
1212
|
+
extensionId: string
|
|
1213
|
+
extensionName: string
|
|
1214
|
+
enabled: boolean
|
|
1215
|
+
isBuiltin: boolean
|
|
1216
|
+
source?: ExtensionMeta['source']
|
|
1217
|
+
managedResources: ExtensionManagedResources
|
|
1218
|
+
}> = []
|
|
1219
|
+
|
|
1220
|
+
for (const [id, entry] of this.extensions.entries()) {
|
|
1221
|
+
const managedResources = entry.managedResources
|
|
1222
|
+
if (!managedResources) continue
|
|
1223
|
+
if (!Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)) continue
|
|
1224
|
+
result.push({
|
|
1225
|
+
extensionId: id,
|
|
1226
|
+
extensionName: entry.meta.name,
|
|
1227
|
+
enabled: entry.meta.enabled,
|
|
1228
|
+
isBuiltin: entry.isBuiltin === true,
|
|
1229
|
+
source: entry.meta.source,
|
|
1230
|
+
managedResources,
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
return result
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
getManagedResources(extensionId: string): ExtensionManagedResources | null {
|
|
1238
|
+
this.load()
|
|
1239
|
+
const candidateIds = expandExtensionIds([extensionId])
|
|
1240
|
+
for (const id of candidateIds) {
|
|
1241
|
+
const loaded = this.extensions.get(id)
|
|
1242
|
+
if (loaded?.managedResources) return loaded.managedResources
|
|
1243
|
+
const builtin = this.builtins.get(id)
|
|
1244
|
+
if (builtin) {
|
|
1245
|
+
return builtin.managedResources || coerceManagedResources(builtin as unknown as Record<string, unknown>) || null
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return null
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1148
1251
|
listExtensionIds(): string[] {
|
|
1149
1252
|
this.load()
|
|
1150
1253
|
return Array.from(this.extensions.keys())
|
|
@@ -1842,11 +1945,14 @@ class ExtensionManager {
|
|
|
1842
1945
|
const failures = this.readFailureState()
|
|
1843
1946
|
const metas: ExtensionMeta[] = []
|
|
1844
1947
|
|
|
1845
|
-
const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields'> => {
|
|
1948
|
+
const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields' | 'managedAgentCount' | 'managedScheduleCount' | 'localFolderCount' | 'gatewayPlatformCount' | 'setupCheckCount'> => {
|
|
1846
1949
|
const tools = loaded?.tools || fallback?.tools || []
|
|
1847
1950
|
const hooks = loaded?.hooks || fallback?.hooks || {}
|
|
1848
1951
|
const providers = loaded?.providers || fallback?.providers || []
|
|
1849
1952
|
const connectors = loaded?.connectors || fallback?.connectors || []
|
|
1953
|
+
const managedResources = loaded?.managedResources
|
|
1954
|
+
|| fallback?.managedResources
|
|
1955
|
+
|| (fallback ? coerceManagedResources(fallback as unknown as Record<string, unknown>) : undefined)
|
|
1850
1956
|
const hasUi = !!(loaded?.ui || fallback?.ui)
|
|
1851
1957
|
const settingsFields = loaded?.ui?.settingsFields || fallback?.ui?.settingsFields
|
|
1852
1958
|
return {
|
|
@@ -1855,6 +1961,11 @@ class ExtensionManager {
|
|
|
1855
1961
|
hasUI: hasUi,
|
|
1856
1962
|
providerCount: Array.isArray(providers) ? providers.length : 0,
|
|
1857
1963
|
connectorCount: Array.isArray(connectors) ? connectors.length : 0,
|
|
1964
|
+
managedAgentCount: managedResources?.agents?.length || 0,
|
|
1965
|
+
managedScheduleCount: (managedResources?.schedules?.length || 0) + (managedResources?.routines?.length || 0),
|
|
1966
|
+
localFolderCount: managedResources?.localFolders?.length || 0,
|
|
1967
|
+
gatewayPlatformCount: managedResources?.gatewayPlatforms?.length || 0,
|
|
1968
|
+
setupCheckCount: managedResources?.setupChecks?.length || 0,
|
|
1858
1969
|
settingsFields: settingsFields?.length ? settingsFields : undefined,
|
|
1859
1970
|
}
|
|
1860
1971
|
}
|
|
@@ -70,6 +70,8 @@ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
|
|
|
70
70
|
externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
|
|
71
71
|
sessionCount: normalizeNullableNumber(stats.sessionCount) ?? undefined,
|
|
72
72
|
presenceCount: normalizeNullableNumber(stats.presenceCount) ?? undefined,
|
|
73
|
+
environmentCount: normalizeNullableNumber(stats.environmentCount) ?? undefined,
|
|
74
|
+
availableEnvironmentCount: normalizeNullableNumber(stats.availableEnvironmentCount) ?? undefined,
|
|
73
75
|
lastTopologyCheckedAt: normalizeNullableNumber(stats.lastTopologyCheckedAt) ?? undefined,
|
|
74
76
|
lastTopologyErrorCount: normalizeNullableNumber(stats.lastTopologyErrorCount) ?? undefined,
|
|
75
77
|
lastTopologyError: normalizeText(stats.lastTopologyError),
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
2
|
import { describe, it } from 'node:test'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildOpenClawGatewayTopology,
|
|
6
|
+
getOpenClawGatewayEnvironmentStatus,
|
|
7
|
+
getOpenClawGatewayFleetTopology,
|
|
8
|
+
} from './gateway-topology'
|
|
5
9
|
import type { GatewayProfile, OpenClawGatewayStats } from '@/types'
|
|
6
10
|
|
|
7
11
|
const now = 1_800_000_000_000
|
|
@@ -60,6 +64,25 @@ describe('OpenClaw gateway topology', () => {
|
|
|
60
64
|
},
|
|
61
65
|
'sessions.list': { sessions: [{ sessionId: 'session_1', title: 'Release room' }] },
|
|
62
66
|
'system-presence': { presence: [{ deviceId: 'phone_1', mode: 'active' }] },
|
|
67
|
+
'environments.list': {
|
|
68
|
+
environments: [
|
|
69
|
+
{
|
|
70
|
+
id: 'gateway',
|
|
71
|
+
type: 'local',
|
|
72
|
+
label: 'Gateway local',
|
|
73
|
+
status: 'available',
|
|
74
|
+
capabilities: ['agent.run', 'sessions', 'tools', 'workspace'],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'node:node_1',
|
|
78
|
+
type: 'node',
|
|
79
|
+
label: 'Mac Studio',
|
|
80
|
+
status: 'available',
|
|
81
|
+
capabilities: ['shell.exec'],
|
|
82
|
+
},
|
|
83
|
+
{ id: 'node:node_2', type: 'node', label: 'Builder', status: 'unavailable' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
63
86
|
}) as never,
|
|
64
87
|
persistStats: (id, input) => {
|
|
65
88
|
assert.equal(id, 'gateway_1')
|
|
@@ -77,6 +100,10 @@ describe('OpenClaw gateway topology', () => {
|
|
|
77
100
|
assert.equal(topology.stats.pairedDeviceCount, 1)
|
|
78
101
|
assert.equal(topology.stats.sessionCount, 1)
|
|
79
102
|
assert.equal(topology.stats.presenceCount, 1)
|
|
103
|
+
assert.equal(topology.stats.environmentCount, 3)
|
|
104
|
+
assert.equal(topology.stats.availableEnvironmentCount, 2)
|
|
105
|
+
assert.equal(topology.environments[0]?.id, 'gateway')
|
|
106
|
+
assert.equal(topology.environments[1]?.capabilities?.[0], 'shell.exec')
|
|
80
107
|
assert.equal(topology.stats.pendingPairingCount, 2)
|
|
81
108
|
assert.equal(topology.stats.hasErrors, false)
|
|
82
109
|
assert.equal(topology.stats.lastTopologyCheckedAt, now)
|
|
@@ -92,15 +119,17 @@ describe('OpenClaw gateway topology', () => {
|
|
|
92
119
|
'device.pair.list': { pending: [], paired: [] },
|
|
93
120
|
'sessions.list': new Error('sessions unavailable'),
|
|
94
121
|
'system-presence': new Error('presence unavailable'),
|
|
122
|
+
'environments.list': new Error('environments unavailable'),
|
|
95
123
|
}) as never,
|
|
96
124
|
persistStats: (id, input) => ({ ...profile({ id }), stats: input.stats as OpenClawGatewayStats }),
|
|
97
125
|
})
|
|
98
126
|
|
|
99
127
|
assert.equal(topology.nodes.length, 1)
|
|
100
128
|
assert.equal(topology.sessions.length, 0)
|
|
101
|
-
assert.
|
|
129
|
+
assert.equal(topology.environments.length, 2)
|
|
130
|
+
assert.deepEqual(topology.errors.map((error) => error.method), ['sessions.list', 'system-presence', 'environments.list'])
|
|
102
131
|
assert.equal(topology.stats.hasErrors, true)
|
|
103
|
-
assert.equal(topology.stats.lastTopologyErrorCount,
|
|
132
|
+
assert.equal(topology.stats.lastTopologyErrorCount, 3)
|
|
104
133
|
assert.equal(topology.stats.lastTopologyError, 'sessions unavailable')
|
|
105
134
|
})
|
|
106
135
|
|
|
@@ -113,6 +142,7 @@ describe('OpenClaw gateway topology', () => {
|
|
|
113
142
|
assert.equal(topology.connected, false)
|
|
114
143
|
assert.equal(topology.stats.hasErrors, true)
|
|
115
144
|
assert.equal(topology.stats.nodeCount, 0)
|
|
145
|
+
assert.equal(topology.stats.environmentCount, 0)
|
|
116
146
|
assert.equal(topology.errors[0]?.method, 'gateway.connect')
|
|
117
147
|
})
|
|
118
148
|
|
|
@@ -132,6 +162,11 @@ describe('OpenClaw gateway topology', () => {
|
|
|
132
162
|
'device.pair.list': { pending: [], paired: [] },
|
|
133
163
|
'sessions.list': { sessions: target?.profileId === 'gateway_a' ? [{ id: 'session_a' }] : [] },
|
|
134
164
|
'system-presence': { presence: [] },
|
|
165
|
+
'environments.list': {
|
|
166
|
+
environments: target?.profileId === 'gateway_a'
|
|
167
|
+
? [{ id: 'gateway', type: 'local', status: 'available' }]
|
|
168
|
+
: [{ id: 'gateway', type: 'local', status: 'available' }, { id: 'node:node_b', type: 'node', status: 'unavailable' }],
|
|
169
|
+
},
|
|
135
170
|
}) as never,
|
|
136
171
|
persistStats: (id, input) => ({
|
|
137
172
|
...(id === 'gateway_a' ? first : second),
|
|
@@ -144,8 +179,29 @@ describe('OpenClaw gateway topology', () => {
|
|
|
144
179
|
assert.equal(fleet.totals.connectedGatewayCount, 2)
|
|
145
180
|
assert.equal(fleet.totals.nodeCount, 2)
|
|
146
181
|
assert.equal(fleet.totals.connectedNodeCount, 1)
|
|
182
|
+
assert.equal(fleet.totals.environmentCount, 3)
|
|
183
|
+
assert.equal(fleet.totals.availableEnvironmentCount, 2)
|
|
147
184
|
assert.equal(fleet.totals.pendingNodePairings, 1)
|
|
148
185
|
assert.equal(fleet.totals.sessionCount, 1)
|
|
149
186
|
assert.equal(fleet.gateways.length, 2)
|
|
150
187
|
})
|
|
188
|
+
|
|
189
|
+
it('returns direct environment status through the gateway protocol', async () => {
|
|
190
|
+
const calls: Array<{ method: string; params?: Record<string, unknown> }> = []
|
|
191
|
+
const status = await getOpenClawGatewayEnvironmentStatus('gateway_1', 'node:node_1', {
|
|
192
|
+
now: () => now,
|
|
193
|
+
getGatewayProfile: () => profile(),
|
|
194
|
+
ensureGatewayConnected: async () => ({
|
|
195
|
+
connected: true,
|
|
196
|
+
rpc: async (method: string, params?: Record<string, unknown>) => {
|
|
197
|
+
calls.push({ method, params })
|
|
198
|
+
return { id: 'node:node_1', type: 'node', label: 'Mac Studio', status: 'available', capabilities: ['shell.exec'] }
|
|
199
|
+
},
|
|
200
|
+
}) as never,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
assert.equal(status?.environment?.id, 'node:node_1')
|
|
204
|
+
assert.equal(status?.environment?.status, 'available')
|
|
205
|
+
assert.deepEqual(calls, [{ method: 'environments.status', params: { environmentId: 'node:node_1' } }])
|
|
206
|
+
})
|
|
151
207
|
})
|
|
@@ -6,8 +6,11 @@ import {
|
|
|
6
6
|
} from './gateway-profile-service'
|
|
7
7
|
import { ensureGatewayConnected } from '@/lib/server/openclaw/gateway'
|
|
8
8
|
import type {
|
|
9
|
+
OpenClawEnvironmentSummary,
|
|
9
10
|
GatewayProfile,
|
|
10
11
|
OpenClawDevicePairRequest,
|
|
12
|
+
OpenClawGatewayEnvironmentList,
|
|
13
|
+
OpenClawGatewayEnvironmentStatusSnapshot,
|
|
11
14
|
OpenClawGatewayFleetTopology,
|
|
12
15
|
OpenClawGatewayPresenceEntry,
|
|
13
16
|
OpenClawGatewayRpcError,
|
|
@@ -26,6 +29,7 @@ type GatewayRpcClient = {
|
|
|
26
29
|
|
|
27
30
|
interface GatewayTopologyDeps {
|
|
28
31
|
ensureGatewayConnected?: typeof ensureGatewayConnected
|
|
32
|
+
getGatewayProfile?: typeof getGatewayProfileById
|
|
29
33
|
listGatewayProfiles?: typeof listOpenClawGatewayProfiles
|
|
30
34
|
now?: () => number
|
|
31
35
|
persistStats?: typeof updateGatewayProfile
|
|
@@ -176,14 +180,72 @@ function normalizePresence(value: unknown): OpenClawGatewayPresenceEntry | null
|
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
function uniqueStrings(...items: Array<readonly string[] | undefined>): string[] {
|
|
184
|
+
const values = new Set<string>()
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
for (const value of item || []) {
|
|
187
|
+
const trimmed = asString(value)
|
|
188
|
+
if (trimmed) values.add(trimmed)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return [...values].sort((left, right) => left.localeCompare(right))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeEnvironmentStatus(value: unknown): OpenClawEnvironmentSummary['status'] {
|
|
195
|
+
return value === 'available'
|
|
196
|
+
|| value === 'starting'
|
|
197
|
+
|| value === 'stopping'
|
|
198
|
+
|| value === 'error'
|
|
199
|
+
? value
|
|
200
|
+
: 'unavailable'
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeEnvironment(value: unknown): OpenClawEnvironmentSummary | null {
|
|
204
|
+
const record = asObject(value)
|
|
205
|
+
const id = asString(record?.id) || asString(record?.environmentId)
|
|
206
|
+
if (!record || !id) return null
|
|
207
|
+
const capabilities = Array.isArray(record.capabilities)
|
|
208
|
+
? uniqueStrings(record.capabilities.map(asString).filter(Boolean) as string[])
|
|
209
|
+
: undefined
|
|
210
|
+
return {
|
|
211
|
+
id,
|
|
212
|
+
type: asString(record.type) || 'local',
|
|
213
|
+
label: asString(record.label) || asString(record.name),
|
|
214
|
+
status: normalizeEnvironmentStatus(record.status),
|
|
215
|
+
capabilities: capabilities && capabilities.length > 0 ? capabilities : undefined,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function deriveEnvironments(profile: GatewayProfile, nodes: OpenClawNode[], connected: boolean): OpenClawEnvironmentSummary[] {
|
|
220
|
+
const gatewayEnvironment: OpenClawEnvironmentSummary = {
|
|
221
|
+
id: 'gateway',
|
|
222
|
+
type: 'local',
|
|
223
|
+
label: profile.name || 'Gateway local',
|
|
224
|
+
status: connected ? 'available' : 'unavailable',
|
|
225
|
+
capabilities: ['agent.run', 'sessions', 'tools', 'workspace'],
|
|
226
|
+
}
|
|
227
|
+
const nodeEnvironments = nodes.map((node) => {
|
|
228
|
+
const capabilities = uniqueStrings(node.caps, node.commands)
|
|
229
|
+
return {
|
|
230
|
+
id: `node:${node.nodeId}`,
|
|
231
|
+
type: 'node',
|
|
232
|
+
label: node.displayName || node.nodeId,
|
|
233
|
+
status: node.connected === true ? 'available' : 'unavailable',
|
|
234
|
+
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
|
235
|
+
} satisfies OpenClawEnvironmentSummary
|
|
236
|
+
})
|
|
237
|
+
return [gatewayEnvironment, ...nodeEnvironments]
|
|
238
|
+
}
|
|
239
|
+
|
|
179
240
|
async function safeRpc<T>(
|
|
180
241
|
gateway: GatewayRpcClient,
|
|
181
242
|
method: string,
|
|
182
243
|
errors: OpenClawGatewayRpcError[],
|
|
183
244
|
normalize: (value: unknown) => T,
|
|
245
|
+
params: Record<string, unknown> = {},
|
|
184
246
|
): Promise<T> {
|
|
185
247
|
try {
|
|
186
|
-
return normalize(await gateway.rpc(method,
|
|
248
|
+
return normalize(await gateway.rpc(method, params))
|
|
187
249
|
} catch (err: unknown) {
|
|
188
250
|
errors.push({ method, message: errorMessage(err) })
|
|
189
251
|
return normalize(null)
|
|
@@ -197,6 +259,7 @@ function topologyStats(params: {
|
|
|
197
259
|
pairedDevices: OpenClawPairedDevice[]
|
|
198
260
|
sessions: OpenClawGatewaySession[]
|
|
199
261
|
presence: OpenClawGatewayPresenceEntry[]
|
|
262
|
+
environments: OpenClawEnvironmentSummary[]
|
|
200
263
|
errors: OpenClawGatewayRpcError[]
|
|
201
264
|
refreshedAt: number
|
|
202
265
|
}): OpenClawGatewayTopologyStats {
|
|
@@ -208,6 +271,8 @@ function topologyStats(params: {
|
|
|
208
271
|
pendingDevicePairings: params.devicePairings.length,
|
|
209
272
|
sessionCount: params.sessions.length,
|
|
210
273
|
presenceCount: params.presence.length,
|
|
274
|
+
environmentCount: params.environments.length,
|
|
275
|
+
availableEnvironmentCount: params.environments.filter((environment) => environment.status === 'available').length,
|
|
211
276
|
pendingPairingCount: params.nodePairings.length + params.devicePairings.length,
|
|
212
277
|
hasErrors: params.errors.length > 0,
|
|
213
278
|
lastTopologyCheckedAt: params.refreshedAt,
|
|
@@ -235,6 +300,7 @@ export async function buildOpenClawGatewayTopology(
|
|
|
235
300
|
pairedDevices: [],
|
|
236
301
|
sessions: [],
|
|
237
302
|
presence: [],
|
|
303
|
+
environments: [],
|
|
238
304
|
errors,
|
|
239
305
|
refreshedAt,
|
|
240
306
|
})
|
|
@@ -249,11 +315,12 @@ export async function buildOpenClawGatewayTopology(
|
|
|
249
315
|
pairedDevices: [],
|
|
250
316
|
sessions: [],
|
|
251
317
|
presence: [],
|
|
318
|
+
environments: [],
|
|
252
319
|
errors,
|
|
253
320
|
}
|
|
254
321
|
}
|
|
255
322
|
|
|
256
|
-
const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence] = await Promise.all([
|
|
323
|
+
const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence, rpcEnvironments] = await Promise.all([
|
|
257
324
|
safeRpc(gateway, 'node.list', errors, (value) =>
|
|
258
325
|
extractArray(value, ['nodes']).map(normalizeNode).filter(Boolean) as OpenClawNode[],
|
|
259
326
|
),
|
|
@@ -265,6 +332,9 @@ export async function buildOpenClawGatewayTopology(
|
|
|
265
332
|
safeRpc(gateway, 'system-presence', errors, (value) =>
|
|
266
333
|
extractArray(value, ['presence']).map(normalizePresence).filter(Boolean) as OpenClawGatewayPresenceEntry[],
|
|
267
334
|
),
|
|
335
|
+
safeRpc(gateway, 'environments.list', errors, (value) =>
|
|
336
|
+
extractArray(value, ['environments']).map(normalizeEnvironment).filter(Boolean) as OpenClawEnvironmentSummary[],
|
|
337
|
+
),
|
|
268
338
|
])
|
|
269
339
|
|
|
270
340
|
const devicePairingsRecord = asObject(devicePairingsRaw) || {}
|
|
@@ -277,6 +347,9 @@ export async function buildOpenClawGatewayTopology(
|
|
|
277
347
|
const pairedDevices = extractArray(devicePairingsRecord.paired)
|
|
278
348
|
.map(normalizePairedDevice)
|
|
279
349
|
.filter(Boolean) as OpenClawPairedDevice[]
|
|
350
|
+
const environments = rpcEnvironments.length > 0
|
|
351
|
+
? rpcEnvironments
|
|
352
|
+
: deriveEnvironments(profile, nodes, gateway.connected)
|
|
280
353
|
const stats = topologyStats({
|
|
281
354
|
nodes,
|
|
282
355
|
nodePairings,
|
|
@@ -284,6 +357,7 @@ export async function buildOpenClawGatewayTopology(
|
|
|
284
357
|
pairedDevices,
|
|
285
358
|
sessions,
|
|
286
359
|
presence,
|
|
360
|
+
environments,
|
|
287
361
|
errors,
|
|
288
362
|
refreshedAt,
|
|
289
363
|
})
|
|
@@ -301,6 +375,54 @@ export async function buildOpenClawGatewayTopology(
|
|
|
301
375
|
pairedDevices,
|
|
302
376
|
sessions,
|
|
303
377
|
presence,
|
|
378
|
+
environments,
|
|
379
|
+
errors,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function listOpenClawGatewayEnvironments(
|
|
384
|
+
id: string,
|
|
385
|
+
deps: GatewayTopologyDeps = {},
|
|
386
|
+
): Promise<OpenClawGatewayEnvironmentList | null> {
|
|
387
|
+
const topology = await getOpenClawGatewayTopology(id, deps)
|
|
388
|
+
if (!topology) return null
|
|
389
|
+
return {
|
|
390
|
+
profile: topology.profile,
|
|
391
|
+
connected: topology.connected,
|
|
392
|
+
refreshedAt: topology.refreshedAt,
|
|
393
|
+
environments: topology.environments,
|
|
394
|
+
errors: topology.errors,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function getOpenClawGatewayEnvironmentStatus(
|
|
399
|
+
id: string,
|
|
400
|
+
environmentId: string,
|
|
401
|
+
deps: GatewayTopologyDeps = {},
|
|
402
|
+
): Promise<OpenClawGatewayEnvironmentStatusSnapshot | null> {
|
|
403
|
+
const profile = (deps.getGatewayProfile ?? getGatewayProfileById)(id)
|
|
404
|
+
if (!profile) return null
|
|
405
|
+
const now = deps.now ?? (() => Date.now())
|
|
406
|
+
const refreshedAt = now()
|
|
407
|
+
const ensureConnected = deps.ensureGatewayConnected ?? ensureGatewayConnected
|
|
408
|
+
const errors: OpenClawGatewayRpcError[] = []
|
|
409
|
+
const gateway = await ensureConnected({ profileId: profile.id }) as GatewayRpcClient | null
|
|
410
|
+
if (!gateway) {
|
|
411
|
+
errors.push({ method: 'gateway.connect', message: 'OpenClaw gateway not connected' })
|
|
412
|
+
return { profile, connected: false, refreshedAt, environment: null, errors }
|
|
413
|
+
}
|
|
414
|
+
const environment = await safeRpc(
|
|
415
|
+
gateway,
|
|
416
|
+
'environments.status',
|
|
417
|
+
errors,
|
|
418
|
+
(value) => normalizeEnvironment(value),
|
|
419
|
+
{ environmentId },
|
|
420
|
+
)
|
|
421
|
+
return {
|
|
422
|
+
profile,
|
|
423
|
+
connected: gateway.connected,
|
|
424
|
+
refreshedAt,
|
|
425
|
+
environment,
|
|
304
426
|
errors,
|
|
305
427
|
}
|
|
306
428
|
}
|
|
@@ -311,6 +433,8 @@ function emptyTotals(generatedAt: number): OpenClawGatewayFleetTopology['totals'
|
|
|
311
433
|
connectedGatewayCount: 0,
|
|
312
434
|
nodeCount: 0,
|
|
313
435
|
connectedNodeCount: 0,
|
|
436
|
+
environmentCount: 0,
|
|
437
|
+
availableEnvironmentCount: 0,
|
|
314
438
|
pendingNodePairings: 0,
|
|
315
439
|
pairedDeviceCount: 0,
|
|
316
440
|
pendingDevicePairings: 0,
|
|
@@ -328,7 +452,7 @@ export async function getOpenClawGatewayTopology(
|
|
|
328
452
|
id: string,
|
|
329
453
|
deps: GatewayTopologyDeps = {},
|
|
330
454
|
): Promise<OpenClawGatewayTopology | null> {
|
|
331
|
-
const profile = getGatewayProfileById(id)
|
|
455
|
+
const profile = (deps.getGatewayProfile ?? getGatewayProfileById)(id)
|
|
332
456
|
if (!profile) return null
|
|
333
457
|
return buildOpenClawGatewayTopology(profile, deps)
|
|
334
458
|
}
|
|
@@ -355,6 +479,8 @@ export async function getOpenClawGatewayFleetTopology(
|
|
|
355
479
|
acc.pendingDevicePairings = (acc.pendingDevicePairings || 0) + (topology.stats.pendingDevicePairings || 0)
|
|
356
480
|
acc.sessionCount = (acc.sessionCount || 0) + (topology.stats.sessionCount || 0)
|
|
357
481
|
acc.presenceCount = (acc.presenceCount || 0) + (topology.stats.presenceCount || 0)
|
|
482
|
+
acc.environmentCount = (acc.environmentCount || 0) + (topology.stats.environmentCount || 0)
|
|
483
|
+
acc.availableEnvironmentCount = (acc.availableEnvironmentCount || 0) + (topology.stats.availableEnvironmentCount || 0)
|
|
358
484
|
acc.pendingPairingCount += topology.stats.pendingPairingCount
|
|
359
485
|
acc.hasErrors = acc.hasErrors || topology.stats.hasErrors
|
|
360
486
|
acc.lastTopologyErrorCount = (acc.lastTopologyErrorCount || 0) + (topology.stats.lastTopologyErrorCount || 0)
|
|
@@ -161,4 +161,33 @@ describe('operation pulse', () => {
|
|
|
161
161
|
assert.ok((pulse.actions[0]?.summary || '').includes('pending OpenClaw pairing'))
|
|
162
162
|
assert.equal(pulse.actions[0]?.href, '/providers')
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
it('raises gateway attention when no execution environments are available', () => {
|
|
166
|
+
const pulse = buildOperationPulse({
|
|
167
|
+
range: '24h',
|
|
168
|
+
now,
|
|
169
|
+
missions: [],
|
|
170
|
+
runs: [],
|
|
171
|
+
approvals: [],
|
|
172
|
+
connectors: [],
|
|
173
|
+
gateways: [
|
|
174
|
+
gateway({
|
|
175
|
+
status: 'healthy',
|
|
176
|
+
stats: {
|
|
177
|
+
nodeCount: 1,
|
|
178
|
+
connectedNodeCount: 1,
|
|
179
|
+
environmentCount: 2,
|
|
180
|
+
availableEnvironmentCount: 0,
|
|
181
|
+
lastTopologyCheckedAt: now - 1000,
|
|
182
|
+
},
|
|
183
|
+
}),
|
|
184
|
+
],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
assert.equal(pulse.kpis.gatewayAttention, 1)
|
|
188
|
+
assert.equal(pulse.actions[0]?.kind, 'gateway')
|
|
189
|
+
assert.equal(pulse.actions[0]?.severity, 'high')
|
|
190
|
+
assert.ok((pulse.actions[0]?.summary || '').includes('no available OpenClaw execution environments'))
|
|
191
|
+
assert.equal(pulse.actions[0]?.evidence.includes('0/2 environments'), true)
|
|
192
|
+
})
|
|
164
193
|
})
|
|
@@ -87,6 +87,7 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
|
87
87
|
const evidence = [
|
|
88
88
|
`status:${gateway.status}`,
|
|
89
89
|
`${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
|
|
90
|
+
`${gateway.stats?.availableEnvironmentCount || 0}/${gateway.stats?.environmentCount || 0} environments`,
|
|
90
91
|
]
|
|
91
92
|
|
|
92
93
|
if (gateway.status === 'offline') {
|
|
@@ -113,6 +114,14 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
if ((gateway.stats?.environmentCount || 0) > 0 && (gateway.stats?.availableEnvironmentCount || 0) === 0) {
|
|
118
|
+
return {
|
|
119
|
+
severity: 'high',
|
|
120
|
+
summary: `${gateway.name} has no available OpenClaw execution environments.`,
|
|
121
|
+
evidence,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
if (pendingPairings > 0) {
|
|
117
126
|
return {
|
|
118
127
|
severity: 'medium',
|
|
@@ -138,6 +138,54 @@ module.exports = {
|
|
|
138
138
|
}
|
|
139
139
|
],
|
|
140
140
|
|
|
141
|
+
// --- Managed Resources (Paperclip-compatible) ---
|
|
142
|
+
managedResources: {
|
|
143
|
+
agents: [
|
|
144
|
+
{
|
|
145
|
+
agentKey: 'researcher',
|
|
146
|
+
displayName: 'Managed Researcher',
|
|
147
|
+
description: 'Reusable agent this extension can provision.',
|
|
148
|
+
systemPrompt: 'Research carefully and cite durable evidence.',
|
|
149
|
+
provider: 'openai',
|
|
150
|
+
model: 'gpt-4o-mini',
|
|
151
|
+
capabilities: ['research', 'analysis'],
|
|
152
|
+
extensions: ['web', 'memory']
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
schedules: [
|
|
156
|
+
{
|
|
157
|
+
scheduleKey: 'daily-digest',
|
|
158
|
+
displayName: 'Daily Digest',
|
|
159
|
+
agentRef: { resourceKind: 'agent', resourceKey: 'researcher' },
|
|
160
|
+
taskPrompt: 'Prepare the daily digest.',
|
|
161
|
+
scheduleType: 'cron',
|
|
162
|
+
cron: '0 9 * * *',
|
|
163
|
+
timezone: 'UTC',
|
|
164
|
+
status: 'paused'
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
localFolders: [
|
|
168
|
+
{
|
|
169
|
+
folderKey: 'workspace',
|
|
170
|
+
displayName: 'Workspace Folder',
|
|
171
|
+
access: 'readWrite',
|
|
172
|
+
requiredDirectories: ['inputs', 'outputs']
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
gatewayPlatforms: [
|
|
176
|
+
{
|
|
177
|
+
platformKey: 'openai-compatible-api',
|
|
178
|
+
displayName: 'OpenAI-compatible API',
|
|
179
|
+
transport: 'http',
|
|
180
|
+
endpoint: 'http://127.0.0.1:8642/v1',
|
|
181
|
+
authMode: 'bearer'
|
|
182
|
+
}
|
|
183
|
+
],
|
|
184
|
+
setupChecks: [
|
|
185
|
+
{ checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY', required: true }
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
|
|
141
189
|
// --- Real OpenClaw Format (register API) ---
|
|
142
190
|
register(api) {
|
|
143
191
|
api.registerHook('agent:start', (ctx) => {
|
|
@@ -162,6 +210,8 @@ Key rules:
|
|
|
162
210
|
- If your extension needs npm/pnpm/yarn/bun packages, include a packageJson object during scaffold or call install_dependencies later.
|
|
163
211
|
- Dependency installs are run by the extension manager inside a per-extension workspace using the selected package manager with scripts disabled.
|
|
164
212
|
- Extension settings are declared through ui.settingsFields and stored per extension ID
|
|
213
|
+
- Managed resources let an extension declare provisionable agents, schedules/routines, trusted local folders, gateway platforms, and setup checks. Operators reconcile them through Extensions > Managed Resources or /api/extensions/managed-resources.
|
|
214
|
+
- Paperclip-compatible top-level agents, routines, and localFolders are also accepted; SwarmClaw reconciles routines as schedules when they include schedule timing.
|
|
165
215
|
- Keep extensions focused: one clear purpose per extension
|
|
166
216
|
`
|
|
167
217
|
}
|