diff --git a/.env.example b/.env.example index 9da5f96..23e8941 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +# === Server Port === +# Port the Next.js server listens on (dev and production) +# PORT=3000 + # === Authentication === # Admin user seeded on first run (only if no users exist in DB) AUTH_USER=admin @@ -65,7 +69,7 @@ NEXT_PUBLIC_GATEWAY_PROTOCOL= NEXT_PUBLIC_GATEWAY_URL= # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token # Gateway client id used in websocket handshake (role=operator UI client). -NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui +NEXT_PUBLIC_GATEWAY_CLIENT_ID=openclaw-control-ui # === Data Paths (all optional, defaults to .data/ in project root) === # MISSION_CONTROL_DATA_DIR=.data diff --git a/Dockerfile b/Dockerfile index b8915af..792ef4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,9 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql RUN mkdir -p .data && chown nextjs:nodejs .data RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/* USER nextjs -EXPOSE 3000 ENV PORT=3000 +EXPOSE 3000 ENV HOSTNAME=0.0.0.0 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD curl -f http://localhost:3000/login || exit 1 + CMD curl -f http://localhost:${PORT:-3000}/login || exit 1 CMD ["node", "server.js"] diff --git a/README.md b/README.md index 7e5dfc8..3da8b94 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ Inter-agent communication via the comms API. Agents can send messages to each ot ### Integrations Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management. +### Workspace Management +Workspaces (tenant instances) are created and managed through the **Super Admin** panel, accessible from the sidebar under **Admin > Super Admin**. From there, admins can: +- **Create** new client instances (slug, display name, Linux user, gateway port, plan tier) +- **Monitor** provisioning jobs and their step-by-step progress +- **Decommission** tenants with optional cleanup of state directories and Linux users + +Each workspace gets its own isolated environment with a dedicated OpenClaw gateway, state directory, and workspace root. See the [Super Admin API](#api-overview) endpoints under `/api/super/*` for programmatic access. + ### Update Checker Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard. @@ -277,6 +285,20 @@ All endpoints require authentication unless noted. Full reference below. +
+Super Admin (Workspace/Tenant Management) + +| Method | Path | Role | Description | +|--------|------|------|-------------| +| `GET` | `/api/super/tenants` | admin | List all tenants with latest provisioning status | +| `POST` | `/api/super/tenants` | admin | Create tenant and queue bootstrap job | +| `POST` | `/api/super/tenants/[id]/decommission` | admin | Queue tenant decommission job | +| `GET` | `/api/super/provision-jobs` | admin | List provisioning jobs (filter: `?tenant_id=`, `?status=`) | +| `POST` | `/api/super/provision-jobs` | admin | Queue additional job for existing tenant | +| `POST` | `/api/super/provision-jobs/[id]/action` | admin | Approve, reject, or cancel a provisioning job | + +
+
Direct CLI @@ -444,6 +466,24 @@ Runtime-tunable thresholds: - `MC_WORKLOAD_ERROR_RATE_SHED` - `MC_WORKLOAD_RECENT_WINDOW_SECONDS` +## Agent Diagnostics Contract + +`GET /api/agents/{id}/diagnostics` is self-scoped by default. + +- Self access: + - Session user where `username === agent.name`, or + - API-key request with `x-agent-name` matching `{id}` agent name +- Cross-agent access: + - Allowed only with explicit `?privileged=1` and admin auth +- Query validation: + - `hours` must be an integer between `1` and `720` + - `section` must be a comma-separated subset of `summary,tasks,errors,activity,trends,tokens` + +Trend alerts in the `trends.alerts` response are derived from current-vs-previous window comparisons: + +- `warning`: error spikes or severe activity drop +- `info`: throughput drops or potential stall patterns + ## Roadmap See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped. diff --git a/docker-compose.yml b/docker-compose.yml index 9a16156..7ae529a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,9 @@ services: build: . container_name: mission-control ports: - - "${MC_PORT:-3000}:3000" + - "${MC_PORT:-3000}:${PORT:-3000}" + environment: + - PORT=${PORT:-3000} env_file: - path: .env required: false diff --git a/package.json b/package.json index 25e8136..91ec7a0 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "version": "1.3.0", "description": "OpenClaw Mission Control — open-source agent orchestration dashboard", "scripts": { - "dev": "next dev --hostname 127.0.0.1", + "dev": "next dev --hostname 127.0.0.1 --port ${PORT:-3000}", "build": "next build", - "start": "next start --hostname 0.0.0.0 --port 3005", + "start": "next start --hostname 0.0.0.0 --port ${PORT:-3000}", "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index 0b0dcaa..e7d05dd 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { NavRail } from '@/components/layout/nav-rail' import { HeaderBar } from '@/components/layout/header-bar' import { LiveFeed } from '@/components/layout/live-feed' @@ -42,6 +42,7 @@ import { useServerEvents } from '@/lib/use-server-events' import { useMissionControl } from '@/store' export default function Home() { + const router = useRouter() const { connect } = useWebSocket() const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl() @@ -62,7 +63,13 @@ export default function Home() { // Fetch current user fetch('/api/auth/me') - .then(res => res.ok ? res.json() : null) + .then(async (res) => { + if (res.ok) return res.json() + if (res.status === 401) { + router.replace(`/login?next=${encodeURIComponent(pathname)}`) + } + return null + }) .then(data => { if (data?.user) setCurrentUser(data.user) }) .catch(() => {}) @@ -120,7 +127,7 @@ export default function Home() { const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}` connect(wsUrl, wsToken) }) - }, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable]) + }, [connect, pathname, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable]) if (!isClient) { return ( diff --git a/src/app/api/agents/[id]/diagnostics/route.ts b/src/app/api/agents/[id]/diagnostics/route.ts new file mode 100644 index 0000000..4810843 --- /dev/null +++ b/src/app/api/agents/[id]/diagnostics/route.ts @@ -0,0 +1,343 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; +import { logger } from '@/lib/logger'; + +const ALLOWED_SECTIONS = ['summary', 'tasks', 'errors', 'activity', 'trends', 'tokens'] as const; +type DiagnosticsSection = (typeof ALLOWED_SECTIONS)[number]; + +function parseHoursParam(raw: string | null): { value?: number; error?: string } { + if (raw === null) return { value: 24 }; + const parsed = Number(raw); + if (!Number.isInteger(parsed)) { + return { error: 'hours must be an integer between 1 and 720' }; + } + if (parsed < 1 || parsed > 720) { + return { error: 'hours must be between 1 and 720' }; + } + return { value: parsed }; +} + +function parseSectionsParam(raw: string | null): { value?: Set; error?: string } { + if (!raw || raw.trim().length === 0) { + return { value: new Set(ALLOWED_SECTIONS) }; + } + + const requested = raw + .split(',') + .map((section) => section.trim()) + .filter(Boolean); + + if (requested.length === 0) { + return { error: 'section must include at least one valid value' }; + } + + const invalid = requested.filter((section) => !ALLOWED_SECTIONS.includes(section as DiagnosticsSection)); + if (invalid.length > 0) { + return { error: `Invalid section value(s): ${invalid.join(', ')}` }; + } + + return { value: new Set(requested as DiagnosticsSection[]) }; +} + +/** + * GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API + * + * Provides an agent with its own performance metrics, error analysis, + * and trend data so it can self-optimize. + * + * Query params: + * hours - Time window in hours (default: 24, max: 720 = 30 days) + * section - Comma-separated sections to include (default: all) + * Options: summary, tasks, errors, activity, trends, tokens + * + * Response includes: + * summary - High-level KPIs (throughput, error rate, activity count) + * tasks - Task completion breakdown by status and priority + * errors - Error frequency, types, and recent error details + * activity - Activity breakdown by type with hourly timeline + * trends - Multi-period comparison for trend detection + * tokens - Token usage by model with cost estimates + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer'); + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + const db = getDatabase(); + const resolvedParams = await params; + const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; + + // Resolve agent by ID or name + let agent: any; + if (/^\d+$/.test(agentId)) { + agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); + } else { + agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); + } + + if (!agent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); + } + + const { searchParams } = new URL(request.url); + const requesterAgentName = (request.headers.get('x-agent-name') || '').trim(); + const privileged = searchParams.get('privileged') === '1'; + const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name; + + // Self-only by default. Cross-agent access requires explicit privileged override. + if (!isSelfRequest && !(privileged && auth.user.role === 'admin')) { + return NextResponse.json( + { error: 'Diagnostics are self-scoped. Use privileged=1 with admin role for cross-agent access.' }, + { status: 403 } + ); + } + + const parsedHours = parseHoursParam(searchParams.get('hours')); + if (parsedHours.error) { + return NextResponse.json({ error: parsedHours.error }, { status: 400 }); + } + + const parsedSections = parseSectionsParam(searchParams.get('section')); + if (parsedSections.error) { + return NextResponse.json({ error: parsedSections.error }, { status: 400 }); + } + + const hours = parsedHours.value as number; + const sections = parsedSections.value as Set; + + const now = Math.floor(Date.now() / 1000); + const since = now - hours * 3600; + + const result: Record = { + agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status }, + timeframe: { hours, since, until: now }, + }; + + if (sections.has('summary')) { + result.summary = buildSummary(db, agent.name, workspaceId, since); + } + + if (sections.has('tasks')) { + result.tasks = buildTaskMetrics(db, agent.name, workspaceId, since); + } + + if (sections.has('errors')) { + result.errors = buildErrorAnalysis(db, agent.name, workspaceId, since); + } + + if (sections.has('activity')) { + result.activity = buildActivityBreakdown(db, agent.name, workspaceId, since); + } + + if (sections.has('trends')) { + result.trends = buildTrends(db, agent.name, workspaceId, hours); + } + + if (sections.has('tokens')) { + result.tokens = buildTokenMetrics(db, agent.name, workspaceId, since); + } + + return NextResponse.json(result); + } catch (error) { + logger.error({ err: error }, 'GET /api/agents/[id]/diagnostics error'); + return NextResponse.json({ error: 'Failed to fetch diagnostics' }, { status: 500 }); + } +} + +/** High-level KPIs */ +function buildSummary(db: any, agentName: string, workspaceId: number, since: number) { + const tasksDone = (db.prepare( + `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ?` + ).get(agentName, workspaceId, since) as any).c; + + const tasksTotal = (db.prepare( + `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ?` + ).get(agentName, workspaceId) as any).c; + + const activityCount = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ?` + ).get(agentName, workspaceId, since) as any).c; + + const errorCount = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND type LIKE '%error%'` + ).get(agentName, workspaceId, since) as any).c; + + const errorRate = activityCount > 0 ? Math.round((errorCount / activityCount) * 10000) / 100 : 0; + + return { + tasks_completed: tasksDone, + tasks_total: tasksTotal, + activity_count: activityCount, + error_count: errorCount, + error_rate_percent: errorRate, + }; +} + +/** Task completion breakdown */ +function buildTaskMetrics(db: any, agentName: string, workspaceId: number, since: number) { + const byStatus = db.prepare( + `SELECT status, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY status` + ).all(agentName, workspaceId) as Array<{ status: string; count: number }>; + + const byPriority = db.prepare( + `SELECT priority, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY priority` + ).all(agentName, workspaceId) as Array<{ priority: string; count: number }>; + + const recentCompleted = db.prepare( + `SELECT id, title, priority, updated_at FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 10` + ).all(agentName, workspaceId, since) as any[]; + + // Estimate throughput: tasks completed per day in the window + const windowDays = Math.max((Math.floor(Date.now() / 1000) - since) / 86400, 1); + const completedInWindow = recentCompleted.length; + const throughputPerDay = Math.round((completedInWindow / windowDays) * 100) / 100; + + return { + by_status: Object.fromEntries(byStatus.map(r => [r.status, r.count])), + by_priority: Object.fromEntries(byPriority.map(r => [r.priority, r.count])), + recent_completed: recentCompleted, + throughput_per_day: throughputPerDay, + }; +} + +/** Error frequency and analysis */ +function buildErrorAnalysis(db: any, agentName: string, workspaceId: number, since: number) { + const errorActivities = db.prepare( + `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') GROUP BY type ORDER BY count DESC` + ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>; + + const recentErrors = db.prepare( + `SELECT id, type, description, data, created_at FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') ORDER BY created_at DESC LIMIT 20` + ).all(agentName, workspaceId, since) as any[]; + + return { + by_type: errorActivities, + total: errorActivities.reduce((sum, e) => sum + e.count, 0), + recent: recentErrors.map(e => ({ + ...e, + data: e.data ? JSON.parse(e.data) : null, + })), + }; +} + +/** Activity breakdown with hourly timeline */ +function buildActivityBreakdown(db: any, agentName: string, workspaceId: number, since: number) { + const byType = db.prepare( + `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY type ORDER BY count DESC` + ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>; + + const timeline = db.prepare( + `SELECT (created_at / 3600) * 3600 as hour_bucket, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY hour_bucket ORDER BY hour_bucket ASC` + ).all(agentName, workspaceId, since) as Array<{ hour_bucket: number; count: number }>; + + return { + by_type: byType, + timeline: timeline.map(t => ({ + timestamp: t.hour_bucket, + hour: new Date(t.hour_bucket * 1000).toISOString(), + count: t.count, + })), + }; +} + +/** Multi-period trend comparison for anomaly/trend detection */ +function buildTrends(db: any, agentName: string, workspaceId: number, hours: number) { + const now = Math.floor(Date.now() / 1000); + + // Compare current period vs previous period of same length + const currentSince = now - hours * 3600; + const previousSince = currentSince - hours * 3600; + + const periodMetrics = (since: number, until: number) => { + const activities = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ?` + ).get(agentName, workspaceId, since, until) as any).c; + + const errors = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ? AND (type LIKE '%error%' OR type LIKE '%fail%')` + ).get(agentName, workspaceId, since, until) as any).c; + + const tasksCompleted = (db.prepare( + `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? AND updated_at < ?` + ).get(agentName, workspaceId, since, until) as any).c; + + return { activities, errors, tasks_completed: tasksCompleted }; + }; + + const current = periodMetrics(currentSince, now); + const previous = periodMetrics(previousSince, currentSince); + + const pctChange = (cur: number, prev: number) => { + if (prev === 0) return cur > 0 ? 100 : 0; + return Math.round(((cur - prev) / prev) * 10000) / 100; + }; + + return { + current_period: { since: currentSince, until: now, ...current }, + previous_period: { since: previousSince, until: currentSince, ...previous }, + change: { + activities_pct: pctChange(current.activities, previous.activities), + errors_pct: pctChange(current.errors, previous.errors), + tasks_completed_pct: pctChange(current.tasks_completed, previous.tasks_completed), + }, + alerts: buildTrendAlerts(current, previous), + }; +} + +/** Generate automatic alerts from trend data */ +function buildTrendAlerts(current: { activities: number; errors: number; tasks_completed: number }, previous: { activities: number; errors: number; tasks_completed: number }) { + const alerts: Array<{ level: string; message: string }> = []; + + // Error rate spike + if (current.errors > 0 && previous.errors > 0) { + const errorIncrease = (current.errors - previous.errors) / previous.errors; + if (errorIncrease > 0.5) { + alerts.push({ level: 'warning', message: `Error count increased ${Math.round(errorIncrease * 100)}% vs previous period` }); + } + } else if (current.errors > 3 && previous.errors === 0) { + alerts.push({ level: 'warning', message: `New error pattern: ${current.errors} errors (none in previous period)` }); + } + + // Throughput drop + if (previous.tasks_completed > 0 && current.tasks_completed === 0) { + alerts.push({ level: 'info', message: 'No tasks completed in current period (possible stall)' }); + } else if (previous.tasks_completed > 2 && current.tasks_completed < previous.tasks_completed * 0.5) { + alerts.push({ level: 'info', message: `Task throughput dropped ${Math.round((1 - current.tasks_completed / previous.tasks_completed) * 100)}%` }); + } + + // Activity drop (possible offline) + if (previous.activities > 5 && current.activities < previous.activities * 0.25) { + alerts.push({ level: 'warning', message: `Activity dropped ${Math.round((1 - current.activities / previous.activities) * 100)}% — agent may be stalled` }); + } + + return alerts; +} + +/** Token usage by model */ +function buildTokenMetrics(db: any, agentName: string, workspaceId: number, since: number) { + try { + // session_id on token_usage may store agent name or session key + const byModel = db.prepare( + `SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COUNT(*) as request_count FROM token_usage WHERE session_id = ? AND workspace_id = ? AND created_at >= ? GROUP BY model ORDER BY (input_tokens + output_tokens) DESC` + ).all(agentName, workspaceId, since) as Array<{ model: string; input_tokens: number; output_tokens: number; request_count: number }>; + + const total = byModel.reduce((acc, r) => ({ + input_tokens: acc.input_tokens + r.input_tokens, + output_tokens: acc.output_tokens + r.output_tokens, + requests: acc.requests + r.request_count, + }), { input_tokens: 0, output_tokens: 0, requests: 0 }); + + return { + by_model: byModel, + total, + }; + } catch { + // token_usage table may not exist + return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 } }; + } +} diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index 3f835f0..1f52638 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createAgentSchema } from '@/lib/validation'; +import { runOpenClaw } from '@/lib/command'; +import { config as appConfig } from '@/lib/config'; +import { resolveWithin } from '@/lib/paths'; +import path from 'node:path'; /** * GET /api/agents - List all agents with optional filtering @@ -123,6 +127,7 @@ export async function POST(request: NextRequest) { const { name, + openclaw_id, role, session_key, soul_content, @@ -130,9 +135,16 @@ export async function POST(request: NextRequest) { config = {}, template, gateway_config, - write_to_gateway + write_to_gateway, + provision_openclaw_workspace, + openclaw_workspace_path } = body; + const openclawId = (openclaw_id || name || 'agent') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + // Resolve template if specified let finalRole = role; let finalConfig: Record = { ...config }; @@ -158,6 +170,32 @@ export async function POST(request: NextRequest) { if (existingAgent) { return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 }); } + + if (provision_openclaw_workspace) { + if (!appConfig.openclawStateDir) { + return NextResponse.json( + { error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' }, + { status: 500 } + ); + } + + const workspacePath = openclaw_workspace_path + ? path.resolve(openclaw_workspace_path) + : resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId)); + + try { + await runOpenClaw( + ['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'], + { timeoutMs: 20000 } + ); + } catch (provisionError: any) { + logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed'); + return NextResponse.json( + { error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' }, + { status: 502 } + ); + } + } const now = Math.floor(Date.now() / 1000); @@ -215,7 +253,6 @@ export async function POST(request: NextRequest) { // Write to gateway config if requested if (write_to_gateway && finalConfig) { try { - const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-'); await writeAgentToConfig({ id: openclawId, name, diff --git a/src/app/api/cleanup/route.ts b/src/app/api/cleanup/route.ts index c590ea3..3cd13fc 100644 --- a/src/app/api/cleanup/route.ts +++ b/src/app/api/cleanup/route.ts @@ -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({ diff --git a/src/app/api/memory/route.ts b/src/app/api/memory/route.ts index 6615d35..a1aa609 100644 --- a/src/app/api/memory/route.ts +++ b/src/app/api/memory/route.ts @@ -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 }) diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index a6be8d4..d88130f 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -23,6 +23,7 @@ const settingDefinitions: Record line.startsWith('Mem:')) - if (memLine) { - const parts = memLine.split(/\s+/) - status.memory = { - total: parseInt(parts[1]) || 0, - used: parseInt(parts[2]) || 0, - available: parseInt(parts[6]) || 0 + // Memory info (cross-platform) + if (process.platform === 'darwin') { + const totalBytes = os.totalmem() + const freeBytes = os.freemem() + const totalMB = Math.round(totalBytes / (1024 * 1024)) + const usedMB = Math.round((totalBytes - freeBytes) / (1024 * 1024)) + const availableMB = Math.round(freeBytes / (1024 * 1024)) + status.memory = { total: totalMB, used: usedMB, available: availableMB } + } else { + const { stdout: memOutput } = await runCommand('free', ['-m'], { + timeoutMs: 3000 + }) + const memLine = memOutput.split('\n').find(line => line.startsWith('Mem:')) + if (memLine) { + const parts = memLine.split(/\s+/) + status.memory = { + total: parseInt(parts[1]) || 0, + used: parseInt(parts[2]) || 0, + available: parseInt(parts[6]) || 0 + } } } } catch (error) { @@ -414,14 +434,17 @@ async function performHealthCheck() { }) } - // Check disk space + // Check disk space (cross-platform: use df -h / and parse capacity column) try { - const { stdout } = await runCommand('df', ['/', '--output=pcent'], { + const { stdout } = await runCommand('df', ['-h', '/'], { timeoutMs: 3000 }) const lines = stdout.trim().split('\n') const last = lines[lines.length - 1] || '' - const usagePercent = parseInt(last.replace('%', '').trim() || '0') + const parts = last.split(/\s+/) + // On macOS capacity is col 4 ("85%"), on Linux use% is col 4 as well + const pctField = parts.find(p => p.endsWith('%')) || '0%' + const usagePercent = parseInt(pctField.replace('%', '') || '0') health.checks.push({ name: 'Disk Space', @@ -436,15 +459,21 @@ async function performHealthCheck() { }) } - // Check memory usage + // Check memory usage (cross-platform) try { - const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 }) - const lines = stdout.split('\n') - const memLine = lines.find((line) => line.startsWith('Mem:')) - const parts = (memLine || '').split(/\s+/) - const total = parseInt(parts[1] || '0') - const available = parseInt(parts[6] || '0') - const usagePercent = Math.round(((total - available) / total) * 100) + let usagePercent: number + if (process.platform === 'darwin') { + const totalBytes = os.totalmem() + const freeBytes = os.freemem() + usagePercent = Math.round(((totalBytes - freeBytes) / totalBytes) * 100) + } else { + const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 }) + const memLine = stdout.split('\n').find((line) => line.startsWith('Mem:')) + const parts = (memLine || '').split(/\s+/) + const total = parseInt(parts[1] || '0') + const available = parseInt(parts[6] || '0') + usagePercent = Math.round(((total - available) / total) * 100) + } health.checks.push({ name: 'Memory Usage', diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 70fd2f9..68898e2 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, updateTaskSchema } from '@/lib/validation'; import { resolveMentionRecipients } from '@/lib/mentions'; +import { normalizeTaskUpdateStatus } from '@/lib/task-status'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined @@ -115,7 +116,7 @@ export async function PUT( const { title, description, - status, + status: requestedStatus, priority, project_id, assigned_to, @@ -125,6 +126,12 @@ export async function PUT( tags, metadata } = body; + const normalizedStatus = normalizeTaskUpdateStatus({ + currentStatus: currentTask.status, + requestedStatus, + assignedTo: assigned_to, + assignedToProvided: assigned_to !== undefined, + }) const now = Math.floor(Date.now() / 1000); const descriptionMentionResolution = description !== undefined @@ -152,15 +159,15 @@ export async function PUT( fieldsToUpdate.push('description = ?'); updateParams.push(description); } - if (status !== undefined) { - if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) { + if (normalizedStatus !== undefined) { + if (normalizedStatus === 'done' && !hasAegisApproval(db, taskId, workspaceId)) { return NextResponse.json( { error: 'Aegis approval is required to move task to done.' }, { status: 403 } ) } fieldsToUpdate.push('status = ?'); - updateParams.push(status); + updateParams.push(normalizedStatus); } if (priority !== undefined) { fieldsToUpdate.push('priority = ?'); @@ -240,8 +247,8 @@ export async function PUT( // Track changes and log activities const changes: string[] = []; - if (status && status !== currentTask.status) { - changes.push(`status: ${currentTask.status} → ${status}`); + if (normalizedStatus !== undefined && normalizedStatus !== currentTask.status) { + changes.push(`status: ${currentTask.status} → ${normalizedStatus}`); // Create notification for status change if assigned if (currentTask.assigned_to) { @@ -249,7 +256,7 @@ export async function PUT( currentTask.assigned_to, 'status_change', 'Task Status Updated', - `Task "${currentTask.title}" status changed to ${status}`, + `Task "${currentTask.title}" status changed to ${normalizedStatus}`, 'task', taskId, workspaceId @@ -322,7 +329,7 @@ export async function PUT( priority: currentTask.priority, assigned_to: currentTask.assigned_to }, - newValues: { title, status, priority, assigned_to } + newValues: { title, status: normalizedStatus ?? currentTask.status, priority, assigned_to } }, workspaceId ); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index bdd1626..09b7883 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; import { resolveMentionRecipients } from '@/lib/mentions'; +import { normalizeTaskCreateStatus } from '@/lib/task-status'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined @@ -163,7 +164,7 @@ export async function POST(request: NextRequest) { const { title, description, - status = 'inbox', + status, priority = 'medium', project_id, assigned_to, @@ -173,6 +174,7 @@ export async function POST(request: NextRequest) { tags = [], metadata = {} } = body; + const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to) // Check for duplicate title const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId); @@ -212,7 +214,7 @@ export async function POST(request: NextRequest) { const dbResult = insertStmt.run( title, description, - status, + normalizedStatus, priority, resolvedProjectId, row.ticket_counter, @@ -234,7 +236,7 @@ export async function POST(request: NextRequest) { // Log activity db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, { title, - status, + status: normalizedStatus, priority, assigned_to }, workspaceId); diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index 38ab1a9..eab1399 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -522,7 +522,7 @@ export function Dashboard() { {isLocal ? ( } onNavigate={navigateToPanel} /> ) : ( - } onNavigate={navigateToPanel} /> + } onNavigate={navigateToPanel} /> )} diff --git a/src/components/layout/live-feed.tsx b/src/components/layout/live-feed.tsx index dae3c77..f64420b 100644 --- a/src/components/layout/live-feed.tsx +++ b/src/components/layout/live-feed.tsx @@ -7,6 +7,7 @@ export function LiveFeed() { const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl() const isLocal = dashboardMode === 'local' const [expanded, setExpanded] = useState(true) + const [hasCollapsed, setHasCollapsed] = useState(false) // Combine logs, activities, and (in local mode) session events into a unified feed const sessionItems = isLocal @@ -70,7 +71,7 @@ export function LiveFeed() { } return ( -
+
{/* Header */}
@@ -80,7 +81,7 @@ export function LiveFeed() {
+ {/* Info Banner */} +
+ Agent Memory vs Workspace Memory:{' '} + This tab edits only this agent's private working memory (a scratchpad stored in the database). + To browse or edit all workspace memory files (daily logs, knowledge base, MEMORY.md, etc.), visit the{' '} + Memory Browser page. +
+ {/* Memory Content */}
+ +
)}
diff --git a/src/components/panels/agent-squad-panel-phase3.tsx b/src/components/panels/agent-squad-panel-phase3.tsx index 97b827c..9dad847 100644 --- a/src/components/panels/agent-squad-panel-phase3.tsx +++ b/src/components/panels/agent-squad-panel-phase3.tsx @@ -96,7 +96,14 @@ export function AgentSquadPanelPhase3() { setSyncToast(null) try { const response = await fetch('/api/agents/sync', { method: 'POST' }) + if (response.status === 401) { + window.location.assign('/login?next=%2Fagents') + return + } const data = await response.json() + if (response.status === 403) { + throw new Error('Admin access required for agent sync') + } if (!response.ok) throw new Error(data.error || 'Sync failed') setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`) fetchAgents() @@ -116,7 +123,17 @@ export function AgentSquadPanelPhase3() { if (agents.length === 0) setLoading(true) const response = await fetch('/api/agents') - if (!response.ok) throw new Error('Failed to fetch agents') + if (response.status === 401) { + window.location.assign('/login?next=%2Fagents') + return + } + if (response.status === 403) { + throw new Error('Access denied') + } + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to fetch agents') + } const data = await response.json() setAgents(data.agents || []) diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index 61c9229..48022b5 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -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" > + + diff --git a/src/components/panels/office-panel.tsx b/src/components/panels/office-panel.tsx index 521301b..ed0ada5 100644 --- a/src/components/panels/office-panel.tsx +++ b/src/components/panels/office-panel.tsx @@ -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([]) const [viewMode, setViewMode] = useState('office') + const [orgSegmentMode, setOrgSegmentMode] = useState('category') const [selectedAgent, setSelectedAgent] = useState(null) const [loading, setLoading] = useState(true) @@ -123,6 +125,64 @@ export function OfficePanel() { return groups }, [displayAgents]) + const categoryGroups = useMemo(() => { + const groups = new Map() + 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() + 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 (
@@ -237,11 +297,40 @@ export function OfficePanel() {
) : (
- {[...roleGroups.entries()].map(([role, members]) => ( -
+
+
+ Segmented by{' '} + + {orgSegmentMode === 'category' ? 'category' : orgSegmentMode} + +
+
+ + + +
+
+ + {[...orgGroups.entries()].map(([segment, members]) => ( +
-

{role}

+

{segment}

({members.length})
diff --git a/src/components/panels/settings-panel.tsx b/src/components/panels/settings-panel.tsx index 08912c8..eacf25f 100644 --- a/src/components/panels/settings-panel.tsx +++ b/src/components/panels/settings-panel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useMissionControl } from '@/store' +import { useNavigateToPanel } from '@/lib/navigation' interface Setting { key: string @@ -24,6 +25,7 @@ const categoryOrder = ['general', 'retention', 'gateway', 'custom'] export function SettingsPanel() { const { currentUser } = useMissionControl() + const navigateToPanel = useNavigateToPanel() const [settings, setSettings] = useState([]) const [grouped, setGrouped] = useState>({}) const [loading, setLoading] = useState(true) @@ -43,12 +45,17 @@ export function SettingsPanel() { const fetchSettings = useCallback(async () => { try { const res = await fetch('/api/settings') + if (res.status === 401) { + window.location.assign('/login?next=%2Fsettings') + return + } if (res.status === 403) { setError('Admin access required') return } if (!res.ok) { - setError('Failed to load settings') + const data = await res.json().catch(() => ({})) + setError(data.error || 'Failed to load settings') return } const data = await res.json() @@ -180,6 +187,21 @@ export function SettingsPanel() {
+ {/* Workspace Info */} + {currentUser?.role === 'admin' && ( +
+ Workspace Management:{' '} + To create or manage workspaces (tenant instances), go to the{' '} + {' '} + panel under Admin > Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs. +
+ )} + {/* Feedback */} {feedback && (
- +
+ + +
@@ -452,17 +460,21 @@ export function SuperAdminPanel() {
)} -
- - {createExpanded && ( + {createExpanded && ( +
+
+

Create New Workspace

+ +
- Add a new workspace/client instance here. Fill the form below and click Create + Queue. + Fill in the workspace details below and click Create + Queue to provision a new client instance.
{gatewayLoadError && (
@@ -540,8 +552,8 @@ export function SuperAdminPanel() {
- )}
+ )}
diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index e7dc395..95df955 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -217,7 +217,7 @@ function MentionTextarea({ className={className} /> {open && filtered.length > 0 && ( -
+
{filtered.map((option, index) => (