chore: resolve merge conflict with main for PR #180

This commit is contained in:
Nyk 2026-03-05 12:41:02 +07:00
commit ee175597be
35 changed files with 5772 additions and 4531 deletions

View File

@ -1,3 +1,7 @@
# === Server Port ===
# Port the Next.js server listens on (dev and production)
# PORT=3000
# === Authentication === # === Authentication ===
# Admin user seeded on first run (only if no users exist in DB) # Admin user seeded on first run (only if no users exist in DB)
AUTH_USER=admin AUTH_USER=admin
@ -65,7 +69,7 @@ NEXT_PUBLIC_GATEWAY_PROTOCOL=
NEXT_PUBLIC_GATEWAY_URL= NEXT_PUBLIC_GATEWAY_URL=
# NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
# Gateway client id used in websocket handshake (role=operator UI client). # 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) === # === Data Paths (all optional, defaults to .data/ in project root) ===
# MISSION_CONTROL_DATA_DIR=.data # MISSION_CONTROL_DATA_DIR=.data

View File

@ -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 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/* RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
USER nextjs USER nextjs
EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
EXPOSE 3000
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ 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"] CMD ["node", "server.js"]

View File

@ -113,6 +113,14 @@ Inter-agent communication via the comms API. Agents can send messages to each ot
### Integrations ### Integrations
Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management. 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 ### Update Checker
Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard. Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard.
@ -286,6 +294,20 @@ All endpoints require authentication unless noted. Full reference below.
</details> </details>
<details>
<summary><strong>Super Admin (Workspace/Tenant Management)</strong></summary>
| 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 |
</details>
<details> <details>
<summary><strong>Direct CLI</strong></summary> <summary><strong>Direct CLI</strong></summary>
@ -427,6 +449,24 @@ pnpm test:e2e # Playwright E2E
pnpm quality:gate # All checks pnpm quality:gate # All checks
``` ```
## 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 ## 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. 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.

View File

@ -3,7 +3,9 @@ services:
build: . build: .
container_name: mission-control container_name: mission-control
ports: ports:
- "${MC_PORT:-3000}:3000" - "${MC_PORT:-3000}:${PORT:-3000}"
environment:
- PORT=${PORT:-3000}
env_file: env_file:
- path: .env - path: .env
required: false required: false

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,9 @@
"version": "1.3.0", "version": "1.3.0",
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard", "description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
"scripts": { "scripts": {
"dev": "next dev --hostname 127.0.0.1", "dev": "next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
"build": "next build", "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 .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",

View File

@ -18,9 +18,18 @@ export default defineConfig({
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } } { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
], ],
webServer: { webServer: {
command: 'pnpm start', command: 'node .next/standalone/server.js',
url: 'http://127.0.0.1:3005', url: 'http://127.0.0.1:3005',
reuseExistingServer: true, reuseExistingServer: true,
timeout: 30_000, timeout: 120_000,
env: {
...process.env,
HOSTNAME: process.env.HOSTNAME || '127.0.0.1',
PORT: process.env.PORT || '3005',
MC_DISABLE_RATE_LIMIT: process.env.MC_DISABLE_RATE_LIMIT || '1',
API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
AUTH_USER: process.env.AUTH_USER || 'testadmin',
AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',
},
} }
}) })

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { NavRail } from '@/components/layout/nav-rail' import { NavRail } from '@/components/layout/nav-rail'
import { HeaderBar } from '@/components/layout/header-bar' import { HeaderBar } from '@/components/layout/header-bar'
import { LiveFeed } from '@/components/layout/live-feed' import { LiveFeed } from '@/components/layout/live-feed'
@ -42,6 +42,7 @@ import { useServerEvents } from '@/lib/use-server-events'
import { useMissionControl } from '@/store' import { useMissionControl } from '@/store'
export default function Home() { export default function Home() {
const router = useRouter()
const { connect } = useWebSocket() const { connect } = useWebSocket()
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl() const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
@ -62,7 +63,13 @@ export default function Home() {
// Fetch current user // Fetch current user
fetch('/api/auth/me') 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) }) .then(data => { if (data?.user) setCurrentUser(data.user) })
.catch(() => {}) .catch(() => {})
@ -120,7 +127,7 @@ export default function Home() {
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}` const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken) connect(wsUrl, wsToken)
}) })
}, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable]) }, [connect, pathname, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
if (!isClient) { if (!isClient) {
return ( return (

View File

@ -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<DiagnosticsSection>; 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<DiagnosticsSection>;
const now = Math.floor(Date.now() / 1000);
const since = now - hours * 3600;
const result: Record<string, any> = {
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 } };
}
}

View File

@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth';
import { mutationLimiter } from '@/lib/rate-limit'; import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { validateBody, createAgentSchema } from '@/lib/validation'; 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 * GET /api/agents - List all agents with optional filtering
@ -123,6 +127,7 @@ export async function POST(request: NextRequest) {
const { const {
name, name,
openclaw_id,
role, role,
session_key, session_key,
soul_content, soul_content,
@ -130,9 +135,16 @@ export async function POST(request: NextRequest) {
config = {}, config = {},
template, template,
gateway_config, gateway_config,
write_to_gateway write_to_gateway,
provision_openclaw_workspace,
openclaw_workspace_path
} = body; } = body;
const openclawId = (openclaw_id || name || 'agent')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
// Resolve template if specified // Resolve template if specified
let finalRole = role; let finalRole = role;
let finalConfig: Record<string, any> = { ...config }; let finalConfig: Record<string, any> = { ...config };
@ -159,6 +171,32 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 }); 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); const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(` const stmt = db.prepare(`
@ -215,7 +253,6 @@ export async function POST(request: NextRequest) {
// Write to gateway config if requested // Write to gateway config if requested
if (write_to_gateway && finalConfig) { if (write_to_gateway && finalConfig) {
try { try {
const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-');
await writeAgentToConfig({ await writeAgentToConfig({
id: openclawId, id: openclawId,
name, name,

View File

@ -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({

View File

@ -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) {
} }
} }
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) 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 })

View File

@ -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 },

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import net from 'node:net' import net from 'node:net'
import os from 'node:os'
import { existsSync, statSync } from 'node:fs' import { existsSync, statSync } from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command' import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
@ -195,23 +196,41 @@ async function getSystemStatus(workspaceId: number) {
} }
try { try {
// System uptime // System uptime (cross-platform)
const { stdout: uptimeOutput } = await runCommand('uptime', ['-s'], { if (process.platform === 'darwin') {
const { stdout } = await runCommand('sysctl', ['-n', 'kern.boottime'], {
timeoutMs: 3000 timeoutMs: 3000
}) })
const bootTime = new Date(uptimeOutput.trim()) // Output format: { sec = 1234567890, usec = 0 } ...
const match = stdout.match(/sec\s*=\s*(\d+)/)
if (match) {
status.uptime = Date.now() - parseInt(match[1]) * 1000
}
} else {
const { stdout } = await runCommand('uptime', ['-s'], {
timeoutMs: 3000
})
const bootTime = new Date(stdout.trim())
status.uptime = Date.now() - bootTime.getTime() status.uptime = Date.now() - bootTime.getTime()
}
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error getting uptime') logger.error({ err: error }, 'Error getting uptime')
} }
try { try {
// Memory info // 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'], { const { stdout: memOutput } = await runCommand('free', ['-m'], {
timeoutMs: 3000 timeoutMs: 3000
}) })
const memLines = memOutput.split('\n') const memLine = memOutput.split('\n').find(line => line.startsWith('Mem:'))
const memLine = memLines.find(line => line.startsWith('Mem:'))
if (memLine) { if (memLine) {
const parts = memLine.split(/\s+/) const parts = memLine.split(/\s+/)
status.memory = { status.memory = {
@ -220,6 +239,7 @@ async function getSystemStatus(workspaceId: number) {
available: parseInt(parts[6]) || 0 available: parseInt(parts[6]) || 0
} }
} }
}
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error getting memory info') logger.error({ err: error }, 'Error getting memory info')
} }
@ -414,14 +434,17 @@ async function performHealthCheck() {
}) })
} }
// Check disk space // Check disk space (cross-platform: use df -h / and parse capacity column)
try { try {
const { stdout } = await runCommand('df', ['/', '--output=pcent'], { const { stdout } = await runCommand('df', ['-h', '/'], {
timeoutMs: 3000 timeoutMs: 3000
}) })
const lines = stdout.trim().split('\n') const lines = stdout.trim().split('\n')
const last = lines[lines.length - 1] || '' 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({ health.checks.push({
name: 'Disk Space', name: 'Disk Space',
@ -436,15 +459,21 @@ async function performHealthCheck() {
}) })
} }
// Check memory usage // Check memory usage (cross-platform)
try { try {
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 { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 })
const lines = stdout.split('\n') const memLine = stdout.split('\n').find((line) => line.startsWith('Mem:'))
const memLine = lines.find((line) => line.startsWith('Mem:'))
const parts = (memLine || '').split(/\s+/) const parts = (memLine || '').split(/\s+/)
const total = parseInt(parts[1] || '0') const total = parseInt(parts[1] || '0')
const available = parseInt(parts[6] || '0') const available = parseInt(parts[6] || '0')
const usagePercent = Math.round(((total - available) / total) * 100) usagePercent = Math.round(((total - available) / total) * 100)
}
health.checks.push({ health.checks.push({
name: 'Memory Usage', name: 'Memory Usage',

View File

@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { validateBody, updateTaskSchema } from '@/lib/validation'; import { validateBody, updateTaskSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions'; import { resolveMentionRecipients } from '@/lib/mentions';
import { normalizeTaskUpdateStatus } from '@/lib/task-status';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -115,7 +116,7 @@ export async function PUT(
const { const {
title, title,
description, description,
status, status: requestedStatus,
priority, priority,
project_id, project_id,
assigned_to, assigned_to,
@ -125,6 +126,12 @@ export async function PUT(
tags, tags,
metadata metadata
} = body; } = body;
const normalizedStatus = normalizeTaskUpdateStatus({
currentStatus: currentTask.status,
requestedStatus,
assignedTo: assigned_to,
assignedToProvided: assigned_to !== undefined,
})
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const descriptionMentionResolution = description !== undefined const descriptionMentionResolution = description !== undefined
@ -152,15 +159,15 @@ export async function PUT(
fieldsToUpdate.push('description = ?'); fieldsToUpdate.push('description = ?');
updateParams.push(description); updateParams.push(description);
} }
if (status !== undefined) { if (normalizedStatus !== undefined) {
if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) { if (normalizedStatus === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Aegis approval is required to move task to done.' }, { error: 'Aegis approval is required to move task to done.' },
{ status: 403 } { status: 403 }
) )
} }
fieldsToUpdate.push('status = ?'); fieldsToUpdate.push('status = ?');
updateParams.push(status); updateParams.push(normalizedStatus);
} }
if (priority !== undefined) { if (priority !== undefined) {
fieldsToUpdate.push('priority = ?'); fieldsToUpdate.push('priority = ?');
@ -240,8 +247,8 @@ export async function PUT(
// Track changes and log activities // Track changes and log activities
const changes: string[] = []; const changes: string[] = [];
if (status && status !== currentTask.status) { if (normalizedStatus !== undefined && normalizedStatus !== currentTask.status) {
changes.push(`status: ${currentTask.status}${status}`); changes.push(`status: ${currentTask.status}${normalizedStatus}`);
// Create notification for status change if assigned // Create notification for status change if assigned
if (currentTask.assigned_to) { if (currentTask.assigned_to) {
@ -249,7 +256,7 @@ export async function PUT(
currentTask.assigned_to, currentTask.assigned_to,
'status_change', 'status_change',
'Task Status Updated', 'Task Status Updated',
`Task "${currentTask.title}" status changed to ${status}`, `Task "${currentTask.title}" status changed to ${normalizedStatus}`,
'task', 'task',
taskId, taskId,
workspaceId workspaceId
@ -322,7 +329,7 @@ export async function PUT(
priority: currentTask.priority, priority: currentTask.priority,
assigned_to: currentTask.assigned_to assigned_to: currentTask.assigned_to
}, },
newValues: { title, status, priority, assigned_to } newValues: { title, status: normalizedStatus ?? currentTask.status, priority, assigned_to }
}, },
workspaceId workspaceId
); );

View File

@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
import { resolveMentionRecipients } from '@/lib/mentions'; import { resolveMentionRecipients } from '@/lib/mentions';
import { normalizeTaskCreateStatus } from '@/lib/task-status';
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@ -163,7 +164,7 @@ export async function POST(request: NextRequest) {
const { const {
title, title,
description, description,
status = 'inbox', status,
priority = 'medium', priority = 'medium',
project_id, project_id,
assigned_to, assigned_to,
@ -173,6 +174,7 @@ export async function POST(request: NextRequest) {
tags = [], tags = [],
metadata = {} metadata = {}
} = body; } = body;
const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
// Check for duplicate title // Check for duplicate title
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId); 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( const dbResult = insertStmt.run(
title, title,
description, description,
status, normalizedStatus,
priority, priority,
resolvedProjectId, resolvedProjectId,
row.ticket_counter, row.ticket_counter,
@ -234,7 +236,7 @@ export async function POST(request: NextRequest) {
// Log activity // Log activity
db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, { db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
title, title,
status, status: normalizedStatus,
priority, priority,
assigned_to assigned_to
}, workspaceId); }, workspaceId);

View File

@ -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>

View File

@ -7,6 +7,7 @@ export function LiveFeed() {
const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl() const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
const isLocal = dashboardMode === 'local' const isLocal = dashboardMode === 'local'
const [expanded, setExpanded] = useState(true) const [expanded, setExpanded] = useState(true)
const [hasCollapsed, setHasCollapsed] = useState(false)
// Combine logs, activities, and (in local mode) session events into a unified feed // Combine logs, activities, and (in local mode) session events into a unified feed
const sessionItems = isLocal const sessionItems = isLocal
@ -70,7 +71,7 @@ export function LiveFeed() {
} }
return ( return (
<div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right"> <div className={`w-72 h-full bg-card border-l border-border flex flex-col shrink-0${hasCollapsed ? ' slide-in-right' : ''}`}>
{/* Header */} {/* Header */}
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0"> <div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -80,7 +81,7 @@ export function LiveFeed() {
</div> </div>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button <button
onClick={() => setExpanded(false)} onClick={() => { setExpanded(false); setHasCollapsed(true) }}
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center" className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
title="Collapse feed" title="Collapse feed"
> >

