@swarmclawai/swarmclaw 1.9.22 → 1.9.24
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 +23 -5
- package/package.json +2 -2
- package/src/app/api/gateways/[id]/control/route.ts +22 -0
- package/src/app/api/gateways/control-route.test.ts +86 -0
- package/src/components/providers/provider-list.tsx +75 -2
- package/src/components/schedules/schedule-console.tsx +3 -0
- 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/server/runtime/scheduler.test.ts +129 -0
- package/src/lib/server/runtime/scheduler.ts +62 -35
- package/src/lib/server/schedules/schedule-history.test.ts +14 -0
- package/src/lib/server/schedules/schedule-history.ts +1 -0
- package/src/lib/server/schedules/schedule-lifecycle.ts +5 -28
- package/src/lib/server/schedules/schedule-normalization.ts +6 -28
- package/src/lib/server/schedules/schedule-timing.test.ts +80 -0
- package/src/lib/server/schedules/schedule-timing.ts +179 -0
- package/src/lib/server/tasks/task-lifecycle.ts +35 -5
- package/src/lib/validation/schemas.ts +5 -0
- package/src/types/misc.ts +12 -0
- package/src/types/schedule.ts +2 -2
- package/src/types/task.ts +1 -0
package/README.md
CHANGED
|
@@ -151,13 +151,13 @@ clawhub install swarmclaw
|
|
|
151
151
|
|
|
152
152
|
[Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
|
|
153
153
|
|
|
154
|
-
## v1.9.
|
|
154
|
+
## v1.9.24 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
|
+
- **Slack peer messages.** Peer bot messages now reach the existing connector policy gates while self-loop messages are still blocked.
|
|
161
161
|
|
|
162
162
|
## Hosted Deploys
|
|
163
163
|
|
|
@@ -409,6 +409,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
409
409
|
|
|
410
410
|
## Releases
|
|
411
411
|
|
|
412
|
+
### v1.9.24 Highlights
|
|
413
|
+
|
|
414
|
+
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.
|
|
415
|
+
|
|
416
|
+
- **Gateway lifecycle controls.** Providers can activate, drain, cordon, and request restart for saved OpenClaw gateway profiles.
|
|
417
|
+
- **Routing guardrails.** OpenClaw route selection skips draining and cordoned profiles, including default, preferred, and pinned gateway paths.
|
|
418
|
+
- **Operations Pulse awareness.** Cordoned and draining gateways now appear as operator attention items before they surprise a handoff or release check.
|
|
419
|
+
- **Slack peer collaboration.** Slack peer-bot messages are no longer dropped before group policy, mention, and self-loop protections run.
|
|
420
|
+
|
|
421
|
+
### v1.9.23 Highlights
|
|
422
|
+
|
|
423
|
+
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.
|
|
424
|
+
|
|
425
|
+
- **Cron drift repair.** Active cron schedules repair missing or invalid `nextRunAt` values and stale future cron slots before the scheduler decides whether work is due.
|
|
426
|
+
- **Tick-time advancement.** Cron and interval schedules now advance from the scheduler tick time instead of the process wall clock, making restart and catch-up behavior deterministic.
|
|
427
|
+
- **Stable stagger.** Schedule stagger offsets are deterministic per schedule, avoiding thundering-herd launches without moving a saved next-run target on every recompute.
|
|
428
|
+
- **Mission continuity.** Schedule-created board tasks attach to a persistent mission link, so recurring runs share the same operational context.
|
|
429
|
+
|
|
412
430
|
### v1.9.22 Highlights
|
|
413
431
|
|
|
414
432
|
Research tools release: agents now get direct `web_extract` and `web_crawl` tools alongside `web_search`, `web_fetch`, and the unified `web` tool.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.24",
|
|
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",
|
|
@@ -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-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
|
+
})
|
|
@@ -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>
|
|
@@ -162,6 +162,8 @@ function historyActionLabel(action: ScheduleHistoryEntry['action']): string {
|
|
|
162
162
|
return 'Skipped'
|
|
163
163
|
case 'failed':
|
|
164
164
|
return 'Failed'
|
|
165
|
+
case 'repaired':
|
|
166
|
+
return 'Repaired'
|
|
165
167
|
default:
|
|
166
168
|
return action
|
|
167
169
|
}
|
|
@@ -171,6 +173,7 @@ function historyActionBadge(action: ScheduleHistoryEntry['action']): string {
|
|
|
171
173
|
if (action === 'created' || action === 'restored' || action === 'run_started') return badgeClass('completed')
|
|
172
174
|
if (action === 'failed') return badgeClass('failed')
|
|
173
175
|
if (action === 'skipped' || action === 'archived') return badgeClass('paused')
|
|
176
|
+
if (action === 'repaired') return badgeClass('running')
|
|
174
177
|
return badgeClass('running')
|
|
175
178
|
}
|
|
176
179
|
|
|
@@ -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
|