@swarmclawai/swarmclaw 0.7.5 → 0.7.7

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.
Files changed (32) hide show
  1. package/README.md +41 -10
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +12 -1
  7. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  8. package/src/app/api/external-agents/[id]/route.ts +38 -6
  9. package/src/app/api/external-agents/route.ts +17 -1
  10. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  11. package/src/app/api/gateways/[id]/route.ts +53 -1
  12. package/src/app/api/gateways/route.ts +53 -0
  13. package/src/app/api/openclaw/deploy/route.ts +240 -0
  14. package/src/cli/index.js +53 -0
  15. package/src/cli/index.test.js +102 -0
  16. package/src/cli/spec.js +79 -0
  17. package/src/components/agents/agent-sheet.tsx +97 -19
  18. package/src/components/auth/setup-wizard.tsx +111 -54
  19. package/src/components/gateways/gateway-sheet.tsx +202 -10
  20. package/src/components/openclaw/openclaw-deploy-panel.tsx +1208 -0
  21. package/src/components/providers/provider-list.tsx +321 -22
  22. package/src/lib/server/agent-runtime-config.ts +142 -7
  23. package/src/lib/server/agent-thread-session.ts +9 -1
  24. package/src/lib/server/chat-execution.ts +8 -2
  25. package/src/lib/server/heartbeat-service.ts +5 -1
  26. package/src/lib/server/openclaw-deploy.test.ts +75 -0
  27. package/src/lib/server/openclaw-deploy.ts +1384 -0
  28. package/src/lib/server/orchestrator.ts +9 -0
  29. package/src/lib/server/queue.ts +45 -2
  30. package/src/lib/setup-defaults.ts +2 -2
  31. package/src/lib/validation/schemas.ts +9 -0
  32. package/src/types/index.ts +65 -0
@@ -1,9 +1,30 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useEffect, useState } from 'react'
4
+ import { toast } from 'sonner'
5
+ import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
4
6
  import { useAppStore } from '@/stores/use-app-store'
5
7
  import { useWs } from '@/hooks/use-ws'
6
8
  import { api } from '@/lib/api-client'
9
+ import type { Credential, GatewayProfile } from '@/types'
10
+
11
+ interface OpenClawDeployDraft {
12
+ endpoint: string
13
+ token?: string
14
+ name?: string
15
+ notes?: string
16
+ deployment?: GatewayProfile['deployment']
17
+ }
18
+
19
+ function formatRuntimeTimestamp(value: number | null | undefined): string {
20
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return 'Never'
21
+ return new Intl.DateTimeFormat(undefined, {
22
+ month: 'short',
23
+ day: 'numeric',
24
+ hour: 'numeric',
25
+ minute: '2-digit',
26
+ }).format(value)
27
+ }
7
28
 
8
29
  export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
9
30
  const providers = useAppStore((s) => s.providers)
@@ -21,6 +42,8 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
21
42
  const setGatewaySheetOpen = useAppStore((s) => s.setGatewaySheetOpen)
22
43
  const setEditingGatewayId = useAppStore((s) => s.setEditingGatewayId)
23
44
  const [loaded, setLoaded] = useState(false)
45
+ const [deployDraft, setDeployDraft] = useState<OpenClawDeployDraft | null>(null)
46
+ const [savingDeploy, setSavingDeploy] = useState(false)
24
47
 
