Merge pull request #177 from bhavikprit/feat/163-agent-diagnostics
feat(#163): add Agent Self-Diagnostics API endpoint
This commit is contained in:
commit
8a577590b5
18
README.md
18
README.md
|
|
@ -440,6 +440,24 @@ pnpm test:e2e # Playwright E2E
|
|||
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
|
||||
|
||||
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.
|
||||
|
|
|
|||
120
openapi.json
120
openapi.json
|
|
@ -3726,6 +3726,126 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/agents/{id}/diagnostics": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Agents"
|
||||
],
|
||||
"summary": "Get self diagnostics for an agent",
|
||||
"description": "Self-scoped diagnostics by default. Cross-agent access requires `privileged=1` with admin credentials. Trend alerts are informational signals derived from current-vs-previous window deltas (error spikes, throughput drops, activity stalls).",
|
||||
"operationId": "getAgentDiagnostics",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Agent numeric ID or name."
|
||||
},
|
||||
{
|
||||
"name": "hours",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 720,
|
||||
"default": 24
|
||||
},
|
||||
"description": "Diagnostics window in hours."
|
||||
},
|
||||
{
|
||||
"name": "section",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Comma-separated sections: summary,tasks,errors,activity,trends,tokens."
|
||||
},
|
||||
{
|
||||
"name": "privileged",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
"description": "Set to `1` to allow explicit admin cross-agent diagnostics access."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Diagnostics payload",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer" },
|
||||
"name": { "type": "string" },
|
||||
"role": { "type": "string" },
|
||||
"status": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"timeframe": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hours": { "type": "integer" },
|
||||
"since": { "type": "integer" },
|
||||
"until": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"summary": { "type": "object" },
|
||||
"tasks": { "type": "object" },
|
||||
"errors": { "type": "object" },
|
||||
"activity": { "type": "object" },
|
||||
"trends": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"current_period": { "type": "object" },
|
||||
"previous_period": { "type": "object" },
|
||||
"change": { "type": "object" },
|
||||
"alerts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": { "type": "string", "enum": ["info", "warning"] },
|
||||
"message": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tokens": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notifications/deliver": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue