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 { config } from '@/lib/config'
|
||||
import { heavyLimiter } from '@/lib/rate-limit'
|
||||
import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
|
||||
|
||||
interface CleanupResult {
|
||||
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' })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
||||
logAuditEvent({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
|
|||
import { logger } from '@/lib/logger'
|
||||
|
||||
const MEMORY_PATH = config.memoryDir
|
||||
const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
|
||||
|
||||
// Ensure memory directory exists on startup
|
||||
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
||||
|
|
@ -24,6 +25,16 @@ interface 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 {
|
||||
if (candidate === base) return true
|
||||
return candidate.startsWith(base + sep)
|
||||
|
|
@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
|
|||
if (!MEMORY_PATH) {
|
||||
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)
|
||||
return NextResponse.json({ tree })
|
||||
}
|
||||
|
||||
if (action === 'content' && path) {
|
||||
// Return file content
|
||||
if (!isPathAllowed(path)) {
|
||||
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||
}
|
||||
if (!MEMORY_PATH) {
|
||||
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({
|
||||
query,
|
||||
|
|
@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
|
|||
if (!path) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
if (!isPathAllowed(path)) {
|
||||
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!MEMORY_PATH) {
|
||||
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
||||
|
|
@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
|
|||
if (!path) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
||||
}
|
||||
if (!isPathAllowed(path)) {
|
||||
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!MEMORY_PATH) {
|
||||
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.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.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
|
||||
|
||||
// Gateway
|
||||
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ export function Dashboard() {
|
|||
{isLocal ? (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
|
|||
setMemoryFiles(data.tree || [])
|
||||
|
||||
// Auto-expand some common directories
|
||||
setExpandedFolders(new Set(['daily', 'knowledge']))
|
||||
setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
|
||||
} catch (error) {
|
||||
log.error('Failed to load file tree:', error)
|
||||
} finally {
|
||||
|
|
@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
|
|||
|
||||
const getFilteredFiles = () => {
|
||||
if (activeTab === 'all') return memoryFiles
|
||||
|
||||
return memoryFiles.filter(file => {
|
||||
if (activeTab === 'daily') {
|
||||
return file.name === 'daily' || file.path.includes('daily/')
|
||||
}
|
||||
if (activeTab === 'knowledge') {
|
||||
return file.name === 'knowledge' || file.path.includes('knowledge/')
|
||||
}
|
||||
return true
|
||||
|
||||
const tabPrefixes = activeTab === 'daily'
|
||||
? ['daily/', 'memory/']
|
||||
: ['knowledge/', 'knowledge-base/']
|
||||
|
||||
return memoryFiles.filter((file) => {
|
||||
const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
|
||||
return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -731,6 +730,8 @@ function CreateFileModal({
|
|||
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"
|
||||
>
|
||||
<option value="knowledge-base/">knowledge-base/</option>
|
||||
<option value="memory/">memory/</option>
|
||||
<option value="knowledge/">knowledge/</option>
|
||||
<option value="daily/">daily/</option>
|
||||
<option value="logs/">logs/</option>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
|||
import { useMissionControl, Agent } from '@/store'
|
||||
|
||||
type ViewMode = 'office' | 'org-chart'
|
||||
type OrgSegmentMode = 'category' | 'role' | 'status'
|
||||
|
||||
interface Desk {
|
||||
agent: Agent
|
||||
|
|
@ -75,6 +76,7 @@ export function OfficePanel() {
|
|||
const { agents } = useMissionControl()
|
||||
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
||||
const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
|
|
@ -123,6 +125,64 @@ export function OfficePanel() {
|
|||
return groups
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -237,11 +297,40 @@ export function OfficePanel() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{[...roleGroups.entries()].map(([role, members]) => (
|
||||
<div key={role} className="bg-card border border-border rounded-xl p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ const openclawStateDir =
|
|||
const openclawConfigPath =
|
||||
explicitOpenClawConfigPath ||
|
||||
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 = {
|
||||
claudeHome:
|
||||
|
|
@ -45,10 +62,11 @@ export const config = {
|
|||
process.env.OPENCLAW_LOG_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
||||
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
||||
memoryDir:
|
||||
process.env.OPENCLAW_MEMORY_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
|
||||
path.join(defaultDataDir, 'memory'),
|
||||
memoryDir: defaultMemoryDir,
|
||||
memoryAllowedPrefixes:
|
||||
defaultMemoryDir === openclawWorkspaceDir
|
||||
? ['memory/', 'knowledge-base/']
|
||||
: [],
|
||||
soulTemplatesDir:
|
||||
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
||||
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
||||
|
|
@ -61,6 +79,7 @@ export const config = {
|
|||
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
||||
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_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 { processWebhookRetries } from './webhooks'
|
||||
import { syncClaudeSessions } from './claude-sessions'
|
||||
import { pruneGatewaySessionsOlderThan } from './sessions'
|
||||
|
||||
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) {
|
||||
logAuditEvent({
|
||||
action: 'auto_cleanup',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,32 @@ export interface GatewaySession {
|
|||
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.
|
||||
*
|
||||
|
|
@ -29,26 +55,11 @@ export interface GatewaySession {
|
|||
* with session metadata as values.
|
||||
*/
|
||||
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 now = Date.now()
|
||||
|
||||
let agentDirs: string[]
|
||||
try {
|
||||
agentDirs = fs.readdirSync(agentsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const agentName of agentDirs) {
|
||||
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
||||
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
||||
const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
|
||||
try {
|
||||
if (!fs.statSync(sessionsFile).isFile()) continue
|
||||
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
|
||||
|
|
@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|||
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.
|
||||
* 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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue