Files
microdao-daarion/backend/services/governance/incidents.service.ts
Apple e233d32ae7 feat(governance): Governance Engine MVP implementation
- 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
2025-11-29 16:02:06 -08:00

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();