Deploy PlantLineageAgent with API endpoints and tests
Add REST API endpoints for the PlantLineageAgent: - GET /api/agents/lineage - Agent status and network statistics - POST /api/agents/lineage - Start/stop/pause/resume agent - GET /api/agents/lineage/[plantId] - Lineage analysis for specific plant - GET /api/agents/lineage/anomalies - List detected lineage anomalies Includes comprehensive test suite with 25 passing tests.
This commit is contained in:
parent
e76550e73a
commit
27cfad5d18
4 changed files with 515 additions and 0 deletions
259
__tests__/api/agents/lineage.test.ts
Normal file
259
__tests__/api/agents/lineage.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* PlantLineageAgent API Tests
|
||||
* Tests for lineage agent API endpoints
|
||||
*/
|
||||
|
||||
import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents';
|
||||
import { getBlockchain, initializeBlockchain } from '../../../lib/blockchain/manager';
|
||||
|
||||
describe('PlantLineageAgent API', () => {
|
||||
let agent: PlantLineageAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get fresh agent instance
|
||||
agent = getPlantLineageAgent();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Ensure agent is stopped after each test
|
||||
if (agent.status === 'running') {
|
||||
await agent.stop();
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/agents/lineage', () => {
|
||||
it('should return agent status and configuration', () => {
|
||||
expect(agent.config.id).toBe('plant-lineage-agent');
|
||||
expect(agent.config.name).toBe('Plant Lineage Agent');
|
||||
expect(agent.config.description).toBeDefined();
|
||||
expect(agent.config.priority).toBe('high');
|
||||
expect(agent.config.intervalMs).toBe(60000);
|
||||
});
|
||||
|
||||
it('should return current metrics', () => {
|
||||
const metrics = agent.getMetrics();
|
||||
|
||||
expect(metrics.agentId).toBe('plant-lineage-agent');
|
||||
expect(metrics.tasksCompleted).toBeGreaterThanOrEqual(0);
|
||||
expect(metrics.tasksFailed).toBeGreaterThanOrEqual(0);
|
||||
expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return network statistics', () => {
|
||||
const networkStats = agent.getNetworkStats();
|
||||
|
||||
expect(networkStats.totalPlants).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.totalLineages).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.avgGenerationDepth).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.avgLineageSize).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.geographicSpread).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return anomaly summary', () => {
|
||||
const anomalies = agent.getAnomalies();
|
||||
|
||||
expect(Array.isArray(anomalies)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/agents/lineage', () => {
|
||||
it('should start agent successfully', async () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
|
||||
await agent.start();
|
||||
|
||||
expect(agent.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should stop agent successfully', async () => {
|
||||
await agent.start();
|
||||
expect(agent.status).toBe('running');
|
||||
|
||||
await agent.stop();
|
||||
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should pause and resume agent', async () => {
|
||||
await agent.start();
|
||||
expect(agent.status).toBe('running');
|
||||
|
||||
agent.pause();
|
||||
expect(agent.status).toBe('paused');
|
||||
|
||||
agent.resume();
|
||||
expect(agent.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should handle start when already running', async () => {
|
||||
await agent.start();
|
||||
const firstStatus = agent.status;
|
||||
|
||||
await agent.start(); // Should not throw
|
||||
|
||||
expect(agent.status).toBe(firstStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/agents/lineage/[plantId]', () => {
|
||||
it('should return null for non-existent plant analysis', () => {
|
||||
const analysis = agent.getLineageAnalysis('non-existent-plant-id');
|
||||
|
||||
expect(analysis).toBeNull();
|
||||
});
|
||||
|
||||
it('should return analysis structure when available', () => {
|
||||
// Analysis would be populated after agent runs
|
||||
// For now, test the structure expectations
|
||||
const analysis = agent.getLineageAnalysis('test-plant-id');
|
||||
|
||||
// Should return null for non-cached plant
|
||||
expect(analysis).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/agents/lineage/anomalies', () => {
|
||||
it('should return empty array when no anomalies', () => {
|
||||
const anomalies = agent.getAnomalies();
|
||||
|
||||
expect(Array.isArray(anomalies)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support filtering by severity', () => {
|
||||
const allAnomalies = agent.getAnomalies();
|
||||
|
||||
const highSeverity = allAnomalies.filter(a => a.severity === 'high');
|
||||
const mediumSeverity = allAnomalies.filter(a => a.severity === 'medium');
|
||||
const lowSeverity = allAnomalies.filter(a => a.severity === 'low');
|
||||
|
||||
expect(highSeverity.length + mediumSeverity.length + lowSeverity.length).toBe(allAnomalies.length);
|
||||
});
|
||||
|
||||
it('should support filtering by type', () => {
|
||||
const allAnomalies = agent.getAnomalies();
|
||||
const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location'];
|
||||
|
||||
for (const anomaly of allAnomalies) {
|
||||
expect(validTypes).toContain(anomaly.type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Lifecycle', () => {
|
||||
it('should track uptime correctly', async () => {
|
||||
const initialMetrics = agent.getMetrics();
|
||||
const initialUptime = initialMetrics.uptime;
|
||||
|
||||
await agent.start();
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const runningMetrics = agent.getMetrics();
|
||||
expect(runningMetrics.uptime).toBeGreaterThan(initialUptime);
|
||||
});
|
||||
|
||||
it('should maintain metrics across start/stop cycles', async () => {
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.uptime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert Management', () => {
|
||||
it('should return alerts array', () => {
|
||||
const alerts = agent.getAlerts();
|
||||
|
||||
expect(Array.isArray(alerts)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have proper alert structure', () => {
|
||||
const alerts = agent.getAlerts();
|
||||
|
||||
for (const alert of alerts) {
|
||||
expect(alert.id).toBeDefined();
|
||||
expect(alert.agentId).toBe('plant-lineage-agent');
|
||||
expect(alert.severity).toBeDefined();
|
||||
expect(alert.title).toBeDefined();
|
||||
expect(alert.message).toBeDefined();
|
||||
expect(alert.timestamp).toBeDefined();
|
||||
expect(typeof alert.acknowledged).toBe('boolean');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle agent operations gracefully', async () => {
|
||||
// Test that agent doesn't throw on basic operations
|
||||
expect(() => agent.getMetrics()).not.toThrow();
|
||||
expect(() => agent.getAnomalies()).not.toThrow();
|
||||
expect(() => agent.getNetworkStats()).not.toThrow();
|
||||
expect(() => agent.getAlerts()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle pause on non-running agent', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
|
||||
agent.pause(); // Should not throw
|
||||
|
||||
// Status should remain idle (only pauses if running)
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should handle resume on non-paused agent', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
|
||||
agent.resume(); // Should not throw
|
||||
|
||||
// Status should remain idle (only resumes if paused)
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return consistent metrics format', () => {
|
||||
const metrics = agent.getMetrics();
|
||||
|
||||
expect(typeof metrics.agentId).toBe('string');
|
||||
expect(typeof metrics.tasksCompleted).toBe('number');
|
||||
expect(typeof metrics.tasksFailed).toBe('number');
|
||||
expect(typeof metrics.averageExecutionMs).toBe('number');
|
||||
expect(typeof metrics.uptime).toBe('number');
|
||||
expect(Array.isArray(metrics.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return consistent network stats format', () => {
|
||||
const stats = agent.getNetworkStats();
|
||||
|
||||
expect(typeof stats.totalPlants).toBe('number');
|
||||
expect(typeof stats.totalLineages).toBe('number');
|
||||
expect(typeof stats.avgGenerationDepth).toBe('number');
|
||||
expect(typeof stats.avgLineageSize).toBe('number');
|
||||
expect(typeof stats.geographicSpread).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlantLineageAgent Integration', () => {
|
||||
it('should be accessible via singleton', () => {
|
||||
const agent1 = getPlantLineageAgent();
|
||||
const agent2 = getPlantLineageAgent();
|
||||
|
||||
expect(agent1).toBe(agent2);
|
||||
});
|
||||
|
||||
it('should have correct priority', () => {
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
expect(agent.config.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('should have correct interval', () => {
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
// Should run every minute
|
||||
expect(agent.config.intervalMs).toBe(60000);
|
||||
});
|
||||
});
|
||||
85
pages/api/agents/lineage/[plantId].ts
Normal file
85
pages/api/agents/lineage/[plantId].ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* API Route: Get lineage analysis for a specific plant
|
||||
* GET /api/agents/lineage/[plantId]
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getPlantLineageAgent } from '../../../../lib/agents';
|
||||
import { getBlockchain } from '../../../../lib/blockchain/manager';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { plantId } = req.query;
|
||||
|
||||
if (!plantId || typeof plantId !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid plant ID' });
|
||||
}
|
||||
|
||||
// Check if plant exists in blockchain
|
||||
const blockchain = getBlockchain();
|
||||
const plant = blockchain.findPlant(plantId);
|
||||
|
||||
if (!plant) {
|
||||
return res.status(404).json({ success: false, error: 'Plant not found' });
|
||||
}
|
||||
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
// Get cached lineage analysis from agent
|
||||
let analysis = agent.getLineageAnalysis(plantId);
|
||||
|
||||
// If not cached, the agent hasn't scanned this plant yet
|
||||
// Return basic info with a note that full analysis is pending
|
||||
if (!analysis) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
plantId,
|
||||
cached: false,
|
||||
message: 'Lineage analysis pending. Agent will analyze on next scan cycle.',
|
||||
plant: {
|
||||
id: plant.id,
|
||||
species: plant.species,
|
||||
generation: plant.generation,
|
||||
status: plant.status,
|
||||
parentPlantId: plant.parentPlantId,
|
||||
childPlants: plant.childPlants || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plantId,
|
||||
cached: true,
|
||||
analysis: {
|
||||
generation: analysis.generation,
|
||||
ancestors: analysis.ancestors,
|
||||
descendants: analysis.descendants,
|
||||
totalLineageSize: analysis.totalLineageSize,
|
||||
propagationChain: analysis.propagationChain,
|
||||
geographicSpread: analysis.geographicSpread,
|
||||
oldestAncestorDate: analysis.oldestAncestorDate,
|
||||
healthScore: analysis.healthScore,
|
||||
},
|
||||
plant: {
|
||||
id: plant.id,
|
||||
species: plant.species,
|
||||
generation: plant.generation,
|
||||
status: plant.status,
|
||||
propagationType: plant.propagationType,
|
||||
location: plant.location,
|
||||
dateAcquired: plant.dateAcquired,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting lineage analysis:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
79
pages/api/agents/lineage/anomalies.ts
Normal file
79
pages/api/agents/lineage/anomalies.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* API Route: Get lineage anomalies detected by PlantLineageAgent
|
||||
* GET /api/agents/lineage/anomalies
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getPlantLineageAgent } from '../../../../lib/agents';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { severity, type, limit } = req.query;
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
let anomalies = agent.getAnomalies();
|
||||
|
||||
// Filter by severity if specified
|
||||
if (severity && typeof severity === 'string') {
|
||||
const validSeverities = ['low', 'medium', 'high'];
|
||||
if (!validSeverities.includes(severity)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid severity. Must be one of: ${validSeverities.join(', ')}`
|
||||
});
|
||||
}
|
||||
anomalies = anomalies.filter(a => a.severity === severity);
|
||||
}
|
||||
|
||||
// Filter by type if specified
|
||||
if (type && typeof type === 'string') {
|
||||
const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid type. Must be one of: ${validTypes.join(', ')}`
|
||||
});
|
||||
}
|
||||
anomalies = anomalies.filter(a => a.type === type);
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
const maxResults = limit ? Math.min(parseInt(limit as string, 10), 100) : 50;
|
||||
anomalies = anomalies.slice(0, maxResults);
|
||||
|
||||
// Group by type for summary
|
||||
const byType: Record<string, number> = {};
|
||||
const bySeverity: Record<string, number> = {};
|
||||
const allAnomalies = agent.getAnomalies();
|
||||
|
||||
for (const anomaly of allAnomalies) {
|
||||
byType[anomaly.type] = (byType[anomaly.type] || 0) + 1;
|
||||
bySeverity[anomaly.severity] = (bySeverity[anomaly.severity] || 0) + 1;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
summary: {
|
||||
total: allAnomalies.length,
|
||||
byType,
|
||||
bySeverity,
|
||||
},
|
||||
filters: {
|
||||
severity: severity || null,
|
||||
type: type || null,
|
||||
limit: maxResults,
|
||||
},
|
||||
anomalies,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting lineage anomalies:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
92
pages/api/agents/lineage/index.ts
Normal file
92
pages/api/agents/lineage/index.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* API Route: PlantLineageAgent Status and Network Stats
|
||||
* GET /api/agents/lineage - Get agent status and network statistics
|
||||
* POST /api/agents/lineage - Start/stop/restart the agent
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getPlantLineageAgent } from '../../../../lib/agents';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const metrics = agent.getMetrics();
|
||||
const networkStats = agent.getNetworkStats();
|
||||
const anomalies = agent.getAnomalies();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
agent: {
|
||||
id: agent.config.id,
|
||||
name: agent.config.name,
|
||||
description: agent.config.description,
|
||||
status: agent.status,
|
||||
priority: agent.config.priority,
|
||||
intervalMs: agent.config.intervalMs,
|
||||
},
|
||||
metrics: {
|
||||
tasksCompleted: metrics.tasksCompleted,
|
||||
tasksFailed: metrics.tasksFailed,
|
||||
averageExecutionMs: Math.round(metrics.averageExecutionMs),
|
||||
lastRunAt: metrics.lastRunAt,
|
||||
lastSuccessAt: metrics.lastSuccessAt,
|
||||
uptime: metrics.uptime,
|
||||
},
|
||||
networkStats,
|
||||
anomalySummary: {
|
||||
total: anomalies.length,
|
||||
bySeverity: {
|
||||
high: anomalies.filter(a => a.severity === 'high').length,
|
||||
medium: anomalies.filter(a => a.severity === 'medium').length,
|
||||
low: anomalies.filter(a => a.severity === 'low').length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting PlantLineageAgent status:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
try {
|
||||
const { action } = req.body;
|
||||
|
||||
if (!action || !['start', 'stop', 'pause', 'resume'].includes(action)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid action. Must be one of: start, stop, pause, resume'
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await agent.start();
|
||||
break;
|
||||
case 'stop':
|
||||
await agent.stop();
|
||||
break;
|
||||
case 'pause':
|
||||
agent.pause();
|
||||
break;
|
||||
case 'resume':
|
||||
agent.resume();
|
||||
break;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
action,
|
||||
newStatus: agent.status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error controlling PlantLineageAgent:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue