@swarmclawai/swarmclaw 1.9.3 → 1.9.5
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 +25 -5
- package/package.json +2 -2
- 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/portability/export/route.test.ts +225 -0
- package/src/app/api/portability/export/route.ts +18 -9
- package/src/app/api/portability/import/route.test.ts +232 -31
- package/src/app/api/portability/import/route.ts +2 -2
- package/src/app/api/tasks/task-workspace-route.test.ts +4 -0
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -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/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/portability/export.ts +244 -38
- package/src/lib/server/portability/import.ts +148 -98
- 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/lib/validation/schemas.ts +54 -1
- package/src/types/misc.ts +31 -0
- package/src/types/task.ts +30 -0
|
@@ -1,39 +1,18 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { spawnSync } from 'node:child_process'
|
|
6
2
|
import test from 'node:test'
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
function runWithTempDataDir(script: string) {
|
|
11
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-portability-import-'))
|
|
12
|
-
try {
|
|
13
|
-
const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
|
|
14
|
-
cwd: repoRoot,
|
|
15
|
-
env: {
|
|
16
|
-
...process.env,
|
|
17
|
-
DATA_DIR: path.join(tempDir, 'data'),
|
|
18
|
-
WORKSPACE_DIR: path.join(tempDir, 'workspace'),
|
|
19
|
-
},
|
|
20
|
-
encoding: 'utf-8',
|
|
21
|
-
})
|
|
22
|
-
assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
|
|
23
|
-
const lines = (result.stdout || '')
|
|
24
|
-
.trim()
|
|
25
|
-
.split('\n')
|
|
26
|
-
.map((line) => line.trim())
|
|
27
|
-
.filter(Boolean)
|
|
28
|
-
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
|
|
29
|
-
return JSON.parse(jsonLine || '{}')
|
|
30
|
-
} finally {
|
|
31
|
-
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
32
|
-
}
|
|
33
|
-
}
|
|
4
|
+
import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
|
|
34
5
|
|
|
35
6
|
test('POST /api/portability/import validates manifest arrays before importing', () => {
|
|
36
|
-
const output = runWithTempDataDir
|
|
7
|
+
const output = runWithTempDataDir<{
|
|
8
|
+
invalidStatus: number
|
|
9
|
+
invalidError: string | null
|
|
10
|
+
invalidPaths: string[]
|
|
11
|
+
validStatus: number
|
|
12
|
+
validAgentsCreated: number | null
|
|
13
|
+
validSkillsCreated: number | null
|
|
14
|
+
validSchedulesCreated: number | null
|
|
15
|
+
}>(`
|
|
37
16
|
const routeMod = await import('./src/app/api/portability/import/route')
|
|
38
17
|
const route = routeMod.default || routeMod
|
|
39
18
|
|
|
@@ -78,3 +57,225 @@ test('POST /api/portability/import validates manifest arrays before importing',
|
|
|
78
57
|
assert.equal(output.validSkillsCreated, 0)
|
|
79
58
|
assert.equal(output.validSchedulesCreated, 0)
|
|
80
59
|
})
|
|
60
|
+
|
|
61
|
+
test('POST /api/portability/import preserves v2 bundle resources after validation', () => {
|
|
62
|
+
const output = runWithTempDataDir<{
|
|
63
|
+
status: number
|
|
64
|
+
created: Record<string, number>
|
|
65
|
+
projectId: string | null
|
|
66
|
+
agentId: string | null
|
|
67
|
+
agentProjectId: string | null
|
|
68
|
+
agentSkillIds: string[]
|
|
69
|
+
agentMcpServerIds: string[]
|
|
70
|
+
agentGoalId: string | null
|
|
71
|
+
skillId: string | null
|
|
72
|
+
skillProjectId: string | null
|
|
73
|
+
skillAgentIds: string[]
|
|
74
|
+
scheduleProjectId: string | null
|
|
75
|
+
scheduleParticipantIds: string[]
|
|
76
|
+
scheduleFacilitatorId: string | null
|
|
77
|
+
scheduleObserverIds: string[]
|
|
78
|
+
chatroomId: string | null
|
|
79
|
+
chatroomAgentIds: string[]
|
|
80
|
+
connectorAgentId: string | null
|
|
81
|
+
connectorChatroomId: string | null
|
|
82
|
+
connectorEnabled: boolean | null
|
|
83
|
+
mcpId: string | null
|
|
84
|
+
mcpEnvKeys: string[]
|
|
85
|
+
goalId: string | null
|
|
86
|
+
goalProjectId: string | null
|
|
87
|
+
goalAgentId: string | null
|
|
88
|
+
needsCredentials: string[]
|
|
89
|
+
}>(`
|
|
90
|
+
const routeMod = await import('./src/app/api/portability/import/route')
|
|
91
|
+
const storageMod = await import('./src/lib/server/storage')
|
|
92
|
+
const agentRepoMod = await import('./src/lib/server/agents/agent-repository')
|
|
93
|
+
const skillRepoMod = await import('./src/lib/server/skills/skill-repository')
|
|
94
|
+
const scheduleRepoMod = await import('./src/lib/server/schedules/schedule-repository')
|
|
95
|
+
const chatroomRepoMod = await import('./src/lib/server/chatrooms/chatroom-repository')
|
|
96
|
+
const connectorRepoMod = await import('./src/lib/server/connectors/connector-repository')
|
|
97
|
+
const route = routeMod.default || routeMod
|
|
98
|
+
const storage = storageMod.default || storageMod
|
|
99
|
+
const agentRepo = agentRepoMod.default || agentRepoMod
|
|
100
|
+
const skillRepo = skillRepoMod.default || skillRepoMod
|
|
101
|
+
const scheduleRepo = scheduleRepoMod.default || scheduleRepoMod
|
|
102
|
+
const chatroomRepo = chatroomRepoMod.default || chatroomRepoMod
|
|
103
|
+
const connectorRepo = connectorRepoMod.default || connectorRepoMod
|
|
104
|
+
const { loadProjects, loadMcpServers, loadGoals } = storage
|
|
105
|
+
const { loadAgents } = agentRepo
|
|
106
|
+
const { loadSkills } = skillRepo
|
|
107
|
+
const { loadSchedules } = scheduleRepo
|
|
108
|
+
const { loadChatrooms } = chatroomRepo
|
|
109
|
+
const { loadConnectors } = connectorRepo
|
|
110
|
+
|
|
111
|
+
const response = await route.POST(new Request('http://local/api/portability/import', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
formatVersion: 2,
|
|
116
|
+
exportedAt: '2026-05-05T00:00:00.000Z',
|
|
117
|
+
scope: { kind: 'project', originalProjectId: 'project-1', projectName: 'Launch Room' },
|
|
118
|
+
projects: [{
|
|
119
|
+
originalId: 'project-1',
|
|
120
|
+
name: 'Launch Room',
|
|
121
|
+
description: 'Shipping workspace',
|
|
122
|
+
objective: 'Ship the fix',
|
|
123
|
+
}],
|
|
124
|
+
skills: [{
|
|
125
|
+
originalId: 'skill-1',
|
|
126
|
+
originalProjectId: 'project-1',
|
|
127
|
+
originalAgentIds: ['agent-1'],
|
|
128
|
+
name: 'Release Skill',
|
|
129
|
+
content: 'Ship carefully',
|
|
130
|
+
scope: 'agent',
|
|
131
|
+
}],
|
|
132
|
+
mcpServers: [{
|
|
133
|
+
originalId: 'mcp-1',
|
|
134
|
+
name: 'Local Tools',
|
|
135
|
+
transport: 'stdio',
|
|
136
|
+
command: 'node',
|
|
137
|
+
args: ['tool.js'],
|
|
138
|
+
envKeys: ['API_TOKEN'],
|
|
139
|
+
credentialsScrubbed: true,
|
|
140
|
+
}],
|
|
141
|
+
agents: [{
|
|
142
|
+
originalId: 'agent-1',
|
|
143
|
+
name: 'Release Lead',
|
|
144
|
+
description: 'Owns launch execution',
|
|
145
|
+
systemPrompt: 'Ship safely',
|
|
146
|
+
provider: 'openai',
|
|
147
|
+
model: 'gpt-4o-mini',
|
|
148
|
+
projectId: 'project-1',
|
|
149
|
+
skillIds: ['skill-1'],
|
|
150
|
+
mcpServerIds: ['mcp-1'],
|
|
151
|
+
goalId: 'goal-1',
|
|
152
|
+
}],
|
|
153
|
+
schedules: [{
|
|
154
|
+
originalId: 'schedule-1',
|
|
155
|
+
originalAgentId: 'agent-1',
|
|
156
|
+
name: 'Launch Check',
|
|
157
|
+
projectId: 'project-1',
|
|
158
|
+
taskPrompt: 'Check release readiness',
|
|
159
|
+
taskMode: 'protocol',
|
|
160
|
+
protocolTemplateId: 'template-1',
|
|
161
|
+
protocolParticipantAgentIds: ['agent-1'],
|
|
162
|
+
protocolFacilitatorAgentId: 'agent-1',
|
|
163
|
+
protocolObserverAgentIds: ['agent-1'],
|
|
164
|
+
protocolConfig: { phase: 'ship' },
|
|
165
|
+
scheduleType: 'interval',
|
|
166
|
+
intervalMs: 60000,
|
|
167
|
+
}],
|
|
168
|
+
chatrooms: [{
|
|
169
|
+
originalId: 'room-1',
|
|
170
|
+
originalAgentIds: ['agent-1'],
|
|
171
|
+
name: 'Launch Room Chat',
|
|
172
|
+
chatMode: 'parallel',
|
|
173
|
+
autoAddress: true,
|
|
174
|
+
routingRules: [{
|
|
175
|
+
type: 'keyword',
|
|
176
|
+
keywords: ['release'],
|
|
177
|
+
originalAgentId: 'agent-1',
|
|
178
|
+
priority: 1,
|
|
179
|
+
}],
|
|
180
|
+
}],
|
|
181
|
+
connectors: [{
|
|
182
|
+
originalId: 'connector-1',
|
|
183
|
+
originalAgentId: 'agent-1',
|
|
184
|
+
originalChatroomId: 'room-1',
|
|
185
|
+
name: 'Launch Slack',
|
|
186
|
+
platform: 'slack',
|
|
187
|
+
isEnabled: false,
|
|
188
|
+
config: { channel: 'launch' },
|
|
189
|
+
credentialsScrubbed: true,
|
|
190
|
+
}],
|
|
191
|
+
goals: [{
|
|
192
|
+
originalId: 'goal-1',
|
|
193
|
+
originalProjectId: 'project-1',
|
|
194
|
+
originalAgentId: 'agent-1',
|
|
195
|
+
title: 'Ship fix',
|
|
196
|
+
level: 'project',
|
|
197
|
+
objective: 'Release the portability fix',
|
|
198
|
+
status: 'active',
|
|
199
|
+
}],
|
|
200
|
+
extensions: [{ name: 'builtin-checks' }],
|
|
201
|
+
}),
|
|
202
|
+
}))
|
|
203
|
+
const payload = await response.json()
|
|
204
|
+
const project = Object.values(loadProjects()).find((item) => item.name === 'Launch Room')
|
|
205
|
+
const agent = Object.values(loadAgents()).find((item) => item.name === 'Release Lead')
|
|
206
|
+
const skill = Object.values(loadSkills()).find((item) => item.name === 'Release Skill')
|
|
207
|
+
const schedule = Object.values(loadSchedules()).find((item) => item.name === 'Launch Check')
|
|
208
|
+
const chatroom = Object.values(loadChatrooms()).find((item) => item.name === 'Launch Room Chat')
|
|
209
|
+
const connector = Object.values(loadConnectors()).find((item) => item.name === 'Launch Slack')
|
|
210
|
+
const mcp = Object.values(loadMcpServers()).find((item) => item.name === 'Local Tools')
|
|
211
|
+
const goal = Object.values(loadGoals()).find((item) => item.title === 'Ship fix')
|
|
212
|
+
|
|
213
|
+
console.log(JSON.stringify({
|
|
214
|
+
status: response.status,
|
|
215
|
+
created: {
|
|
216
|
+
agents: payload.agents.created,
|
|
217
|
+
skills: payload.skills.created,
|
|
218
|
+
schedules: payload.schedules.created,
|
|
219
|
+
connectors: payload.connectors.created,
|
|
220
|
+
chatrooms: payload.chatrooms.created,
|
|
221
|
+
mcpServers: payload.mcpServers.created,
|
|
222
|
+
projects: payload.projects.created,
|
|
223
|
+
goals: payload.goals.created,
|
|
224
|
+
},
|
|
225
|
+
projectId: project?.id || null,
|
|
226
|
+
agentId: agent?.id || null,
|
|
227
|
+
agentProjectId: agent?.projectId || null,
|
|
228
|
+
agentSkillIds: agent?.skillIds || [],
|
|
229
|
+
agentMcpServerIds: agent?.mcpServerIds || [],
|
|
230
|
+
agentGoalId: agent?.goalId || null,
|
|
231
|
+
skillId: skill?.id || null,
|
|
232
|
+
skillProjectId: skill?.projectId || null,
|
|
233
|
+
skillAgentIds: skill?.agentIds || [],
|
|
234
|
+
scheduleProjectId: schedule?.projectId || null,
|
|
235
|
+
scheduleParticipantIds: schedule?.protocolParticipantAgentIds || [],
|
|
236
|
+
scheduleFacilitatorId: schedule?.protocolFacilitatorAgentId || null,
|
|
237
|
+
scheduleObserverIds: schedule?.protocolObserverAgentIds || [],
|
|
238
|
+
chatroomId: chatroom?.id || null,
|
|
239
|
+
chatroomAgentIds: chatroom?.agentIds || [],
|
|
240
|
+
connectorAgentId: connector?.agentId || null,
|
|
241
|
+
connectorChatroomId: connector?.chatroomId || null,
|
|
242
|
+
connectorEnabled: connector?.isEnabled ?? null,
|
|
243
|
+
mcpId: mcp?.id || null,
|
|
244
|
+
mcpEnvKeys: Object.keys(mcp?.env || {}),
|
|
245
|
+
goalId: goal?.id || null,
|
|
246
|
+
goalProjectId: goal?.projectId || null,
|
|
247
|
+
goalAgentId: goal?.agentId || null,
|
|
248
|
+
needsCredentials: payload.mcpServers.needsCredentials,
|
|
249
|
+
}))
|
|
250
|
+
`)
|
|
251
|
+
|
|
252
|
+
assert.equal(output.status, 200)
|
|
253
|
+
assert.deepEqual(output.created, {
|
|
254
|
+
agents: 1,
|
|
255
|
+
skills: 1,
|
|
256
|
+
schedules: 1,
|
|
257
|
+
connectors: 1,
|
|
258
|
+
chatrooms: 1,
|
|
259
|
+
mcpServers: 1,
|
|
260
|
+
projects: 1,
|
|
261
|
+
goals: 1,
|
|
262
|
+
})
|
|
263
|
+
assert.equal(output.agentProjectId, output.projectId)
|
|
264
|
+
assert.deepEqual(output.agentSkillIds, [output.skillId])
|
|
265
|
+
assert.deepEqual(output.agentMcpServerIds, [output.mcpId])
|
|
266
|
+
assert.equal(output.agentGoalId, output.goalId)
|
|
267
|
+
assert.equal(output.skillProjectId, output.projectId)
|
|
268
|
+
assert.deepEqual(output.skillAgentIds, [output.agentId])
|
|
269
|
+
assert.equal(output.scheduleProjectId, output.projectId)
|
|
270
|
+
assert.deepEqual(output.scheduleParticipantIds, [output.agentId])
|
|
271
|
+
assert.equal(output.scheduleFacilitatorId, output.agentId)
|
|
272
|
+
assert.deepEqual(output.scheduleObserverIds, [output.agentId])
|
|
273
|
+
assert.deepEqual(output.chatroomAgentIds, [output.agentId])
|
|
274
|
+
assert.equal(output.connectorAgentId, output.agentId)
|
|
275
|
+
assert.equal(output.connectorChatroomId, output.chatroomId)
|
|
276
|
+
assert.equal(output.connectorEnabled, false)
|
|
277
|
+
assert.deepEqual(output.mcpEnvKeys, ['API_TOKEN'])
|
|
278
|
+
assert.equal(output.goalProjectId, output.projectId)
|
|
279
|
+
assert.equal(output.goalAgentId, output.agentId)
|
|
280
|
+
assert.deepEqual(output.needsCredentials, ['Local Tools'])
|
|
281
|
+
})
|
|
@@ -16,11 +16,11 @@ export async function POST(req: Request) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
try {
|
|
19
|
-
const result = importConfig(parsed.data as PortableManifest)
|
|
19
|
+
const result = importConfig(parsed.data as unknown as PortableManifest)
|
|
20
20
|
return NextResponse.json(result)
|
|
21
21
|
} catch (err) {
|
|
22
22
|
const message = err instanceof Error ? err.message : 'Failed to import manifest'
|
|
23
|
-
if (
|
|
23
|
+
if (message.startsWith('Unsupported format version ')) {
|
|
24
24
|
return NextResponse.json({ error: message }, { status: 400 })
|
|
25
25
|
}
|
|
26
26
|
return NextResponse.json({ error: message }, { status: 500 })
|
|
@@ -81,9 +81,13 @@ test('PUT /api/tasks/:id provisions an execution workspace and preview links', a
|
|
|
81
81
|
assert.equal(response.status, 200)
|
|
82
82
|
const body = await response.json() as BoardTask
|
|
83
83
|
assert.equal(body.executionWorkspace?.sourceCwd, '/source/repo')
|
|
84
|
+
assert.equal(body.executionWorkspace?.context?.taskId, 'task-route-workspace')
|
|
85
|
+
assert.equal(body.executionWorkspace?.envHints?.some((hint) => hint.key === 'WORKSPACE_CWD'), true)
|
|
84
86
|
assert.equal(body.previewLinks?.[0]?.url, 'http://127.0.0.1:3456')
|
|
85
87
|
assert.equal(body.runtimeServices?.[0]?.name, 'Next dev')
|
|
86
88
|
assert.equal(fs.existsSync(body.executionWorkspace?.path || ''), true)
|
|
89
|
+
assert.equal(fs.existsSync(body.executionWorkspace?.contextPath || ''), true)
|
|
90
|
+
assert.equal(fs.existsSync(body.executionWorkspace?.envPath || ''), true)
|
|
87
91
|
})
|
|
88
92
|
|
|
89
93
|
test('GET /api/tasks returns computed blocked liveness without persisting a task patch', async () => {
|
package/src/cli/index.js
CHANGED
|
@@ -273,6 +273,8 @@ const COMMAND_GROUPS = [
|
|
|
273
273
|
cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
|
|
274
274
|
cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
|
|
275
275
|
cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
|
|
276
|
+
cmd('environments', 'GET', '/gateways/:id/environments', 'List OpenClaw gateway execution environments'),
|
|
277
|
+
cmd('environment-status', 'GET', '/gateways/:id/environments/:environmentId', 'Get one OpenClaw gateway execution environment status'),
|
|
276
278
|
cmd('fleet', 'GET', '/gateways/fleet', 'Refresh and return fleet-wide gateway topology'),
|
|
277
279
|
],
|
|
278
280
|
},
|
package/src/cli/spec.js
CHANGED
|
@@ -221,6 +221,8 @@ const COMMAND_GROUPS = {
|
|
|
221
221
|
delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
|
|
222
222
|
health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
|
|
223
223
|
topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
|
|
224
|
+
environments: { description: 'List OpenClaw gateway execution environments', method: 'GET', path: '/gateways/:id/environments', params: ['id'] },
|
|
225
|
+
'environment-status': { description: 'Get one OpenClaw gateway execution environment status', method: 'GET', path: '/gateways/:id/environments/:environmentId', params: ['id', 'environmentId'] },
|
|
224
226
|
fleet: { description: 'Refresh and return fleet-wide gateway topology', method: 'GET', path: '/gateways/fleet' },
|
|
225
227
|
},
|
|
226
228
|
},
|
|
@@ -475,10 +475,14 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
475
475
|
<div className="text-[12px] font-800 text-text">Gateway fleet topology</div>
|
|
476
476
|
<div className="mt-1 text-[11px] text-text-3/70">
|
|
477
477
|
{gatewayFleetTopology.totals.connectedGatewayCount}/{gatewayFleetTopology.totals.gatewayCount} gateways connected ·{' '}
|
|
478
|
-
{gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected
|
|
478
|
+
{gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected ·{' '}
|
|
479
|
+
{gatewayFleetTopology.totals.availableEnvironmentCount || 0}/{gatewayFleetTopology.totals.environmentCount || 0} environments available
|
|
479
480
|
</div>
|
|
480
481
|
</div>
|
|
481
482
|
<div className="flex flex-wrap gap-2 text-[11px] text-text-3/70">
|
|
483
|
+
<span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
|
|
484
|
+
{gatewayFleetTopology.totals.environmentCount || 0} environments
|
|
485
|
+
</span>
|
|
482
486
|
<span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
|
|
483
487
|
{gatewayFleetTopology.totals.sessionCount || 0} sessions
|
|
484
488
|
</span>
|
|
@@ -536,6 +540,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
536
540
|
const topologyErrors = topology?.errors || []
|
|
537
541
|
const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
|
|
538
542
|
const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
|
|
543
|
+
const environments = topology?.environments || []
|
|
539
544
|
return (
|
|
540
545
|
<div
|
|
541
546
|
key={gateway.id}
|
|
@@ -602,6 +607,12 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
602
607
|
{stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
|
|
603
608
|
</div>
|
|
604
609
|
</div>
|
|
610
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
611
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Environments</div>
|
|
612
|
+
<div className="mt-1 text-text-2">
|
|
613
|
+
{stats?.availableEnvironmentCount ?? 0}/{stats?.environmentCount ?? 0} available
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
605
616
|
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
606
617
|
<div className="uppercase tracking-[0.08em] text-text-3/50">Sessions</div>
|
|
607
618
|
<div className="mt-1 text-text-2">
|
|
@@ -616,6 +627,28 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
616
627
|
</div>
|
|
617
628
|
</div>
|
|
618
629
|
)}
|
|
630
|
+
{!inSidebar && environments.length > 0 && (
|
|
631
|
+
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
632
|
+
{environments.slice(0, 4).map((environment) => (
|
|
633
|
+
<span
|
|
634
|
+
key={`${gateway.id}-${environment.id}`}
|
|
635
|
+
className={`rounded-full border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${
|
|
636
|
+
environment.status === 'available'
|
|
637
|
+
? 'border-emerald-400/20 bg-emerald-400/[0.06] text-emerald-300'
|
|
638
|
+
: 'border-white/[0.06] bg-white/[0.03] text-text-3/70'
|
|
639
|
+
}`}
|
|
640
|
+
title={environment.capabilities?.join(', ') || environment.id}
|
|
641
|
+
>
|
|
642
|
+
{environment.label || environment.id}
|
|
643
|
+
</span>
|
|
644
|
+
))}
|
|
645
|
+
{environments.length > 4 && (
|
|
646
|
+
<span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/70">
|
|
647
|
+
+{environments.length - 4}
|
|
648
|
+
</span>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
)}
|
|
619
652
|
{!inSidebar && (pendingPairings > 0 || topologyErrorCount > 0) && (
|
|
620
653
|
<div className={`mt-3 rounded-[10px] border px-3 py-2 text-[11px] leading-relaxed ${
|
|
621
654
|
topologyErrorCount > 0
|
|
@@ -462,6 +462,31 @@ export function TaskSheet() {
|
|
|
462
462
|
{editing.executionWorkspace?.path && (
|
|
463
463
|
<code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
|
|
464
464
|
)}
|
|
465
|
+
{(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
|
|
466
|
+
<div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
|
|
467
|
+
{editing.executionWorkspace.contextPath && (
|
|
468
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
469
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
|
|
470
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
{editing.executionWorkspace.envPath && (
|
|
474
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
475
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
|
|
476
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</div>
|
|
480
|
+
)}
|
|
481
|
+
{editing.executionWorkspace?.envHints?.length ? (
|
|
482
|
+
<div className="flex flex-wrap gap-1.5">
|
|
483
|
+
{editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
|
|
484
|
+
<InfoChip key={hint.key} tone="neutral" title={hint.value}>
|
|
485
|
+
<span className="max-w-[220px] truncate">{hint.key}</span>
|
|
486
|
+
</InfoChip>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
489
|
+
) : null}
|
|
465
490
|
{previewLinks.length > 0 && (
|
|
466
491
|
<div className="flex flex-wrap gap-2">
|
|
467
492
|
{previewLinks.map((link) => (
|
|
@@ -942,6 +967,31 @@ export function TaskSheet() {
|
|
|
942
967
|
{editing.executionWorkspace?.path && (
|
|
943
968
|
<code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
|
|
944
969
|
)}
|
|
970
|
+
{(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
|
|
971
|
+
<div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
|
|
972
|
+
{editing.executionWorkspace.contextPath && (
|
|
973
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
974
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
|
|
975
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
|
|
976
|
+
</div>
|
|
977
|
+
)}
|
|
978
|
+
{editing.executionWorkspace.envPath && (
|
|
979
|
+
<div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
|
|
980
|
+
<div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
|
|
981
|
+
<code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
|
|
982
|
+
</div>
|
|
983
|
+
)}
|
|
984
|
+
</div>
|
|
985
|
+
)}
|
|
986
|
+
{editing.executionWorkspace?.envHints?.length ? (
|
|
987
|
+
<div className="flex flex-wrap gap-1.5">
|
|
988
|
+
{editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
|
|
989
|
+
<InfoChip key={hint.key} tone="neutral" title={hint.value}>
|
|
990
|
+
<span className="max-w-[220px] truncate">{hint.key}</span>
|
|
991
|
+
</InfoChip>
|
|
992
|
+
))}
|
|
993
|
+
</div>
|
|
994
|
+
) : null}
|
|
945
995
|
{previewLinks.length > 0 && (
|
|
946
996
|
<div className="flex flex-wrap gap-2">
|
|
947
997
|
{previewLinks.map((link) => (
|
|
@@ -3,6 +3,7 @@ import { api } from '@/lib/app/api-client'
|
|
|
3
3
|
import { credentialQueryKeys } from '@/features/credentials/queries'
|
|
4
4
|
import type {
|
|
5
5
|
GatewayProfile,
|
|
6
|
+
OpenClawEnvironmentSummary,
|
|
6
7
|
OpenClawDevicePairRequest,
|
|
7
8
|
OpenClawGatewayFleetTopology,
|
|
8
9
|
OpenClawGatewayPresenceEntry,
|
|
@@ -60,6 +61,7 @@ export interface RefreshGatewayTopologyResult {
|
|
|
60
61
|
pairedDevices: OpenClawPairedDevice[]
|
|
61
62
|
sessions: OpenClawGatewaySession[]
|
|
62
63
|
presence: OpenClawGatewayPresenceEntry[]
|
|
64
|
+
environments: OpenClawEnvironmentSummary[]
|
|
63
65
|
errors: OpenClawGatewayRpcError[]
|
|
64
66
|
topology: OpenClawGatewayTopology
|
|
65
67
|
}
|
|
@@ -190,6 +192,7 @@ export function useRefreshGatewayTopologyMutation() {
|
|
|
190
192
|
pairedDevices: topology.pairedDevices,
|
|
191
193
|
sessions: topology.sessions,
|
|
192
194
|
presence: topology.presence,
|
|
195
|
+
environments: topology.environments,
|
|
193
196
|
errors: topology.errors,
|
|
194
197
|
topology,
|
|
195
198
|
}
|
|
@@ -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
|
})
|