@swarmclawai/swarmclaw 1.9.23 → 1.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/package.json +3 -3
- package/src/app/api/gateways/[id]/control/route.ts +22 -0
- package/src/app/api/gateways/control-route.test.ts +86 -0
- package/src/cli/index.js +17 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +5 -0
- package/src/components/providers/provider-list.tsx +75 -2
- package/src/features/gateways/queries.ts +17 -0
- package/src/lib/server/agents/agent-runtime-config.test.ts +80 -0
- package/src/lib/server/agents/agent-runtime-config.ts +55 -4
- package/src/lib/server/connectors/slack.test.ts +30 -0
- package/src/lib/server/connectors/slack.ts +8 -3
- package/src/lib/server/gateways/gateway-profile-service.ts +71 -0
- package/src/lib/server/operations/operation-pulse.test.ts +34 -0
- package/src/lib/server/operations/operation-pulse.ts +18 -0
- package/src/lib/validation/schemas.ts +5 -0
- package/src/types/misc.ts +12 -0
package/README.md
CHANGED
|
@@ -151,13 +151,14 @@ clawhub install swarmclaw
|
|
|
151
151
|
|
|
152
152
|
[Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
|
|
153
153
|
|
|
154
|
-
## v1.9.
|
|
154
|
+
## v1.9.25 Highlights
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
Gateway lifecycle controls and Slack peer-agent collaboration are now safer for multi-agent operations.
|
|
157
157
|
|
|
158
|
-
- **
|
|
159
|
-
- **
|
|
160
|
-
- **
|
|
158
|
+
- **Gateway lifecycle controls.** Providers can activate, drain, cordon, or request restart for saved OpenClaw gateways.
|
|
159
|
+
- **Routing guardrails.** New OpenClaw work skips draining or cordoned gateway profiles, and Operations Pulse flags unavailable gateways.
|
|
160
|
+
- **CLI lifecycle access.** `swarmclaw gateways activate`, `drain`, `cordon`, and `restart` now reach the same lifecycle control endpoint as the provider UI.
|
|
161
|
+
- **Slack peer messages.** Peer bot messages now reach the existing connector policy gates while self-loop messages are still blocked.
|
|
161
162
|
|
|
162
163
|
## Hosted Deploys
|
|
163
164
|
|
|
@@ -409,6 +410,16 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
409
410
|
|
|
410
411
|
## Releases
|
|
411
412
|
|
|
413
|
+
### v1.9.25 Highlights
|
|
414
|
+
|
|
415
|
+
Gateway lifecycle release: saved OpenClaw gateways now have explicit operator lifecycle controls, automatic routing avoids gateways that should not receive new work, and Slack peer-agent messages flow through the existing connector policy gates.
|
|
416
|
+
|
|
417
|
+
- **Gateway lifecycle controls.** Providers can activate, drain, cordon, and request restart for saved OpenClaw gateway profiles.
|
|
418
|
+
- **Routing guardrails.** OpenClaw route selection skips draining and cordoned profiles, including default, preferred, and pinned gateway paths.
|
|
419
|
+
- **Operations Pulse awareness.** Cordoned and draining gateways now appear as operator attention items before they surprise a handoff or release check.
|
|
420
|
+
- **CLI lifecycle access.** `swarmclaw gateways activate`, `drain`, `cordon`, and `restart` now post the matching lifecycle action for automation and release scripts.
|
|
421
|
+
- **Slack peer collaboration.** Slack peer-bot messages are no longer dropped before group policy, mention, and self-loop protections run.
|
|
422
|
+
|
|
412
423
|
### v1.9.23 Highlights
|
|
413
424
|
|
|
414
425
|
Schedule reliability release: recurring work now repairs stale timing state before it can skip the nearest run, and scheduled board tasks keep mission context across repeat launches.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.25",
|
|
4
4
|
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
|
|
5
5
|
"main": "electron-dist/main.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"node": ">=22.6.0"
|
|
40
40
|
},
|
|
41
41
|
"bin": {
|
|
42
|
-
"swarmclaw": "
|
|
42
|
+
"swarmclaw": "bin/swarmclaw.js"
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"bin/",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
|
|
89
89
|
"test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
|
|
90
90
|
"test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/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/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.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/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
|
|
91
|
-
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
91
|
+
"test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/connectors/slack.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/gateways/control-route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
|
|
92
92
|
"test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
|
|
93
93
|
"test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
|
|
94
94
|
"test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { controlGatewayProfile } from '@/lib/server/gateways/gateway-profile-service'
|
|
4
|
+
import { notFound } from '@/lib/server/collection-helpers'
|
|
5
|
+
import { GatewayControlSchema, formatZodError } from '@/lib/validation/schemas'
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic'
|
|
8
|
+
|
|
9
|
+
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
10
|
+
const { id } = await params
|
|
11
|
+
const raw = await req.json().catch(() => null)
|
|
12
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
13
|
+
return NextResponse.json({ error: 'Invalid or missing request body' }, { status: 400 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const parsed = GatewayControlSchema.safeParse(raw)
|
|
17
|
+
if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
|
|
18
|
+
|
|
19
|
+
const result = controlGatewayProfile(id, parsed.data)
|
|
20
|
+
if (!result) return notFound()
|
|
21
|
+
return NextResponse.json(result)
|
|
22
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { afterEach, test } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { POST } from '@/app/api/gateways/[id]/control/route'
|
|
5
|
+
import { loadGatewayProfiles, saveGatewayProfiles } from '@/lib/server/storage'
|
|
6
|
+
|
|
7
|
+
const originalGateways = loadGatewayProfiles()
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
saveGatewayProfiles(originalGateways)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
function saveTestGateway(id = 'gateway-control-test') {
|
|
14
|
+
saveGatewayProfiles({
|
|
15
|
+
...loadGatewayProfiles(),
|
|
16
|
+
[id]: {
|
|
17
|
+
id,
|
|
18
|
+
name: 'Gateway Control Test',
|
|
19
|
+
provider: 'openclaw',
|
|
20
|
+
endpoint: 'http://127.0.0.1:18789/v1',
|
|
21
|
+
wsUrl: 'ws://127.0.0.1:18789',
|
|
22
|
+
credentialId: null,
|
|
23
|
+
status: 'healthy',
|
|
24
|
+
lifecycleState: 'active',
|
|
25
|
+
notes: null,
|
|
26
|
+
tags: [],
|
|
27
|
+
lastError: null,
|
|
28
|
+
lastCheckedAt: null,
|
|
29
|
+
lastModelCount: null,
|
|
30
|
+
discoveredHost: null,
|
|
31
|
+
discoveredPort: null,
|
|
32
|
+
deployment: null,
|
|
33
|
+
stats: null,
|
|
34
|
+
isDefault: false,
|
|
35
|
+
createdAt: 1,
|
|
36
|
+
updatedAt: 1,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('gateway control route drains a gateway profile', async () => {
|
|
42
|
+
saveTestGateway()
|
|
43
|
+
|
|
44
|
+
const response = await POST(new Request('http://local/api/gateways/gateway-control-test/control', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'content-type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ action: 'drain', reason: 'maintenance' }),
|
|
48
|
+
}), { params: Promise.resolve({ id: 'gateway-control-test' }) })
|
|
49
|
+
|
|
50
|
+
assert.equal(response.status, 200)
|
|
51
|
+
const payload = await response.json()
|
|
52
|
+
assert.equal(payload.lifecycleState, 'draining')
|
|
53
|
+
assert.equal(payload.lastControlAction, 'drain')
|
|
54
|
+
assert.equal(payload.lastControlReason, 'maintenance')
|
|
55
|
+
assert.equal(payload.controlRequest, null)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('gateway control route records restart requests without changing lifecycle', async () => {
|
|
59
|
+
saveTestGateway()
|
|
60
|
+
|
|
61
|
+
const response = await POST(new Request('http://local/api/gateways/gateway-control-test/control', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ action: 'restart' }),
|
|
65
|
+
}), { params: Promise.resolve({ id: 'gateway-control-test' }) })
|
|
66
|
+
|
|
67
|
+
assert.equal(response.status, 200)
|
|
68
|
+
const payload = await response.json()
|
|
69
|
+
assert.equal(payload.lifecycleState, 'active')
|
|
70
|
+
assert.equal(payload.lastControlAction, 'restart')
|
|
71
|
+
assert.equal(payload.controlRequest?.action, 'restart')
|
|
72
|
+
assert.equal(payload.controlRequest?.source, 'swarmclaw')
|
|
73
|
+
assert.equal(typeof payload.controlRequest?.requestedAt, 'number')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('gateway control route validates control actions', async () => {
|
|
77
|
+
saveTestGateway()
|
|
78
|
+
|
|
79
|
+
const response = await POST(new Request('http://local/api/gateways/gateway-control-test/control', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ action: 'pause' }),
|
|
83
|
+
}), { params: Promise.resolve({ id: 'gateway-control-test' }) })
|
|
84
|
+
|
|
85
|
+
assert.equal(response.status, 400)
|
|
86
|
+
})
|
package/src/cli/index.js
CHANGED
|
@@ -278,6 +278,23 @@ const COMMAND_GROUPS = [
|
|
|
278
278
|
cmd('create', 'POST', '/gateways', 'Create a gateway profile', { expectsJsonBody: true }),
|
|
279
279
|
cmd('update', 'PUT', '/gateways/:id', 'Update a gateway profile', { expectsJsonBody: true }),
|
|
280
280
|
cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
|
|
281
|
+
cmd('control', 'POST', '/gateways/:id/control', 'Run a gateway lifecycle control action', { expectsJsonBody: true }),
|
|
282
|
+
cmd('activate', 'POST', '/gateways/:id/control', 'Return a gateway to active routing', {
|
|
283
|
+
expectsJsonBody: true,
|
|
284
|
+
defaultBody: { action: 'activate' },
|
|
285
|
+
}),
|
|
286
|
+
cmd('drain', 'POST', '/gateways/:id/control', 'Drain a gateway from new automatic work', {
|
|
287
|
+
expectsJsonBody: true,
|
|
288
|
+
defaultBody: { action: 'drain' },
|
|
289
|
+
}),
|
|
290
|
+
cmd('cordon', 'POST', '/gateways/:id/control', 'Cordon a gateway from automatic work', {
|
|
291
|
+
expectsJsonBody: true,
|
|
292
|
+
defaultBody: { action: 'cordon' },
|
|
293
|
+
}),
|
|
294
|
+
cmd('restart', 'POST', '/gateways/:id/control', 'Request a gateway restart', {
|
|
295
|
+
expectsJsonBody: true,
|
|
296
|
+
defaultBody: { action: 'restart' },
|
|
297
|
+
}),
|
|
281
298
|
cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
|
|
282
299
|
cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
|
|
283
300
|
cmd('environments', 'GET', '/gateways/:id/environments', 'List OpenClaw gateway execution environments'),
|
package/src/cli/index.test.js
CHANGED
|
@@ -225,6 +225,36 @@ test('tasks execution-policy-decision posts policy decisions', async () => {
|
|
|
225
225
|
assert.equal(stderr.toString(), '')
|
|
226
226
|
})
|
|
227
227
|
|
|
228
|
+
test('gateways drain command posts a lifecycle control action', async () => {
|
|
229
|
+
const stdout = makeWritable()
|
|
230
|
+
const stderr = makeWritable()
|
|
231
|
+
const calls = []
|
|
232
|
+
|
|
233
|
+
const fetchImpl = async (url, init) => {
|
|
234
|
+
calls.push({ url: String(url), init })
|
|
235
|
+
return jsonResponse({ ok: true })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const exitCode = await runCli(
|
|
239
|
+
['gateways', 'drain', 'gateway-1', '--json'],
|
|
240
|
+
{
|
|
241
|
+
fetchImpl,
|
|
242
|
+
stdout,
|
|
243
|
+
stderr,
|
|
244
|
+
env: {
|
|
245
|
+
SWARMCLAW_API_KEY: 'test-key',
|
|
246
|
+
},
|
|
247
|
+
cwd: process.cwd(),
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
assert.equal(exitCode, 0)
|
|
252
|
+
assert.equal(calls.length, 1)
|
|
253
|
+
assert.match(calls[0].url, /\/api\/gateways\/gateway-1\/control$/)
|
|
254
|
+
assert.equal(calls[0].init.method, 'POST')
|
|
255
|
+
assert.deepEqual(JSON.parse(calls[0].init.body), { action: 'drain' })
|
|
256
|
+
})
|
|
257
|
+
|
|
228
258
|
test('openclaw deploy bundle command merges action with provided JSON body', async () => {
|
|
229
259
|
const stdout = makeWritable()
|
|
230
260
|
const stderr = makeWritable()
|
package/src/cli/spec.js
CHANGED
|
@@ -219,6 +219,11 @@ const COMMAND_GROUPS = {
|
|
|
219
219
|
create: { description: 'Create a gateway profile', method: 'POST', path: '/gateways' },
|
|
220
220
|
update: { description: 'Update a gateway profile', method: 'PUT', path: '/gateways/:id', params: ['id'] },
|
|
221
221
|
delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
|
|
222
|
+
control: { description: 'Run a gateway lifecycle control action', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
|
|
223
|
+
activate: { description: 'Return a gateway to active routing', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
|
|
224
|
+
drain: { description: 'Drain a gateway from new automatic work', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
|
|
225
|
+
cordon: { description: 'Cordon a gateway from automatic work', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
|
|
226
|
+
restart: { description: 'Request a gateway restart', method: 'POST', path: '/gateways/:id/control', params: ['id'] },
|
|
222
227
|
health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
|
|
223
228
|
topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
|
|
224
229
|
environments: { description: 'List OpenClaw gateway execution environments', method: 'GET', path: '/gateways/:id/environments', params: ['id'] },
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { useCredentialsQuery, useCreateCredentialMutation } from '@/features/credentials/queries'
|
|
14
14
|
import {
|
|
15
15
|
useCloneGatewayProfileMutation,
|
|
16
|
+
useGatewayControlMutation,
|
|
16
17
|
useDeleteGatewayProfileMutation,
|
|
17
18
|
useGatewayFleetTopologyQuery,
|
|
18
19
|
useGatewayHealthCheckMutation,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
useVerifyOpenClawDeployMutation,
|
|
23
24
|
} from '@/features/gateways/queries'
|
|
24
25
|
import { useExternalAgentsQuery, useExternalAgentRuntimeMutation } from '@/features/external-agents/queries'
|
|
25
|
-
import type { GatewayProfile } from '@/types'
|
|
26
|
+
import type { GatewayControlAction, GatewayLifecycleState, GatewayProfile } from '@/types'
|
|
26
27
|
import { dedup } from '@/lib/shared-utils'
|
|
27
28
|
import { PageLoader } from '@/components/ui/page-loader'
|
|
28
29
|
import { StatusDot } from '@/components/ui/status-dot'
|
|
@@ -45,6 +46,18 @@ function formatRuntimeTimestamp(value: number | null | undefined): string {
|
|
|
45
46
|
}).format(value)
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
function gatewayLifecycleLabel(value: GatewayLifecycleState | null | undefined): string {
|
|
50
|
+
if (value === 'draining') return 'Draining'
|
|
51
|
+
if (value === 'cordoned') return 'Cordoned'
|
|
52
|
+
return 'Active'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gatewayLifecycleBadgeClass(value: GatewayLifecycleState | null | undefined): string {
|
|
56
|
+
if (value === 'draining') return 'border-amber-400/20 bg-amber-400/[0.08] text-amber-200'
|
|
57
|
+
if (value === 'cordoned') return 'border-rose-400/20 bg-rose-400/[0.08] text-rose-200'
|
|
58
|
+
return 'border-emerald-400/20 bg-emerald-400/[0.08] text-emerald-200'
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
49
62
|
const setProviderSheetOpen = useAppStore((s) => s.setProviderSheetOpen)
|
|
50
63
|
const setEditingProviderId = useAppStore((s) => s.setEditingProviderId)
|
|
@@ -63,6 +76,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
63
76
|
const saveGatewayMutation = useSaveGatewayProfileMutation()
|
|
64
77
|
const deleteGatewayMutation = useDeleteGatewayProfileMutation()
|
|
65
78
|
const healthCheckGatewayMutation = useGatewayHealthCheckMutation()
|
|
79
|
+
const gatewayControlMutation = useGatewayControlMutation()
|
|
66
80
|
const refreshGatewayTopologyMutation = useRefreshGatewayTopologyMutation()
|
|
67
81
|
const verifyDeployMutation = useVerifyOpenClawDeployMutation()
|
|
68
82
|
const cloneGatewayMutation = useCloneGatewayProfileMutation()
|
|
@@ -211,6 +225,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
211
225
|
credentialId: gateway.credentialId || null,
|
|
212
226
|
notes: gateway.notes || null,
|
|
213
227
|
tags: gateway.tags || [],
|
|
228
|
+
lifecycleState: 'active',
|
|
214
229
|
deployment: gateway.deployment || null,
|
|
215
230
|
stats: gateway.stats || null,
|
|
216
231
|
isDefault: false,
|
|
@@ -221,6 +236,27 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
221
236
|
}
|
|
222
237
|
}
|
|
223
238
|
|
|
239
|
+
const handleGatewayControl = async (
|
|
240
|
+
e: React.MouseEvent,
|
|
241
|
+
gatewayId: string,
|
|
242
|
+
action: GatewayControlAction,
|
|
243
|
+
) => {
|
|
244
|
+
e.stopPropagation()
|
|
245
|
+
try {
|
|
246
|
+
await gatewayControlMutation.mutateAsync({ id: gatewayId, action })
|
|
247
|
+
const actionLabel = action === 'activate'
|
|
248
|
+
? 'Gateway activated'
|
|
249
|
+
: action === 'drain'
|
|
250
|
+
? 'Gateway draining'
|
|
251
|
+
: action === 'cordon'
|
|
252
|
+
? 'Gateway cordoned'
|
|
253
|
+
: 'Restart requested'
|
|
254
|
+
toast.success(actionLabel)
|
|
255
|
+
} catch (err: unknown) {
|
|
256
|
+
toast.error(err instanceof Error ? err.message : 'Gateway control failed')
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
224
260
|
const handleRuntimeAction = async (
|
|
225
261
|
e: React.MouseEvent,
|
|
226
262
|
runtimeId: string,
|
|
@@ -537,6 +573,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
537
573
|
const deployment = gateway.deployment || null
|
|
538
574
|
const topology = topologyByGatewayId.get(gateway.id) || null
|
|
539
575
|
const stats = topology?.stats || gateway.stats || null
|
|
576
|
+
const lifecycleState = gateway.lifecycleState || 'active'
|
|
540
577
|
const topologyErrors = topology?.errors || []
|
|
541
578
|
const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
|
|
542
579
|
const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
|
|
@@ -569,6 +606,9 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
569
606
|
{gateway.isDefault && (
|
|
570
607
|
<span className="text-[10px] font-700 px-2 py-0.5 rounded-[5px] bg-accent-bright/10 text-accent-bright uppercase tracking-wider">Default</span>
|
|
571
608
|
)}
|
|
609
|
+
<span className={`text-[10px] font-700 px-2 py-0.5 rounded-[5px] border uppercase tracking-wider ${gatewayLifecycleBadgeClass(lifecycleState)}`}>
|
|
610
|
+
{gatewayLifecycleLabel(lifecycleState)}
|
|
611
|
+
</span>
|
|
572
612
|
<StatusDot
|
|
573
613
|
status={
|
|
574
614
|
gateway.status === 'healthy'
|
|
@@ -666,7 +706,40 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
|
|
|
666
706
|
</div>
|
|
667
707
|
)}
|
|
668
708
|
{!inSidebar && (
|
|
669
|
-
<div className="mt-3 flex items-center gap-2">
|
|
709
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
710
|
+
{lifecycleState !== 'active' ? (
|
|
711
|
+
<button
|
|
712
|
+
onClick={(e) => void handleGatewayControl(e, gateway.id, 'activate')}
|
|
713
|
+
disabled={gatewayControlMutation.isPending}
|
|
714
|
+
className="px-2.5 py-1.5 rounded-[8px] border border-emerald-400/20 bg-emerald-400/[0.06] text-[11px] font-700 text-emerald-200 hover:bg-emerald-400/[0.1] cursor-pointer transition-all disabled:opacity-40"
|
|
715
|
+
>
|
|
716
|
+
Activate
|
|
717
|
+
</button>
|
|
718
|
+
) : (
|
|
719
|
+
<>
|
|
720
|
+
<button
|
|
721
|
+
onClick={(e) => void handleGatewayControl(e, gateway.id, 'drain')}
|
|
722
|
+
disabled={gatewayControlMutation.isPending}
|
|
723
|
+
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-200 hover:bg-amber-400/[0.1] cursor-pointer transition-all disabled:opacity-40"
|
|
724
|
+
>
|
|
725
|
+
Drain
|
|
726
|
+
</button>
|
|
727
|
+
<button
|
|
728
|
+
onClick={(e) => void handleGatewayControl(e, gateway.id, 'cordon')}
|
|
729
|
+
disabled={gatewayControlMutation.isPending}
|
|
730
|
+
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 disabled:opacity-40"
|
|
731
|
+
>
|
|
732
|
+
Cordon
|
|
733
|
+
</button>
|
|
734
|
+
</>
|
|
735
|
+
)}
|
|
736
|
+
<button
|
|
737
|
+
onClick={(e) => void handleGatewayControl(e, gateway.id, 'restart')}
|
|
738
|
+
disabled={gatewayControlMutation.isPending}
|
|
739
|
+
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 disabled:opacity-40"
|
|
740
|
+
>
|
|
741
|
+
Restart
|
|
742
|
+
</button>
|
|
670
743
|
<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">
|
|
671
744
|
Health
|
|
672
745
|
</button>
|
|
@@ -38,6 +38,12 @@ interface SaveGatewayProfileInput {
|
|
|
38
38
|
payload: Record<string, unknown>
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
interface GatewayControlInput {
|
|
42
|
+
id: string
|
|
43
|
+
action: 'activate' | 'drain' | 'cordon' | 'restart'
|
|
44
|
+
reason?: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
interface VerifyOpenClawDeployInput {
|
|
42
48
|
endpoint: string
|
|
43
49
|
token?: string
|
|
@@ -130,6 +136,17 @@ export function useGatewayHealthCheckMutation() {
|
|
|
130
136
|
})
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
export function useGatewayControlMutation() {
|
|
140
|
+
const queryClient = useQueryClient()
|
|
141
|
+
return useMutation({
|
|
142
|
+
mutationFn: ({ id, action, reason }: GatewayControlInput) =>
|
|
143
|
+
api('POST', `/gateways/${id}/control`, { action, reason }),
|
|
144
|
+
onSuccess: async () => {
|
|
145
|
+
await invalidateGatewayQueries(queryClient)
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
133
150
|
export function useGatewayFleetTopologyQuery(options: QueryOptions = {}) {
|
|
134
151
|
return useQuery<OpenClawGatewayFleetTopology>({
|
|
135
152
|
queryKey: gatewayQueryKeys.fleet(),
|
|
@@ -17,6 +17,11 @@ function makeGateway(overrides: Partial<GatewayProfile> = {}): GatewayProfile {
|
|
|
17
17
|
wsUrl: 'wss://gateway.example.com',
|
|
18
18
|
credentialId: 'cred-gateway',
|
|
19
19
|
status: 'healthy',
|
|
20
|
+
lifecycleState: 'active',
|
|
21
|
+
lastControlAction: null,
|
|
22
|
+
lastControlActionAt: null,
|
|
23
|
+
lastControlReason: null,
|
|
24
|
+
controlRequest: null,
|
|
20
25
|
tags: [],
|
|
21
26
|
isDefault: true,
|
|
22
27
|
createdAt: now,
|
|
@@ -62,6 +67,81 @@ test('resolveAgentRouteCandidatesWithProfiles applies the default OpenClaw gatew
|
|
|
62
67
|
assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://gateway.example.com/v1'))
|
|
63
68
|
})
|
|
64
69
|
|
|
70
|
+
test('resolveAgentRouteCandidatesWithProfiles routes around draining default gateways', () => {
|
|
71
|
+
const gateways = [
|
|
72
|
+
makeGateway({
|
|
73
|
+
lifecycleState: 'draining',
|
|
74
|
+
}),
|
|
75
|
+
makeGateway({
|
|
76
|
+
id: 'gateway-secondary',
|
|
77
|
+
name: 'Gateway Secondary',
|
|
78
|
+
endpoint: 'https://secondary.example.com/v1',
|
|
79
|
+
wsUrl: 'wss://secondary.example.com',
|
|
80
|
+
credentialId: 'cred-secondary',
|
|
81
|
+
isDefault: false,
|
|
82
|
+
}),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent(), gateways)
|
|
86
|
+
assert.ok(route)
|
|
87
|
+
assert.equal(route.gatewayProfileId, 'gateway-secondary')
|
|
88
|
+
assert.equal(route.credentialId, 'cred-secondary')
|
|
89
|
+
assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://secondary.example.com/v1'))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('resolveAgentRouteCandidatesWithProfiles ignores cordoned preferred gateways', () => {
|
|
93
|
+
const gateways = [
|
|
94
|
+
makeGateway({
|
|
95
|
+
id: 'gateway-specialized',
|
|
96
|
+
name: 'Specialized Gateway',
|
|
97
|
+
tags: ['browser'],
|
|
98
|
+
lifecycleState: 'cordoned',
|
|
99
|
+
}),
|
|
100
|
+
makeGateway({
|
|
101
|
+
id: 'gateway-active',
|
|
102
|
+
name: 'Active Gateway',
|
|
103
|
+
endpoint: 'https://active.example.com/v1',
|
|
104
|
+
wsUrl: 'wss://active.example.com',
|
|
105
|
+
credentialId: 'cred-active',
|
|
106
|
+
tags: [],
|
|
107
|
+
isDefault: false,
|
|
108
|
+
}),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent(), gateways, undefined, undefined, {
|
|
112
|
+
preferredGatewayTags: ['browser'],
|
|
113
|
+
})
|
|
114
|
+
assert.ok(route)
|
|
115
|
+
assert.equal(route.gatewayProfileId, 'gateway-active')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('resolveAgentRouteCandidatesWithProfiles drops OpenClaw routes when every gateway is cordoned', () => {
|
|
119
|
+
const routes = resolveAgentRouteCandidatesWithProfiles(makeAgent({
|
|
120
|
+
gatewayProfileId: 'gateway-default',
|
|
121
|
+
}), [
|
|
122
|
+
makeGateway({
|
|
123
|
+
lifecycleState: 'cordoned',
|
|
124
|
+
}),
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
assert.equal(routes.length, 0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('resolveAgentRouteCandidatesWithProfiles keeps explicit direct OpenClaw routes while saved gateways are cordoned', () => {
|
|
131
|
+
const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent({
|
|
132
|
+
gatewayProfileId: null,
|
|
133
|
+
apiEndpoint: 'https://direct.example.com/v1',
|
|
134
|
+
}), [
|
|
135
|
+
makeGateway({
|
|
136
|
+
lifecycleState: 'cordoned',
|
|
137
|
+
}),
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
assert.ok(route)
|
|
141
|
+
assert.equal(route.gatewayProfileId, null)
|
|
142
|
+
assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://direct.example.com/v1'))
|
|
143
|
+
})
|
|
144
|
+
|
|
65
145
|
test('resolveAgentRouteCandidatesWithProfiles respects routing strategy but deprioritizes cooling providers', () => {
|
|
66
146
|
const gateways = [
|
|
67
147
|
makeGateway({
|
|
@@ -65,6 +65,29 @@ function normalizeNullableNumber(value: unknown): number | null {
|
|
|
65
65
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function normalizeGatewayLifecycleState(value: unknown): NonNullable<GatewayProfile['lifecycleState']> {
|
|
69
|
+
return value === 'draining' || value === 'cordoned' ? value : 'active'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeGatewayControlAction(value: unknown): GatewayProfile['lastControlAction'] {
|
|
73
|
+
return value === 'activate' || value === 'drain' || value === 'cordon' || value === 'restart'
|
|
74
|
+
? value
|
|
75
|
+
: null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeGatewayControlRequest(value: unknown): GatewayProfile['controlRequest'] {
|
|
79
|
+
if (!value || typeof value !== 'object') return null
|
|
80
|
+
const request = value as Record<string, unknown>
|
|
81
|
+
const requestedAt = normalizeNullableNumber(request.requestedAt)
|
|
82
|
+
if (request.action !== 'restart' || !requestedAt) return null
|
|
83
|
+
return {
|
|
84
|
+
action: 'restart',
|
|
85
|
+
requestedAt,
|
|
86
|
+
source: 'swarmclaw',
|
|
87
|
+
reason: normalizeText(request.reason),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
function normalizeGatewayDeployment(
|
|
69
92
|
value: unknown,
|
|
70
93
|
): GatewayProfile['deployment'] {
|
|
@@ -109,6 +132,13 @@ function normalizeGatewayStats(value: unknown): GatewayProfile['stats'] {
|
|
|
109
132
|
pairedDeviceCount: normalizeNullableNumber(stats.pairedDeviceCount) ?? undefined,
|
|
110
133
|
pendingDevicePairings: normalizeNullableNumber(stats.pendingDevicePairings) ?? undefined,
|
|
111
134
|
externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
|
|
135
|
+
sessionCount: normalizeNullableNumber(stats.sessionCount) ?? undefined,
|
|
136
|
+
presenceCount: normalizeNullableNumber(stats.presenceCount) ?? undefined,
|
|
137
|
+
environmentCount: normalizeNullableNumber(stats.environmentCount) ?? undefined,
|
|
138
|
+
availableEnvironmentCount: normalizeNullableNumber(stats.availableEnvironmentCount) ?? undefined,
|
|
139
|
+
lastTopologyCheckedAt: normalizeNullableNumber(stats.lastTopologyCheckedAt) ?? undefined,
|
|
140
|
+
lastTopologyErrorCount: normalizeNullableNumber(stats.lastTopologyErrorCount) ?? undefined,
|
|
141
|
+
lastTopologyError: normalizeText(stats.lastTopologyError),
|
|
112
142
|
}
|
|
113
143
|
}
|
|
114
144
|
|
|
@@ -139,6 +169,11 @@ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
|
|
|
139
169
|
wsUrl: typeof gateway.wsUrl === 'string' && gateway.wsUrl.trim() ? gateway.wsUrl.trim() : deriveOpenClawWsUrl(endpoint),
|
|
140
170
|
credentialId: typeof gateway.credentialId === 'string' && gateway.credentialId.trim() ? gateway.credentialId.trim() : null,
|
|
141
171
|
status: gateway.status === 'healthy' || gateway.status === 'degraded' || gateway.status === 'offline' || gateway.status === 'pending' ? gateway.status : 'unknown',
|
|
172
|
+
lifecycleState: normalizeGatewayLifecycleState(gateway.lifecycleState),
|
|
173
|
+
lastControlAction: normalizeGatewayControlAction(gateway.lastControlAction),
|
|
174
|
+
lastControlActionAt: normalizeNullableNumber(gateway.lastControlActionAt),
|
|
175
|
+
lastControlReason: normalizeText(gateway.lastControlReason),
|
|
176
|
+
controlRequest: normalizeGatewayControlRequest(gateway.controlRequest),
|
|
142
177
|
notes: typeof gateway.notes === 'string' ? gateway.notes : null,
|
|
143
178
|
tags: ensureStringArray(gateway.tags),
|
|
144
179
|
lastError: typeof gateway.lastError === 'string' ? gateway.lastError : null,
|
|
@@ -163,6 +198,18 @@ function findGatewayProfile(
|
|
|
163
198
|
return gatewayProfiles.find((profile) => profile.id === id) || null
|
|
164
199
|
}
|
|
165
200
|
|
|
201
|
+
export function isGatewayAcceptingNewWork(gatewayProfile: GatewayProfile | null | undefined): boolean {
|
|
202
|
+
return normalizeGatewayLifecycleState(gatewayProfile?.lifecycleState) === 'active'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function findAcceptingGatewayProfile(
|
|
206
|
+
gatewayProfiles: GatewayProfile[],
|
|
207
|
+
profileId?: string | null,
|
|
208
|
+
): GatewayProfile | null {
|
|
209
|
+
const gatewayProfile = findGatewayProfile(gatewayProfiles, profileId)
|
|
210
|
+
return isGatewayAcceptingNewWork(gatewayProfile) ? gatewayProfile : null
|
|
211
|
+
}
|
|
212
|
+
|
|
166
213
|
export function getGatewayProfiles(provider: GatewayProfile['provider'] | null = null): GatewayProfile[] {
|
|
167
214
|
const all = loadGatewayProfiles()
|
|
168
215
|
return Object.entries(all)
|
|
@@ -180,13 +227,15 @@ export function getGatewayProfile(profileId?: string | null): GatewayProfile | n
|
|
|
180
227
|
}
|
|
181
228
|
|
|
182
229
|
function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfile | null {
|
|
183
|
-
|
|
230
|
+
const accepting = gatewayProfiles.filter(isGatewayAcceptingNewWork)
|
|
231
|
+
return accepting.find((profile) => profile.isDefault) || accepting[0] || null
|
|
184
232
|
}
|
|
185
233
|
|
|
186
234
|
function gatewayPreferenceScore(
|
|
187
235
|
gatewayProfile: GatewayProfile,
|
|
188
236
|
preferences?: GatewayRoutePreferences | null,
|
|
189
237
|
): number {
|
|
238
|
+
if (!isGatewayAcceptingNewWork(gatewayProfile)) return -1
|
|
190
239
|
const normalized = normalizeRoutePreferences(preferences)
|
|
191
240
|
const preferredTags = normalized.preferredGatewayTags || []
|
|
192
241
|
const preferredUseCase = normalized.preferredGatewayUseCase || null
|
|
@@ -263,13 +312,15 @@ function buildRouteFromSeed(
|
|
|
263
312
|
preferredGatewayTags: seed.preferredGatewayTags ?? routePreferences?.preferredGatewayTags,
|
|
264
313
|
preferredGatewayUseCase: seed.preferredGatewayUseCase ?? routePreferences?.preferredGatewayUseCase,
|
|
265
314
|
})
|
|
266
|
-
let gatewayProfile =
|
|
315
|
+
let gatewayProfile = findAcceptingGatewayProfile(gatewayProfiles, seed.gatewayProfileId ?? null)
|
|
267
316
|
if (!gatewayProfile && provider === 'openclaw') {
|
|
268
317
|
gatewayProfile = pickPreferredGatewayProfile(gatewayProfiles, mergedPreferences)
|
|
269
|
-
||
|
|
318
|
+
|| findAcceptingGatewayProfile(gatewayProfiles, agentGatewayProfileId ?? null)
|
|
270
319
|
|| defaultGatewayProfile(gatewayProfiles)
|
|
271
320
|
}
|
|
272
|
-
const
|
|
321
|
+
const hasExplicitDirectEndpoint = typeof seed.apiEndpoint === 'string' && seed.apiEndpoint.trim().length > 0
|
|
322
|
+
if (provider === 'openclaw' && gatewayProfiles.length > 0 && !gatewayProfile && !hasExplicitDirectEndpoint) return null
|
|
323
|
+
const gatewayProfileId = gatewayProfile?.id ?? null
|
|
273
324
|
|
|
274
325
|
const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
|
|
275
326
|
const model = (seed.model || '').trim() || (providerFromGateway === 'openclaw' ? DEFAULT_OPENCLAW_MODEL : '')
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { test } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import { shouldAcceptSlackMessageEvent } from './slack'
|
|
5
|
+
|
|
6
|
+
test('shouldAcceptSlackMessageEvent accepts peer bot text messages', () => {
|
|
7
|
+
assert.equal(shouldAcceptSlackMessageEvent({
|
|
8
|
+
type: 'message',
|
|
9
|
+
text: 'Marian, can you review this?',
|
|
10
|
+
user: 'U_PEER_AGENT',
|
|
11
|
+
bot_id: 'B_PEER_AGENT',
|
|
12
|
+
}, 'U_SELF_AGENT'), true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('shouldAcceptSlackMessageEvent still rejects self-loop messages', () => {
|
|
16
|
+
assert.equal(shouldAcceptSlackMessageEvent({
|
|
17
|
+
type: 'message',
|
|
18
|
+
text: 'Loopback',
|
|
19
|
+
user: 'U_SELF_AGENT',
|
|
20
|
+
bot_id: 'B_SELF_AGENT',
|
|
21
|
+
}, 'U_SELF_AGENT'), false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('shouldAcceptSlackMessageEvent rejects non-text message events', () => {
|
|
25
|
+
assert.equal(shouldAcceptSlackMessageEvent({
|
|
26
|
+
type: 'message',
|
|
27
|
+
subtype: 'channel_join',
|
|
28
|
+
user: 'U_PEER_AGENT',
|
|
29
|
+
}, 'U_SELF_AGENT'), false)
|
|
30
|
+
})
|
|
@@ -106,6 +106,12 @@ async function hydrateSlackThreadContext(params: {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
export function shouldAcceptSlackMessageEvent(message: unknown, botUserId?: string): boolean {
|
|
110
|
+
if (!message || typeof message !== 'object' || !('text' in message)) return false
|
|
111
|
+
const msg = message as { user?: unknown }
|
|
112
|
+
return !(botUserId && typeof msg.user === 'string' && msg.user === botUserId)
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
const slack: PlatformConnector = {
|
|
110
116
|
async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
|
|
111
117
|
const appToken = connector.config.appToken || ''
|
|
@@ -173,10 +179,9 @@ const slack: PlatformConnector = {
|
|
|
173
179
|
|
|
174
180
|
// Handle messages
|
|
175
181
|
app.message(async ({ message, say, client }) => {
|
|
176
|
-
//
|
|
177
|
-
if (!(
|
|
182
|
+
// Bot-to-bot collaboration still flows through the normal connector policy gates.
|
|
183
|
+
if (!shouldAcceptSlackMessageEvent(message, botUserId)) return
|
|
178
184
|
const msg = message as any
|
|
179
|
-
if (botUserId && msg.user === botUserId) return
|
|
180
185
|
|
|
181
186
|
const channelId = msg.channel
|
|
182
187
|
if (allowedChannels && !allowedChannels.includes(channelId)) return
|
|
@@ -27,6 +27,29 @@ function normalizeNullableNumber(value: unknown): number | null {
|
|
|
27
27
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function normalizeLifecycleState(value: unknown): NonNullable<GatewayProfile['lifecycleState']> {
|
|
31
|
+
return value === 'draining' || value === 'cordoned' ? value : 'active'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeControlAction(value: unknown): GatewayProfile['lastControlAction'] {
|
|
35
|
+
return value === 'activate' || value === 'drain' || value === 'cordon' || value === 'restart'
|
|
36
|
+
? value
|
|
37
|
+
: null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeControlRequest(value: unknown): GatewayProfile['controlRequest'] {
|
|
41
|
+
if (!value || typeof value !== 'object') return null
|
|
42
|
+
const request = value as Record<string, unknown>
|
|
43
|
+
const requestedAt = normalizeNullableNumber(request.requestedAt)
|
|
44
|
+
if (request.action !== 'restart' || !requestedAt) return null
|
|
45
|
+
return {
|
|
46
|
+
action: 'restart',
|
|
47
|
+
requestedAt,
|
|
48
|
+
source: 'swarmclaw',
|
|
49
|
+
reason: normalizeText(request.reason),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
function normalizeDeployment(value: unknown): OpenClawDeploymentConfig | null {
|
|
31
54
|
if (!value || typeof value !== 'object') return null
|
|
32
55
|
const deployment = value as Record<string, unknown>
|
|
@@ -106,6 +129,11 @@ export function createGatewayProfile(input: Record<string, unknown>): GatewayPro
|
|
|
106
129
|
wsUrl: normalizeText(input.wsUrl),
|
|
107
130
|
credentialId: normalizeText(input.credentialId),
|
|
108
131
|
status: typeof input.status === 'string' && input.status.trim() ? input.status as GatewayProfile['status'] : 'unknown',
|
|
132
|
+
lifecycleState: normalizeLifecycleState(input.lifecycleState),
|
|
133
|
+
lastControlAction: normalizeControlAction(input.lastControlAction),
|
|
134
|
+
lastControlActionAt: normalizeNullableNumber(input.lastControlActionAt),
|
|
135
|
+
lastControlReason: normalizeText(input.lastControlReason),
|
|
136
|
+
controlRequest: normalizeControlRequest(input.controlRequest),
|
|
109
137
|
notes: typeof input.notes === 'string' ? input.notes : null,
|
|
110
138
|
tags: normalizeTags(input.tags),
|
|
111
139
|
lastError: null,
|
|
@@ -149,6 +177,11 @@ export function updateGatewayProfile(id: string, input: Record<string, unknown>)
|
|
|
149
177
|
: 'unknown'
|
|
150
178
|
gateway.status = nextStatus
|
|
151
179
|
}
|
|
180
|
+
if (input.lifecycleState !== undefined) gateway.lifecycleState = normalizeLifecycleState(input.lifecycleState)
|
|
181
|
+
if (input.lastControlAction !== undefined) gateway.lastControlAction = normalizeControlAction(input.lastControlAction)
|
|
182
|
+
if (input.lastControlActionAt !== undefined) gateway.lastControlActionAt = normalizeNullableNumber(input.lastControlActionAt)
|
|
183
|
+
if (input.lastControlReason !== undefined) gateway.lastControlReason = normalizeText(input.lastControlReason)
|
|
184
|
+
if (input.controlRequest !== undefined) gateway.controlRequest = normalizeControlRequest(input.controlRequest)
|
|
152
185
|
if (input.notes !== undefined) gateway.notes = typeof input.notes === 'string' ? input.notes : null
|
|
153
186
|
if (input.tags !== undefined) gateway.tags = normalizeTags(input.tags)
|
|
154
187
|
if (input.lastError !== undefined) gateway.lastError = typeof input.lastError === 'string' ? input.lastError : null
|
|
@@ -167,6 +200,44 @@ export function updateGatewayProfile(id: string, input: Record<string, unknown>)
|
|
|
167
200
|
return gateway
|
|
168
201
|
}
|
|
169
202
|
|
|
203
|
+
export function controlGatewayProfile(
|
|
204
|
+
id: string,
|
|
205
|
+
input: { action: NonNullable<GatewayProfile['lastControlAction']>; reason?: string | null },
|
|
206
|
+
now = Date.now(),
|
|
207
|
+
): GatewayProfile | null {
|
|
208
|
+
const gateways = loadGatewayProfiles()
|
|
209
|
+
const gateway = gateways[id]
|
|
210
|
+
if (!gateway) return null
|
|
211
|
+
|
|
212
|
+
const action = normalizeControlAction(input.action)
|
|
213
|
+
if (!action) return null
|
|
214
|
+
|
|
215
|
+
gateway.lifecycleState = action === 'drain'
|
|
216
|
+
? 'draining'
|
|
217
|
+
: action === 'cordon'
|
|
218
|
+
? 'cordoned'
|
|
219
|
+
: action === 'activate'
|
|
220
|
+
? 'active'
|
|
221
|
+
: normalizeLifecycleState(gateway.lifecycleState)
|
|
222
|
+
gateway.lastControlAction = action
|
|
223
|
+
gateway.lastControlActionAt = now
|
|
224
|
+
gateway.lastControlReason = normalizeText(input.reason)
|
|
225
|
+
gateway.controlRequest = action === 'restart'
|
|
226
|
+
? {
|
|
227
|
+
action: 'restart',
|
|
228
|
+
requestedAt: now,
|
|
229
|
+
source: 'swarmclaw',
|
|
230
|
+
reason: normalizeText(input.reason),
|
|
231
|
+
}
|
|
232
|
+
: null
|
|
233
|
+
gateway.updatedAt = now
|
|
234
|
+
|
|
235
|
+
gateways[id] = gateway
|
|
236
|
+
saveGatewayProfiles(gateways)
|
|
237
|
+
notify('gateways')
|
|
238
|
+
return gateway
|
|
239
|
+
}
|
|
240
|
+
|
|
170
241
|
export function deleteGatewayProfileAndDetachAgents(id: string): boolean {
|
|
171
242
|
const gateways = loadGatewayProfiles()
|
|
172
243
|
const deleted = gateways[id]
|
|
@@ -88,6 +88,11 @@ function gateway(overrides: Partial<GatewayProfile>): GatewayProfile {
|
|
|
88
88
|
wsUrl: overrides.wsUrl ?? null,
|
|
89
89
|
credentialId: overrides.credentialId ?? null,
|
|
90
90
|
status: overrides.status || 'healthy',
|
|
91
|
+
lifecycleState: overrides.lifecycleState || 'active',
|
|
92
|
+
lastControlAction: overrides.lastControlAction ?? null,
|
|
93
|
+
lastControlActionAt: overrides.lastControlActionAt ?? null,
|
|
94
|
+
lastControlReason: overrides.lastControlReason ?? null,
|
|
95
|
+
controlRequest: overrides.controlRequest ?? null,
|
|
91
96
|
notes: overrides.notes ?? null,
|
|
92
97
|
tags: overrides.tags || [],
|
|
93
98
|
lastError: overrides.lastError ?? null,
|
|
@@ -190,4 +195,33 @@ describe('operation pulse', () => {
|
|
|
190
195
|
assert.ok((pulse.actions[0]?.summary || '').includes('no available OpenClaw execution environments'))
|
|
191
196
|
assert.equal(pulse.actions[0]?.evidence.includes('0/2 environments'), true)
|
|
192
197
|
})
|
|
198
|
+
|
|
199
|
+
it('surfaces gateways that are unavailable for automatic new work', () => {
|
|
200
|
+
const pulse = buildOperationPulse({
|
|
201
|
+
range: '24h',
|
|
202
|
+
now,
|
|
203
|
+
missions: [],
|
|
204
|
+
runs: [],
|
|
205
|
+
approvals: [],
|
|
206
|
+
connectors: [],
|
|
207
|
+
gateways: [
|
|
208
|
+
gateway({
|
|
209
|
+
lifecycleState: 'cordoned',
|
|
210
|
+
stats: {
|
|
211
|
+
nodeCount: 1,
|
|
212
|
+
connectedNodeCount: 1,
|
|
213
|
+
environmentCount: 1,
|
|
214
|
+
availableEnvironmentCount: 1,
|
|
215
|
+
lastTopologyCheckedAt: now - 1000,
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
],
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
assert.equal(pulse.kpis.gatewayAttention, 1)
|
|
222
|
+
assert.equal(pulse.actions[0]?.kind, 'gateway')
|
|
223
|
+
assert.equal(pulse.actions[0]?.severity, 'medium')
|
|
224
|
+
assert.ok((pulse.actions[0]?.summary || '').includes('cordoned from automatic new work'))
|
|
225
|
+
assert.equal(pulse.actions[0]?.evidence.includes('lifecycle:cordoned'), true)
|
|
226
|
+
})
|
|
193
227
|
})
|
|
@@ -84,8 +84,10 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
|
84
84
|
const errorCount = gateway.stats?.lastTopologyErrorCount || 0
|
|
85
85
|
const checkedAt = gateway.stats?.lastTopologyCheckedAt || gateway.lastCheckedAt || null
|
|
86
86
|
const staleTopology = !checkedAt || now - checkedAt > GATEWAY_TOPOLOGY_STALE_MS
|
|
87
|
+
const lifecycleState = gateway.lifecycleState || 'active'
|
|
87
88
|
const evidence = [
|
|
88
89
|
`status:${gateway.status}`,
|
|
90
|
+
`lifecycle:${lifecycleState}`,
|
|
89
91
|
`${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
|
|
90
92
|
`${gateway.stats?.availableEnvironmentCount || 0}/${gateway.stats?.environmentCount || 0} environments`,
|
|
91
93
|
]
|
|
@@ -106,6 +108,22 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
|
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
if (lifecycleState === 'cordoned') {
|
|
112
|
+
return {
|
|
113
|
+
severity: 'medium',
|
|
114
|
+
summary: `${gateway.name} is cordoned from automatic new work.`,
|
|
115
|
+
evidence,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (lifecycleState === 'draining') {
|
|
120
|
+
return {
|
|
121
|
+
severity: 'medium',
|
|
122
|
+
summary: `${gateway.name} is draining and will not receive automatic new work.`,
|
|
123
|
+
evidence,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
if (errorCount > 0) {
|
|
110
128
|
return {
|
|
111
129
|
severity: 'medium',
|
|
@@ -364,6 +364,11 @@ export const ExternalAgentUpdateSchema = z.object({
|
|
|
364
364
|
}).nullable().optional(),
|
|
365
365
|
})
|
|
366
366
|
|
|
367
|
+
export const GatewayControlSchema = z.object({
|
|
368
|
+
action: z.enum(['activate', 'drain', 'cordon', 'restart']),
|
|
369
|
+
reason: z.string().max(500).nullable().optional(),
|
|
370
|
+
}).strict()
|
|
371
|
+
|
|
367
372
|
export const ChatroomCreateSchema = z.object({
|
|
368
373
|
name: z.string().min(1, 'Chatroom name is required'),
|
|
369
374
|
agentIds: z.array(z.string()).min(1, 'Select at least one agent').default([]),
|
package/src/types/misc.ts
CHANGED
|
@@ -644,6 +644,8 @@ export interface McpServerConfig {
|
|
|
644
644
|
|
|
645
645
|
export type GatewayProvider = 'openclaw'
|
|
646
646
|
export type GatewayHealthState = 'unknown' | 'healthy' | 'degraded' | 'offline' | 'pending'
|
|
647
|
+
export type GatewayLifecycleState = 'active' | 'draining' | 'cordoned'
|
|
648
|
+
export type GatewayControlAction = 'activate' | 'drain' | 'cordon' | 'restart'
|
|
647
649
|
export type OpenClawDeploymentMethod = 'local' | 'bundle' | 'ssh' | 'imported'
|
|
648
650
|
export type OpenClawDeploymentProvider =
|
|
649
651
|
| 'local'
|
|
@@ -714,6 +716,16 @@ export interface GatewayProfile {
|
|
|
714
716
|
wsUrl?: string | null
|
|
715
717
|
credentialId?: string | null
|
|
716
718
|
status: GatewayHealthState
|
|
719
|
+
lifecycleState?: GatewayLifecycleState
|
|
720
|
+
lastControlAction?: GatewayControlAction | null
|
|
721
|
+
lastControlActionAt?: number | null
|
|
722
|
+
lastControlReason?: string | null
|
|
723
|
+
controlRequest?: {
|
|
724
|
+
action: 'restart'
|
|
725
|
+
requestedAt: number
|
|
726
|
+
source: 'swarmclaw'
|
|
727
|
+
reason?: string | null
|
|
728
|
+
} | null
|
|
717
729
|
notes?: string | null
|
|
718
730
|
tags?: string[]
|
|
719
731
|
lastError?: string | null
|