@restnfeel/agentc-starter-kit
Version:
한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템
734 lines (646 loc) • 19.4 kB
text/typescript
/**
* Configuration Control Board (CCB) Service
* High-level API that integrates all CCB components and manages the complete lifecycle
*/
import {
ChangeRequest,
ConfigurationItem,
ImpactAssessment,
CCBDecision,
CCBConfiguration,
CCBMetrics,
CCBSearchFilters,
CCBSearchResult,
ChangeRequestStatus,
Priority,
ChangeType,
CCBMember,
Vote,
ImpactCategory,
} from "../types/ccb.js";
import {
ChangeRequestModel,
ConfigurationItemModel,
ImpactAssessmentModel,
CCBDecisionModel,
CCBMetricsModel,
CCBConfigurationModel,
} from "./models.js";
import { CCBWorkflow } from "./workflow.js";
export class CCBService {
private static requests: Map<string, ChangeRequest> = new Map();
private static assessments: Map<string, ImpactAssessment> = new Map();
private static decisions: Map<string, CCBDecision> = new Map();
private static members: Map<string, CCBMember> = new Map();
// Initialize default CCB members
static {
this.initializeDefaultMembers();
}
/**
* Initialize default CCB members for AgentC
*/
private static initializeDefaultMembers(): void {
const defaultMembers: CCBMember[] = [
{
id: "ccb-chair",
name: "CCB Chairperson",
role: "chairperson",
department: "Management",
votingPower: 3,
},
{
id: "tech-lead",
name: "Technical Lead",
role: "technical_lead",
department: "Engineering",
votingPower: 2,
},
{
id: "product-manager",
name: "Product Manager",
role: "product_manager",
department: "Product",
votingPower: 2,
},
{
id: "security-officer",
name: "Security Officer",
role: "security_officer",
department: "Security",
votingPower: 2,
},
{
id: "qa-lead",
name: "QA Lead",
role: "qa_lead",
department: "Quality Assurance",
votingPower: 1,
},
];
defaultMembers.forEach((member) => this.members.set(member.id, member));
}
// ========================================
// Change Request Management
// ========================================
/**
* Submit a new change request
*/
static submitChangeRequest(data: {
title: string;
description: string;
requesterId: string;
requesterName: string;
priority: Priority;
changeType: ChangeType;
affectedItems: ConfigurationItem[];
justification: string;
expectedBenefits: string;
risksIdentified: string;
proposedImplementationDate?: Date;
}): { success: boolean; request?: ChangeRequest; errors?: string[] } {
// Validate the request
const errors = ChangeRequestModel.validateRequest(data);
if (errors.length > 0) {
return { success: false, errors };
}
// Create the change request
const request = ChangeRequestModel.create({
...data,
requestedAt: new Date(),
});
// Store the request
this.requests.set(request.id, request);
// Add initial comment
const requestWithComment = ChangeRequestModel.addComment(request, {
authorId: data.requesterId,
authorName: data.requesterName,
content: `Change request submitted: ${data.title}`,
isInternal: false,
});
this.requests.set(request.id, requestWithComment);
return { success: true, request: requestWithComment };
}
/**
* Get a change request by ID
*/
static getChangeRequest(id: string): ChangeRequest | undefined {
return this.requests.get(id);
}
/**
* Search change requests with filters
*/
static searchChangeRequests(
filters: CCBSearchFilters = {},
page = 1,
limit = 20
): CCBSearchResult {
let allRequests = Array.from(this.requests.values());
// Apply filters
if (filters.status && filters.status.length > 0) {
allRequests = allRequests.filter((r) =>
filters.status!.includes(r.status)
);
}
if (filters.priority && filters.priority.length > 0) {
allRequests = allRequests.filter((r) =>
filters.priority!.includes(r.priority)
);
}
if (filters.changeType && filters.changeType.length > 0) {
allRequests = allRequests.filter((r) =>
filters.changeType!.includes(r.changeType)
);
}
if (filters.requesterId) {
allRequests = allRequests.filter(
(r) => r.requesterId === filters.requesterId
);
}
if (filters.assignedReviewer) {
allRequests = allRequests.filter(
(r) => r.assignedReviewer === filters.assignedReviewer
);
}
if (filters.dateRange) {
allRequests = allRequests.filter(
(r) =>
r.createdAt >= filters.dateRange!.start &&
r.createdAt <= filters.dateRange!.end
);
}
// Sort by creation date (newest first)
allRequests.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Paginate
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedRequests = allRequests.slice(startIndex, endIndex);
return {
items: paginatedRequests,
totalCount: allRequests.length,
page,
limit,
hasMore: endIndex < allRequests.length,
};
}
/**
* Assign a reviewer to a change request
*/
static assignReviewer(
requestId: string,
reviewerId: string
): { success: boolean; request?: ChangeRequest; error?: string } {
const request = this.requests.get(requestId);
if (!request) {
return { success: false, error: "Request not found" };
}
const updatedRequest = ChangeRequestModel.assignReviewer(
request,
reviewerId
);
this.requests.set(requestId, updatedRequest);
return { success: true, request: updatedRequest };
}
/**
* Add a comment to a change request
*/
static addComment(
requestId: string,
comment: {
authorId: string;
authorName: string;
content: string;
isInternal?: boolean;
}
): { success: boolean; request?: ChangeRequest; error?: string } {
const request = this.requests.get(requestId);
if (!request) {
return { success: false, error: "Request not found" };
}
const updatedRequest = ChangeRequestModel.addComment(request, {
...comment,
isInternal: comment.isInternal || false,
});
this.requests.set(requestId, updatedRequest);
return { success: true, request: updatedRequest };
}
// ========================================
// Impact Assessment Management
// ========================================
/**
* Create an impact assessment for a change request
*/
static createImpactAssessment(data: {
changeRequestId: string;
assessorId: string;
assessorName: string;
overallRisk: "low" | "medium" | "high" | "critical";
impacts: Array<{
category: ImpactCategory;
description: string;
severity: "minimal" | "moderate" | "significant" | "major";
likelihood: "low" | "medium" | "high";
estimatedCost?: number;
scheduleImpact?: number;
mitigationRequired: boolean;
}>;
estimatedEffort: {
developmentHours: number;
testingHours: number;
deploymentHours: number;
documentationHours: number;
totalHours: number;
estimatedCost: number;
confidence: "low" | "medium" | "high";
};
resourceRequirements: Array<{
type: "developer" | "designer" | "qa" | "devops" | "product_manager";
skillLevel: "junior" | "mid" | "senior" | "lead";
hoursRequired: number;
availability: "available" | "limited" | "unavailable";
}>;
dependencies: Array<{
itemId: string;
itemName: string;
impactType:
| "breaking_change"
| "compatibility_issue"
| "enhancement"
| "no_impact";
description: string;
actionRequired: string;
}>;
testingRequirements: string;
rollbackPlan: string;
mitigationStrategies: string[];
recommendations: string;
}): { success: boolean; assessment?: ImpactAssessment; error?: string } {
const request = this.requests.get(data.changeRequestId);
if (!request) {
return { success: false, error: "Change request not found" };
}
const assessment = ImpactAssessmentModel.create(data);
this.assessments.set(assessment.id, assessment);
// Update request status to impact_assessment if currently under_review
if (request.status === "under_review") {
const transitionResult = CCBWorkflow.transition(
request,
"impact_assessment",
"Impact assessment created"
);
if (transitionResult.success && transitionResult.request) {
this.requests.set(data.changeRequestId, transitionResult.request);
}
}
return { success: true, assessment };
}
/**
* Approve an impact assessment
*/
static approveImpactAssessment(
assessmentId: string,
approverId: string
): { success: boolean; assessment?: ImpactAssessment; error?: string } {
const assessment = this.assessments.get(assessmentId);
if (!assessment) {
return { success: false, error: "Assessment not found" };
}
const approvedAssessment = ImpactAssessmentModel.approve(
assessment,
approverId
);
this.assessments.set(assessmentId, approvedAssessment);
return { success: true, assessment: approvedAssessment };
}
// ========================================
// CCB Decision Management
// ========================================
/**
* Submit a CCB decision
*/
static submitCCBDecision(data: {
changeRequestId: string;
decision: "approved" | "rejected" | "deferred" | "escalated" | "remanded";
decidedBy: string;
rationale: string;
conditions?: string[];
nextReviewDate?: Date;
escalationReason?: string;
votes: Vote[];
}): {
success: boolean;
request?: ChangeRequest;
decision?: CCBDecision;
error?: string;
} {
const request = this.requests.get(data.changeRequestId);
if (!request) {
return { success: false, error: "Change request not found" };
}
// Create the decision
const decision = CCBDecisionModel.create({
...data,
votingMembers: Array.from(this.members.values()),
});
this.decisions.set(decision.id, decision);
// Process the decision through workflow
const workflowResult = CCBWorkflow.processDecision(request, decision);
if (!workflowResult.success) {
return { success: false, error: workflowResult.error };
}
if (workflowResult.request) {
this.requests.set(data.changeRequestId, workflowResult.request);
}
return {
success: true,
request: workflowResult.request,
decision,
};
}
/**
* Get CCB members
*/
static getCCBMembers(): CCBMember[] {
return Array.from(this.members.values());
}
/**
* Add CCB member
*/
static addCCBMember(member: CCBMember): void {
this.members.set(member.id, member);
}
// ========================================
// Workflow Management
// ========================================
/**
* Transition a change request to a new status
*/
static transitionRequest(
requestId: string,
targetStatus: ChangeRequestStatus,
reason?: string
): { success: boolean; request?: ChangeRequest; error?: string } {
const request = this.requests.get(requestId);
if (!request) {
return { success: false, error: "Request not found" };
}
const transitionResult = CCBWorkflow.transition(
request,
targetStatus,
reason
);
if (transitionResult.success && transitionResult.request) {
this.requests.set(requestId, transitionResult.request);
}
return transitionResult;
}
/**
* Check and apply auto-transitions for all requests
*/
static processAutoTransitions(): {
processed: number;
transitions: Array<{
requestId: string;
fromStatus: ChangeRequestStatus;
toStatus: ChangeRequestStatus;
reason: string;
}>;
} {
const transitions: Array<{
requestId: string;
fromStatus: ChangeRequestStatus;
toStatus: ChangeRequestStatus;
reason: string;
}> = [];
const config = CCBConfigurationModel.getDefault();
for (const [requestId, request] of this.requests) {
const autoTransition = CCBWorkflow.checkAutoTransitions(request, config);
if (autoTransition.shouldTransition && autoTransition.targetStatus) {
const transitionResult = CCBWorkflow.transition(
request,
autoTransition.targetStatus,
autoTransition.reason
);
if (transitionResult.success && transitionResult.request) {
this.requests.set(requestId, transitionResult.request);
transitions.push({
requestId,
fromStatus: request.status,
toStatus: autoTransition.targetStatus,
reason: autoTransition.reason || "Auto-transition",
});
}
}
}
return {
processed: transitions.length,
transitions,
};
}
/**
* Get workflow information for a request
*/
static getWorkflowInfo(requestId: string): {
currentStatus: ChangeRequestStatus;
allowedTransitions: ChangeRequestStatus[];
requiredActions: string[];
suggestedActions: string[];
} | null {
const request = this.requests.get(requestId);
if (!request) return null;
return {
currentStatus: request.status,
allowedTransitions: CCBWorkflow.getAllowedTransitions(request.status),
requiredActions: CCBWorkflow.getRequiredActions(request.status),
suggestedActions: CCBWorkflow.suggestNextActions(request),
};
}
// ========================================
// Configuration Management
// ========================================
/**
* Get current CCB configuration
*/
static getConfiguration(): CCBConfiguration {
return CCBConfigurationModel.getDefault();
}
/**
* Update CCB configuration
*/
static updateConfiguration(
updates: Partial<CCBConfiguration>
): CCBConfiguration {
return CCBConfigurationModel.update(updates);
}
// ========================================
// Metrics and Reporting
// ========================================
/**
* Get CCB metrics
*/
static getMetrics(): CCBMetrics {
const allRequests = Array.from(this.requests.values());
return CCBMetricsModel.calculate(allRequests);
}
/**
* Get dashboard summary
*/
static getDashboardSummary(): {
metrics: CCBMetrics;
recentRequests: ChangeRequest[];
pendingActions: Array<{
requestId: string;
action: string;
daysOverdue: number;
}>;
upcomingMeetings: Array<{
requestId: string;
meetingDate: Date;
priority: Priority;
}>;
} {
const metrics = this.getMetrics();
const allRequests = Array.from(this.requests.values());
// Get 10 most recent requests
const recentRequests = allRequests
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, 10);
// Get pending actions (requests overdue for review)
const pendingActions: Array<{
requestId: string;
action: string;
daysOverdue: number;
}> = [];
const config = this.getConfiguration();
const now = new Date();
for (const request of allRequests) {
const daysSinceUpdate = Math.floor(
(now.getTime() - request.updatedAt.getTime()) / (1000 * 60 * 60 * 24)
);
if (
request.status === "submitted" &&
!request.assignedReviewer &&
daysSinceUpdate > 1
) {
pendingActions.push({
requestId: request.id,
action: "Assign reviewer",
daysOverdue: daysSinceUpdate - 1,
});
}
if (
request.status === "under_review" &&
daysSinceUpdate > config.defaultReviewDays
) {
pendingActions.push({
requestId: request.id,
action: "Complete review",
daysOverdue: daysSinceUpdate - config.defaultReviewDays,
});
}
}
// Get upcoming CCB meetings
const upcomingMeetings = allRequests
.filter((r) => r.ccbMeetingDate && r.ccbMeetingDate > now)
.sort((a, b) => a.ccbMeetingDate!.getTime() - b.ccbMeetingDate!.getTime())
.slice(0, 5)
.map((r) => ({
requestId: r.id,
meetingDate: r.ccbMeetingDate!,
priority: r.priority,
}));
return {
metrics,
recentRequests,
pendingActions,
upcomingMeetings,
};
}
// ========================================
// Utility Methods
// ========================================
/**
* Get all change requests (for testing/admin)
*/
static getAllRequests(): ChangeRequest[] {
return Array.from(this.requests.values());
}
/**
* Clear all data (for testing)
*/
static clearAllData(): void {
this.requests.clear();
this.assessments.clear();
this.decisions.clear();
// Don't clear members as they're defaults
}
/**
* Get system health check
*/
static getHealthCheck(): {
status: "healthy" | "warning" | "error";
issues: string[];
stats: {
totalRequests: number;
oldestPendingRequest?: { id: string; daysPending: number };
configurationItems: number;
ccbMembers: number;
};
} {
const issues: string[] = [];
const allRequests = Array.from(this.requests.values());
const now = new Date();
// Check for very old pending requests
const pendingRequests = allRequests.filter(
(r) =>
!["approved", "rejected", "closed", "implemented"].includes(r.status)
);
let oldestPendingRequest: { id: string; daysPending: number } | undefined;
for (const request of pendingRequests) {
const daysPending = Math.floor(
(now.getTime() - request.createdAt.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysPending > 30) {
issues.push(
`Request ${request.id} has been pending for ${daysPending} days`
);
}
if (
!oldestPendingRequest ||
daysPending > oldestPendingRequest.daysPending
) {
oldestPendingRequest = { id: request.id, daysPending };
}
}
// Check CCB configuration
const config = this.getConfiguration();
if (config.quorumSize < 3) {
issues.push("CCB quorum size is below recommended minimum of 3");
}
const status =
issues.length === 0 ? "healthy" : issues.length < 5 ? "warning" : "error";
return {
status,
issues,
stats: {
totalRequests: allRequests.length,
oldestPendingRequest,
configurationItems: ConfigurationItemModel.getAll().length,
ccbMembers: this.members.size,
},
};
}
private validateMetrics(metrics: Record<string, unknown>): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
// Basic validation for required metrics
if (typeof metrics.approvalTimeHours !== "number") {
errors.push("approvalTimeHours must be a number");
}
return {
isValid: errors.length === 0,
errors,
};
}
}