View File

@ -517,7 +517,7 @@ export function MemoryTab({
<div> <div>
<h4 className="text-lg font-medium text-foreground">Working Memory</h4> <h4 className="text-lg font-medium text-foreground">Working Memory</h4>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files. This is <strong className="text-foreground">agent-level</strong> scratchpad memory (stored as WORKING.md in the database), not the workspace memory folder.
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@ -543,6 +543,14 @@ export function MemoryTab({
</div> </div>
</div> </div>
{/* Info Banner */}
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
<strong className="text-blue-200">Agent Memory vs Workspace Memory:</strong>{' '}
This tab edits only this agent&apos;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{' '}
<Link href="/memory" className="text-blue-400 underline hover:text-blue-300">Memory Browser</Link> page.
</div>
{/* Memory Content */} {/* Memory Content */}
<div> <div>
<label className="block text-sm font-medium text-muted-foreground mb-1"> <label className="block text-sm font-medium text-muted-foreground mb-1">
@ -852,6 +860,7 @@ export function CreateAgentModal({
dockerNetwork: 'none' as 'none' | 'bridge', dockerNetwork: 'none' as 'none' | 'bridge',
session_key: '', session_key: '',
write_to_gateway: true, write_to_gateway: true,
provision_openclaw_workspace: true,
}) })
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -916,10 +925,12 @@ export function CreateAgentModal({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
name: formData.name, name: formData.name,
openclaw_id: formData.id || undefined,
role: formData.role, role: formData.role,
session_key: formData.session_key || undefined, session_key: formData.session_key || undefined,
template: selectedTemplate || undefined, template: selectedTemplate || undefined,
write_to_gateway: formData.write_to_gateway, write_to_gateway: formData.write_to_gateway,
provision_openclaw_workspace: formData.provision_openclaw_workspace,
gateway_config: { gateway_config: {
model: { primary: primaryModel }, model: { primary: primaryModel },
identity: { name: formData.name, theme: formData.role, emoji: formData.emoji }, identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
@ -1199,6 +1210,16 @@ export function CreateAgentModal({
/> />
<span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span> <span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
</label> </label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.provision_openclaw_workspace}
onChange={(e) => setFormData(prev => ({ ...prev, provision_openclaw_workspace: e.target.checked }))}
className="w-4 h-4 rounded border-border"
/>
<span className="text-sm text-foreground">Provision full OpenClaw workspace (`openclaw agents add`)</span>
</label>
</div> </div>
)} )}
</div> </div>

View File

@ -96,7 +96,14 @@ export function AgentSquadPanelPhase3() {
setSyncToast(null) setSyncToast(null)
try { try {
const response = await fetch('/api/agents/sync', { method: 'POST' }) 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() 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') if (!response.ok) throw new Error(data.error || 'Sync failed')
setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`) setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`)
fetchAgents() fetchAgents()
@ -116,7 +123,17 @@ export function AgentSquadPanelPhase3() {
if (agents.length === 0) setLoading(true) if (agents.length === 0) setLoading(true)
const response = await fetch('/api/agents') 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() const data = await response.json()
setAgents(data.agents || []) setAgents(data.agents || [])

View File

@ -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 {
@ -62,14 +62,13 @@ 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>

View File

@ -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">

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useMissionControl } from '@/store' import { useMissionControl } from '@/store'
import { useNavigateToPanel } from '@/lib/navigation'
interface Setting { interface Setting {
key: string key: string
@ -24,6 +25,7 @@ const categoryOrder = ['general', 'retention', 'gateway', 'custom']
export function SettingsPanel() { export function SettingsPanel() {
const { currentUser } = useMissionControl() const { currentUser } = useMissionControl()
const navigateToPanel = useNavigateToPanel()
const [settings, setSettings] = useState<Setting[]>([]) const [settings, setSettings] = useState<Setting[]>([])
const [grouped, setGrouped] = useState<Record<string, Setting[]>>({}) const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -43,12 +45,17 @@ export function SettingsPanel() {
const fetchSettings = useCallback(async () => { const fetchSettings = useCallback(async () => {
try { try {
const res = await fetch('/api/settings') const res = await fetch('/api/settings')
if (res.status === 401) {
window.location.assign('/login?next=%2Fsettings')
return
}
if (res.status === 403) { if (res.status === 403) {
setError('Admin access required') setError('Admin access required')
return return
} }
if (!res.ok) { if (!res.ok) {
setError('Failed to load settings') const data = await res.json().catch(() => ({}))
setError(data.error || 'Failed to load settings')
return return
} }
const data = await res.json() const data = await res.json()
@ -180,6 +187,21 @@ export function SettingsPanel() {
</div> </div>
</div> </div>
{/* Workspace Info */}
{currentUser?.role === 'admin' && (
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
<strong className="text-blue-200">Workspace Management:</strong>{' '}
To create or manage workspaces (tenant instances), go to the{' '}
<button
onClick={() => navigateToPanel('super-admin')}
className="text-blue-400 underline hover:text-blue-300 cursor-pointer"
>
Super Admin
</button>{' '}
panel under Admin &gt; Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs.
</div>
)}
{/* Feedback */} {/* Feedback */}
{feedback && ( {feedback && (
<div className={`rounded-lg p-3 text-xs font-medium ${ <div className={`rounded-lg p-3 text-xs font-medium ${

View File

@ -409,6 +409,13 @@ export function SuperAdminPanel() {
Multi-tenant provisioning control plane with approval gates and safer destructive actions. Multi-tenant provisioning control plane with approval gates and safer destructive actions.
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<button
onClick={() => setCreateExpanded(true)}
className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-smooth"
>
+ Add Workspace
</button>
<button <button
onClick={load} onClick={load}
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth" className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
@ -416,6 +423,7 @@ export function SuperAdminPanel() {
Refresh Refresh
</button> </button>
</div> </div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="rounded-lg border border-border bg-card px-4 py-3"> <div className="rounded-lg border border-border bg-card px-4 py-3">
@ -452,17 +460,21 @@ export function SuperAdminPanel() {
</div> </div>
)} )}
<div className="rounded-lg border border-border bg-card overflow-hidden">
<button
onClick={() => setCreateExpanded((v) => !v)}
className="w-full px-4 py-3 border-b border-border text-left text-sm font-medium text-foreground hover:bg-secondary/20"
>
{createExpanded ? 'Hide' : 'Show'} Create Client Instance
</button>
{createExpanded && ( {createExpanded && (
<div className="rounded-lg border border-primary/30 bg-card overflow-hidden">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Create New Workspace</h3>
<button
onClick={() => setCreateExpanded(false)}
className="text-muted-foreground hover:text-foreground text-lg leading-none transition-smooth"
aria-label="Close create form"
>
×
</button>
</div>
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Add a new workspace/client instance here. Fill the form below and click <span className="text-foreground font-medium">Create + Queue</span>. Fill in the workspace details below and click <span className="text-foreground font-medium">Create + Queue</span> to provision a new client instance.
</div> </div>
{gatewayLoadError && ( {gatewayLoadError && (
<div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20"> <div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
@ -540,8 +552,8 @@ export function SuperAdminPanel() {
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
)}
<div className="rounded-lg border border-border bg-card overflow-hidden"> <div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="px-3 py-2 border-b border-border flex items-center gap-2"> <div className="px-3 py-2 border-b border-border flex items-center gap-2">

View File

@ -217,7 +217,7 @@ function MentionTextarea({
className={className} className={className}
/> />
{open && filtered.length > 0 && ( {open && filtered.length > 0 && (
<div className="absolute z-20 mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto"> <div className="absolute z-[60] mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
{filtered.map((option, index) => ( {filtered.map((option, index) => (
<button <button
key={`${option.type}-${option.handle}-${option.recipient}`} key={`${option.type}-${option.handle}-${option.recipient}`}
@ -770,13 +770,14 @@ function TaskDetailModal({
onUpdate: () => void onUpdate: () => void
onEdit: (task: Task) => void onEdit: (task: Task) => void
}) { }) {
const { currentUser } = useMissionControl()
const commentAuthor = currentUser?.username || 'system'
const resolvedProjectName = const resolvedProjectName =
task.project_name || task.project_name ||
projects.find((project) => project.id === task.project_id)?.name projects.find((project) => project.id === task.project_id)?.name
const [comments, setComments] = useState<Comment[]>([]) const [comments, setComments] = useState<Comment[]>([])
const [loadingComments, setLoadingComments] = useState(false) const [loadingComments, setLoadingComments] = useState(false)
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [commentAuthor, setCommentAuthor] = useState('system')
const [commentError, setCommentError] = useState<string | null>(null) const [commentError, setCommentError] = useState<string | null>(null)
const [broadcastMessage, setBroadcastMessage] = useState('') const [broadcastMessage, setBroadcastMessage] = useState('')
const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null) const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
@ -1026,14 +1027,9 @@ function TaskDetailModal({
)} )}
<form onSubmit={handleAddComment} className="mt-4 space-y-3"> <form onSubmit={handleAddComment} className="mt-4 space-y-3">
<div> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<label className="block text-xs text-muted-foreground mb-1">Author</label> <span>Posting as</span>
<input <span className="font-medium text-foreground">{commentAuthor}</span>
type="text"
value={commentAuthor}
onChange={(e) => setCommentAuthor(e.target.value)}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div> </div>
<div> <div>
<label className="block text-xs text-muted-foreground mb-1">New Comment</label> <label className="block text-xs text-muted-foreground mb-1">New Comment</label>
@ -1056,18 +1052,25 @@ function TaskDetailModal({
</div> </div>
</form> </form>
<div className="mt-5 bg-blue-500/5 border border-blue-500/15 rounded-lg p-3 text-xs text-muted-foreground space-y-1">
<div className="font-medium text-blue-300">How notifications work</div>
<div><strong className="text-foreground">Comments</strong> are persisted on the task and notify all subscribers. Subscribers are auto-added when they: create the task, are assigned to it, comment on it, or are @mentioned.</div>
<div><strong className="text-foreground">Broadcasts</strong> send a one-time notification to all current subscribers without creating a comment record.</div>
</div>
<div className="mt-6 border-t border-border pt-4"> <div className="mt-6 border-t border-border pt-4">
<h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5> <h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
{broadcastStatus && ( {broadcastStatus && (
<div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div> <div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
)} )}
<form onSubmit={handleBroadcast} className="space-y-2"> <form onSubmit={handleBroadcast} className="space-y-2">
<textarea <MentionTextarea
value={broadcastMessage} value={broadcastMessage}
onChange={(e) => setBroadcastMessage(e.target.value)} onChange={setBroadcastMessage}
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50" className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
rows={2} rows={2}
placeholder="Send a message to all task subscribers..." placeholder="Send a message to all task subscribers... (use @ to mention)"
mentionTargets={mentionTargets}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { normalizeTaskCreateStatus, normalizeTaskUpdateStatus } from '../task-status'
describe('task status normalization', () => {
it('sets assigned status on create when assignee is present', () => {
expect(normalizeTaskCreateStatus(undefined, 'main')).toBe('assigned')
expect(normalizeTaskCreateStatus('inbox', 'main')).toBe('assigned')
})
it('keeps explicit non-inbox status on create', () => {
expect(normalizeTaskCreateStatus('in_progress', 'main')).toBe('in_progress')
})
it('auto-promotes inbox to assigned when assignment is added via update', () => {
expect(
normalizeTaskUpdateStatus({
currentStatus: 'inbox',
requestedStatus: undefined,
assignedTo: 'main',
assignedToProvided: true,
})
).toBe('assigned')
})
it('auto-demotes assigned to inbox when assignment is removed via update', () => {
expect(
normalizeTaskUpdateStatus({
currentStatus: 'assigned',
requestedStatus: undefined,
assignedTo: '',
assignedToProvided: true,
})
).toBe('inbox')
})
it('does not override explicit status changes on update', () => {
expect(
normalizeTaskUpdateStatus({
currentStatus: 'inbox',
requestedStatus: 'in_progress',
assignedTo: 'main',
assignedToProvided: true,
})
).toBe('in_progress')
})
})

View File

@ -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'),
}, },
} }

View File

@ -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',

View File

@ -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 { try {
agentDirs = fs.readdirSync(agentsDir)
} catch {
return []
}
for (const agentName of agentDirs) {
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
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 }

40
src/lib/task-status.ts Normal file
View File

@ -0,0 +1,40 @@
import type { Task } from './db'
export type TaskStatus = Task['status']
function hasAssignee(assignedTo: string | null | undefined): boolean {
return Boolean(assignedTo && assignedTo.trim())
}
/**
* Keep task state coherent when a task is created with an assignee.
* If caller asks for `inbox` but also sets `assigned_to`, normalize to `assigned`.
*/
export function normalizeTaskCreateStatus(
requestedStatus: TaskStatus | undefined,
assignedTo: string | undefined
): TaskStatus {
const status = requestedStatus ?? 'inbox'
if (status === 'inbox' && hasAssignee(assignedTo)) return 'assigned'
return status
}
/**
* Auto-adjust status for assignment-only updates when caller does not
* explicitly request a status transition.
*/
export function normalizeTaskUpdateStatus(args: {
currentStatus: TaskStatus
requestedStatus: TaskStatus | undefined
assignedTo: string | null | undefined
assignedToProvided: boolean
}): TaskStatus | undefined {
const { currentStatus, requestedStatus, assignedTo, assignedToProvided } = args
if (requestedStatus !== undefined) return requestedStatus
if (!assignedToProvided) return undefined
if (hasAssignee(assignedTo) && currentStatus === 'inbox') return 'assigned'
if (!hasAssignee(assignedTo) && currentStatus === 'assigned') return 'inbox'
return undefined
}

View File

@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial()
export const createAgentSchema = z.object({ export const createAgentSchema = z.object({
name: z.string().min(1, 'Name is required').max(100), name: z.string().min(1, 'Name is required').max(100),
openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(),
role: z.string().min(1, 'Role is required').max(100).optional(), role: z.string().min(1, 'Role is required').max(100).optional(),
session_key: z.string().max(200).optional(), session_key: z.string().max(200).optional(),
soul_content: z.string().max(50000).optional(), soul_content: z.string().max(50000).optional(),
@ -53,6 +54,8 @@ export const createAgentSchema = z.object({
template: z.string().max(100).optional(), template: z.string().max(100).optional(),
gateway_config: z.record(z.string(), z.unknown()).optional(), gateway_config: z.record(z.string(), z.unknown()).optional(),
write_to_gateway: z.boolean().optional(), write_to_gateway: z.boolean().optional(),
provision_openclaw_workspace: z.boolean().optional(),
openclaw_workspace_path: z.string().min(1).max(500).optional(),
}) })
export const bulkUpdateTaskStatusSchema = z.object({ export const bulkUpdateTaskStatusSchema = z.object({

View File

@ -16,7 +16,7 @@ const log = createClientLogger('WebSocket')
// Gateway protocol version (v3 required by OpenClaw 2026.x) // Gateway protocol version (v3 required by OpenClaw 2026.x)
const PROTOCOL_VERSION = 3 const PROTOCOL_VERSION = 3
const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui' const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'openclaw-control-ui'
// Heartbeat configuration // Heartbeat configuration
const PING_INTERVAL_MS = 30_000 const PING_INTERVAL_MS = 30_000
@ -59,6 +59,8 @@ export function useWebSocket() {
const pingCounterRef = useRef<number>(0) const pingCounterRef = useRef<number>(0)
const pingSentTimestamps = useRef<Map<string, number>>(new Map()) const pingSentTimestamps = useRef<Map<string, number>>(new Map())
const missedPongsRef = useRef<number>(0) const missedPongsRef = useRef<number>(0)
// Compat flag for gateway versions that may not implement ping RPC.
const gatewaySupportsPingRef = useRef<boolean>(true)
const { const {
connection, connection,
@ -116,6 +118,7 @@ export function useWebSocket() {
pingIntervalRef.current = setInterval(() => { pingIntervalRef.current = setInterval(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return
if (!gatewaySupportsPingRef.current) return
// Check missed pongs // Check missed pongs
if (missedPongsRef.current >= MAX_MISSED_PONGS) { if (missedPongsRef.current >= MAX_MISSED_PONGS) {
@ -358,6 +361,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
} }

View File

@ -1,161 +0,0 @@
'use client'
import { useMissionControl } from '@/store'
import { useEffect, useState } from 'react'
export function LiveFeed() {
const { logs, sessions, activities, connection, toggleLiveFeed } = useMissionControl()
const [expanded, setExpanded] = useState(true)
// Combine logs and activities into a unified feed
const feedItems = [
...logs.slice(0, 30).map(log => ({
id: log.id,
type: 'log' as const,
level: log.level,
message: log.message,
source: log.source,
timestamp: log.timestamp,
})),
...activities.slice(0, 20).map(act => ({
id: `act-${act.id}`,
type: 'activity' as const,
level: 'info' as const,
message: act.description,
source: act.actor,
timestamp: act.created_at * 1000,
})),
].sort((a, b) => b.timestamp - a.timestamp).slice(0, 40)
if (!expanded) {
return (
<div className="w-10 bg-card border-l border-border flex flex-col items-center py-3 shrink-0">
<button
onClick={() => setExpanded(true)}
className="w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
title="Show live feed"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{/* Mini indicators */}
<div className="mt-4 flex flex-col gap-2 items-center">
{feedItems.slice(0, 5).map((item) => (
<div
key={item.id}
className={`w-1.5 h-1.5 rounded-full ${
item.level === 'error' ? 'bg-red-500' :
item.level === 'warn' ? 'bg-amber-500' :
'bg-blue-500/40'
}`}
/>
))}
</div>
</div>
)
}
return (
<div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right">
{/* Header */}
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 pulse-dot" />
<span className="text-xs font-semibold text-foreground">Live Feed</span>
<span className="text-2xs text-muted-foreground font-mono-tight">{feedItems.length}</span>
</div>
<div className="flex items-center gap-0.5">
<button
onClick={() => setExpanded(false)}
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
title="Collapse feed"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 3l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
onClick={toggleLiveFeed}
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
title="Close feed"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
{/* Feed items */}
<div className="flex-1 overflow-y-auto">
{feedItems.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
No activity yet
</div>
) : (
<div className="divide-y divide-border/50">
{feedItems.map((item) => (
<FeedItem key={item.id} item={item} />
))}
</div>
)}
</div>
{/* Active sessions mini-list */}
<div className="border-t border-border px-3 py-2 shrink-0">
<div className="text-2xs font-medium text-muted-foreground mb-1.5">Active Sessions</div>
<div className="space-y-1">
{sessions.filter(s => s.active).slice(0, 4).map(session => (
<div key={session.id} className="flex items-center gap-1.5 text-2xs">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
<span className="text-foreground truncate flex-1 font-mono-tight">{session.key || session.id}</span>
<span className="text-muted-foreground">{session.model?.split('/').pop()?.slice(0, 8)}</span>
</div>
))}
{sessions.filter(s => s.active).length === 0 && (
<div className="text-2xs text-muted-foreground">No active sessions</div>
)}
</div>
</div>
</div>
)
}
function FeedItem({ item }: { item: { id: string; type: string; level: string; message: string; source: string; timestamp: number } }) {
const levelIndicator = item.level === 'error'
? 'bg-red-500'
: item.level === 'warn'
? 'bg-amber-500'
: item.level === 'debug'
? 'bg-gray-500'
: 'bg-blue-500/50'
const timeStr = formatRelativeTime(item.timestamp)
return (
<div className="px-3 py-2 hover:bg-secondary/50 transition-smooth group">
<div className="flex items-start gap-2">
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${levelIndicator}`} />
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground/90 leading-relaxed break-words">
{item.message.length > 120 ? item.message.slice(0, 120) + '...' : item.message}
</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-2xs text-muted-foreground font-mono-tight">{item.source}</span>
<span className="text-2xs text-muted-foreground/50">·</span>
<span className="text-2xs text-muted-foreground">{timeStr}</span>
</div>
</div>
</div>
</div>
)
}
function formatRelativeTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 60_000) return 'now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
return `${Math.floor(diff / 86_400_000)}d`
}

View File

@ -1,166 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { NavRail } from '@/components/layout/nav-rail'
import { HeaderBar } from '@/components/layout/header-bar'
import { LiveFeed } from '@/components/layout/live-feed'
import { Dashboard } from '@/components/dashboard/dashboard'
import { AgentSpawnPanel } from '@/components/panels/agent-spawn-panel'
import { LogViewerPanel } from '@/components/panels/log-viewer-panel'
import { CronManagementPanel } from '@/components/panels/cron-management-panel'
import { MemoryBrowserPanel } from '@/components/panels/memory-browser-panel'
import { TokenDashboardPanel } from '@/components/panels/token-dashboard-panel'
import { SessionDetailsPanel } from '@/components/panels/session-details-panel'
import { TaskBoardPanel } from '@/components/panels/task-board-panel'
import { ActivityFeedPanel } from '@/components/panels/activity-feed-panel'
import { AgentSquadPanelPhase3 } from '@/components/panels/agent-squad-panel-phase3'
import { StandupPanel } from '@/components/panels/standup-panel'
import { OrchestrationBar } from '@/components/panels/orchestration-bar'
import { NotificationsPanel } from '@/components/panels/notifications-panel'
import { UserManagementPanel } from '@/components/panels/user-management-panel'
import { AuditTrailPanel } from '@/components/panels/audit-trail-panel'
import { AgentHistoryPanel } from '@/components/panels/agent-history-panel'
import { WebhookPanel } from '@/components/panels/webhook-panel'
import { SettingsPanel } from '@/components/panels/settings-panel'
import { GatewayConfigPanel } from '@/components/panels/gateway-config-panel'
import { IntegrationsPanel } from '@/components/panels/integrations-panel'
import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { useWebSocket } from '@/lib/websocket'
import { useServerEvents } from '@/lib/use-server-events'
import { useMissionControl } from '@/store'
export default function Home() {
const { connect } = useWebSocket()
const { activeTab, setCurrentUser, liveFeedOpen, toggleLiveFeed } = useMissionControl()
// Connect to SSE for real-time local DB events (tasks, agents, chat, etc.)
useServerEvents()
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
// Fetch current user
fetch('/api/auth/me')
.then(res => res.ok ? res.json() : null)
.then(data => { if (data?.user) setCurrentUser(data.user) })
.catch(() => {})
// Auto-connect to gateway on mount
const wsToken = process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || ''
const gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
const gatewayHost = window.location.hostname
const wsUrl = `ws://${gatewayHost}:${gatewayPort}`
connect(wsUrl, wsToken)
}, [connect, setCurrentUser])
if (!isClient) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-sm">MC</span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
<span className="text-sm text-muted-foreground">Loading Mission Control...</span>
</div>
</div>
</div>
)
}
return (
<div className="flex h-screen bg-background overflow-hidden">
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
<NavRail />
{/* Center: Header + Content */}
<div className="flex-1 flex flex-col min-w-0">
<HeaderBar />
<main className="flex-1 overflow-auto pb-16 md:pb-0">
<ContentRouter tab={activeTab} />
</main>
</div>
{/* Right: Live feed (hidden on mobile) */}
{liveFeedOpen && (
<div className="hidden lg:flex h-full">
<LiveFeed />
</div>
)}
{/* Floating button to reopen LiveFeed when closed */}
{!liveFeedOpen && (
<button
onClick={toggleLiveFeed}
className="hidden lg:flex fixed right-0 top-1/2 -translate-y-1/2 z-30 w-6 h-12 items-center justify-center bg-card border border-r-0 border-border rounded-l-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-all duration-200"
title="Show live feed"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
{/* Chat panel overlay */}
<ChatPanel />
</div>
)
}
function ContentRouter({ tab }: { tab: string }) {
switch (tab) {
case 'overview':
return <Dashboard />
case 'tasks':
return <TaskBoardPanel />
case 'agents':
return (
<>
<OrchestrationBar />
<AgentSquadPanelPhase3 />
</>
)
case 'activity':
return <ActivityFeedPanel />
case 'notifications':
return <NotificationsPanel />
case 'standup':
return <StandupPanel />
case 'spawn':
return <AgentSpawnPanel />
case 'sessions':
return <SessionDetailsPanel />
case 'logs':
return <LogViewerPanel />
case 'cron':
return <CronManagementPanel />
case 'memory':
return <MemoryBrowserPanel />
case 'tokens':
return <TokenDashboardPanel />
case 'users':
return <UserManagementPanel />
case 'history':
return <AgentHistoryPanel />
case 'audit':
return <AuditTrailPanel />
case 'webhooks':
return <WebhookPanel />
case 'alerts':
return <AlertRulesPanel />
case 'gateways':
return <MultiGatewayPanel />
case 'gateway-config':
return <GatewayConfigPanel />
case 'integrations':
return <IntegrationsPanel />
case 'settings':
return <SettingsPanel />
default:
return <Dashboard />
}
}

View File

@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test'
import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
test.describe('Agent Diagnostics API', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup) {
await deleteTestAgent(request, id).catch(() => {})
}
cleanup.length = 0
})
test('self access is allowed with x-agent-name', async ({ request }) => {
const { id, name } = await createTestAgent(request)
cleanup.push(id)
const res = await request.get(`/api/agents/${name}/diagnostics?section=summary`, {
headers: { ...API_KEY_HEADER, 'x-agent-name': name },
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.agent.name).toBe(name)
expect(body.summary).toBeDefined()
})
test('cross-agent access is denied by default', async ({ request }) => {
const a = await createTestAgent(request)
const b = await createTestAgent(request)
cleanup.push(a.id, b.id)
const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary`, {
headers: { ...API_KEY_HEADER, 'x-agent-name': b.name },
})
expect(res.status()).toBe(403)
})
test('cross-agent access is allowed with privileged=1 for admin', async ({ request }) => {
const a = await createTestAgent(request)
const b = await createTestAgent(request)
cleanup.push(a.id, b.id)
const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary&privileged=1`, {
headers: { ...API_KEY_HEADER, 'x-agent-name': b.name },
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.agent.name).toBe(a.name)
expect(body.summary).toBeDefined()
})
test('invalid section query is rejected', async ({ request }) => {
const { id, name } = await createTestAgent(request)
cleanup.push(id)
const res = await request.get(`/api/agents/${name}/diagnostics?section=summary,invalid`, {
headers: { ...API_KEY_HEADER, 'x-agent-name': name },
})
expect(res.status()).toBe(400)
})
test('invalid hours query is rejected', async ({ request }) => {
const { id, name } = await createTestAgent(request)
cleanup.push(id)
const res = await request.get(`/api/agents/${name}/diagnostics?hours=0`, {
headers: { ...API_KEY_HEADER, 'x-agent-name': name },
})
expect(res.status()).toBe(400)
})
})