Mission Control: Habi readiness wiring + office segmentation (#187)
* fix mission control wiring for habi memory/orchestration/retention * feat office org-chart segmentation controls --------- Co-authored-by: Jeremy Phelps <kokoro@Kokoro.local>
This commit is contained in:
parent
9d39e51f56
commit
41bfff8f79
|
|
@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
|
||||||
import { getDatabase, logAuditEvent } from '@/lib/db'
|
import { getDatabase, logAuditEvent } from '@/lib/db'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
import { heavyLimiter } from '@/lib/rate-limit'
|
import { heavyLimiter } from '@/lib/rate-limit'
|
||||||
|
import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
|
||||||
|
|
||||||
interface CleanupResult {
|
interface CleanupResult {
|
||||||
table: string
|
table: string
|
||||||
|
|
@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
|
||||||
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ret.gatewaySessions > 0) {
|
||||||
|
preview.push({
|
||||||
|
table: 'Gateway Session Store',
|
||||||
|
retention_days: ret.gatewaySessions,
|
||||||
|
stale_count: countStaleGatewaySessions(ret.gatewaySessions),
|
||||||
|
note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ retention: config.retention, preview })
|
return NextResponse.json({ retention: config.retention, preview })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ret.gatewaySessions > 0) {
|
||||||
|
const sessionPrune = dryRun
|
||||||
|
? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
|
||||||
|
: pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
||||||
|
results.push({
|
||||||
|
table: 'Gateway Session Store',
|
||||||
|
deleted: sessionPrune.deleted,
|
||||||
|
cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
|
||||||
|
retention_days: ret.gatewaySessions,
|
||||||
|
})
|
||||||
|
totalDeleted += sessionPrune.deleted
|
||||||
|
}
|
||||||
|
|
||||||
if (!dryRun && totalDeleted > 0) {
|
if (!dryRun && totalDeleted > 0) {
|
||||||
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
|
||||||
import { logger } from '@/lib/logger'
|
import { logger } from '@/lib/logger'
|
||||||
|
|
||||||
const MEMORY_PATH = config.memoryDir
|
const MEMORY_PATH = config.memoryDir
|
||||||
|
const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
|
||||||
|
|
||||||
// Ensure memory directory exists on startup
|
// Ensure memory directory exists on startup
|
||||||
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
||||||
|
|
@ -24,6 +25,16 @@ interface MemoryFile {
|
||||||
children?: MemoryFile[]
|
children?: MemoryFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(value: string): string {
|
||||||
|
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(relativePath: string): boolean {
|
||||||
|
if (!MEMORY_ALLOWED_PREFIXES.length) return true
|
||||||
|
const normalized = normalizeRelativePath(relativePath)
|
||||||
|
return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
function isWithinBase(base: string, candidate: string): boolean {
|
function isWithinBase(base: string, candidate: string): boolean {
|
||||||
if (candidate === base) return true
|
if (candidate === base) return true
|
||||||
return candidate.startsWith(base + sep)
|
return candidate.startsWith(base + sep)
|
||||||
|
|
@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
|
||||||
if (!MEMORY_PATH) {
|
if (!MEMORY_PATH) {
|
||||||
return NextResponse.json({ tree: [] })
|
return NextResponse.json({ tree: [] })
|
||||||
}
|
}
|
||||||
|
if (MEMORY_ALLOWED_PREFIXES.length) {
|
||||||
|
const tree: MemoryFile[] = []
|
||||||
|
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
||||||
|
const folder = prefix.replace(/\/$/, '')
|
||||||
|
const fullPath = join(MEMORY_PATH, folder)
|
||||||
|
if (!existsSync(fullPath)) continue
|
||||||
|
try {
|
||||||
|
const stats = await stat(fullPath)
|
||||||
|
if (!stats.isDirectory()) continue
|
||||||
|
tree.push({
|
||||||
|
path: folder,
|
||||||
|
name: folder,
|
||||||
|
type: 'directory',
|
||||||
|
modified: stats.mtime.getTime(),
|
||||||
|
children: await buildFileTree(fullPath, folder),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable roots
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NextResponse.json({ tree })
|
||||||
|
}
|
||||||
const tree = await buildFileTree(MEMORY_PATH)
|
const tree = await buildFileTree(MEMORY_PATH)
|
||||||
return NextResponse.json({ tree })
|
return NextResponse.json({ tree })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'content' && path) {
|
if (action === 'content' && path) {
|
||||||
// Return file content
|
// Return file content
|
||||||
|
if (!isPathAllowed(path)) {
|
||||||
|
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
if (!MEMORY_PATH) {
|
if (!MEMORY_PATH) {
|
||||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await searchDirectory(MEMORY_PATH)
|
if (MEMORY_ALLOWED_PREFIXES.length) {
|
||||||
|
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
||||||
|
const folder = prefix.replace(/\/$/, '')
|
||||||
|
const fullPath = join(MEMORY_PATH, folder)
|
||||||
|
if (!existsSync(fullPath)) continue
|
||||||
|
await searchDirectory(fullPath, folder)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await searchDirectory(MEMORY_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
query,
|
query,
|
||||||
|
|
@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
if (!isPathAllowed(path)) {
|
||||||
|
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
if (!MEMORY_PATH) {
|
if (!MEMORY_PATH) {
|
||||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||||
|
|
@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
if (!isPathAllowed(path)) {
|
||||||
|
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
if (!MEMORY_PATH) {
|
if (!MEMORY_PATH) {
|
||||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
|
||||||
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
||||||
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
||||||
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
||||||
|
'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
|
||||||
|
|
||||||
// Gateway
|
// Gateway
|
||||||
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
||||||
|
|
|
||||||
|
|
@ -522,7 +522,7 @@ export function Dashboard() {
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
||||||
) : (
|
) : (
|
||||||
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
|
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
|
||||||
setMemoryFiles(data.tree || [])
|
setMemoryFiles(data.tree || [])
|
||||||
|
|
||||||
// Auto-expand some common directories
|
// Auto-expand some common directories
|
||||||
setExpandedFolders(new Set(['daily', 'knowledge']))
|
setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to load file tree:', error)
|
log.error('Failed to load file tree:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
|
||||||
|
|
||||||
const getFilteredFiles = () => {
|
const getFilteredFiles = () => {
|
||||||
if (activeTab === 'all') return memoryFiles
|
if (activeTab === 'all') return memoryFiles
|
||||||
|
|
||||||
return memoryFiles.filter(file => {
|
const tabPrefixes = activeTab === 'daily'
|
||||||
if (activeTab === 'daily') {
|
? ['daily/', 'memory/']
|
||||||
return file.name === 'daily' || file.path.includes('daily/')
|
: ['knowledge/', 'knowledge-base/']
|
||||||
}
|
|
||||||
if (activeTab === 'knowledge') {
|
return memoryFiles.filter((file) => {
|
||||||
return file.name === 'knowledge' || file.path.includes('knowledge/')
|
const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
|
||||||
}
|
return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -731,6 +730,8 @@ function CreateFileModal({
|
||||||
onChange={(e) => setFilePath(e.target.value)}
|
onChange={(e) => setFilePath(e.target.value)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
|
<option value="knowledge-base/">knowledge-base/</option>
|
||||||
|
<option value="memory/">memory/</option>
|
||||||
<option value="knowledge/">knowledge/</option>
|
<option value="knowledge/">knowledge/</option>
|
||||||
<option value="daily/">daily/</option>
|
<option value="daily/">daily/</option>
|
||||||
<option value="logs/">logs/</option>
|
<option value="logs/">logs/</option>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useMissionControl, Agent } from '@/store'
|
import { useMissionControl, Agent } from '@/store'
|
||||||
|
|
||||||
type ViewMode = 'office' | 'org-chart'
|
type ViewMode = 'office' | 'org-chart'
|
||||||
|
type OrgSegmentMode = 'category' | 'role' | 'status'
|
||||||
|
|
||||||
interface Desk {
|
interface Desk {
|
||||||
agent: Agent
|
agent: Agent
|
||||||
|
|
@ -75,6 +76,7 @@ export function OfficePanel() {
|
||||||
const { agents } = useMissionControl()
|
const { agents } = useMissionControl()
|
||||||
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
||||||
|
const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
|
||||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
|
@ -123,6 +125,64 @@ export function OfficePanel() {
|
||||||
return groups
|
return groups
|
||||||
}, [displayAgents])
|
}, [displayAgents])
|
||||||
|
|
||||||
|
const categoryGroups = useMemo(() => {
|
||||||
|
const groups = new Map<string, Agent[]>()
|
||||||
|
const getCategory = (agent: Agent): string => {
|
||||||
|
const name = (agent.name || '').toLowerCase()
|
||||||
|
if (name.startsWith('habi-')) return 'Habi Lanes'
|
||||||
|
if (name.startsWith('ops-')) return 'Ops Automation'
|
||||||
|
if (name.includes('canary')) return 'Canary'
|
||||||
|
if (name.startsWith('main')) return 'Core'
|
||||||
|
if (name.startsWith('remote-')) return 'Remote'
|
||||||
|
return 'Other'
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of displayAgents) {
|
||||||
|
const category = getCategory(a)
|
||||||
|
if (!groups.has(category)) groups.set(category, [])
|
||||||
|
groups.get(category)!.push(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
|
||||||
|
return new Map(
|
||||||
|
[...groups.entries()].sort(([a], [b]) => {
|
||||||
|
const ai = order.indexOf(a)
|
||||||
|
const bi = order.indexOf(b)
|
||||||
|
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
||||||
|
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
||||||
|
if (av !== bv) return av - bv
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [displayAgents])
|
||||||
|
|
||||||
|
const statusGroups = useMemo(() => {
|
||||||
|
const groups = new Map<string, Agent[]>()
|
||||||
|
for (const a of displayAgents) {
|
||||||
|
const key = statusLabel[a.status] || a.status
|
||||||
|
if (!groups.has(key)) groups.set(key, [])
|
||||||
|
groups.get(key)!.push(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = ['Working', 'Available', 'Error', 'Away']
|
||||||
|
return new Map(
|
||||||
|
[...groups.entries()].sort(([a], [b]) => {
|
||||||
|
const ai = order.indexOf(a)
|
||||||
|
const bi = order.indexOf(b)
|
||||||
|
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
||||||
|
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
||||||
|
if (av !== bv) return av - bv
|
||||||
|
return a.localeCompare(b)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [displayAgents])
|
||||||
|
|
||||||
|
const orgGroups = useMemo(() => {
|
||||||
|
if (orgSegmentMode === 'role') return roleGroups
|
||||||
|
if (orgSegmentMode === 'status') return statusGroups
|
||||||
|
return categoryGroups
|
||||||
|
}, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
|
||||||
|
|
||||||
if (loading && displayAgents.length === 0) {
|
if (loading && displayAgents.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
|
|
@ -237,11 +297,40 @@ export function OfficePanel() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{[...roleGroups.entries()].map(([role, members]) => (
|
<div className="flex items-center justify-between">
|
||||||
<div key={role} className="bg-card border border-border rounded-xl p-5">
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Segmented by{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded-md overflow-hidden border border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setOrgSegmentMode('category')}
|
||||||
|
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'}`}
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOrgSegmentMode('role')}
|
||||||
|
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'}`}
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOrgSegmentMode('status')}
|
||||||
|
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'}`}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[...orgGroups.entries()].map(([segment, members]) => (
|
||||||
|
<div key={segment} className="bg-card border border-border rounded-xl p-5">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<div className="w-1 h-6 bg-primary rounded-full" />
|
<div className="w-1 h-6 bg-primary rounded-full" />
|
||||||
<h3 className="font-semibold text-foreground">{role}</h3>
|
<h3 className="font-semibold text-foreground">{segment}</h3>
|
||||||
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,23 @@ const openclawStateDir =
|
||||||
const openclawConfigPath =
|
const openclawConfigPath =
|
||||||
explicitOpenClawConfigPath ||
|
explicitOpenClawConfigPath ||
|
||||||
path.join(openclawStateDir, 'openclaw.json')
|
path.join(openclawStateDir, 'openclaw.json')
|
||||||
|
const openclawWorkspaceDir =
|
||||||
|
process.env.OPENCLAW_WORKSPACE_DIR ||
|
||||||
|
process.env.MISSION_CONTROL_WORKSPACE_DIR ||
|
||||||
|
(openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
|
||||||
|
const defaultMemoryDir = (() => {
|
||||||
|
if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
|
||||||
|
// Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
|
||||||
|
// when available; fallback to legacy sqlite memory path.
|
||||||
|
if (
|
||||||
|
openclawWorkspaceDir &&
|
||||||
|
(fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
|
||||||
|
fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
|
||||||
|
) {
|
||||||
|
return openclawWorkspaceDir
|
||||||
|
}
|
||||||
|
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
|
||||||
|
})()
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
claudeHome:
|
claudeHome:
|
||||||
|
|
@ -45,10 +62,11 @@ export const config = {
|
||||||
process.env.OPENCLAW_LOG_DIR ||
|
process.env.OPENCLAW_LOG_DIR ||
|
||||||
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
||||||
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
||||||
memoryDir:
|
memoryDir: defaultMemoryDir,
|
||||||
process.env.OPENCLAW_MEMORY_DIR ||
|
memoryAllowedPrefixes:
|
||||||
(openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
|
defaultMemoryDir === openclawWorkspaceDir
|
||||||
path.join(defaultDataDir, 'memory'),
|
? ['memory/', 'knowledge-base/']
|
||||||
|
: [],
|
||||||
soulTemplatesDir:
|
soulTemplatesDir:
|
||||||
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
||||||
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
||||||
|
|
@ -61,6 +79,7 @@ export const config = {
|
||||||
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
||||||
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
||||||
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
||||||
|
gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
import { processWebhookRetries } from './webhooks'
|
import { processWebhookRetries } from './webhooks'
|
||||||
import { syncClaudeSessions } from './claude-sessions'
|
import { syncClaudeSessions } from './claude-sessions'
|
||||||
|
import { pruneGatewaySessionsOlderThan } from './sessions'
|
||||||
|
|
||||||
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
||||||
|
|
||||||
|
|
@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ret.gatewaySessions > 0) {
|
||||||
|
const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
||||||
|
totalDeleted += sessionCleanup.deleted
|
||||||
|
}
|
||||||
|
|
||||||
if (totalDeleted > 0) {
|
if (totalDeleted > 0) {
|
||||||
logAuditEvent({
|
logAuditEvent({
|
||||||
action: 'auto_cleanup',
|
action: 'auto_cleanup',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,32 @@ export interface GatewaySession {
|
||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGatewaySessionStoreFiles(): string[] {
|
||||||
|
const openclawStateDir = config.openclawStateDir
|
||||||
|
if (!openclawStateDir) return []
|
||||||
|
|
||||||
|
const agentsDir = path.join(openclawStateDir, 'agents')
|
||||||
|
if (!fs.existsSync(agentsDir)) return []
|
||||||
|
|
||||||
|
let agentDirs: string[]
|
||||||
|
try {
|
||||||
|
agentDirs = fs.readdirSync(agentsDir)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = []
|
||||||
|
for (const agentName of agentDirs) {
|
||||||
|
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
||||||
|
try {
|
||||||
|
if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
|
||||||
|
} catch {
|
||||||
|
// Skip missing or unreadable session stores.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read all sessions from OpenClaw agent session stores on disk.
|
* Read all sessions from OpenClaw agent session stores on disk.
|
||||||
*
|
*
|
||||||
|
|
@ -29,26 +55,11 @@ export interface GatewaySession {
|
||||||
* with session metadata as values.
|
* with session metadata as values.
|
||||||
*/
|
*/
|
||||||
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
||||||
const openclawStateDir = config.openclawStateDir
|
|
||||||
if (!openclawStateDir) return []
|
|
||||||
|
|
||||||
const agentsDir = path.join(openclawStateDir, 'agents')
|
|
||||||
if (!fs.existsSync(agentsDir)) return []
|
|
||||||
|
|
||||||
const sessions: GatewaySession[] = []
|
const sessions: GatewaySession[] = []
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||||
let agentDirs: string[]
|
const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
|
||||||
try {
|
|
||||||
agentDirs = fs.readdirSync(agentsDir)
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agentName of agentDirs) {
|
|
||||||
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
|
||||||
try {
|
try {
|
||||||
if (!fs.statSync(sessionsFile).isFile()) continue
|
|
||||||
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||||
const data = JSON.parse(raw)
|
const data = JSON.parse(raw)
|
||||||
|
|
||||||
|
|
@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function countStaleGatewaySessions(retentionDays: number): number {
|
||||||
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
|
||||||
|
const cutoff = Date.now() - retentionDays * 86400000
|
||||||
|
let stale = 0
|
||||||
|
|
||||||
|
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||||
|
const data = JSON.parse(raw) as Record<string, any>
|
||||||
|
for (const entry of Object.values(data)) {
|
||||||
|
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
||||||
|
if (updatedAt > 0 && updatedAt < cutoff) stale += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed session stores.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
|
||||||
|
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
|
||||||
|
const cutoff = Date.now() - retentionDays * 86400000
|
||||||
|
let deleted = 0
|
||||||
|
let filesTouched = 0
|
||||||
|
|
||||||
|
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||||
|
const data = JSON.parse(raw) as Record<string, any>
|
||||||
|
const nextEntries: Record<string, any> = {}
|
||||||
|
let fileDeleted = 0
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(data)) {
|
||||||
|
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
||||||
|
if (updatedAt > 0 && updatedAt < cutoff) {
|
||||||
|
fileDeleted += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextEntries[key] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileDeleted > 0) {
|
||||||
|
const tempPath = `${sessionsFile}.tmp`
|
||||||
|
fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
|
||||||
|
fs.renameSync(tempPath, sessionsFile)
|
||||||
|
deleted += fileDeleted
|
||||||
|
filesTouched += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed/unwritable session stores.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted, filesTouched }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive agent active/idle/offline status from their sessions.
|
* Derive agent active/idle/offline status from their sessions.
|
||||||
* Returns a map of agentName -> { status, lastActivity, channel }
|
* Returns a map of agentName -> { status, lastActivity, channel }
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,13 @@ export function useWebSocket() {
|
||||||
|
|
||||||
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
||||||
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
||||||
|
const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
|
||||||
|
if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
|
||||||
|
gatewaySupportsPingRef.current = false
|
||||||
|
missedPongsRef.current = 0
|
||||||
|
pingSentTimestamps.current.clear()
|
||||||
|
log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
|
||||||
|
}
|
||||||
handlePong(frame.id)
|
handlePong(frame.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue