feat: Add close confirmation dialog to ScreenModal and enhance SelectedItemsDetailInputComponent

- Implemented a confirmation dialog in ScreenModal to prevent accidental closure, allowing users to confirm before exiting and potentially losing unsaved data.
- Enhanced SelectedItemsDetailInputComponent by ensuring that base records are created even when detail data is absent, maintaining item-client mapping.
- Improved logging for better traceability during the UPSERT process and refined the handling of parent data mappings for more robust data management.
This commit is contained in:
DDD1542 2026-02-09 13:22:48 +09:00
parent bb4d90fd58
commit 2e500f066f
3 changed files with 454 additions and 116 deletions

View File

@ -1,7 +1,17 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
@ -67,6 +77,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
const [resetKey, setResetKey] = useState(0);
// 모달 닫기 확인 다이얼로그 표시 상태
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
// localStorage에서 연속 모드 상태 복원
useEffect(() => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
@ -218,10 +231,33 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
// 부모 데이터 소스
const rawParentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: splitPanelContext?.selectedLeftData || {};
// 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드)
// 예: screen 150→226→227 전환 시:
// - splitPanelParentData: item_info 데이터 (screen 226에서 전달)
// - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택)
// - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등)
const contextData = splitPanelContext?.selectedLeftData || {};
const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: {};
// 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용
// 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨
const previousLinkFields: Record<string, any> = {};
if (formData && typeof formData === "object" && !Array.isArray(formData)) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"];
for (const [key, value] of Object.entries(formData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
previousLinkFields[key] = value;
}
}
}
const rawParentData = { ...previousLinkFields, ...contextData, ...eventData };
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
const parentData: Record<string, any> = {};
@ -495,14 +531,31 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
};
const handleClose = () => {
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시
const handleCloseAttempt = useCallback(() => {
setShowCloseConfirm(true);
}, []);
// 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => {
setShowCloseConfirm(false);
handleCloseInternal();
}, []);
// 닫기 취소 (계속 작업)
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false);
}, []);
const handleCloseInternal = () => {
// 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등)
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete("mode");
currentUrl.searchParams.delete("editId");
currentUrl.searchParams.delete("tableName");
currentUrl.searchParams.delete("groupByColumns");
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
}
@ -514,8 +567,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
});
setScreenData(null);
setFormData({}); // 폼 데이터 초기화
setOriginalData(null); // 원본 데이터 초기화
setSelectedData([]); // 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false");
};
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
const handleClose = handleCloseInternal;
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
const getModalStyle = () => {
if (!screenDimensions) {
@ -615,10 +675,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
]);
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<Dialog
open={modalState.isOpen}
onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) {
handleCloseAttempt();
}
}}
>
<DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
{...(modalStyle.style && { style: modalStyle.style })}
// 바깥 클릭 시 바로 닫히지 않도록 방지
onInteractOutside={(e) => {
e.preventDefault();
handleCloseAttempt();
}}
// ESC 키 누를 때도 바로 닫히지 않도록 방지
onEscapeKeyDown={(e) => {
e.preventDefault();
handleCloseAttempt();
}}
>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
@ -838,6 +916,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
</DialogContent>
{/* 모달 닫기 확인 다이얼로그 */}
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
?
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
.
<br />
&apos; &apos; .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
onClick={handleCancelClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmClose}
className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
};

View File

@ -490,22 +490,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
if (allGroupsEmpty) {
// 🔧 아이템이 1개뿐이면 기본 레코드 생성 (첫 저장 시)
// 아이템이 여러 개면 빈 아이템은 건너뛰기 (불필요한 NULL 레코드 방지)
if (itemsList.length === 1) {
console.log("📝 [generateCartesianProduct] 단일 아이템, 모든 그룹 비어있음 - 기본 레코드 생성", {
itemIndex,
itemId: item.id,
});
// 빈 객체를 추가하면 parentKeys와 합쳐져서 기본 레코드가 됨
allRecords.push({});
} else {
console.log("⏭️ [generateCartesianProduct] 다중 아이템 중 빈 아이템 - 건너뜀", {
itemIndex,
itemId: item.id,
totalItems: itemsList.length,
});
}
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
const baseRecord: Record<string, any> = {};
additionalFields.forEach((f) => {
if (f.autoFillFrom && item.originalData) {
const value = item.originalData[f.autoFillFrom];
if (value !== undefined && value !== null) {
baseRecord[f.name] = value;
}
}
});
console.log("📝 [generateCartesianProduct] 모든 그룹 비어있음 - 기본 레코드 생성 (매핑 유지)", {
itemIndex,
itemId: item.id,
baseRecord,
totalItems: itemsList.length,
});
allRecords.push(baseRecord);
return;
}
@ -579,17 +582,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return;
}
// 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면)
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
const isEditMode = mode === "edit";
// parentDataMapping이 있으면 UPSERT API로 직접 저장 (생성/수정 모드 무관)
const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0;
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode });
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { hasParentMapping });
if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) {
// 🔄 수정 모드: UPSERT API 사용
if (hasParentMapping) {
// UPSERT API로 직접 DB 저장
try {
console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작");
console.log("🔄 [SelectedItemsDetailInput] UPSERT 저장 시작");
console.log("📋 [SelectedItemsDetailInput] componentConfig:", {
targetTable: componentConfig.targetTable,
parentDataMapping: componentConfig.parentDataMapping,
@ -622,14 +623,30 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
);
componentConfig.parentDataMapping.forEach((mapping) => {
// 🆕 Entity Join 필드도 처리 (예: customer_code -> customer_id_customer_code)
const value = getFieldValue(sourceData, mapping.sourceField);
// 1차: formData(sourceData)에서 찾기
let value = getFieldValue(sourceData, mapping.sourceField);
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용
if ((value === undefined || value === null) && mapping.sourceTable) {
const registryData = dataRegistry[mapping.sourceTable];
if (registryData && registryData.length > 0) {
const registryItem = registryData[0].originalData || registryData[0];
value = registryItem[mapping.sourceField];
console.log(
`🔄 [parentKeys] dataRegistry["${mapping.sourceTable}"]에서 찾음: ${mapping.sourceField} =`,
value,
);
}
}
if (value !== undefined && value !== null) {
parentKeys[mapping.targetField] = value;
console.log(`✅ [parentKeys] ${mapping.sourceField}${mapping.targetField}:`, value);
} else {
console.warn(
`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField}${mapping.targetField}`,
`(sourceData, dataRegistry["${mapping.sourceTable}"] 모두 확인)`,
);
}
});
@ -657,15 +674,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return;
}
// items를 Cartesian Product로 변환
const records = generateCartesianProduct(items);
console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", {
parentKeys,
recordCount: records.length,
records,
});
// targetTable 검증
if (!componentConfig.targetTable) {
console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!");
@ -674,57 +682,228 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
detail: { message: "대상 테이블이 설정되지 않았습니다." },
}),
);
// 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (targetTable 없음)");
}
return;
}
// 🔧 먼저 기본 저장 로직 건너뛰기 설정 (UPSERT 전에!)
// buttonActions.ts에서 beforeSaveEventDetail 객체를 event.detail로 전달하므로 직접 수정 가능
// 🔧 기본 저장 건너뛰기 설정 (UPSERT 전에!)
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)", event.detail);
} else {
console.error("❌ [SelectedItemsDetailInput] event.detail이 없습니다! 기본 저장이 실행될 수 있습니다.", event);
console.log("🚫 [SelectedItemsDetailInput] skipDefaultSave = true (UPSERT 처리)");
}
console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable);
console.log("📡 [SelectedItemsDetailInput] UPSERT API 호출 직전:", {
tableName: componentConfig.targetTable,
tableNameType: typeof componentConfig.targetTable,
tableNameLength: componentConfig.targetTable?.length,
parentKeys,
recordsCount: records.length,
const { dataApi } = await import("@/lib/api/data");
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!;
// fieldGroup별 sourceTable 분류
const groupsByTable = new Map<string, typeof groups>();
groups.forEach((group) => {
const table = group.sourceTable || mainTable;
if (!groupsByTable.has(table)) {
groupsByTable.set(table, []);
}
groupsByTable.get(table)!.push(group);
});
// UPSERT API 호출
const { dataApi } = await import("@/lib/api/data");
const result = await dataApi.upsertGroupedRecords(componentConfig.targetTable, parentKeys, records);
// 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable)
const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable);
const hasDetailTable = detailTables.length > 0;
if (result.success) {
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
console.log("🏗️ [SelectedItemsDetailInput] 저장 구조:", {
mainTable,
detailTables,
hasDetailTable,
groupsByTable: Object.fromEntries(groupsByTable),
});
if (hasDetailTable) {
// ============================================================
// 🆕 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장
// 예: customer_item_mapping (매핑) + customer_item_prices (가격)
// ============================================================
const mainGroups = groupsByTable.get(mainTable) || [];
let totalInserted = 0;
let totalUpdated = 0;
for (const item of items) {
// Step 1: 메인 테이블 매핑 레코드 생성/갱신
const mappingData: Record<string, any> = { ...parentKeys };
// 메인 그룹 필드 추출 (customer_item_code, customer_item_name 등)
mainGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
if (entries.length > 0) {
groupFields.forEach((field) => {
if (entries[0][field.name] !== undefined) {
mappingData[field.name] = entries[0][field.name];
}
});
}
// autoFillFrom 필드 처리 (item_id 등)
groupFields.forEach((field) => {
if (field.autoFillFrom && item.originalData) {
const value = item.originalData[field.autoFillFrom];
if (value !== undefined && value !== null) {
mappingData[field.name] = value;
}
}
});
});
console.log("📋 [2단계 저장] Step 1 - 매핑 데이터:", mappingData);
// 기존 매핑 레코드 찾기
let mappingId: string | null = null;
const searchFilters: Record<string, any> = {};
// parentKeys + item_id로 검색
Object.entries(parentKeys).forEach(([key, value]) => {
searchFilters[key] = value;
});
if (mappingData.item_id) {
searchFilters.item_id = mappingData.item_id;
}
try {
const searchResult = await dataApi.getTableData(mainTable, {
filters: searchFilters,
size: 1,
});
if (searchResult.data && searchResult.data.length > 0) {
// 기존 매핑 업데이트
mappingId = searchResult.data[0].id;
console.log("📌 [2단계 저장] 기존 매핑 발견:", mappingId);
await dataApi.updateRecord(mainTable, mappingId, mappingData);
totalUpdated++;
} else {
// 새 매핑 생성
const createResult = await dataApi.createRecord(mainTable, mappingData);
if (createResult.success && createResult.data) {
mappingId = createResult.data.id;
console.log("✨ [2단계 저장] 새 매핑 생성:", mappingId);
totalInserted++;
}
}
} catch (err) {
console.error("❌ [2단계 저장] 매핑 저장 실패:", err);
continue;
}
if (!mappingId) {
console.error("❌ [2단계 저장] mapping_id 획득 실패 - item:", mappingData.item_id);
continue;
}
// Step 2: 디테일 테이블에 가격 레코드 저장
for (const detailTable of detailTables) {
const detailGroups = groupsByTable.get(detailTable) || [];
const priceRecords: Record<string, any>[] = [];
detailGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
entries.forEach((entry) => {
// 실제 값이 있는 엔트리만 저장
const hasValues = groupFields.some((field) => {
const value = entry[field.name];
return value !== undefined && value !== null && value !== "";
});
if (hasValues) {
const priceRecord: Record<string, any> = {
mapping_id: mappingId,
// 비정규화: 직접 필터링을 위해 customer_id, item_id 포함
...parentKeys,
item_id: mappingData.item_id,
};
groupFields.forEach((field) => {
if (entry[field.name] !== undefined) {
priceRecord[field.name] = entry[field.name];
}
});
priceRecords.push(priceRecord);
}
});
});
if (priceRecords.length > 0) {
console.log(`📋 [2단계 저장] Step 2 - ${detailTable} 레코드:`, {
mappingId,
count: priceRecords.length,
records: priceRecords,
});
const detailResult = await dataApi.upsertGroupedRecords(
detailTable,
{ mapping_id: mappingId },
priceRecords,
);
if (detailResult.success) {
console.log(`✅ [2단계 저장] ${detailTable} 저장 성공:`, detailResult);
} else {
console.error(`❌ [2단계 저장] ${detailTable} 저장 실패:`, detailResult.error);
}
} else {
console.log(`⏭️ [2단계 저장] ${detailTable} - 가격 레코드 없음 (빈 항목)`);
}
}
}
console.log("✅ [SelectedItemsDetailInput] 2단계 저장 완료:", {
inserted: totalInserted,
updated: totalUpdated,
});
// 저장 성공 이벤트 발생
// 저장 성공 이벤트
window.dispatchEvent(
new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}),
);
} else {
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: result.error || "데이터 저장 실패" },
}),
);
// ============================================================
// 단일 테이블 저장 (기존 로직 - detailTable 없는 경우)
// ============================================================
const records = generateCartesianProduct(items);
console.log("📦 [SelectedItemsDetailInput] 단일 테이블 UPSERT:", {
tableName: mainTable,
parentKeys,
recordCount: records.length,
});
const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records);
if (result.success) {
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
window.dispatchEvent(
new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}),
);
} else {
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: result.error || "데이터 저장 실패" },
}),
);
}
}
} catch (error) {
console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error);
@ -769,7 +948,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]);
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct, dataRegistry]);
// 스타일 계산
const componentStyle: React.CSSProperties = {
@ -844,15 +1023,27 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const unitMatch = roundingTypeLabel.match(/(\d+)/);
const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1;
const priceBeforeRounding = price;
// roundingUnit 라벨로 반올림 방법 결정
if (roundingUnitLabel.includes("반올림") && !roundingUnitLabel.includes("없음")) {
price = Math.round(price / unit) * unit;
if (roundingUnitLabel.includes("없음") || !roundingUnitCode) {
// 반올림없음: 할인 적용된 원래 값 그대로
// price 변경 없음
} else if (roundingUnitLabel.includes("절삭")) {
price = Math.floor(price / unit) * unit;
} else if (roundingUnitLabel.includes("올림")) {
price = Math.ceil(price / unit) * unit;
} else if (roundingUnitLabel.includes("반올림")) {
price = Math.round(price / unit) * unit;
}
// "반올림없음"이면 그대로
console.log("🔢 [calculatePrice] 반올림 처리:", {
roundingTypeLabel,
roundingUnitLabel,
unit,
priceBeforeRounding,
priceAfterRounding: price,
});
return price;
},

View File

@ -40,32 +40,21 @@ const server = new Server(
);
/**
* Cursor Agent CLI를
* Cursor Team Plan - API !
*
* spawn + stdin
*
* :
* - Windows: agent (PATH에서 )
* - Mac/Linux: ~/.local/bin/agent
* 유틸: ms만큼
*/
async function callAgentCLI(
agentType: AgentType,
task: string,
context?: string
): Promise<string> {
const config = AGENT_CONFIGS[agentType];
// 모델 선택: PM은 opus, 나머지는 sonnet
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
logger.info(`Calling ${agentType} agent via CLI (spawn)`, { model, task: task.substring(0, 100) });
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const userMessage = context
? `${task}\n\n배경 정보:\n${context}`
: task;
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
/**
* Cursor Agent CLI ()
* spawn + stdin
*/
function spawnAgentOnce(
agentType: AgentType,
fullPrompt: string,
model: string
): Promise<string> {
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
return new Promise<string>((resolve, reject) => {
@ -93,7 +82,6 @@ async function callAgentCLI(
child.on('error', (err: Error) => {
if (!settled) {
settled = true;
logger.error(`${agentType} agent spawn error`, err);
reject(err);
}
});
@ -103,7 +91,6 @@ async function callAgentCLI(
settled = true;
if (stderr) {
// 경고/정보 레벨 stderr는 무시
const significantStderr = stderr
.split('\n')
.filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug'))
@ -114,13 +101,11 @@ async function callAgentCLI(
}
if (code === 0 || stdout.trim().length > 0) {
// 정상 종료이거나, 에러 코드여도 stdout에 결과가 있으면 성공 처리
logger.info(`${agentType} agent completed via CLI (exit code: ${code})`);
resolve(stdout.trim());
} else {
const errorMsg = `Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`;
logger.error(`${agentType} agent CLI error`, { code, stderr: stderr.substring(0, 1000) });
reject(new Error(errorMsg));
reject(new Error(
`Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`
));
}
});
@ -129,22 +114,69 @@ async function callAgentCLI(
if (!settled) {
settled = true;
child.kill('SIGTERM');
logger.error(`${agentType} agent timed out after 5 minutes`);
reject(new Error(`${agentType} agent timed out after 5 minutes`));
}
}, 300000);
// 프로세스 종료 시 타이머 클리어
child.on('close', () => clearTimeout(timeout));
// stdin으로 프롬프트 직접 전달 (쉘 이스케이프 문제 없음!)
// stdin으로 프롬프트 직접 전달
child.stdin.write(fullPrompt);
child.stdin.end();
logger.debug(`Prompt sent to ${agentType} agent via stdin (${fullPrompt.length} chars)`);
});
}
/**
* Cursor Agent CLI를 ( )
*
* - 2 ( 3 )
* - 2 (Cursor CLI )
*/
async function callAgentCLI(
agentType: AgentType,
task: string,
context?: string
): Promise<string> {
const config = AGENT_CONFIGS[agentType];
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
const maxRetries = 2;
logger.info(`Calling ${agentType} agent via CLI (spawn+retry)`, {
model,
task: task.substring(0, 100),
});
const userMessage = context
? `${task}\n\n배경 정보:\n${context}`
: task;
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
const delay = attempt * 2000; // 2초, 4초
logger.info(`${agentType} agent retry ${attempt}/${maxRetries} (waiting ${delay}ms)`);
await sleep(delay);
}
const result = await spawnAgentOnce(agentType, fullPrompt, model);
logger.info(`${agentType} agent completed (attempt ${attempt + 1})`);
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
logger.warn(`${agentType} agent attempt ${attempt + 1} failed`, {
error: lastError.message.substring(0, 200),
});
}
}
// 모든 재시도 실패
logger.error(`${agentType} agent failed after ${maxRetries + 1} attempts`);
throw lastError!;
}
/**
*
*/
@ -310,12 +342,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}>;
};
logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`);
logger.info(`Parallel ask to ${requests.length} agents (STAGGERED PARALLEL)`);
// 시차 병렬 실행: 각 에이전트를 500ms 간격으로 시작
// Cursor Agent CLI 동시 실행 제한 대응
const STAGGER_DELAY = 500; // ms
// 진짜 병렬 실행! 모든 에이전트가 동시에 작업
const results: ParallelResult[] = await Promise.all(
requests.map(async (req) => {
requests.map(async (req, index) => {
try {
// 시차 적용 (첫 번째는 즉시, 이후 500ms 간격)
if (index > 0) {
await sleep(index * STAGGER_DELAY);
}
const result = await callAgentCLI(req.agent, req.task, req.context);
return { agent: req.agent, result };
} catch (error) {