Spaces:
Runtime error
Runtime error
ResistanceDown Jeremy Phelps commited on
Mission Control: Habi readiness wiring + office segmentation (#187)
Browse files* fix mission control wiring for habi memory/orchestration/retention
* feat office org-chart segmentation controls
---------
Co-authored-by: Jeremy Phelps <kokoro@Kokoro.local>
- src/app/api/cleanup/route.ts +25 -0
- src/app/api/memory/route.ts +52 -1
- src/app/api/settings/route.ts +1 -0
- src/components/dashboard/dashboard.tsx +1 -1
- src/components/panels/memory-browser-panel.tsx +11 -10
- src/components/panels/office-panel.tsx +92 -3
- src/lib/config.ts +23 -4
- src/lib/scheduler.ts +6 -0
- src/lib/sessions.ts +83 -14
- src/lib/websocket.ts +7 -0
src/app/api/cleanup/route.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
|
|
| 3 |
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
import { config } from '@/lib/config'
|
| 5 |
import { heavyLimiter } from '@/lib/rate-limit'
|
|
|
|
| 6 |
|
| 7 |
interface CleanupResult {
|
| 8 |
table: string
|
|
@@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
|
|
| 59 |
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return NextResponse.json({ retention: config.retention, preview })
|
| 63 |
}
|
| 64 |
|
|
@@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
|
|
| 137 |
}
|
| 138 |
}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
if (!dryRun && totalDeleted > 0) {
|
| 141 |
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 142 |
logAuditEvent({
|
|
|
|
| 3 |
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
import { config } from '@/lib/config'
|
| 5 |
import { heavyLimiter } from '@/lib/rate-limit'
|
| 6 |
+
import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
|
| 7 |
|
| 8 |
interface CleanupResult {
|
| 9 |
table: string
|
|
|
|
| 60 |
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
| 61 |
}
|
| 62 |
|
| 63 |
+
if (ret.gatewaySessions > 0) {
|
| 64 |
+
preview.push({
|
| 65 |
+
table: 'Gateway Session Store',
|
| 66 |
+
retention_days: ret.gatewaySessions,
|
| 67 |
+
stale_count: countStaleGatewaySessions(ret.gatewaySessions),
|
| 68 |
+
note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
|
| 69 |
+
})
|
| 70 |
+
} else {
|
| 71 |
+
preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
return NextResponse.json({ retention: config.retention, preview })
|
| 75 |
}
|
| 76 |
|
|
|
|
| 149 |
}
|
| 150 |
}
|
| 151 |
|
| 152 |
+
if (ret.gatewaySessions > 0) {
|
| 153 |
+
const sessionPrune = dryRun
|
| 154 |
+
? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
|
| 155 |
+
: pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
| 156 |
+
results.push({
|
| 157 |
+
table: 'Gateway Session Store',
|
| 158 |
+
deleted: sessionPrune.deleted,
|
| 159 |
+
cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
|
| 160 |
+
retention_days: ret.gatewaySessions,
|
| 161 |
+
})
|
| 162 |
+
totalDeleted += sessionPrune.deleted
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
if (!dryRun && totalDeleted > 0) {
|
| 166 |
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 167 |
logAuditEvent({
|
src/app/api/memory/route.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
|
|
| 9 |
import { logger } from '@/lib/logger'
|
| 10 |
|
| 11 |
const MEMORY_PATH = config.memoryDir
|
|
|
|
| 12 |
|
| 13 |
// Ensure memory directory exists on startup
|
| 14 |
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
|
@@ -24,6 +25,16 @@ interface MemoryFile {
|
|
| 24 |
children?: MemoryFile[]
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
function isWithinBase(base: string, candidate: string): boolean {
|
| 28 |
if (candidate === base) return true
|
| 29 |
return candidate.startsWith(base + sep)
|
|
@@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
|
|
| 137 |
if (!MEMORY_PATH) {
|
| 138 |
return NextResponse.json({ tree: [] })
|
| 139 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
const tree = await buildFileTree(MEMORY_PATH)
|
| 141 |
return NextResponse.json({ tree })
|
| 142 |
}
|
| 143 |
|
| 144 |
if (action === 'content' && path) {
|
| 145 |
// Return file content
|
|
|
|
|
|
|
|
|
|
| 146 |
if (!MEMORY_PATH) {
|
| 147 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
| 148 |
}
|
|
@@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
|
|
| 227 |
}
|
| 228 |
}
|
| 229 |
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
return NextResponse.json({
|
| 233 |
query,
|
|
@@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
|
|
| 256 |
if (!path) {
|
| 257 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 258 |
}
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
if (!MEMORY_PATH) {
|
| 261 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
|
@@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
|
|
| 316 |
if (!path) {
|
| 317 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 318 |
}
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
if (!MEMORY_PATH) {
|
| 321 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
|
|
|
| 9 |
import { logger } from '@/lib/logger'
|
| 10 |
|
| 11 |
const MEMORY_PATH = config.memoryDir
|
| 12 |
+
const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
|
| 13 |
|
| 14 |
// Ensure memory directory exists on startup
|
| 15 |
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
|
|
|
| 25 |
children?: MemoryFile[]
|
| 26 |
}
|
| 27 |
|
| 28 |
+
function normalizeRelativePath(value: string): string {
|
| 29 |
+
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function isPathAllowed(relativePath: string): boolean {
|
| 33 |
+
if (!MEMORY_ALLOWED_PREFIXES.length) return true
|
| 34 |
+
const normalized = normalizeRelativePath(relativePath)
|
| 35 |
+
return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
function isWithinBase(base: string, candidate: string): boolean {
|
| 39 |
if (candidate === base) return true
|
| 40 |
return candidate.startsWith(base + sep)
|
|
|
|
| 148 |
if (!MEMORY_PATH) {
|
| 149 |
return NextResponse.json({ tree: [] })
|
| 150 |
}
|
| 151 |
+
if (MEMORY_ALLOWED_PREFIXES.length) {
|
| 152 |
+
const tree: MemoryFile[] = []
|
| 153 |
+
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
| 154 |
+
const folder = prefix.replace(/\/$/, '')
|
| 155 |
+
const fullPath = join(MEMORY_PATH, folder)
|
| 156 |
+
if (!existsSync(fullPath)) continue
|
| 157 |
+
try {
|
| 158 |
+
const stats = await stat(fullPath)
|
| 159 |
+
if (!stats.isDirectory()) continue
|
| 160 |
+
tree.push({
|
| 161 |
+
path: folder,
|
| 162 |
+
name: folder,
|
| 163 |
+
type: 'directory',
|
| 164 |
+
modified: stats.mtime.getTime(),
|
| 165 |
+
children: await buildFileTree(fullPath, folder),
|
| 166 |
+
})
|
| 167 |
+
} catch {
|
| 168 |
+
// Skip unreadable roots
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
return NextResponse.json({ tree })
|
| 172 |
+
}
|
| 173 |
const tree = await buildFileTree(MEMORY_PATH)
|
| 174 |
return NextResponse.json({ tree })
|
| 175 |
}
|
| 176 |
|
| 177 |
if (action === 'content' && path) {
|
| 178 |
// Return file content
|
| 179 |
+
if (!isPathAllowed(path)) {
|
| 180 |
+
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
| 181 |
+
}
|
| 182 |
if (!MEMORY_PATH) {
|
| 183 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
| 184 |
}
|
|
|
|
| 263 |
}
|
| 264 |
}
|
| 265 |
|
| 266 |
+
if (MEMORY_ALLOWED_PREFIXES.length) {
|
| 267 |
+
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
| 268 |
+
const folder = prefix.replace(/\/$/, '')
|
| 269 |
+
const fullPath = join(MEMORY_PATH, folder)
|
| 270 |
+
if (!existsSync(fullPath)) continue
|
| 271 |
+
await searchDirectory(fullPath, folder)
|
| 272 |
+
}
|
| 273 |
+
} else {
|
| 274 |
+
await searchDirectory(MEMORY_PATH)
|
| 275 |
+
}
|
| 276 |
|
| 277 |
return NextResponse.json({
|
| 278 |
query,
|
|
|
|
| 301 |
if (!path) {
|
| 302 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 303 |
}
|
| 304 |
+
if (!isPathAllowed(path)) {
|
| 305 |
+
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
| 306 |
+
}
|
| 307 |
|
| 308 |
if (!MEMORY_PATH) {
|
| 309 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
|
|
|
| 364 |
if (!path) {
|
| 365 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 366 |
}
|
| 367 |
+
if (!isPathAllowed(path)) {
|
| 368 |
+
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
| 369 |
+
}
|
| 370 |
|
| 371 |
if (!MEMORY_PATH) {
|
| 372 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
src/app/api/settings/route.ts
CHANGED
|
@@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
|
|
| 23 |
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
| 24 |
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
| 25 |
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
|
|
|
| 26 |
|
| 27 |
// Gateway
|
| 28 |
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
|
|
|
| 23 |
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
| 24 |
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
| 25 |
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
| 26 |
+
'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
|
| 27 |
|
| 28 |
// Gateway
|
| 29 |
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
src/components/dashboard/dashboard.tsx
CHANGED
|
@@ -522,7 +522,7 @@ export function Dashboard() {
|
|
| 522 |
{isLocal ? (
|
| 523 |
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
| 524 |
) : (
|
| 525 |
-
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="
|
| 526 |
)}
|
| 527 |
</div>
|
| 528 |
</div>
|
|
|
|
| 522 |
{isLocal ? (
|
| 523 |
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
| 524 |
) : (
|
| 525 |
+
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
|
| 526 |
)}
|
| 527 |
</div>
|
| 528 |
</div>
|
src/components/panels/memory-browser-panel.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
|
|
| 47 |
setMemoryFiles(data.tree || [])
|
| 48 |
|
| 49 |
// Auto-expand some common directories
|
| 50 |
-
setExpandedFolders(new Set(['daily', 'knowledge']))
|
| 51 |
} catch (error) {
|
| 52 |
log.error('Failed to load file tree:', error)
|
| 53 |
} finally {
|
|
@@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
|
|
| 61 |
|
| 62 |
const getFilteredFiles = () => {
|
| 63 |
if (activeTab === 'all') return memoryFiles
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
return true
|
| 73 |
})
|
| 74 |
}
|
| 75 |
|
|
@@ -731,6 +730,8 @@ function CreateFileModal({
|
|
| 731 |
onChange={(e) => setFilePath(e.target.value)}
|
| 732 |
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 733 |
>
|
|
|
|
|
|
|
| 734 |
<option value="knowledge/">knowledge/</option>
|
| 735 |
<option value="daily/">daily/</option>
|
| 736 |
<option value="logs/">logs/</option>
|
|
|
|
| 47 |
setMemoryFiles(data.tree || [])
|
| 48 |
|
| 49 |
// Auto-expand some common directories
|
| 50 |
+
setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
|
| 51 |
} catch (error) {
|
| 52 |
log.error('Failed to load file tree:', error)
|
| 53 |
} finally {
|
|
|
|
| 61 |
|
| 62 |
const getFilteredFiles = () => {
|
| 63 |
if (activeTab === 'all') return memoryFiles
|
| 64 |
+
|
| 65 |
+
const tabPrefixes = activeTab === 'daily'
|
| 66 |
+
? ['daily/', 'memory/']
|
| 67 |
+
: ['knowledge/', 'knowledge-base/']
|
| 68 |
+
|
| 69 |
+
return memoryFiles.filter((file) => {
|
| 70 |
+
const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
|
| 71 |
+
return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
|
|
|
|
| 72 |
})
|
| 73 |
}
|
| 74 |
|
|
|
|
| 730 |
onChange={(e) => setFilePath(e.target.value)}
|
| 731 |
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 732 |
>
|
| 733 |
+
<option value="knowledge-base/">knowledge-base/</option>
|
| 734 |
+
<option value="memory/">memory/</option>
|
| 735 |
<option value="knowledge/">knowledge/</option>
|
| 736 |
<option value="daily/">daily/</option>
|
| 737 |
<option value="logs/">logs/</option>
|
src/components/panels/office-panel.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
| 4 |
import { useMissionControl, Agent } from '@/store'
|
| 5 |
|
| 6 |
type ViewMode = 'office' | 'org-chart'
|
|
|
|
| 7 |
|
| 8 |
interface Desk {
|
| 9 |
agent: Agent
|
|
@@ -75,6 +76,7 @@ export function OfficePanel() {
|
|
| 75 |
const { agents } = useMissionControl()
|
| 76 |
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
| 77 |
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
|
|
|
| 78 |
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
| 79 |
const [loading, setLoading] = useState(true)
|
| 80 |
|
|
@@ -123,6 +125,64 @@ export function OfficePanel() {
|
|
| 123 |
return groups
|
| 124 |
}, [displayAgents])
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
if (loading && displayAgents.length === 0) {
|
| 127 |
return (
|
| 128 |
<div className="flex items-center justify-center h-64">
|
|
@@ -237,11 +297,40 @@ export function OfficePanel() {
|
|
| 237 |
</div>
|
| 238 |
) : (
|
| 239 |
<div className="space-y-6">
|
| 240 |
-
|
| 241 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
<div className="flex items-center gap-2 mb-4">
|
| 243 |
<div className="w-1 h-6 bg-primary rounded-full" />
|
| 244 |
-
<h3 className="font-semibold text-foreground">{
|
| 245 |
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
| 246 |
</div>
|
| 247 |
<div className="flex flex-wrap gap-3">
|
|
|
|
| 4 |
import { useMissionControl, Agent } from '@/store'
|
| 5 |
|
| 6 |
type ViewMode = 'office' | 'org-chart'
|
| 7 |
+
type OrgSegmentMode = 'category' | 'role' | 'status'
|
| 8 |
|
| 9 |
interface Desk {
|
| 10 |
agent: Agent
|
|
|
|
| 76 |
const { agents } = useMissionControl()
|
| 77 |
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
| 78 |
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
| 79 |
+
const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
|
| 80 |
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
| 81 |
const [loading, setLoading] = useState(true)
|
| 82 |
|
|
|
|
| 125 |
return groups
|
| 126 |
}, [displayAgents])
|
| 127 |
|
| 128 |
+
const categoryGroups = useMemo(() => {
|
| 129 |
+
const groups = new Map<string, Agent[]>()
|
| 130 |
+
const getCategory = (agent: Agent): string => {
|
| 131 |
+
const name = (agent.name || '').toLowerCase()
|
| 132 |
+
if (name.startsWith('habi-')) return 'Habi Lanes'
|
| 133 |
+
if (name.startsWith('ops-')) return 'Ops Automation'
|
| 134 |
+
if (name.includes('canary')) return 'Canary'
|
| 135 |
+
if (name.startsWith('main')) return 'Core'
|
| 136 |
+
if (name.startsWith('remote-')) return 'Remote'
|
| 137 |
+
return 'Other'
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
for (const a of displayAgents) {
|
| 141 |
+
const category = getCategory(a)
|
| 142 |
+
if (!groups.has(category)) groups.set(category, [])
|
| 143 |
+
groups.get(category)!.push(a)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
|
| 147 |
+
return new Map(
|
| 148 |
+
[...groups.entries()].sort(([a], [b]) => {
|
| 149 |
+
const ai = order.indexOf(a)
|
| 150 |
+
const bi = order.indexOf(b)
|
| 151 |
+
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
| 152 |
+
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
| 153 |
+
if (av !== bv) return av - bv
|
| 154 |
+
return a.localeCompare(b)
|
| 155 |
+
})
|
| 156 |
+
)
|
| 157 |
+
}, [displayAgents])
|
| 158 |
+
|
| 159 |
+
const statusGroups = useMemo(() => {
|
| 160 |
+
const groups = new Map<string, Agent[]>()
|
| 161 |
+
for (const a of displayAgents) {
|
| 162 |
+
const key = statusLabel[a.status] || a.status
|
| 163 |
+
if (!groups.has(key)) groups.set(key, [])
|
| 164 |
+
groups.get(key)!.push(a)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const order = ['Working', 'Available', 'Error', 'Away']
|
| 168 |
+
return new Map(
|
| 169 |
+
[...groups.entries()].sort(([a], [b]) => {
|
| 170 |
+
const ai = order.indexOf(a)
|
| 171 |
+
const bi = order.indexOf(b)
|
| 172 |
+
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
| 173 |
+
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
| 174 |
+
if (av !== bv) return av - bv
|
| 175 |
+
return a.localeCompare(b)
|
| 176 |
+
})
|
| 177 |
+
)
|
| 178 |
+
}, [displayAgents])
|
| 179 |
+
|
| 180 |
+
const orgGroups = useMemo(() => {
|
| 181 |
+
if (orgSegmentMode === 'role') return roleGroups
|
| 182 |
+
if (orgSegmentMode === 'status') return statusGroups
|
| 183 |
+
return categoryGroups
|
| 184 |
+
}, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
|
| 185 |
+
|
| 186 |
if (loading && displayAgents.length === 0) {
|
| 187 |
return (
|
| 188 |
<div className="flex items-center justify-center h-64">
|
|
|
|
| 297 |
</div>
|
| 298 |
) : (
|
| 299 |
<div className="space-y-6">
|
| 300 |
+
<div className="flex items-center justify-between">
|
| 301 |
+
<div className="text-sm text-muted-foreground">
|
| 302 |
+
Segmented by{' '}
|
| 303 |
+
<span className="font-medium text-foreground">
|
| 304 |
+
{orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
|
| 305 |
+
</span>
|
| 306 |
+
</div>
|
| 307 |
+
<div className="flex rounded-md overflow-hidden border border-border">
|
| 308 |
+
<button
|
| 309 |
+
onClick={() => setOrgSegmentMode('category')}
|
| 310 |
+
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'category' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
| 311 |
+
>
|
| 312 |
+
Category
|
| 313 |
+
</button>
|
| 314 |
+
<button
|
| 315 |
+
onClick={() => setOrgSegmentMode('role')}
|
| 316 |
+
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'role' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
| 317 |
+
>
|
| 318 |
+
Role
|
| 319 |
+
</button>
|
| 320 |
+
<button
|
| 321 |
+
onClick={() => setOrgSegmentMode('status')}
|
| 322 |
+
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'status' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
| 323 |
+
>
|
| 324 |
+
Status
|
| 325 |
+
</button>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
{[...orgGroups.entries()].map(([segment, members]) => (
|
| 330 |
+
<div key={segment} className="bg-card border border-border rounded-xl p-5">
|
| 331 |
<div className="flex items-center gap-2 mb-4">
|
| 332 |
<div className="w-1 h-6 bg-primary rounded-full" />
|
| 333 |
+
<h3 className="font-semibold text-foreground">{segment}</h3>
|
| 334 |
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
| 335 |
</div>
|
| 336 |
<div className="flex flex-wrap gap-3">
|
src/lib/config.ts
CHANGED
|
@@ -21,6 +21,23 @@ const openclawStateDir =
|
|
| 21 |
const openclawConfigPath =
|
| 22 |
explicitOpenClawConfigPath ||
|
| 23 |
path.join(openclawStateDir, 'openclaw.json')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
export const config = {
|
| 26 |
claudeHome:
|
|
@@ -45,10 +62,11 @@ export const config = {
|
|
| 45 |
process.env.OPENCLAW_LOG_DIR ||
|
| 46 |
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
| 47 |
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
| 48 |
-
memoryDir:
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
soulTemplatesDir:
|
| 53 |
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
| 54 |
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
|
@@ -61,6 +79,7 @@ export const config = {
|
|
| 61 |
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
| 62 |
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
| 63 |
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
|
|
|
| 64 |
},
|
| 65 |
}
|
| 66 |
|
|
|
|
| 21 |
const openclawConfigPath =
|
| 22 |
explicitOpenClawConfigPath ||
|
| 23 |
path.join(openclawStateDir, 'openclaw.json')
|
| 24 |
+
const openclawWorkspaceDir =
|
| 25 |
+
process.env.OPENCLAW_WORKSPACE_DIR ||
|
| 26 |
+
process.env.MISSION_CONTROL_WORKSPACE_DIR ||
|
| 27 |
+
(openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
|
| 28 |
+
const defaultMemoryDir = (() => {
|
| 29 |
+
if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
|
| 30 |
+
// Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
|
| 31 |
+
// when available; fallback to legacy sqlite memory path.
|
| 32 |
+
if (
|
| 33 |
+
openclawWorkspaceDir &&
|
| 34 |
+
(fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
|
| 35 |
+
fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
|
| 36 |
+
) {
|
| 37 |
+
return openclawWorkspaceDir
|
| 38 |
+
}
|
| 39 |
+
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
|
| 40 |
+
})()
|
| 41 |
|
| 42 |
export const config = {
|
| 43 |
claudeHome:
|
|
|
|
| 62 |
process.env.OPENCLAW_LOG_DIR ||
|
| 63 |
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
| 64 |
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
| 65 |
+
memoryDir: defaultMemoryDir,
|
| 66 |
+
memoryAllowedPrefixes:
|
| 67 |
+
defaultMemoryDir === openclawWorkspaceDir
|
| 68 |
+
? ['memory/', 'knowledge-base/']
|
| 69 |
+
: [],
|
| 70 |
soulTemplatesDir:
|
| 71 |
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
| 72 |
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
|
|
|
| 79 |
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
| 80 |
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
| 81 |
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
| 82 |
+
gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
|
| 83 |
},
|
| 84 |
}
|
| 85 |
|
src/lib/scheduler.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
|
|
| 6 |
import { logger } from './logger'
|
| 7 |
import { processWebhookRetries } from './webhooks'
|
| 8 |
import { syncClaudeSessions } from './claude-sessions'
|
|
|
|
| 9 |
|
| 10 |
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
| 11 |
|
|
@@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
|
|
| 130 |
}
|
| 131 |
}
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
if (totalDeleted > 0) {
|
| 134 |
logAuditEvent({
|
| 135 |
action: 'auto_cleanup',
|
|
|
|
| 6 |
import { logger } from './logger'
|
| 7 |
import { processWebhookRetries } from './webhooks'
|
| 8 |
import { syncClaudeSessions } from './claude-sessions'
|
| 9 |
+
import { pruneGatewaySessionsOlderThan } from './sessions'
|
| 10 |
|
| 11 |
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
| 12 |
|
|
|
|
| 131 |
}
|
| 132 |
}
|
| 133 |
|
| 134 |
+
if (ret.gatewaySessions > 0) {
|
| 135 |
+
const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
| 136 |
+
totalDeleted += sessionCleanup.deleted
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
if (totalDeleted > 0) {
|
| 140 |
logAuditEvent({
|
| 141 |
action: 'auto_cleanup',
|
src/lib/sessions.ts
CHANGED
|
@@ -19,25 +19,13 @@ export interface GatewaySession {
|
|
| 19 |
active: boolean
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
* Read all sessions from OpenClaw agent session stores on disk.
|
| 24 |
-
*
|
| 25 |
-
* OpenClaw stores sessions per-agent at:
|
| 26 |
-
* {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
|
| 27 |
-
*
|
| 28 |
-
* Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
|
| 29 |
-
* with session metadata as values.
|
| 30 |
-
*/
|
| 31 |
-
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
| 32 |
const openclawStateDir = config.openclawStateDir
|
| 33 |
if (!openclawStateDir) return []
|
| 34 |
|
| 35 |
const agentsDir = path.join(openclawStateDir, 'agents')
|
| 36 |
if (!fs.existsSync(agentsDir)) return []
|
| 37 |
|
| 38 |
-
const sessions: GatewaySession[] = []
|
| 39 |
-
const now = Date.now()
|
| 40 |
-
|
| 41 |
let agentDirs: string[]
|
| 42 |
try {
|
| 43 |
agentDirs = fs.readdirSync(agentsDir)
|
|
@@ -45,10 +33,33 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|
| 45 |
return []
|
| 46 |
}
|
| 47 |
|
|
|
|
| 48 |
for (const agentName of agentDirs) {
|
| 49 |
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
| 50 |
try {
|
| 51 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 53 |
const data = JSON.parse(raw)
|
| 54 |
|
|
@@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|
| 80 |
return sessions
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
/**
|
| 84 |
* Derive agent active/idle/offline status from their sessions.
|
| 85 |
* Returns a map of agentName -> { status, lastActivity, channel }
|
|
|
|
| 19 |
active: boolean
|
| 20 |
}
|
| 21 |
|
| 22 |
+
function getGatewaySessionStoreFiles(): string[] {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const openclawStateDir = config.openclawStateDir
|
| 24 |
if (!openclawStateDir) return []
|
| 25 |
|
| 26 |
const agentsDir = path.join(openclawStateDir, 'agents')
|
| 27 |
if (!fs.existsSync(agentsDir)) return []
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
let agentDirs: string[]
|
| 30 |
try {
|
| 31 |
agentDirs = fs.readdirSync(agentsDir)
|
|
|
|
| 33 |
return []
|
| 34 |
}
|
| 35 |
|
| 36 |
+
const files: string[] = []
|
| 37 |
for (const agentName of agentDirs) {
|
| 38 |
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
| 39 |
try {
|
| 40 |
+
if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
|
| 41 |
+
} catch {
|
| 42 |
+
// Skip missing or unreadable session stores.
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
return files
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Read all sessions from OpenClaw agent session stores on disk.
|
| 50 |
+
*
|
| 51 |
+
* OpenClaw stores sessions per-agent at:
|
| 52 |
+
* {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
|
| 53 |
+
*
|
| 54 |
+
* Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
|
| 55 |
+
* with session metadata as values.
|
| 56 |
+
*/
|
| 57 |
+
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
| 58 |
+
const sessions: GatewaySession[] = []
|
| 59 |
+
const now = Date.now()
|
| 60 |
+
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
| 61 |
+
const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
|
| 62 |
+
try {
|
| 63 |
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 64 |
const data = JSON.parse(raw)
|
| 65 |
|
|
|
|
| 91 |
return sessions
|
| 92 |
}
|
| 93 |
|
| 94 |
+
export function countStaleGatewaySessions(retentionDays: number): number {
|
| 95 |
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
|
| 96 |
+
const cutoff = Date.now() - retentionDays * 86400000
|
| 97 |
+
let stale = 0
|
| 98 |
+
|
| 99 |
+
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
| 100 |
+
try {
|
| 101 |
+
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 102 |
+
const data = JSON.parse(raw) as Record<string, any>
|
| 103 |
+
for (const entry of Object.values(data)) {
|
| 104 |
+
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
| 105 |
+
if (updatedAt > 0 && updatedAt < cutoff) stale += 1
|
| 106 |
+
}
|
| 107 |
+
} catch {
|
| 108 |
+
// Ignore malformed session stores.
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return stale
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
|
| 116 |
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
|
| 117 |
+
const cutoff = Date.now() - retentionDays * 86400000
|
| 118 |
+
let deleted = 0
|
| 119 |
+
let filesTouched = 0
|
| 120 |
+
|
| 121 |
+
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
| 122 |
+
try {
|
| 123 |
+
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 124 |
+
const data = JSON.parse(raw) as Record<string, any>
|
| 125 |
+
const nextEntries: Record<string, any> = {}
|
| 126 |
+
let fileDeleted = 0
|
| 127 |
+
|
| 128 |
+
for (const [key, entry] of Object.entries(data)) {
|
| 129 |
+
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
| 130 |
+
if (updatedAt > 0 && updatedAt < cutoff) {
|
| 131 |
+
fileDeleted += 1
|
| 132 |
+
continue
|
| 133 |
+
}
|
| 134 |
+
nextEntries[key] = entry
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (fileDeleted > 0) {
|
| 138 |
+
const tempPath = `${sessionsFile}.tmp`
|
| 139 |
+
fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
|
| 140 |
+
fs.renameSync(tempPath, sessionsFile)
|
| 141 |
+
deleted += fileDeleted
|
| 142 |
+
filesTouched += 1
|
| 143 |
+
}
|
| 144 |
+
} catch {
|
| 145 |
+
// Ignore malformed/unwritable session stores.
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return { deleted, filesTouched }
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
/**
|
| 153 |
* Derive agent active/idle/offline status from their sessions.
|
| 154 |
* Returns a map of agentName -> { status, lastActivity, channel }
|
src/lib/websocket.ts
CHANGED
|
@@ -358,6 +358,13 @@ export function useWebSocket() {
|
|
| 358 |
|
| 359 |
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
| 360 |
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
handlePong(frame.id)
|
| 362 |
return
|
| 363 |
}
|
|
|
|
| 358 |
|
| 359 |
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
| 360 |
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
| 361 |
+
const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
|
| 362 |
+
if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
|
| 363 |
+
gatewaySupportsPingRef.current = false
|
| 364 |
+
missedPongsRef.current = 0
|
| 365 |
+
pingSentTimestamps.current.clear()
|
| 366 |
+
log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
|
| 367 |
+
}
|
| 368 |
handlePong(frame.id)
|
| 369 |
return
|
| 370 |
}
|