- Backend: - Migration 032: agent_gov_level, status, incidents, permissions tables - Domain types for governance layer - Permission Engine with all governance checks - Governance Service (promote/demote/roles) - Revocation Service (revoke/suspend/reinstate) - Audit Service (events filtering and stats) - Incidents Service (create/assign/escalate/resolve) - REST API routes for governance, audit, incidents - Frontend: - TypeScript types for governance - API clients for governance, audit, incidents - GovernanceLevelBadge component - CityGovernancePanel component - AuditDashboard component - IncidentsList component with detail modal Based on: Agent_Governance_Protocol_v1.md
553 lines
15 KiB
TypeScript
553 lines
15 KiB
TypeScript
/**
|
|
* Incidents Service
|
|
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
|
*
|
|
* Handles incident creation, escalation, and resolution
|
|
*/
|
|
|
|
import { db } from '../../infra/db/client';
|
|
import { logger } from '../../infra/logger/logger';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
Incident,
|
|
IncidentStatus,
|
|
IncidentPriority,
|
|
EscalationLevel,
|
|
TargetScopeType,
|
|
IncidentHistory,
|
|
CreateIncidentRequest,
|
|
AssignIncidentRequest,
|
|
EscalateIncidentRequest,
|
|
ResolveIncidentRequest,
|
|
} from '../../domain/governance/types';
|
|
import { governanceService } from './governance.service';
|
|
|
|
interface IncidentRow {
|
|
id: string;
|
|
created_by_dais_id: string;
|
|
target_scope_type: TargetScopeType;
|
|
target_scope_id: string;
|
|
status: IncidentStatus;
|
|
priority: IncidentPriority;
|
|
assigned_to_dais_id: string | null;
|
|
escalation_level: EscalationLevel;
|
|
title: string;
|
|
description: string | null;
|
|
resolution: string | null;
|
|
metadata: Record<string, unknown>;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
resolved_at: Date | null;
|
|
closed_at: Date | null;
|
|
}
|
|
|
|
export class IncidentsService {
|
|
/**
|
|
* Create a new incident
|
|
*/
|
|
async createIncident(request: CreateIncidentRequest): Promise<Incident> {
|
|
const id = uuidv4();
|
|
|
|
// Determine initial escalation level based on target scope
|
|
const escalationLevel = this.determineInitialEscalation(request.targetScopeType);
|
|
|
|
try {
|
|
const result = await db.query<IncidentRow>(
|
|
`INSERT INTO incidents (
|
|
id, created_by_dais_id, target_scope_type, target_scope_id,
|
|
status, priority, escalation_level, title, description, metadata
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING *`,
|
|
[
|
|
id,
|
|
request.createdByDaisId,
|
|
request.targetScopeType,
|
|
request.targetScopeId,
|
|
'open',
|
|
request.priority || 'medium',
|
|
escalationLevel,
|
|
request.title,
|
|
request.description || null,
|
|
JSON.stringify(request.metadata || {}),
|
|
]
|
|
);
|
|
|
|
const incident = this.mapRowToIncident(result.rows[0]);
|
|
|
|
// Log history
|
|
await this.addHistory(id, 'created', request.createdByDaisId, null, {
|
|
title: request.title,
|
|
priority: request.priority || 'medium',
|
|
escalationLevel,
|
|
});
|
|
|
|
// Log governance event
|
|
await governanceService.logEvent(
|
|
'incident.created',
|
|
request.createdByDaisId,
|
|
id,
|
|
`${request.targetScopeType}:${request.targetScopeId}`,
|
|
{ title: request.title, priority: request.priority }
|
|
);
|
|
|
|
logger.info(`Incident created: ${id}`, { title: request.title });
|
|
|
|
return incident;
|
|
} catch (error) {
|
|
logger.error('Failed to create incident', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get incident by ID
|
|
*/
|
|
async getIncident(incidentId: string): Promise<Incident | null> {
|
|
const result = await db.query<IncidentRow>(
|
|
`SELECT * FROM incidents WHERE id = $1`,
|
|
[incidentId]
|
|
);
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return this.mapRowToIncident(result.rows[0]);
|
|
}
|
|
|
|
/**
|
|
* List incidents with filters
|
|
*/
|
|
async listIncidents(filters: {
|
|
status?: IncidentStatus;
|
|
priority?: IncidentPriority;
|
|
escalationLevel?: EscalationLevel;
|
|
targetScopeType?: TargetScopeType;
|
|
targetScopeId?: string;
|
|
assignedToDaisId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}): Promise<{ incidents: Incident[]; total: number }> {
|
|
const conditions: string[] = [];
|
|
const params: (string | number)[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (filters.status) {
|
|
conditions.push(`status = $${paramIndex++}`);
|
|
params.push(filters.status);
|
|
}
|
|
|
|
if (filters.priority) {
|
|
conditions.push(`priority = $${paramIndex++}`);
|
|
params.push(filters.priority);
|
|
}
|
|
|
|
if (filters.escalationLevel) {
|
|
conditions.push(`escalation_level = $${paramIndex++}`);
|
|
params.push(filters.escalationLevel);
|
|
}
|
|
|
|
if (filters.targetScopeType) {
|
|
conditions.push(`target_scope_type = $${paramIndex++}`);
|
|
params.push(filters.targetScopeType);
|
|
}
|
|
|
|
if (filters.targetScopeId) {
|
|
conditions.push(`target_scope_id = $${paramIndex++}`);
|
|
params.push(filters.targetScopeId);
|
|
}
|
|
|
|
if (filters.assignedToDaisId) {
|
|
conditions.push(`assigned_to_dais_id = $${paramIndex++}`);
|
|
params.push(filters.assignedToDaisId);
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
|
|
// Get total count
|
|
const countResult = await db.query<{ count: string }>(
|
|
`SELECT COUNT(*) as count FROM incidents ${whereClause}`,
|
|
params
|
|
);
|
|
|
|
const total = parseInt(countResult.rows[0].count, 10);
|
|
|
|
// Get incidents
|
|
const limit = filters.limit || 50;
|
|
const offset = filters.offset || 0;
|
|
|
|
params.push(limit, offset);
|
|
|
|
const result = await db.query<IncidentRow>(
|
|
`SELECT * FROM incidents ${whereClause}
|
|
ORDER BY
|
|
CASE priority
|
|
WHEN 'critical' THEN 1
|
|
WHEN 'high' THEN 2
|
|
WHEN 'medium' THEN 3
|
|
WHEN 'low' THEN 4
|
|
END,
|
|
created_at DESC
|
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
|
params
|
|
);
|
|
|
|
const incidents = result.rows.map(row => this.mapRowToIncident(row));
|
|
|
|
return { incidents, total };
|
|
}
|
|
|
|
/**
|
|
* Assign incident to an agent
|
|
*/
|
|
async assignIncident(request: AssignIncidentRequest): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const incident = await this.getIncident(request.incidentId);
|
|
|
|
if (!incident) {
|
|
return { success: false, error: 'Incident not found' };
|
|
}
|
|
|
|
if (incident.status === 'closed') {
|
|
return { success: false, error: 'Cannot assign closed incident' };
|
|
}
|
|
|
|
const oldValue = { assignedToDaisId: incident.assignedToDaisId };
|
|
|
|
await db.query(
|
|
`UPDATE incidents
|
|
SET assigned_to_dais_id = $1,
|
|
status = 'in_progress',
|
|
updated_at = now()
|
|
WHERE id = $2`,
|
|
[request.assignedToDaisId, request.incidentId]
|
|
);
|
|
|
|
await this.addHistory(
|
|
request.incidentId,
|
|
'assigned',
|
|
request.actorDaisId,
|
|
oldValue,
|
|
{ assignedToDaisId: request.assignedToDaisId }
|
|
);
|
|
|
|
await governanceService.logEvent(
|
|
'incident.assigned',
|
|
request.actorDaisId,
|
|
request.incidentId,
|
|
'city',
|
|
{ assignedTo: request.assignedToDaisId }
|
|
);
|
|
|
|
logger.info(`Incident assigned: ${request.incidentId} → ${request.assignedToDaisId}`);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Failed to assign incident', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escalate incident to higher level
|
|
*/
|
|
async escalateIncident(request: EscalateIncidentRequest): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const incident = await this.getIncident(request.incidentId);
|
|
|
|
if (!incident) {
|
|
return { success: false, error: 'Incident not found' };
|
|
}
|
|
|
|
if (incident.status === 'closed' || incident.status === 'resolved') {
|
|
return { success: false, error: 'Cannot escalate resolved/closed incident' };
|
|
}
|
|
|
|
const levelOrder: EscalationLevel[] = ['microdao', 'district', 'city'];
|
|
const currentIndex = levelOrder.indexOf(incident.escalationLevel);
|
|
const newIndex = levelOrder.indexOf(request.newLevel);
|
|
|
|
if (newIndex <= currentIndex) {
|
|
return { success: false, error: 'Can only escalate to higher level' };
|
|
}
|
|
|
|
const oldValue = { escalationLevel: incident.escalationLevel };
|
|
|
|
await db.query(
|
|
`UPDATE incidents
|
|
SET escalation_level = $1,
|
|
updated_at = now()
|
|
WHERE id = $2`,
|
|
[request.newLevel, request.incidentId]
|
|
);
|
|
|
|
await this.addHistory(
|
|
request.incidentId,
|
|
'escalated',
|
|
request.actorDaisId,
|
|
oldValue,
|
|
{ escalationLevel: request.newLevel, reason: request.reason }
|
|
);
|
|
|
|
await governanceService.logEvent(
|
|
'incident.escalated',
|
|
request.actorDaisId,
|
|
request.incidentId,
|
|
'city',
|
|
{
|
|
fromLevel: incident.escalationLevel,
|
|
toLevel: request.newLevel,
|
|
reason: request.reason,
|
|
}
|
|
);
|
|
|
|
logger.info(`Incident escalated: ${request.incidentId} → ${request.newLevel}`);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Failed to escalate incident', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve incident
|
|
*/
|
|
async resolveIncident(request: ResolveIncidentRequest): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const incident = await this.getIncident(request.incidentId);
|
|
|
|
if (!incident) {
|
|
return { success: false, error: 'Incident not found' };
|
|
}
|
|
|
|
if (incident.status === 'closed' || incident.status === 'resolved') {
|
|
return { success: false, error: 'Incident already resolved/closed' };
|
|
}
|
|
|
|
await db.query(
|
|
`UPDATE incidents
|
|
SET status = 'resolved',
|
|
resolution = $1,
|
|
resolved_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $2`,
|
|
[request.resolution, request.incidentId]
|
|
);
|
|
|
|
await this.addHistory(
|
|
request.incidentId,
|
|
'resolved',
|
|
request.actorDaisId,
|
|
{ status: incident.status },
|
|
{ status: 'resolved', resolution: request.resolution }
|
|
);
|
|
|
|
await governanceService.logEvent(
|
|
'incident.resolved',
|
|
request.actorDaisId,
|
|
request.incidentId,
|
|
'city',
|
|
{ resolution: request.resolution }
|
|
);
|
|
|
|
logger.info(`Incident resolved: ${request.incidentId}`);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Failed to resolve incident', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close incident
|
|
*/
|
|
async closeIncident(
|
|
incidentId: string,
|
|
actorDaisId: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const incident = await this.getIncident(incidentId);
|
|
|
|
if (!incident) {
|
|
return { success: false, error: 'Incident not found' };
|
|
}
|
|
|
|
await db.query(
|
|
`UPDATE incidents
|
|
SET status = 'closed',
|
|
closed_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1`,
|
|
[incidentId]
|
|
);
|
|
|
|
await this.addHistory(
|
|
incidentId,
|
|
'closed',
|
|
actorDaisId,
|
|
{ status: incident.status },
|
|
{ status: 'closed' }
|
|
);
|
|
|
|
await governanceService.logEvent(
|
|
'incident.closed',
|
|
actorDaisId,
|
|
incidentId,
|
|
'city',
|
|
{}
|
|
);
|
|
|
|
logger.info(`Incident closed: ${incidentId}`);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Failed to close incident', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add comment to incident
|
|
*/
|
|
async addComment(
|
|
incidentId: string,
|
|
actorDaisId: string,
|
|
comment: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
await this.addHistory(incidentId, 'comment', actorDaisId, null, null, comment);
|
|
|
|
logger.info(`Comment added to incident: ${incidentId}`);
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Failed to add comment to incident', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get incident history
|
|
*/
|
|
async getIncidentHistory(incidentId: string): Promise<IncidentHistory[]> {
|
|
const result = await db.query<{
|
|
id: string;
|
|
incident_id: string;
|
|
action: IncidentHistory['action'];
|
|
actor_dais_id: string;
|
|
old_value: Record<string, unknown> | null;
|
|
new_value: Record<string, unknown> | null;
|
|
comment: string | null;
|
|
created_at: Date;
|
|
}>(
|
|
`SELECT * FROM incident_history WHERE incident_id = $1 ORDER BY created_at ASC`,
|
|
[incidentId]
|
|
);
|
|
|
|
return result.rows.map(row => ({
|
|
id: row.id,
|
|
incidentId: row.incident_id,
|
|
action: row.action,
|
|
actorDaisId: row.actor_dais_id,
|
|
oldValue: row.old_value || undefined,
|
|
newValue: row.new_value || undefined,
|
|
comment: row.comment || undefined,
|
|
createdAt: row.created_at,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get open incidents count by escalation level
|
|
*/
|
|
async getOpenIncidentsCount(): Promise<{
|
|
microdao: number;
|
|
district: number;
|
|
city: number;
|
|
total: number;
|
|
}> {
|
|
const result = await db.query<{ escalation_level: EscalationLevel; count: string }>(
|
|
`SELECT escalation_level, COUNT(*) as count
|
|
FROM incidents
|
|
WHERE status IN ('open', 'in_progress')
|
|
GROUP BY escalation_level`
|
|
);
|
|
|
|
const counts = { microdao: 0, district: 0, city: 0, total: 0 };
|
|
|
|
result.rows.forEach(row => {
|
|
counts[row.escalation_level] = parseInt(row.count, 10);
|
|
counts.total += parseInt(row.count, 10);
|
|
});
|
|
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* Determine initial escalation level based on target scope
|
|
*/
|
|
private determineInitialEscalation(targetScopeType: TargetScopeType): EscalationLevel {
|
|
switch (targetScopeType) {
|
|
case 'city':
|
|
return 'city';
|
|
case 'district':
|
|
return 'district';
|
|
default:
|
|
return 'microdao';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add entry to incident history
|
|
*/
|
|
private async addHistory(
|
|
incidentId: string,
|
|
action: IncidentHistory['action'],
|
|
actorDaisId: string,
|
|
oldValue: Record<string, unknown> | null,
|
|
newValue: Record<string, unknown> | null,
|
|
comment?: string
|
|
): Promise<void> {
|
|
await db.query(
|
|
`INSERT INTO incident_history (incident_id, action, actor_dais_id, old_value, new_value, comment)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[
|
|
incidentId,
|
|
action,
|
|
actorDaisId,
|
|
oldValue ? JSON.stringify(oldValue) : null,
|
|
newValue ? JSON.stringify(newValue) : null,
|
|
comment || null,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Map database row to Incident type
|
|
*/
|
|
private mapRowToIncident(row: IncidentRow): Incident {
|
|
return {
|
|
id: row.id,
|
|
createdByDaisId: row.created_by_dais_id,
|
|
targetScopeType: row.target_scope_type,
|
|
targetScopeId: row.target_scope_id,
|
|
status: row.status,
|
|
priority: row.priority,
|
|
assignedToDaisId: row.assigned_to_dais_id || undefined,
|
|
escalationLevel: row.escalation_level,
|
|
title: row.title,
|
|
description: row.description || undefined,
|
|
resolution: row.resolution || undefined,
|
|
metadata: row.metadata,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
resolvedAt: row.resolved_at || undefined,
|
|
closedAt: row.closed_at || undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const incidentsService = new IncidentsService();
|
|
|