fix: dynamic coordinator routing + boxed doctor parsing (#279)
* fix: route coordinator sends to live sessions and parse boxed doctor output * feat: make coordinator routing user-configurable and deployment-agnostic * feat: add coordinator target dropdown in settings * feat(settings): preview live coordinator routing resolution
This commit is contained in:
parent
f5551a2c93
commit
84e197b3dc
|
|
@ -7,6 +7,7 @@ import { requireRole } from '@/lib/auth'
|
|||
import { logger } from '@/lib/logger'
|
||||
import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard'
|
||||
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
|
||||
import { resolveCoordinatorDeliveryTarget } from '@/lib/coordinator-routing'
|
||||
|
||||
type ForwardInfo = {
|
||||
attempted: boolean
|
||||
|
|
@ -415,35 +416,54 @@ export async function POST(request: NextRequest) {
|
|||
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
|
||||
.get(to, workspaceId) as any
|
||||
|
||||
// Use explicit session key from caller if provided, then DB, then on-disk lookup
|
||||
let sessionKey: string | null = typeof body.sessionKey === 'string' && body.sessionKey
|
||||
const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey
|
||||
? body.sessionKey
|
||||
: agent?.session_key || null
|
||||
: null
|
||||
const sessions = getAllGatewaySessions()
|
||||
const isCoordinatorSend = String(to).toLowerCase() === COORDINATOR_AGENT.toLowerCase()
|
||||
const allAgents = isCoordinatorSend
|
||||
? (db
|
||||
.prepare('SELECT name, session_key, config FROM agents WHERE workspace_id = ?')
|
||||
.all(workspaceId) as Array<{ name: string; session_key?: string | null; config?: string | null }>)
|
||||
: []
|
||||
const configuredCoordinatorTarget = isCoordinatorSend
|
||||
? (db
|
||||
.prepare("SELECT value FROM settings WHERE key = 'chat.coordinator_target_agent'")
|
||||
.get() as { value?: string } | undefined)?.value || null
|
||||
: null
|
||||
|
||||
const coordinatorResolution = resolveCoordinatorDeliveryTarget({
|
||||
to: String(to),
|
||||
coordinatorAgent: COORDINATOR_AGENT,
|
||||
directAgent: agent
|
||||
? {
|
||||
name: String(agent.name || to),
|
||||
session_key: typeof agent.session_key === 'string' ? agent.session_key : null,
|
||||
config: typeof agent.config === 'string' ? agent.config : null,
|
||||
}
|
||||
: null,
|
||||
allAgents,
|
||||
sessions,
|
||||
explicitSessionKey,
|
||||
configuredCoordinatorTarget,
|
||||
})
|
||||
|
||||
// Use explicit session key from caller if provided, then DB, then on-disk lookup
|
||||
let sessionKey: string | null = coordinatorResolution.sessionKey
|
||||
|
||||
// Fallback: derive session from on-disk gateway session stores
|
||||
if (!sessionKey) {
|
||||
const sessions = getAllGatewaySessions()
|
||||
const match = sessions.find(
|
||||
(s) => s.agent.toLowerCase() === String(to).toLowerCase()
|
||||
(s) =>
|
||||
s.agent.toLowerCase() === String(to).toLowerCase() ||
|
||||
s.agent.toLowerCase() === coordinatorResolution.deliveryName.toLowerCase() ||
|
||||
s.agent.toLowerCase() === String(coordinatorResolution.openclawAgentId || '').toLowerCase()
|
||||
)
|
||||
sessionKey = match?.key || match?.sessionId || null
|
||||
}
|
||||
|
||||
// Prefer configured openclawId when present, fallback to normalized name
|
||||
let openclawAgentId: string | null = null
|
||||
if (agent?.config) {
|
||||
try {
|
||||
const cfg = JSON.parse(agent.config)
|
||||
if (cfg?.openclawId && typeof cfg.openclawId === 'string') {
|
||||
openclawAgentId = cfg.openclawId
|
||||
}
|
||||
} catch {
|
||||
// ignore parse issues
|
||||
}
|
||||
}
|
||||
if (!openclawAgentId && typeof to === 'string') {
|
||||
openclawAgentId = to.toLowerCase().replace(/\s+/g, '-')
|
||||
}
|
||||
let openclawAgentId: string | null = coordinatorResolution.openclawAgentId
|
||||
|
||||
if (!sessionKey && !openclawAgentId) {
|
||||
forwardInfo.reason = 'no_active_session'
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ const settingDefinitions: Record<string, { category: string; description: string
|
|||
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
||||
'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) },
|
||||
|
||||
// Chat
|
||||
'chat.coordinator_target_agent': {
|
||||
category: 'chat',
|
||||
description: 'Optional coordinator routing target (agent name or openclawId). When set, coordinator inbox messages are forwarded to this agent before default/main-session fallback.',
|
||||
default: '',
|
||||
},
|
||||
|
||||
// General
|
||||
'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' },
|
||||
'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { useNavigateToPanel } from '@/lib/navigation'
|
|||
import { SecurityScanCard } from '@/components/onboarding/security-scan-card'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session'
|
||||
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
|
||||
import type { GatewaySession } from '@/lib/sessions'
|
||||
|
||||
interface Setting {
|
||||
key: string
|
||||
|
|
@ -25,16 +27,60 @@ interface ApiKeyInfo {
|
|||
last_rotated_by: string | null
|
||||
}
|
||||
|
||||
interface CoordinatorTargetAgent {
|
||||
name: string
|
||||
openclawId: string
|
||||
isDefault: boolean
|
||||
sessionKey: string | null
|
||||
configRaw: string
|
||||
}
|
||||
|
||||
type CoordinatorSession = GatewaySession & { source?: string }
|
||||
|
||||
const COORDINATOR_AGENT = (process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').toLowerCase()
|
||||
|
||||
function parseCoordinatorTargetAgents(rawAgents: any[]): CoordinatorTargetAgent[] {
|
||||
const out: CoordinatorTargetAgent[] = []
|
||||
for (const raw of rawAgents || []) {
|
||||
const name = typeof raw?.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) continue
|
||||
const config = raw?.config && typeof raw.config === 'object' ? raw.config : {}
|
||||
const openclawIdRaw = typeof config.openclawId === 'string' && config.openclawId.trim()
|
||||
? config.openclawId.trim()
|
||||
: name
|
||||
const openclawId = openclawIdRaw.toLowerCase().replace(/\s+/g, '-')
|
||||
out.push({
|
||||
name,
|
||||
openclawId,
|
||||
isDefault: config.isDefault === true,
|
||||
sessionKey: typeof raw?.session_key === 'string' && raw.session_key.trim() ? raw.session_key.trim() : null,
|
||||
configRaw: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
const unique = new Map<string, CoordinatorTargetAgent>()
|
||||
for (const agent of out) {
|
||||
const key = agent.openclawId || agent.name.toLowerCase()
|
||||
if (!unique.has(key)) unique.set(key, agent)
|
||||
}
|
||||
|
||||
return Array.from(unique.values()).sort((a, b) => {
|
||||
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
|
||||
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
|
||||
security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' },
|
||||
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
|
||||
chat: { label: 'Chat', icon: '💬', description: 'Coordinator routing and chat behavior settings' },
|
||||
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
|
||||
profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' },
|
||||
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
|
||||
}
|
||||
|
||||
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'gateway', 'custom']
|
||||
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'chat', 'gateway', 'custom']
|
||||
|
||||
// Dropdown options for subscription plan settings
|
||||
const subscriptionDropdowns: Record<string, { label: string; value: string }[]> = {
|
||||
|
|
@ -79,6 +125,8 @@ export function SettingsPanel() {
|
|||
const [showSecurityScan, setShowSecurityScan] = useState(false)
|
||||
const [hookProfile, setHookProfile] = useState<string>('standard')
|
||||
const [hookProfileSaving, setHookProfileSaving] = useState(false)
|
||||
const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState<CoordinatorTargetAgent[]>([])
|
||||
const [coordinatorSessions, setCoordinatorSessions] = useState<CoordinatorSession[]>([])
|
||||
|
||||
// Replay onboarding state
|
||||
const [replayingOnboarding, setReplayingOnboarding] = useState(false)
|
||||
|
|
@ -104,6 +152,36 @@ export function SettingsPanel() {
|
|||
setTimeout(() => setFeedback(null), 3000)
|
||||
}
|
||||
|
||||
const getCoordinatorResolutionPreview = useCallback((configuredTarget: string) => {
|
||||
const allAgents: CoordinatorAgentRecord[] = coordinatorTargetAgents.map(agent => ({
|
||||
name: agent.name,
|
||||
session_key: agent.sessionKey,
|
||||
config: agent.configRaw,
|
||||
}))
|
||||
const directAgent = allAgents.find(agent => agent.name.toLowerCase() === COORDINATOR_AGENT) || null
|
||||
const gatewaySessions = coordinatorSessions.filter(session => (session.source || 'gateway') === 'gateway')
|
||||
|
||||
const resolved = resolveCoordinatorDeliveryTarget({
|
||||
to: COORDINATOR_AGENT,
|
||||
coordinatorAgent: COORDINATOR_AGENT,
|
||||
directAgent,
|
||||
allAgents,
|
||||
sessions: gatewaySessions,
|
||||
configuredCoordinatorTarget: configuredTarget || null,
|
||||
})
|
||||
|
||||
const viaLabel: Record<string, string> = {
|
||||
configured: 'configured target',
|
||||
default: 'default agent',
|
||||
main_session: 'live :main session',
|
||||
direct: 'coordinator record',
|
||||
fallback: 'fallback',
|
||||
}
|
||||
|
||||
const targetLabel = `${resolved.deliveryName}${resolved.openclawAgentId ? ` (${resolved.openclawAgentId})` : ''}`
|
||||
return `Resolves now to ${targetLabel} via ${viaLabel[resolved.resolvedBy] || resolved.resolvedBy}.`
|
||||
}, [coordinatorTargetAgents, coordinatorSessions])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
|
|
@ -126,6 +204,45 @@ export function SettingsPanel() {
|
|||
// Load hook profile from settings
|
||||
const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile')
|
||||
if (hpSetting) setHookProfile(hpSetting.value)
|
||||
|
||||
// Load agent options for coordinator routing dropdown
|
||||
try {
|
||||
const agentsRes = await fetch('/api/agents?limit=200')
|
||||
if (agentsRes.ok) {
|
||||
const agentsData = await agentsRes.json()
|
||||
setCoordinatorTargetAgents(parseCoordinatorTargetAgents(agentsData.agents || []))
|
||||
}
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
|
||||
// Load live sessions to preview coordinator routing resolution
|
||||
try {
|
||||
const sessionsRes = await fetch('/api/sessions')
|
||||
if (sessionsRes.ok) {
|
||||
const sessionsData = await sessionsRes.json()
|
||||
const mapped: CoordinatorSession[] = Array.isArray(sessionsData.sessions)
|
||||
? sessionsData.sessions.map((session: any) => ({
|
||||
key: String(session?.key || ''),
|
||||
agent: String(session?.agent || ''),
|
||||
source: typeof session?.source === 'string' ? session.source : undefined,
|
||||
sessionId: String(session?.id || session?.key || ''),
|
||||
updatedAt: Number(session?.lastActivity || session?.startTime || 0),
|
||||
chatType: String(session?.kind || 'unknown'),
|
||||
channel: String(session?.channel || ''),
|
||||
model: String(session?.model || ''),
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
contextTokens: 0,
|
||||
active: Boolean(session?.active),
|
||||
})).filter((session: CoordinatorSession) => session.key && session.agent)
|
||||
: []
|
||||
setCoordinatorSessions(mapped)
|
||||
}
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to load settings')
|
||||
} finally {
|
||||
|
|
@ -735,7 +852,19 @@ export function SettingsPanel() {
|
|||
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
|
||||
const isBooleanish = setting.value === 'true' || setting.value === 'false'
|
||||
const isNumeric = /^\d+$/.test(setting.value)
|
||||
const dropdownOptions = subscriptionDropdowns[setting.key]
|
||||
const coordinatorTargetOptions = setting.key === 'chat.coordinator_target_agent'
|
||||
? [
|
||||
{ label: 'Auto (default/main-session fallback)', value: '' },
|
||||
...coordinatorTargetAgents.map(agent => ({
|
||||
label: `${agent.name}${agent.isDefault ? ' (default)' : ''} — ${agent.openclawId}`,
|
||||
value: agent.openclawId,
|
||||
})),
|
||||
]
|
||||
: null
|
||||
const dropdownOptions = coordinatorTargetOptions || subscriptionDropdowns[setting.key]
|
||||
const coordinatorPreview = setting.key === 'chat.coordinator_target_agent'
|
||||
? getCoordinatorResolutionPreview(currentValue)
|
||||
: null
|
||||
const shortKey = setting.key.split('.').pop() || setting.key
|
||||
|
||||
return (
|
||||
|
|
@ -760,18 +889,22 @@ export function SettingsPanel() {
|
|||
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{dropdownOptions ? (
|
||||
<select
|
||||
value={currentValue}
|
||||
onChange={e => handleEdit(setting.key, e.target.value)}
|
||||
className="w-48 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
|
||||
>
|
||||
{dropdownOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : isBooleanish ? (
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{dropdownOptions ? (
|
||||
<select
|
||||
value={currentValue}
|
||||
onChange={e => handleEdit(setting.key, e.target.value)}
|
||||
className="w-64 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
|
||||
>
|
||||
{dropdownOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
{currentValue && !dropdownOptions.some(opt => opt.value === currentValue) && (
|
||||
<option value={currentValue}>Custom: {currentValue}</option>
|
||||
)}
|
||||
</select>
|
||||
) : isBooleanish ? (
|
||||
<button
|
||||
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
|
||||
className={`w-10 h-5 rounded-full relative transition-colors select-none ${
|
||||
|
|
@ -798,19 +931,23 @@ export function SettingsPanel() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{!setting.is_default && (
|
||||
<Button
|
||||
onClick={() => handleReset(setting.key)}
|
||||
title="Reset to default"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
|
||||
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Button>
|
||||
{!setting.is_default && (
|
||||
<Button
|
||||
onClick={() => handleReset(setting.key)}
|
||||
title="Reset to default"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
|
||||
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{coordinatorPreview && (
|
||||
<p className="text-2xs text-muted-foreground max-w-72 text-right">{coordinatorPreview}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
|
||||
import type { GatewaySession } from '@/lib/sessions'
|
||||
|
||||
function mkSession(agent: string, key: string): GatewaySession {
|
||||
return {
|
||||
key,
|
||||
agent,
|
||||
sessionId: `${agent}-session`,
|
||||
updatedAt: Date.now(),
|
||||
chatType: 'direct',
|
||||
channel: 'test',
|
||||
model: 'test-model',
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
contextTokens: 0,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveCoordinatorDeliveryTarget', () => {
|
||||
it('returns direct resolution when target agent exists', () => {
|
||||
const directAgent: CoordinatorAgentRecord = {
|
||||
name: 'dev',
|
||||
session_key: 'agent:dev:main',
|
||||
config: JSON.stringify({ openclawId: 'dev' }),
|
||||
}
|
||||
|
||||
const resolved = resolveCoordinatorDeliveryTarget({
|
||||
to: 'dev',
|
||||
coordinatorAgent: 'Coordinator',
|
||||
directAgent,
|
||||
allAgents: [],
|
||||
sessions: [mkSession('dev', 'agent:dev:main')],
|
||||
})
|
||||
|
||||
expect(resolved).toEqual({
|
||||
deliveryName: 'dev',
|
||||
sessionKey: 'agent:dev:main',
|
||||
openclawAgentId: 'dev',
|
||||
resolvedBy: 'direct',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves coordinator to explicitly configured target when present', () => {
|
||||
const allAgents: CoordinatorAgentRecord[] = [
|
||||
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
|
||||
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
|
||||
]
|
||||
|
||||
const resolved = resolveCoordinatorDeliveryTarget({
|
||||
to: 'Coordinator',
|
||||
coordinatorAgent: 'Coordinator',
|
||||
directAgent: null,
|
||||
allAgents,
|
||||
sessions: [mkSession('jarv', 'agent:jarv:main')],
|
||||
configuredCoordinatorTarget: 'jarv',
|
||||
})
|
||||
|
||||
expect(resolved).toEqual({
|
||||
deliveryName: 'jarv',
|
||||
sessionKey: 'agent:jarv:main',
|
||||
openclawAgentId: 'jarv',
|
||||
resolvedBy: 'configured',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves coordinator to default agent when no explicit target is configured', () => {
|
||||
const allAgents: CoordinatorAgentRecord[] = [
|
||||
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
|
||||
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
|
||||
]
|
||||
|
||||
const resolved = resolveCoordinatorDeliveryTarget({
|
||||
to: 'Coordinator',
|
||||
coordinatorAgent: 'Coordinator',
|
||||
directAgent: null,
|
||||
allAgents,
|
||||
sessions: [mkSession('dev', 'agent:dev:main')],
|
||||
})
|
||||
|
||||
expect(resolved).toEqual({
|
||||
deliveryName: 'dev',
|
||||
sessionKey: 'agent:dev:main',
|
||||
openclawAgentId: 'dev',
|
||||
resolvedBy: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves coordinator to first live main session when no default agent exists', () => {
|
||||
const resolved = resolveCoordinatorDeliveryTarget({
|
||||
to: 'Coordinator',
|
||||
coordinatorAgent: 'Coordinator',
|
||||
directAgent: null,
|
||||
allAgents: [{ name: 'admin', config: JSON.stringify({ openclawId: 'admin' }) }],
|
||||
sessions: [mkSession('jarv', 'agent:jarv:main')],
|
||||
})
|
||||
|
||||
expect(resolved).toEqual({
|
||||
deliveryName: 'jarv',
|
||||
sessionKey: 'agent:jarv:main',
|
||||
openclawAgentId: 'jarv',
|
||||
resolvedBy: 'main_session',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to normalized destination when nothing else matches', () => {
|
||||
const resolved = resolveCoordinatorDeliveryTarget({
|
||||
to: 'Coordinator Team',
|
||||
coordinatorAgent: 'Coordinator',
|
||||
directAgent: null,
|
||||
allAgents: [],
|
||||
sessions: [],
|
||||
})
|
||||
|
||||
expect(resolved).toEqual({
|
||||
deliveryName: 'Coordinator Team',
|
||||
sessionKey: null,
|
||||
openclawAgentId: 'coordinator-team',
|
||||
resolvedBy: 'fallback',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -89,6 +89,28 @@ Run "openclaw doctor --fix" to apply changes.
|
|||
expect(result.raw).not.toContain('Multiple state directories detected')
|
||||
})
|
||||
|
||||
it('parses state integrity blocks when lines are prefixed by box-drawing gutters', () => {
|
||||
const result = parseOpenClawDoctorOutput(`
|
||||
┌ OpenClaw doctor
|
||||
│
|
||||
◇ State integrity
|
||||
│ - Multiple state directories detected. This can split session history.
|
||||
│ - $OPENCLAW_HOME/.openclaw
|
||||
│ - /home/nefes/.openclaw
|
||||
│ Active state dir: $OPENCLAW_HOME
|
||||
│ - Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.
|
||||
Run "openclaw doctor --fix" to apply changes.
|
||||
`, 0, { stateDir: '/home/openclaw/.openclaw' })
|
||||
|
||||
expect(result.level).toBe('warning')
|
||||
expect(result.category).toBe('state')
|
||||
expect(result.issues).toEqual([
|
||||
'Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.',
|
||||
])
|
||||
expect(result.raw).not.toContain('/home/nefes/.openclaw')
|
||||
expect(result.raw).not.toContain('Multiple state directories detected')
|
||||
})
|
||||
|
||||
it('marks clean output as healthy', () => {
|
||||
const result = parseOpenClawDoctorOutput('OK: configuration valid', 0)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
import type { GatewaySession } from './sessions'
|
||||
|
||||
export interface CoordinatorAgentRecord {
|
||||
name: string
|
||||
session_key?: string | null
|
||||
config?: string | null
|
||||
}
|
||||
|
||||
export interface ResolvedCoordinatorTarget {
|
||||
deliveryName: string
|
||||
sessionKey: string | null
|
||||
openclawAgentId: string | null
|
||||
resolvedBy: 'direct' | 'configured' | 'default' | 'main_session' | 'fallback'
|
||||
}
|
||||
|
||||
function normalizeName(value: string | null | undefined): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeOpenClawId(value: string | null | undefined): string {
|
||||
return normalizeName(value).replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
function parseConfig(raw: string | null | undefined): Record<string, unknown> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getConfigOpenClawId(agent: CoordinatorAgentRecord): string | null {
|
||||
const parsed = parseConfig(agent.config)
|
||||
return typeof parsed.openclawId === 'string' && parsed.openclawId.trim()
|
||||
? parsed.openclawId.trim()
|
||||
: null
|
||||
}
|
||||
|
||||
function getConfigIsDefault(agent: CoordinatorAgentRecord): boolean {
|
||||
const parsed = parseConfig(agent.config)
|
||||
return parsed.isDefault === true
|
||||
}
|
||||
|
||||
function findSessionForAgent(
|
||||
agent: CoordinatorAgentRecord,
|
||||
sessions: GatewaySession[],
|
||||
): GatewaySession | undefined {
|
||||
const name = normalizeName(agent.name)
|
||||
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
|
||||
return sessions.find((session) => {
|
||||
const sessionAgent = normalizeName(session.agent)
|
||||
return sessionAgent === name || sessionAgent === openclawId
|
||||
})
|
||||
}
|
||||
|
||||
function resolveConfiguredCoordinatorTarget(
|
||||
preferredTarget: string,
|
||||
allAgents: CoordinatorAgentRecord[],
|
||||
sessions: GatewaySession[],
|
||||
): CoordinatorAgentRecord | null {
|
||||
const wanted = normalizeName(preferredTarget)
|
||||
if (!wanted) return null
|
||||
|
||||
return allAgents.find((agent) => {
|
||||
const byName = normalizeName(agent.name) === wanted
|
||||
const byOpenClawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) === wanted
|
||||
const session = findSessionForAgent(agent, sessions)
|
||||
const bySessionAgent = session ? normalizeName(session.agent) === wanted : false
|
||||
return byName || byOpenClawId || bySessionAgent
|
||||
}) || null
|
||||
}
|
||||
|
||||
export function resolveCoordinatorDeliveryTarget(params: {
|
||||
to: string
|
||||
coordinatorAgent: string
|
||||
directAgent: CoordinatorAgentRecord | null
|
||||
allAgents: CoordinatorAgentRecord[]
|
||||
sessions: GatewaySession[]
|
||||
explicitSessionKey?: string | null
|
||||
configuredCoordinatorTarget?: string | null
|
||||
}): ResolvedCoordinatorTarget {
|
||||
const normalizedTo = normalizeName(params.to)
|
||||
const normalizedCoordinatorAgent = normalizeName(params.coordinatorAgent)
|
||||
const explicitSessionKey = params.explicitSessionKey?.trim() || null
|
||||
|
||||
const buildResult = (
|
||||
agent: CoordinatorAgentRecord,
|
||||
resolvedBy: ResolvedCoordinatorTarget['resolvedBy'],
|
||||
): ResolvedCoordinatorTarget => {
|
||||
const openclawAgentId = getConfigOpenClawId(agent) || normalizeOpenClawId(agent.name)
|
||||
const sessionKey =
|
||||
explicitSessionKey ||
|
||||
agent.session_key?.trim() ||
|
||||
findSessionForAgent(agent, params.sessions)?.key ||
|
||||
null
|
||||
|
||||
return {
|
||||
deliveryName: agent.name,
|
||||
sessionKey,
|
||||
openclawAgentId,
|
||||
resolvedBy,
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTo === normalizedCoordinatorAgent) {
|
||||
const configuredTarget = (params.configuredCoordinatorTarget || '').trim()
|
||||
if (configuredTarget) {
|
||||
const configuredAgent = resolveConfiguredCoordinatorTarget(configuredTarget, params.allAgents, params.sessions)
|
||||
if (configuredAgent) {
|
||||
return buildResult(configuredAgent, 'configured')
|
||||
}
|
||||
}
|
||||
|
||||
const defaultAgent = params.allAgents.find(getConfigIsDefault)
|
||||
if (defaultAgent) {
|
||||
return buildResult(defaultAgent, 'default')
|
||||
}
|
||||
|
||||
const mainSession = params.sessions.find((session) => /:main$/i.test(session.key))
|
||||
if (mainSession) {
|
||||
const matchingAgent = params.allAgents.find((agent) => {
|
||||
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
|
||||
const agentName = normalizeName(agent.name)
|
||||
const sessionAgent = normalizeName(mainSession.agent)
|
||||
return sessionAgent === agentName || sessionAgent === openclawId
|
||||
})
|
||||
|
||||
return {
|
||||
deliveryName: matchingAgent?.name || mainSession.agent,
|
||||
sessionKey: explicitSessionKey || mainSession.key || null,
|
||||
openclawAgentId:
|
||||
getConfigOpenClawId(matchingAgent || { name: mainSession.agent }) ||
|
||||
normalizeOpenClawId(mainSession.agent),
|
||||
resolvedBy: 'main_session',
|
||||
}
|
||||
}
|
||||
|
||||
if (params.directAgent) {
|
||||
return buildResult(params.directAgent, 'direct')
|
||||
}
|
||||
}
|
||||
|
||||
if (params.directAgent) {
|
||||
return buildResult(params.directAgent, 'direct')
|
||||
}
|
||||
|
||||
return {
|
||||
deliveryName: params.to,
|
||||
sessionKey: explicitSessionKey,
|
||||
openclawAgentId: normalizeOpenClawId(params.to),
|
||||
resolvedBy: 'fallback',
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,10 @@ export interface OpenClawDoctorStatus {
|
|||
}
|
||||
|
||||
function normalizeLine(line: string): string {
|
||||
return line.replace(/\u001b\[[0-9;]*m/g, '').trim()
|
||||
return line
|
||||
.replace(/\u001b\[[0-9;]*m/g, '')
|
||||
.replace(/^[\s│┃║┆┊╎╏]+/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function isSessionAgingLine(line: string): boolean {
|
||||
|
|
|
|||
Loading…
Reference in New Issue