@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
package/README.md CHANGED
@@ -18,6 +18,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
18
18
 
19
19
  - [Getting Started](https://swarmclaw.ai/docs/getting-started) - install and first-run setup
20
20
  - [Providers](https://swarmclaw.ai/docs/providers) - provider setup and failover options
21
+ - [OpenClaw Setup](https://swarmclaw.ai/docs/openclaw-setup) - local, VPS, and hosted OpenClaw deployment paths
21
22
  - [Agents](https://swarmclaw.ai/docs/agents) - agent configuration, tools, and platform capabilities
22
23
  - [Tools](https://swarmclaw.ai/docs/tools) - built-in tool reference and guardrails
23
24
  - [Orchestration](https://swarmclaw.ai/docs/orchestration) - multi-agent flows, checkpoints, and restore
@@ -39,11 +40,29 @@ SwarmClaw includes the `openclaw` CLI as a bundled dependency, so there is no se
39
40
 
40
41
  The Providers screen now supports named OpenClaw gateway profiles with discovery, health checks, default-gateway selection, and an External Agent Runtimes view for remote workers that register/heartbeat into SwarmClaw.
41
42
 
43
+ SwarmClaw now also includes **Smart Deploy** for OpenClaw in three places:
44
+
45
+ - **Onboarding** - non-technical users can launch a local OpenClaw runtime or generate a remote bundle before they finish first-run setup
46
+ - **Providers -> OpenClaw Gateways** - operators can deploy or prepare more gateways later without leaving the main app
47
+ - **Gateway editor** - every gateway profile includes the same deploy panel for local restarts, VPS bundles, and hosted repo-backed deployments
48
+
49
+ The deployment flow stays **in-house and official-only**:
50
+
51
+ - local deploys run the bundled official `openclaw` CLI directly from SwarmClaw
52
+ - VPS deploys use the official OpenClaw Docker image with prefilled `.env`, `docker-compose.yml`, `bootstrap.sh`, and `cloud-init.yaml`
53
+ - hosted templates target the official OpenClaw repo for Render, Fly.io, and Railway
54
+
55
+ Supported VPS presets currently include Hetzner, DigitalOcean, Vultr, Linode, Lightsail, Google Cloud, Azure, OCI, and a generic Ubuntu host path. Smart defaults prefill the gateway token, endpoint, storage paths, and copy-paste commands so the resulting gateway can be saved into SwarmClaw with minimal manual editing.
56
+
57
+ For existing hosts, SwarmClaw can also push the same official-image bundle **over SSH** and then keep remote lifecycle controls attached to that saved gateway profile: start, stop, restart, upgrade, backup, restore, and token rotation.
58
+
42
59
  The OpenClaw Control Plane in SwarmClaw adds:
43
60
  - Reload mode switching (`hot`, `hybrid`, `full`)
44
61
  - Config issue detection and guided repair
45
62
  - Remote history sync
46
63
  - Live execution approval handling
64
+ - Gateway import/export JSON, clone flows, and richer external runtime fleet visibility
65
+ - Agent and route-target preferences for steering work toward OpenClaw gateways by tags or use case (`local-dev`, `single-vps`, `private-tailnet`, `browser-heavy`, `team-control`)
47
66
 
48
67
  The Agent Inspector Panel lets you edit OpenClaw files (`SOUL.md`, `IDENTITY.md`, `USER.md`), tune personality/system behavior, and manage OpenClaw-compatible skills. SwarmClaw also supports importing OpenClaw `SKILL.md` files from URL.
49
68
 
@@ -59,6 +78,19 @@ Each agent can point to a **different** OpenClaw gateway profile or direct endpo
59
78
 
60
79
  URLs without a protocol are auto-prefixed with `http://`. For remote gateways with TLS, use `https://` explicitly.
61
80
 
81
+ CLI operators can use the same deploy surface without opening the UI:
82
+
83
+ ```bash
84
+ swarmclaw openclaw deploy-status
85
+ swarmclaw openclaw deploy-local-start --data '{"port":18789}'
86
+ swarmclaw openclaw deploy-local-restart --data '{"port":18789}'
87
+ swarmclaw openclaw deploy-bundle --data '{"template":"docker","provider":"hetzner","target":"openclaw.example.com"}'
88
+ swarmclaw openclaw deploy-ssh --data '{"target":"openclaw.example.com","provider":"hetzner","ssh":{"host":"your-vps-ip"}}'
89
+ swarmclaw openclaw remote-backup --data '{"ssh":{"host":"your-vps-ip"}}'
90
+ swarmclaw openclaw remote-restore --data '{"backupPath":"/opt/openclaw/backups/openclaw-backup-123.tgz","ssh":{"host":"your-vps-ip"}}'
91
+ swarmclaw openclaw deploy-verify --data '{"endpoint":"https://openclaw.example.com/v1"}'
92
+ ```
93
+
62
94
  ## SwarmClaw ClawHub Skill
63
95
 
64
96
  Use the `swarmclaw` ClawHub skill when you want an OpenClaw agent to operate your SwarmClaw control plane directly from chat: list agents, dispatch tasks, check chats, run diagnostics, and coordinate multi-agent work.
@@ -78,7 +110,6 @@ Skill source and runbook: [`swarmclaw/SKILL.md`](swarmclaw/SKILL.md).
78
110
 
79
111
  ## Requirements
80
112
 
81
- - **Node.js** 22.6+
82
113
  - **Node.js** 22.6+
83
114
  - One of: **npm** 10+, **pnpm**, **Yarn**, or **Bun**
84
115
  - **Claude Code CLI** (optional, for `claude-cli` provider) — [Install](https://docs.anthropic.com/en/docs/claude-code/overview)
@@ -117,7 +148,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
117
148
  ```
118
149
 
119
150
  The installer resolves the latest stable release tag and installs that version by default.
120
- To pin a version: `SWARMCLAW_VERSION=v0.7.5 curl ... | bash`
151
+ To pin a version: `SWARMCLAW_VERSION=v0.7.7 curl ... | bash`
121
152
 
122
153
  Or run locally from the repo (friendly for non-technical users):
123
154
 
@@ -670,7 +701,7 @@ npm run update:easy # safe update helper for local installs
670
701
  SwarmClaw uses tag-based releases (`vX.Y.Z`) as the stable channel.
671
702
 
672
703
  ```bash
673
- # example patch release (v0.7.5 style)
704
+ # example patch release (v0.7.7 style)
674
705
  npm version patch
675
706
  git push origin main --follow-tags
676
707
  ```
@@ -680,15 +711,15 @@ On `v*` tags, GitHub Actions will:
680
711
  2. Create a GitHub Release
681
712
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
682
713
 
683
- #### v0.7.5 Release Readiness Notes
714
+ #### v0.7.7 Release Readiness Notes
684
715
 
685
- Before shipping `v0.7.5`, confirm the following user-facing changes are reflected in docs:
716
+ Before shipping `v0.7.7`, confirm the following user-facing changes are reflected in docs:
686
717
 
687
- 1. Sandbox docs are updated everywhere to reflect the current Deno-only `sandbox_exec` behavior and the guidance to prefer `http_request` for simple API calls.
688
- 2. OpenClaw docs cover the current gateway/runtime behavior, including per-agent gateway routing, control-plane actions, and inspector-side advanced controls.
689
- 3. Site and README install/version strings are updated to `v0.7.5`, including install snippets, release notes index text, and sidebar/footer labels.
690
- 4. Release notes summarize the user-visible setup/auth/runtime changes from the current worktree, especially gateway/external-agent/setup flow improvements.
691
- 5. CLI and tool docs do not reference removed or non-functional surfaces such as the old `openclaw_sandbox` bridge.
718
+ 1. OpenClaw docs cover Smart Deploy end-to-end: onboarding, Providers, gateway editor, official-only SSH/VPS flows, safe exposure presets, and restore/backup lifecycle controls.
719
+ 2. Agent and provider docs explain gateway routing by tags/use-case, richer external runtime visibility, and import/export/clone flows for saved gateways.
720
+ 3. Site and README install/version strings are updated to `v0.7.7`, including install snippets, release notes index text, and sidebar/footer labels.
721
+ 4. Release notes summarize the user-visible operator changes from the current worktree, especially SSH deploy, remote lifecycle controls, routing preferences, and onboarding persistence.
722
+ 5. CLI docs include the expanded `openclaw deploy-*`, `openclaw remote-*`, and verify surfaces and do not reference removed or unofficial deployment paths.
692
723
 
693
724
  ## CLI
694
725
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -57,7 +57,7 @@
57
57
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
58
58
  "cli": "node ./bin/swarmclaw.js",
59
59
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js",
60
- "test:openclaw": "tsx --test src/lib/openclaw-agent-id.test.ts src/lib/openclaw-endpoint.test.ts src/lib/server/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw-agent-resolver.test.ts src/lib/server/openclaw-skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/task-quality-gate.test.ts src/lib/server/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts",
60
+ "test:openclaw": "tsx --test src/lib/openclaw-agent-id.test.ts src/lib/openclaw-endpoint.test.ts src/lib/server/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw-agent-resolver.test.ts src/lib/server/openclaw-deploy.test.ts src/lib/server/openclaw-skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/task-quality-gate.test.ts src/lib/server/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts",
61
61
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
62
62
  "postinstall": "node ./scripts/postinstall.mjs"
63
63
  },
@@ -23,6 +23,16 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
23
23
  body.apiEndpoint,
24
24
  )
25
25
  }
26
+ if (body.preferredGatewayTags !== undefined) {
27
+ agent.preferredGatewayTags = Array.isArray(body.preferredGatewayTags)
28
+ ? body.preferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
29
+ : []
30
+ }
31
+ if (body.preferredGatewayUseCase !== undefined) {
32
+ agent.preferredGatewayUseCase = typeof body.preferredGatewayUseCase === 'string' && body.preferredGatewayUseCase.trim()
33
+ ? body.preferredGatewayUseCase.trim()
34
+ : null
35
+ }
26
36
  if (body.routingTargets !== undefined && Array.isArray(body.routingTargets)) {
27
37
  agent.routingTargets = body.routingTargets.map((target: Record<string, unknown>, index: number) => ({
28
38
  id: typeof target.id === 'string' && target.id.trim() ? target.id.trim() : `route-${index + 1}`,
@@ -37,6 +47,12 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
37
47
  typeof target.apiEndpoint === 'string' ? target.apiEndpoint : null,
38
48
  ),
39
49
  gatewayProfileId: target.gatewayProfileId ?? null,
50
+ preferredGatewayTags: Array.isArray(target.preferredGatewayTags)
51
+ ? target.preferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
52
+ : [],
53
+ preferredGatewayUseCase: typeof target.preferredGatewayUseCase === 'string' && target.preferredGatewayUseCase.trim()
54
+ ? target.preferredGatewayUseCase.trim()
55
+ : null,
40
56
  priority: typeof target.priority === 'number' ? target.priority : index + 1,
41
57
  }))
42
58
  }
@@ -64,6 +64,8 @@ export async function POST(req: Request) {
64
64
  fallbackCredentialIds: body.fallbackCredentialIds,
65
65
  apiEndpoint: normalizeProviderEndpoint(body.provider, body.apiEndpoint || null),
66
66
  gatewayProfileId: body.gatewayProfileId,
67
+ preferredGatewayTags: body.preferredGatewayTags,
68
+ preferredGatewayUseCase: body.preferredGatewayUseCase,
67
69
  routingStrategy: body.routingStrategy,
68
70
  routingTargets: body.routingTargets?.map((target) => ({
69
71
  ...target,
@@ -18,7 +18,20 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
18
18
  }
19
19
 
20
20
  const linkedAgent = nextAgentId ? loadAgents()[nextAgentId] : null
21
- const linkedRoute = linkedAgent ? resolvePrimaryAgentRoute(linkedAgent) : null
21
+ const routePreferredGatewayTags = updates.routePreferredGatewayTags !== undefined
22
+ ? (Array.isArray(updates.routePreferredGatewayTags)
23
+ ? updates.routePreferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
24
+ : [])
25
+ : (sessions[id].routePreferredGatewayTags || [])
26
+ const routePreferredGatewayUseCase = updates.routePreferredGatewayUseCase !== undefined
27
+ ? (typeof updates.routePreferredGatewayUseCase === 'string' && updates.routePreferredGatewayUseCase.trim()
28
+ ? updates.routePreferredGatewayUseCase.trim()
29
+ : null)
30
+ : (sessions[id].routePreferredGatewayUseCase || null)
31
+ const linkedRoute = linkedAgent ? resolvePrimaryAgentRoute(linkedAgent, undefined, {
32
+ preferredGatewayTags: routePreferredGatewayTags,
33
+ preferredGatewayUseCase: routePreferredGatewayUseCase,
34
+ }) : null
22
35
 
23
36
  if (updates.name !== undefined) sessions[id].name = updates.name
24
37
  if (updates.cwd !== undefined) sessions[id].cwd = updates.cwd
@@ -39,6 +52,13 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
39
52
  if (updates.gatewayProfileId !== undefined) sessions[id].gatewayProfileId = updates.gatewayProfileId
40
53
  else if (agentIdUpdateProvided && linkedRoute) sessions[id].gatewayProfileId = linkedRoute.gatewayProfileId ?? null
41
54
 
55
+ if (updates.routePreferredGatewayTags !== undefined) {
56
+ sessions[id].routePreferredGatewayTags = routePreferredGatewayTags
57
+ }
58
+ if (updates.routePreferredGatewayUseCase !== undefined) {
59
+ sessions[id].routePreferredGatewayUseCase = routePreferredGatewayUseCase
60
+ }
61
+
42
62
  if (updates.plugins !== undefined) sessions[id].plugins = updates.plugins
43
63
  else if (agentIdUpdateProvided && linkedAgent) sessions[id].plugins = Array.isArray(linkedAgent.plugins) ? linkedAgent.plugins : []
44
64
 
@@ -61,7 +61,16 @@ export async function POST(req: Request) {
61
61
  const id = body.id || genId()
62
62
  const sessions = loadSessions()
63
63
  const agent = body.agentId ? loadAgents()[body.agentId] : null
64
- const resolvedRoute = agent ? resolvePrimaryAgentRoute(agent) : null
64
+ const routePreferredGatewayTags = Array.isArray(body.routePreferredGatewayTags)
65
+ ? body.routePreferredGatewayTags.filter((tag: unknown): tag is string => typeof tag === 'string' && tag.trim().length > 0)
66
+ : []
67
+ const routePreferredGatewayUseCase = typeof body.routePreferredGatewayUseCase === 'string' && body.routePreferredGatewayUseCase.trim()
68
+ ? body.routePreferredGatewayUseCase.trim()
69
+ : null
70
+ const resolvedRoute = agent ? resolvePrimaryAgentRoute(agent, undefined, {
71
+ preferredGatewayTags: routePreferredGatewayTags,
72
+ preferredGatewayUseCase: routePreferredGatewayUseCase,
73
+ }) : null
65
74
  const requestedPlugins = Array.isArray(body.plugins) ? body.plugins : (Array.isArray(body.tools) ? body.tools : null)
66
75
  const resolvedPlugins = requestedPlugins ?? (Array.isArray(agent?.plugins) ? agent.plugins : (Array.isArray(agent?.tools) ? agent.tools : []))
67
76
 
@@ -83,6 +92,8 @@ export async function POST(req: Request) {
83
92
  body.provider || agent?.provider || 'claude-cli',
84
93
  body.apiEndpoint || agent?.apiEndpoint || null,
85
94
  ),
95
+ routePreferredGatewayTags,
96
+ routePreferredGatewayUseCase,
86
97
  claudeSessionId: null,
87
98
  codexThreadId: null,
88
99
  opencodeSessionId: null,
@@ -15,6 +15,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
15
15
  runtime.lastSeenAt = now
16
16
  runtime.updatedAt = now
17
17
  runtime.status = body.status || 'online'
18
+ if (typeof body.lifecycleState === 'string' && body.lifecycleState) runtime.lifecycleState = body.lifecycleState
19
+ if (typeof body.version === 'string') runtime.version = body.version || null
20
+ if (typeof body.lastHealthNote === 'string') runtime.lastHealthNote = body.lastHealthNote || null
18
21
  if (body.tokenStats && typeof body.tokenStats === 'object') {
19
22
  runtime.tokenStats = {
20
23
  ...(runtime.tokenStats || {}),
@@ -10,12 +10,44 @@ const ops: CollectionOps<any> = { load: loadExternalAgents, save: saveExternalAg
10
10
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
11
  const { id } = await params
12
12
  const body = await req.json().catch(() => ({}))
13
- const result = mutateItem(ops, id, (runtime) => ({
14
- ...runtime,
15
- ...body,
16
- id,
17
- updatedAt: Date.now(),
18
- }))
13
+ const now = Date.now()
14
+ const result = mutateItem(ops, id, (runtime) => {
15
+ const action = typeof body.action === 'string' ? body.action : ''
16
+ const nextMetadata = body.metadata && typeof body.metadata === 'object'
17
+ ? { ...(runtime.metadata || {}), ...body.metadata }
18
+ : runtime.metadata
19
+
20
+ const next = {
21
+ ...runtime,
22
+ ...body,
23
+ id,
24
+ metadata: nextMetadata,
25
+ updatedAt: now,
26
+ }
27
+
28
+ if (action === 'activate') {
29
+ next.lifecycleState = 'active'
30
+ next.lastHealthNote = body.lastHealthNote || 'Runtime returned to active service.'
31
+ } else if (action === 'drain') {
32
+ next.lifecycleState = 'draining'
33
+ next.lastHealthNote = body.lastHealthNote || 'Runtime draining after current work.'
34
+ } else if (action === 'cordon') {
35
+ next.lifecycleState = 'cordoned'
36
+ next.lastHealthNote = body.lastHealthNote || 'Runtime cordoned from new work.'
37
+ } else if (action === 'restart') {
38
+ next.metadata = {
39
+ ...(next.metadata || {}),
40
+ controlRequest: {
41
+ action: 'restart',
42
+ requestedAt: now,
43
+ source: 'swarmclaw',
44
+ },
45
+ }
46
+ next.lastHealthNote = body.lastHealthNote || 'Restart requested from SwarmClaw.'
47
+ }
48
+
49
+ return next
50
+ })
19
51
  if (!result) return notFound()
20
52
  return NextResponse.json(result)
21
53
  }
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
3
  import { formatZodError, ExternalAgentRegisterSchema } from '@/lib/validation/schemas'
4
- import { loadExternalAgents, saveExternalAgents } from '@/lib/server/storage'
4
+ import { loadExternalAgents, loadGatewayProfiles, saveExternalAgents } from '@/lib/server/storage'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
6
  import type { ExternalAgentRuntime } from '@/types'
7
7
  import { z } from 'zod'
@@ -13,9 +13,20 @@ function withDerivedStatus(record: ExternalAgentRuntime): ExternalAgentRuntime {
13
13
  const staleMs = 3 * 60_000
14
14
  if (!lastSeenAt) return { ...record, status: record.status || 'offline' }
15
15
  if (record.status === 'offline') return record
16
+ const gateways = loadGatewayProfiles()
17
+ const gateway = record.gatewayProfileId ? gateways[record.gatewayProfileId] as Record<string, unknown> | undefined : undefined
18
+ const gatewayTags = Array.isArray(gateway?.tags)
19
+ ? gateway?.tags.filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0)
20
+ : []
21
+ const gatewayUseCase = gateway?.deployment && typeof gateway.deployment === 'object' && typeof (gateway.deployment as Record<string, unknown>).useCase === 'string'
22
+ ? (gateway.deployment as Record<string, unknown>).useCase as string
23
+ : null
16
24
  return {
17
25
  ...record,
18
26
  status: now - lastSeenAt > staleMs ? 'stale' : (record.status || 'online'),
27
+ lifecycleState: record.lifecycleState || 'active',
28
+ gatewayTags: record.gatewayTags?.length ? record.gatewayTags : gatewayTags,
29
+ gatewayUseCase: record.gatewayUseCase || gatewayUseCase,
19
30
  }
20
31
  }
21
32
 
@@ -53,6 +64,11 @@ export async function POST(req: Request) {
53
64
  gatewayProfileId: body.gatewayProfileId || null,
54
65
  capabilities: body.capabilities,
55
66
  labels: body.labels,
67
+ lifecycleState: body.lifecycleState || existing?.lifecycleState || 'active',
68
+ gatewayTags: body.gatewayTags,
69
+ gatewayUseCase: body.gatewayUseCase || null,
70
+ version: body.version || null,
71
+ lastHealthNote: body.lastHealthNote || null,
56
72
  metadata: body.metadata,
57
73
  tokenStats: body.tokenStats,
58
74
  lastHeartbeatAt: existing?.lastHeartbeatAt || now,
@@ -20,6 +20,14 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
20
20
  gateway.lastCheckedAt = Date.now()
21
21
  gateway.lastError = result.ok ? null : (result.error || result.hint || 'Gateway health check failed.')
22
22
  gateway.lastModelCount = Array.isArray(result.models) ? result.models.length : 0
23
+ gateway.deployment = {
24
+ ...(gateway.deployment || {}),
25
+ lastVerifiedAt: Date.now(),
26
+ lastVerifiedOk: result.ok,
27
+ lastVerifiedMessage: result.ok
28
+ ? `Verified ${Array.isArray(result.models) ? result.models.length : 0} model${result.models?.length === 1 ? '' : 's'}`
29
+ : (result.error || result.hint || 'Gateway health check failed.'),
30
+ }
23
31
  gateway.updatedAt = Date.now()
24
32
  saveGatewayProfiles(gateways)
25
33
  notify('gateways')
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { normalizeOpenClawEndpoint } from '@/lib/openclaw-endpoint'
3
3
  import { loadAgents, loadGatewayProfiles, saveAgents, saveGatewayProfiles } from '@/lib/server/storage'
4
4
  import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
5
- import type { Agent, AgentRoutingTarget, GatewayProfile } from '@/types'
5
+ import type { Agent, AgentRoutingTarget, GatewayProfile, OpenClawDeploymentConfig, OpenClawGatewayStats } from '@/types'
6
6
 
7
7
  const ops: CollectionOps<GatewayProfile> = {
8
8
  load: loadGatewayProfiles,
@@ -17,6 +17,56 @@ function normalizeTags(value: unknown): string[] {
17
17
  .filter(Boolean)
18
18
  }
19
19
 
20
+ function normalizeText(value: unknown): string | null {
21
+ return typeof value === 'string' && value.trim() ? value.trim() : null
22
+ }
23
+
24
+ function normalizeNullableNumber(value: unknown): number | null {
25
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
26
+ }
27
+
28
+ function normalizeDeployment(value: unknown): OpenClawDeploymentConfig | null {
29
+ if (!value || typeof value !== 'object') return null
30
+ const deployment = value as Record<string, unknown>
31
+ return {
32
+ method: normalizeText(deployment.method) as OpenClawDeploymentConfig['method'],
33
+ provider: normalizeText(deployment.provider) as OpenClawDeploymentConfig['provider'],
34
+ remoteTarget: normalizeText(deployment.remoteTarget) as OpenClawDeploymentConfig['remoteTarget'],
35
+ useCase: normalizeText(deployment.useCase) as OpenClawDeploymentConfig['useCase'],
36
+ exposure: normalizeText(deployment.exposure) as OpenClawDeploymentConfig['exposure'],
37
+ managedBy: normalizeText(deployment.managedBy) as OpenClawDeploymentConfig['managedBy'],
38
+ targetHost: normalizeText(deployment.targetHost),
39
+ sshHost: normalizeText(deployment.sshHost),
40
+ sshUser: normalizeText(deployment.sshUser),
41
+ sshPort: normalizeNullableNumber(deployment.sshPort),
42
+ sshKeyPath: normalizeText(deployment.sshKeyPath),
43
+ sshTargetDir: normalizeText(deployment.sshTargetDir),
44
+ image: normalizeText(deployment.image),
45
+ version: normalizeText(deployment.version),
46
+ lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
47
+ lastDeployAction: normalizeText(deployment.lastDeployAction),
48
+ lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
49
+ lastDeploySummary: normalizeText(deployment.lastDeploySummary),
50
+ lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
51
+ lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
52
+ lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
53
+ lastBackupPath: normalizeText(deployment.lastBackupPath),
54
+ }
55
+ }
56
+
57
+ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
58
+ if (!value || typeof value !== 'object') return null
59
+ const stats = value as Record<string, unknown>
60
+ return {
61
+ nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
62
+ connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
63
+ pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
64
+ pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
65
+ pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
66
+ externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
67
+ }
68
+ }
69
+
20
70
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
21
71
  const { id } = await params
22
72
  const body = await req.json().catch(() => ({}))
@@ -39,6 +89,8 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
39
89
  if (body.lastModelCount !== undefined) gateway.lastModelCount = body.lastModelCount || null
40
90
  if (body.discoveredHost !== undefined) gateway.discoveredHost = body.discoveredHost || null
41
91
  if (body.discoveredPort !== undefined) gateway.discoveredPort = body.discoveredPort || null
92
+ if (body.deployment !== undefined) gateway.deployment = { ...(gateway.deployment || {}), ...(normalizeDeployment(body.deployment) || {}) }
93
+ if (body.stats !== undefined) gateway.stats = { ...(gateway.stats || {}), ...(normalizeStats(body.stats) || {}) }
42
94
  if (body.isDefault !== undefined) gateway.isDefault = body.isDefault === true
43
95
  gateway.updatedAt = Date.now()
44
96
  return gateway
@@ -4,6 +4,7 @@ import { normalizeOpenClawEndpoint } from '@/lib/openclaw-endpoint'
4
4
  import { getGatewayProfiles } from '@/lib/server/agent-runtime-config'
5
5
  import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
6
6
  import { notify } from '@/lib/server/ws-hub'
7
+ import type { OpenClawDeploymentConfig, OpenClawGatewayStats } from '@/types'
7
8
  export const dynamic = 'force-dynamic'
8
9
 
9
10
  function normalizeTags(value: unknown): string[] {
@@ -13,6 +14,56 @@ function normalizeTags(value: unknown): string[] {
13
14
  .filter(Boolean)
14
15
  }
15
16
 
17
+ function normalizeText(value: unknown): string | null {
18
+ return typeof value === 'string' && value.trim() ? value.trim() : null
19
+ }
20
+
21
+ function normalizeNullableNumber(value: unknown): number | null {
22
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
23
+ }
24
+
25
+ function normalizeDeployment(value: unknown): OpenClawDeploymentConfig | null {
26
+ if (!value || typeof value !== 'object') return null
27
+ const deployment = value as Record<string, unknown>
28
+ return {
29
+ method: normalizeText(deployment.method) as OpenClawDeploymentConfig['method'],
30
+ provider: normalizeText(deployment.provider) as OpenClawDeploymentConfig['provider'],
31
+ remoteTarget: normalizeText(deployment.remoteTarget) as OpenClawDeploymentConfig['remoteTarget'],
32
+ useCase: normalizeText(deployment.useCase) as OpenClawDeploymentConfig['useCase'],
33
+ exposure: normalizeText(deployment.exposure) as OpenClawDeploymentConfig['exposure'],
34
+ managedBy: normalizeText(deployment.managedBy) as OpenClawDeploymentConfig['managedBy'],
35
+ targetHost: normalizeText(deployment.targetHost),
36
+ sshHost: normalizeText(deployment.sshHost),
37
+ sshUser: normalizeText(deployment.sshUser),
38
+ sshPort: normalizeNullableNumber(deployment.sshPort),
39
+ sshKeyPath: normalizeText(deployment.sshKeyPath),
40
+ sshTargetDir: normalizeText(deployment.sshTargetDir),
41
+ image: normalizeText(deployment.image),
42
+ version: normalizeText(deployment.version),
43
+ lastDeployAt: normalizeNullableNumber(deployment.lastDeployAt),
44
+ lastDeployAction: normalizeText(deployment.lastDeployAction),
45
+ lastDeployProcessId: normalizeText(deployment.lastDeployProcessId),
46
+ lastDeploySummary: normalizeText(deployment.lastDeploySummary),
47
+ lastVerifiedAt: normalizeNullableNumber(deployment.lastVerifiedAt),
48
+ lastVerifiedOk: typeof deployment.lastVerifiedOk === 'boolean' ? deployment.lastVerifiedOk : null,
49
+ lastVerifiedMessage: normalizeText(deployment.lastVerifiedMessage),
50
+ lastBackupPath: normalizeText(deployment.lastBackupPath),
51
+ }
52
+ }
53
+
54
+ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
55
+ if (!value || typeof value !== 'object') return null
56
+ const stats = value as Record<string, unknown>
57
+ return {
58
+ nodeCount: normalizeNullableNumber(stats.nodeCount) ?? undefined,
59
+ connectedNodeCount: normalizeNullableNumber(stats.connectedNodeCount) ?? undefined,
60
+ pendingNodePairings: normalizeNullableNumber(stats.pendingNodePairings) ?? undefined,
61
+ pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
62
+ pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
63
+ externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
64
+ }
65
+ }
66
+
16
67
  export async function GET() {
17
68
  return NextResponse.json(getGatewayProfiles('openclaw'))
18
69
  }
@@ -46,6 +97,8 @@ export async function POST(req: Request) {
46
97
  lastModelCount: null,
47
98
  discoveredHost: typeof body.discoveredHost === 'string' ? body.discoveredHost : null,
48
99
  discoveredPort: typeof body.discoveredPort === 'number' ? body.discoveredPort : null,
100
+ deployment: normalizeDeployment(body.deployment),
101
+ stats: normalizeStats(body.stats),
49
102
  isDefault,
50
103
  createdAt: now,
51
104
  updatedAt: now,