@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.
@@ -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
- const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../../..')
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 (/^Unsupported format version /i.test(message)) {
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 { buildOpenClawGatewayTopology, getOpenClawGatewayFleetTopology } from './gateway-topology'
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.deepEqual(topology.errors.map((error) => error.method), ['sessions.list', 'system-presence'])
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, 2)
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
  })