25
48
  const refresh = useCallback(async () => {
26
49
  await Promise.all([loadProviders(), loadProviderConfigs(), loadGatewayProfiles(), loadExternalAgents(), loadCredentials()])
@@ -66,6 +89,132 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
66
89
  await loadGatewayProfiles()
67
90
  }
68
91
 
92
+ const handleDeployApply = (patch: { endpoint?: string; token?: string; name?: string; notes?: string; deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null }) => {
93
+ if (!patch.endpoint) return
94
+ setDeployDraft({
95
+ endpoint: patch.endpoint,
96
+ token: patch.token,
97
+ name: patch.name,
98
+ notes: patch.notes,
99
+ deployment: (patch.deployment as GatewayProfile['deployment']) || null,
100
+ })
101
+ }
102
+
103
+ const handleSavePreparedGateway = async () => {
104
+ if (!deployDraft?.endpoint) return
105
+ setSavingDeploy(true)
106
+ try {
107
+ let nextCredentialId: string | null = null
108
+ if (deployDraft.token?.trim()) {
109
+ const credential = await api<Credential>('POST', '/credentials', {
110
+ provider: 'openclaw',
111
+ name: `${deployDraft.name || 'OpenClaw Gateway'} token`,
112
+ apiKey: deployDraft.token.trim(),
113
+ })
114
+ nextCredentialId = credential.id
115
+ }
116
+
117
+ const existing = gatewayProfiles.find((gateway) => gateway.endpoint === deployDraft.endpoint) || null
118
+ const nextTags = Array.from(new Set([
119
+ ...(existing?.tags || []),
120
+ 'managed-deploy',
121
+ ...(deployDraft.deployment?.useCase ? [deployDraft.deployment.useCase] : []),
122
+ ...(deployDraft.deployment?.exposure ? [deployDraft.deployment.exposure] : []),
123
+ ]))
124
+ const verify = await api<{
125
+ ok: boolean
126
+ verify?: {
127
+ ok: boolean
128
+ error?: string
129
+ hint?: string
130
+ models?: string[]
131
+ }
132
+ }>('POST', '/openclaw/deploy', {
133
+ action: 'verify',
134
+ endpoint: deployDraft.endpoint,
135
+ token: deployDraft.token?.trim() || undefined,
136
+ }).catch(() => ({ ok: false, verify: undefined as undefined }))
137
+ const verifiedOk = verify.verify?.ok === true
138
+ const payload = {
139
+ name: deployDraft.name || existing?.name || 'OpenClaw Gateway',
140
+ endpoint: deployDraft.endpoint,
141
+ credentialId: nextCredentialId || existing?.credentialId || null,
142
+ notes: deployDraft.notes || existing?.notes || 'Managed OpenClaw deploy prepared from SwarmClaw.',
143
+ tags: nextTags,
144
+ status: verifiedOk ? 'healthy' : (existing?.status || 'pending'),
145
+ deployment: {
146
+ ...(existing?.deployment || {}),
147
+ ...(deployDraft.deployment || {}),
148
+ managedBy: 'swarmclaw',
149
+ lastVerifiedAt: verify.verify ? Date.now() : (existing?.deployment?.lastVerifiedAt || null),
150
+ lastVerifiedOk: verify.verify ? verifiedOk : (existing?.deployment?.lastVerifiedOk ?? null),
151
+ lastVerifiedMessage: verify.verify
152
+ ? (verifiedOk
153
+ ? `Verified during save with ${verify.verify.models?.length || 0} model${(verify.verify.models?.length || 0) === 1 ? '' : 's'}.`
154
+ : (verify.verify.error || verify.verify.hint || 'Verification failed.'))
155
+ : (existing?.deployment?.lastVerifiedMessage || null),
156
+ },
157
+ isDefault: existing?.isDefault === true || gatewayProfiles.length === 0,
158
+ }
159
+
160
+ if (existing) {
161
+ await api('PUT', `/gateways/${existing.id}`, payload)
162
+ } else {
163
+ await api('POST', '/gateways', payload)
164
+ }
165
+
166
+ await Promise.all([loadGatewayProfiles(), loadCredentials()])
167
+ setDeployDraft(null)
168
+ toast.success(existing ? 'Gateway profile updated' : 'Gateway profile saved')
169
+ } catch (err: unknown) {
170
+ toast.error(err instanceof Error ? err.message : 'Failed to save prepared gateway')
171
+ } finally {
172
+ setSavingDeploy(false)
173
+ }
174
+ }
175
+
176
+ const handleCloneGateway = async (e: React.MouseEvent, gateway: GatewayProfile) => {
177
+ e.stopPropagation()
178
+ try {
179
+ await api('POST', '/gateways', {
180
+ name: `${gateway.name} Copy`,
181
+ endpoint: gateway.endpoint,
182
+ credentialId: gateway.credentialId || null,
183
+ notes: gateway.notes || null,
184
+ tags: gateway.tags || [],
185
+ deployment: gateway.deployment || null,
186
+ stats: gateway.stats || null,
187
+ isDefault: false,
188
+ })
189
+ await loadGatewayProfiles()
190
+ toast.success('Gateway cloned')
191
+ } catch (err: unknown) {
192
+ toast.error(err instanceof Error ? err.message : 'Failed to clone gateway')
193
+ }
194
+ }
195
+
196
+ const handleRuntimeAction = async (
197
+ e: React.MouseEvent,
198
+ runtimeId: string,
199
+ action: 'activate' | 'drain' | 'cordon' | 'restart',
200
+ ) => {
201
+ e.stopPropagation()
202
+ try {
203
+ await api('PUT', `/external-agents/${runtimeId}`, { action })
204
+ await loadExternalAgents()
205
+ const actionLabel = action === 'activate'
206
+ ? 'Runtime activated'
207
+ : action === 'drain'
208
+ ? 'Runtime draining'
209
+ : action === 'cordon'
210
+ ? 'Runtime cordoned'
211
+ : 'Restart requested'
212
+ toast.success(actionLabel)
213
+ } catch (err: unknown) {
214
+ toast.error(err instanceof Error ? err.message : 'Runtime action failed')
215
+ }
216
+ }
217
+
69
218
  // Merge built-in providers with custom configs
70
219
  const builtinItems = providers.map((p) => ({
71
220
  id: p.id,
@@ -88,6 +237,18 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
88
237
  }))
89
238
 
90
239
  const allItems = [...builtinItems, ...customItems]
240
+ const gatewayNameById = new Map(gatewayProfiles.map((gateway) => [gateway.id, gateway.name]))
241
+ const runtimeHealthByGateway = externalAgents.reduce<Record<string, { total: number; active: number; lastHeartbeatAt: number | null }>>((acc, runtime) => {
242
+ if (!runtime.gatewayProfileId) return acc
243
+ const current = acc[runtime.gatewayProfileId] || { total: 0, active: 0, lastHeartbeatAt: null }
244
+ current.total += 1
245
+ if (runtime.status === 'online' || runtime.status === 'idle') current.active += 1
246
+ if (typeof runtime.lastSeenAt === 'number' && (!current.lastHeartbeatAt || runtime.lastSeenAt > current.lastHeartbeatAt)) {
247
+ current.lastHeartbeatAt = runtime.lastSeenAt
248
+ }
249
+ acc[runtime.gatewayProfileId] = current
250
+ return acc
251
+ }, {})
91
252
 
92
253
  if (!loaded) {
93
254
  return (
@@ -180,17 +341,54 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
180
341
  <div className="mt-8 mb-4 flex items-center justify-between">
181
342
  <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div>
182
343
  {!inSidebar && (
183
- <button
184
- type="button"
185
- onClick={() => handleEditGateway(null)}
186
- className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer"
187
- >
188
- + New Gateway
189
- </button>
344
+ <div className="flex items-center gap-2">
345
+ <button
346
+ type="button"
347
+ onClick={() => handleEditGateway(null)}
348
+ className="px-3 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] transition-all cursor-pointer"
349
+ >
350
+ + New Gateway
351
+ </button>
352
+ </div>
190
353
  )}
191
354
  </div>
355
+ {!inSidebar && (
356
+ <div className="mb-4 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
357
+ <OpenClawDeployPanel
358
+ compact
359
+ title="Deploy OpenClaw Control Planes"
360
+ description="Use official OpenClaw sources only. Start a local control plane on this machine, or generate a pre-configured remote bundle for Docker VPS hosts like Hetzner, DigitalOcean, Vultr, Linode, Lightsail, plus Render, Fly.io, and Railway."
361
+ onApply={handleDeployApply}
362
+ />
363
+ {deployDraft?.endpoint && (
364
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-[12px] border border-emerald-500/20 bg-emerald-500/[0.05] px-4 py-3">
365
+ <div>
366
+ <div className="text-[13px] font-700 text-emerald-300">Prepared gateway profile</div>
367
+ <div className="mt-1 text-[12px] text-text-3">
368
+ {deployDraft.name || 'OpenClaw Gateway'} · <code className="text-text-2">{deployDraft.endpoint}</code>
369
+ </div>
370
+ </div>
371
+ <div className="flex flex-wrap gap-2">
372
+ <button
373
+ type="button"
374
+ onClick={() => void handleSavePreparedGateway()}
375
+ disabled={savingDeploy}
376
+ className="rounded-[10px] bg-accent-bright px-3.5 py-2 text-[12px] font-700 text-white border-none cursor-pointer hover:brightness-110 transition-all disabled:opacity-40"
377
+ >
378
+ {savingDeploy ? 'Saving…' : 'Save Prepared Gateway'}
379
+ </button>
380
+ </div>
381
+ </div>
382
+ )}
383
+ </div>
384
+ )}
192
385
  <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
193
386
  {gatewayProfiles.map((gateway, idx) => (
387
+ (() => {
388
+ const runtimeStats = runtimeHealthByGateway[gateway.id] || { total: 0, active: 0, lastHeartbeatAt: null }
389
+ const deployment = gateway.deployment || null
390
+ const stats = gateway.stats || null
391
+ return (
194
392
  <div
195
393
  key={gateway.id}
196
394
  role="button"
@@ -232,21 +430,61 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
232
430
  <div className="text-[12px] text-text-3/70">
233
431
  {gateway.tags?.length ? gateway.tags.join(', ') : (gateway.notes || 'Dedicated OpenClaw control plane')}
234
432
  </div>
433
+ {!inSidebar && (
434
+ <div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-3/65">
435
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
436
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Deploy</div>
437
+ <div className="mt-1 text-text-2">
438
+ {deployment?.method || 'manual'}
439
+ {deployment?.provider ? ` · ${deployment.provider}` : ''}
440
+ </div>
441
+ </div>
442
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
443
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Route hints</div>
444
+ <div className="mt-1 text-text-2">
445
+ {deployment?.useCase || 'general'}
446
+ {deployment?.exposure ? ` · ${deployment.exposure}` : ''}
447
+ </div>
448
+ </div>
449
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
450
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Nodes / devices</div>
451
+ <div className="mt-1 text-text-2">
452
+ {stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
453
+ </div>
454
+ </div>
455
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
456
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Runtimes</div>
457
+ <div className="mt-1 text-text-2">
458
+ {runtimeStats.active}/{runtimeStats.total} active
459
+ </div>
460
+ </div>
461
+ </div>
462
+ )}
463
+ {!inSidebar && deployment?.lastVerifiedMessage && (
464
+ <div className="mt-3 text-[11px] text-text-3/60">
465
+ {deployment.lastVerifiedMessage}
466
+ </div>
467
+ )}
235
468
  {!inSidebar && (
236
469
  <div className="mt-3 flex items-center gap-2">
237
470
  <button onClick={(e) => void handleHealthCheckGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
238
471
  Health
239
472
  </button>
473
+ <button onClick={(e) => void handleCloneGateway(e, gateway)} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
474
+ Clone
475
+ </button>
240
476
  <button onClick={(e) => handleDeleteGateway(e, gateway.id)} className="px-2.5 py-1.5 rounded-[8px] border border-red-400/20 bg-red-400/[0.06] text-[11px] font-700 text-red-300 hover:bg-red-400/[0.1] cursor-pointer transition-all">
241
477
  Delete
242
478
  </button>
243
479
  </div>
244
480
  )}
245
481
  </div>
482
+ )
483
+ })()
246
484
  ))}
247
485
  {gatewayProfiles.length === 0 && (
248
486
  <div className="p-4 rounded-[14px] border border-dashed border-white/[0.08] text-[13px] text-text-3/70">
249
- No gateway profiles yet. Add one to route OpenClaw agents by named control plane instead of a singleton default.
487
+ No gateway profiles yet. Use Smart Deploy above for a local runtime, a Docker VPS bundle, or a hosted OpenClaw deployment profile.
250
488
  </div>
251
489
  )}
252
490
  </div>
@@ -268,23 +506,84 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
268
506
  <div className="flex items-center justify-between gap-3 mb-2">
269
507
  <div className="min-w-0">
270
508
  <div className="font-display text-[14px] font-600 text-text truncate">{runtime.name}</div>
271
- <div className="text-[11px] text-text-3/60 truncate">{runtime.sourceType} · {runtime.transport || 'custom'}</div>
509
+ <div className="text-[11px] text-text-3/60 truncate">
510
+ {runtime.sourceType} · {runtime.transport || 'custom'}
511
+ {runtime.version ? ` · ${runtime.version}` : ''}
512
+ </div>
513
+ </div>
514
+ <div className="flex flex-wrap items-center justify-end gap-2">
515
+ <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
516
+ runtime.lifecycleState === 'cordoned'
517
+ ? 'bg-red-400/10 text-red-300'
518
+ : runtime.lifecycleState === 'draining'
519
+ ? 'bg-amber-400/10 text-amber-300'
520
+ : 'bg-blue-400/10 text-blue-300'
521
+ }`}>
522
+ {runtime.lifecycleState || 'active'}
523
+ </span>
524
+ <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
525
+ runtime.status === 'online'
526
+ ? 'bg-emerald-400/10 text-emerald-300'
527
+ : runtime.status === 'stale'
528
+ ? 'bg-amber-400/10 text-amber-300'
529
+ : 'bg-white/[0.04] text-text-3'
530
+ }`}>
531
+ {runtime.status}
532
+ </span>
533
+ </div>
534
+ </div>
535
+ <div className="grid grid-cols-2 gap-2 text-[11px] text-text-3/65">
536
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
537
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Provider</div>
538
+ <div className="mt-1 text-text-2">
539
+ {runtime.provider || 'No provider'}
540
+ {runtime.model ? ` · ${runtime.model}` : ''}
541
+ </div>
542
+ </div>
543
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
544
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Gateway</div>
545
+ <div className="mt-1 text-text-2">
546
+ {runtime.gatewayProfileId ? (gatewayNameById.get(runtime.gatewayProfileId) || runtime.gatewayProfileId) : 'Standalone'}
547
+ </div>
548
+ </div>
549
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
550
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Template</div>
551
+ <div className="mt-1 text-text-2">{runtime.gatewayUseCase || 'general'}</div>
552
+ </div>
553
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
554
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Last seen</div>
555
+ <div className="mt-1 text-text-2">{formatRuntimeTimestamp(runtime.lastSeenAt || runtime.lastHeartbeatAt)}</div>
272
556
  </div>
273
- <span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] uppercase tracking-wider ${
274
- runtime.status === 'online'
275
- ? 'bg-emerald-400/10 text-emerald-300'
276
- : runtime.status === 'stale'
277
- ? 'bg-amber-400/10 text-amber-300'
278
- : 'bg-white/[0.04] text-text-3'
279
- }`}>
280
- {runtime.status}
281
- </span>
282
557
  </div>
283
- <div className="text-[12px] text-text-3/70">
284
- {runtime.provider || 'No provider'}
285
- {runtime.model ? ` · ${runtime.model}` : ''}
558
+ <div className="text-[11px] text-text-3/55 mt-3 font-mono truncate">{runtime.endpoint || runtime.workspace || runtime.id}</div>
559
+ {runtime.gatewayTags?.length ? (
560
+ <div className="mt-3 flex flex-wrap gap-1.5">
561
+ {runtime.gatewayTags.slice(0, 6).map((tag) => (
562
+ <span key={`${runtime.id}-${tag}`} 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">
563
+ {tag}
564
+ </span>
565
+ ))}
566
+ </div>
567
+ ) : null}
568
+ {runtime.lastHealthNote && (
569
+ <div className="mt-3 text-[11px] text-text-3/65 leading-relaxed">
570
+ {runtime.lastHealthNote}
571
+ </div>
572
+ )}
573
+ <div className="mt-3 flex flex-wrap gap-2">
574
+ <button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'activate')} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
575
+ Activate
576
+ </button>
577
+ <button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'drain')} className="px-2.5 py-1.5 rounded-[8px] border border-amber-400/20 bg-amber-400/[0.06] text-[11px] font-700 text-amber-300 hover:bg-amber-400/[0.1] cursor-pointer transition-all">
578
+ Drain
579
+ </button>
580
+ <button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'cordon')} className="px-2.5 py-1.5 rounded-[8px] border border-red-400/20 bg-red-400/[0.06] text-[11px] font-700 text-red-300 hover:bg-red-400/[0.1] cursor-pointer transition-all">
581
+ Cordon
582
+ </button>
583
+ <button onClick={(e) => void handleRuntimeAction(e, runtime.id, 'restart')} className="px-2.5 py-1.5 rounded-[8px] border border-white/[0.08] bg-transparent text-[11px] font-700 text-text-2 hover:bg-white/[0.04] cursor-pointer transition-all">
584
+ Restart
585
+ </button>
286
586
  </div>
287
- <div className="text-[11px] text-text-3/55 mt-2 font-mono truncate">{runtime.endpoint || runtime.workspace || runtime.id}</div>
288
587
  </div>
289
588
  ))}
290
589
  {externalAgents.length === 0 && (
@@ -26,6 +26,11 @@ export interface ResolvedAgentRoute {
26
26
  source: 'agent' | 'routing-target'
27
27
  }
28
28
 
29
+ interface GatewayRoutePreferences {
30
+ preferredGatewayTags?: string[]
31
+ preferredGatewayUseCase?: string | null
32
+ }
33
+
29
34
  interface RouteSeed {
30
35
  id: string
31
36
  label?: string
@@ -35,6 +40,8 @@ interface RouteSeed {
35
40
  fallbackCredentialIds?: string[]
36
41
  apiEndpoint?: string | null
37
42
  gatewayProfileId?: string | null
43
+ preferredGatewayTags?: string[]
44
+ preferredGatewayUseCase?: string | null
38
45
  role?: AgentRoutingTarget['role']
39
46
  priority?: number
40
47
  source: ResolvedAgentRoute['source']
@@ -47,6 +54,68 @@ function ensureStringArray(value: unknown): string[] {
47
54
  .filter(Boolean)
48
55
  }
49
56
 
57
+ function normalizeText(value: unknown): string | null {
58
+ return typeof value === 'string' && value.trim() ? value.trim() : null
59
+ }
60
+
61
+ function normalizeNullableNumber(value: unknown): number | null {
62
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
63
+ }
64
+
65
+ function normalizeGatewayDeployment(
66
+ value: unknown,
67
+ ): GatewayProfile['deployment'] {
68
+ if (!value || typeof value !== 'object') return null
69
+ const deployment = value as Record<string, unknown>
70
+ type DeploymentConfig = NonNullable<GatewayProfile['deployment']>
71
+ return {
72
+ method: normalizeText(deployment.method) as DeploymentConfig['method'],
73
+ provider: normalizeText(deployment.provider) as DeploymentConfig['provider'],
74
+ remoteTarget: normalizeText(deployment.remoteTarget) as DeploymentConfig['remoteTarget'],
75
+ useCase: normalizeText(deployment.useCase) as DeploymentConfig['useCase'],
76
+ exposure: normalizeText(deployment.exposure) as DeploymentConfig['exposure'],
77
+ managedBy: normalizeText(deployment.managedBy) as DeploymentConfig['managedBy'],
78
+ targetHost: normalizeText(deployment.targetHost),
79
+ sshHost: normalizeText(deployment.sshHost),
80
+ sshUser: normalizeText(deployment.sshUser),
81
+ sshPort: normalizeNullableNumber(deployment.sshPort),
82
+ sshKeyPath: normalizeText(deployment.sshKeyPath),
83
+ sshTargetDir: normalizeText(deployment.sshTargetDir),
84
+ image: normalizeText(deployment.image),
85
+ version: normalizeText(deployment.version),
86
+ lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
87
+ lastDeployAction: normalizeText(deployment.lastDeployAction),
88
+ lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
89
+ lastDeploySummary: normalizeText(deployment.lastDeploySummary),
90
+ lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
91
+ lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
92
+ lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
93
+ lastBackupPath: normalizeText(deployment.lastBackupPath),
94
+ }
95
+ }
96
+
97
+ function normalizeGatewayStats(value: unknown): GatewayProfile['stats'] {
98
+ if (!value || typeof value !== 'object') return null
99
+ const stats = value as Record<string, unknown>
100
+ return {
101
+ nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
102
+ connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
103
+ pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
104
+ pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
105
+ pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
106
+ externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
107
+ }
108
+ }
109
+
110
+ function normalizeRoutePreferences(
111
+ value?: GatewayRoutePreferences | null,
112
+ ): GatewayRoutePreferences {
113
+ return {
114
+ preferredGatewayTags: ensureStringArray(value?.preferredGatewayTags),
115
+ preferredGatewayUseCase: normalizeText(value?.preferredGatewayUseCase),
116
+ }
117
+ }
118
+
50
119
  function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
51
120
  if (!raw || typeof raw !== 'object') return null
52
121
  const gateway = raw as Partial<GatewayProfile> & Record<string, unknown>
@@ -72,6 +141,8 @@ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
72
141
  lastModelCount: typeof gateway.lastModelCount === 'number' ? gateway.lastModelCount : null,
73
142
  discoveredHost: typeof gateway.discoveredHost === 'string' ? gateway.discoveredHost : null,
74
143
  discoveredPort: typeof gateway.discoveredPort === 'number' ? gateway.discoveredPort : null,
144
+ deployment: normalizeGatewayDeployment(gateway.deployment),
145
+ stats: normalizeGatewayStats(gateway.stats),
75
146
  isDefault: gateway.isDefault === true,
76
147
  createdAt: typeof gateway.createdAt === 'number' ? gateway.createdAt : Date.now(),
77
148
  updatedAt: typeof gateway.updatedAt === 'number' ? gateway.updatedAt : Date.now(),
@@ -107,6 +178,50 @@ function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfil
107
178
  return gatewayProfiles.find((profile) => profile.isDefault) || gatewayProfiles[0] || null
108
179
  }
109
180
 
181
+ function gatewayPreferenceScore(
182
+ gatewayProfile: GatewayProfile,
183
+ preferences?: GatewayRoutePreferences | null,
184
+ ): number {
185
+ const normalized = normalizeRoutePreferences(preferences)
186
+ const preferredTags = normalized.preferredGatewayTags || []
187
+ const preferredUseCase = normalized.preferredGatewayUseCase || null
188
+ const gatewayTags = new Set(ensureStringArray(gatewayProfile.tags))
189
+ const gatewayUseCase = normalizeText(gatewayProfile.deployment?.useCase)
190
+
191
+ let score = 0
192
+ if (preferredUseCase) {
193
+ if (gatewayUseCase !== preferredUseCase) return -1
194
+ score += 30
195
+ }
196
+ if (preferredTags.length > 0) {
197
+ const matchedTagCount = preferredTags.filter((tag) => gatewayTags.has(tag)).length
198
+ if (matchedTagCount === 0) return -1
199
+ score += matchedTagCount * 10
200
+ if (matchedTagCount === preferredTags.length) score += 8
201
+ }
202
+ if (gatewayProfile.status === 'healthy') score += 4
203
+ else if (gatewayProfile.status === 'degraded') score += 2
204
+ if (gatewayProfile.isDefault) score += 3
205
+ return score
206
+ }
207
+
208
+ function pickPreferredGatewayProfile(
209
+ gatewayProfiles: GatewayProfile[],
210
+ preferences?: GatewayRoutePreferences | null,
211
+ ): GatewayProfile | null {
212
+ const normalized = normalizeRoutePreferences(preferences)
213
+ if (!(normalized.preferredGatewayTags?.length || normalized.preferredGatewayUseCase)) {
214
+ return null
215
+ }
216
+ return gatewayProfiles
217
+ .map((profile) => ({ profile, score: gatewayPreferenceScore(profile, normalized) }))
218
+ .filter((entry) => entry.score >= 0)
219
+ .sort((left, right) => {
220
+ if (left.score !== right.score) return right.score - left.score
221
+ return left.profile.name.localeCompare(right.profile.name)
222
+ })[0]?.profile || null
223
+ }
224
+
110
225
  function roleWeight(strategy: AgentRoutingStrategy, role?: AgentRoutingTarget['role']): number {
111
226
  const normalized = role || 'primary'
112
227
  const matrix: Record<AgentRoutingStrategy, Record<string, number>> = {
@@ -135,14 +250,21 @@ function dedupeCredentialIds(primary: string | null | undefined, candidates: str
135
250
  function buildRouteFromSeed(
136
251
  seed: RouteSeed,
137
252
  gatewayProfiles: GatewayProfile[],
253
+ routePreferences?: GatewayRoutePreferences | null,
138
254
  agentGatewayProfileId?: string | null,
139
255
  ): ResolvedAgentRoute | null {
140
256
  const provider = (seed.provider || 'claude-cli') as ProviderType
141
- let gatewayProfileId = seed.gatewayProfileId ?? null
142
- if (!gatewayProfileId && provider === 'openclaw') {
143
- gatewayProfileId = agentGatewayProfileId ?? defaultGatewayProfile(gatewayProfiles)?.id ?? null
257
+ const mergedPreferences = normalizeRoutePreferences({
258
+ preferredGatewayTags: seed.preferredGatewayTags ?? routePreferences?.preferredGatewayTags,
259
+ preferredGatewayUseCase: seed.preferredGatewayUseCase ?? routePreferences?.preferredGatewayUseCase,
260
+ })
261
+ let gatewayProfile = findGatewayProfile(gatewayProfiles, seed.gatewayProfileId ?? null)
262
+ if (!gatewayProfile && provider === 'openclaw') {
263
+ gatewayProfile = pickPreferredGatewayProfile(gatewayProfiles, mergedPreferences)
264
+ || findGatewayProfile(gatewayProfiles, agentGatewayProfileId ?? null)
265
+ || defaultGatewayProfile(gatewayProfiles)
144
266
  }
145
- const gatewayProfile = findGatewayProfile(gatewayProfiles, gatewayProfileId)
267
+ const gatewayProfileId = gatewayProfile?.id ?? seed.gatewayProfileId ?? agentGatewayProfileId ?? null
146
268
 
147
269
  const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
148
270
  const apiEndpoint = normalizeProviderEndpoint(
@@ -189,8 +311,9 @@ function dedupeRoutes(routes: ResolvedAgentRoute[]): ResolvedAgentRoute[] {
189
311
  export function resolveAgentRouteCandidates(
190
312
  agent: Agent | null | undefined,
191
313
  preferredStrategy?: AgentRoutingStrategy | null,
314
+ routePreferences?: GatewayRoutePreferences | null,
192
315
  ): ResolvedAgentRoute[] {
193
- return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy)
316
+ return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy, undefined, routePreferences)
194
317
  }
195
318
 
196
319
  export function resolveAgentRouteCandidatesWithProfiles(
@@ -198,9 +321,16 @@ export function resolveAgentRouteCandidatesWithProfiles(
198
321
  gatewayProfiles: GatewayProfile[],
199
322
  preferredStrategy?: AgentRoutingStrategy | null,
200
323
  isCoolingDown: (providerId: string) => boolean = isProviderCoolingDown,
324
+ routePreferences?: GatewayRoutePreferences | null,
201
325
  ): ResolvedAgentRoute[] {
202
326
  if (!agent) return []
203
327
  const strategy = preferredStrategy || agent.routingStrategy || 'single'
328
+ const resolvedPreferences = normalizeRoutePreferences({
329
+ preferredGatewayTags: routePreferences?.preferredGatewayTags?.length
330
+ ? routePreferences.preferredGatewayTags
331
+ : agent.preferredGatewayTags,
332
+ preferredGatewayUseCase: routePreferences?.preferredGatewayUseCase || agent.preferredGatewayUseCase,
333
+ })
204
334
  const seeds: RouteSeed[] = [
205
335
  {
206
336
  id: 'base',
@@ -211,6 +341,8 @@ export function resolveAgentRouteCandidatesWithProfiles(
211
341
  fallbackCredentialIds: agent.fallbackCredentialIds || [],
212
342
  apiEndpoint: agent.apiEndpoint ?? null,
213
343
  gatewayProfileId: agent.gatewayProfileId ?? null,
344
+ preferredGatewayTags: agent.preferredGatewayTags || [],
345
+ preferredGatewayUseCase: agent.preferredGatewayUseCase ?? null,
214
346
  role: 'primary',
215
347
  priority: 0,
216
348
  source: 'agent',
@@ -224,6 +356,8 @@ export function resolveAgentRouteCandidatesWithProfiles(
224
356
  fallbackCredentialIds: target.fallbackCredentialIds || [],
225
357
  apiEndpoint: target.apiEndpoint ?? null,
226
358
  gatewayProfileId: target.gatewayProfileId ?? null,
359
+ preferredGatewayTags: target.preferredGatewayTags || [],
360
+ preferredGatewayUseCase: target.preferredGatewayUseCase ?? null,
227
361
  role: target.role,
228
362
  priority: typeof target.priority === 'number' ? target.priority : index + 1,
229
363
  source: 'routing-target' as const,
@@ -232,7 +366,7 @@ export function resolveAgentRouteCandidatesWithProfiles(
232
366
 
233
367
  return dedupeRoutes(
234
368
  seeds
235
- .map((seed) => buildRouteFromSeed(seed, gatewayProfiles, agent.gatewayProfileId ?? null))
369
+ .map((seed) => buildRouteFromSeed(seed, gatewayProfiles, resolvedPreferences, agent.gatewayProfileId ?? null))
236
370
  .filter((route): route is ResolvedAgentRoute => Boolean(route)),
237
371
  ).sort((left, right) => {
238
372
  const leftCooling = isCoolingDown(left.provider)
@@ -249,8 +383,9 @@ export function resolveAgentRouteCandidatesWithProfiles(
249
383
  export function resolvePrimaryAgentRoute(
250
384
  agent: Agent | null | undefined,
251
385
  preferredStrategy?: AgentRoutingStrategy | null,
386
+ routePreferences?: GatewayRoutePreferences | null,
252
387
  ): ResolvedAgentRoute | null {
253
- return resolveAgentRouteCandidates(agent, preferredStrategy)[0] || null
388
+ return resolveAgentRouteCandidates(agent, preferredStrategy, routePreferences)[0] || null
254
389
  }
255
390
 
256
391
  export function applyResolvedRoute<T extends {