1717 lines
56 KiB
JavaScript
Executable File
1717 lines
56 KiB
JavaScript
Executable File
import { createHash } from 'node:crypto';
|
|
import { execFile, spawn } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { access, cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const EXEC_MAX_BUFFER = 20 * 1024 * 1024;
|
|
|
|
const repoPath = process.env.PLAN_REPO_PATH ?? process.cwd();
|
|
const apiBaseUrl = process.env.PLAN_API_BASE_URL ?? 'http://127.0.0.1:3100/api';
|
|
const accessToken = process.env.PLAN_ACCESS_TOKEN?.trim() ?? '';
|
|
const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null;
|
|
const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex';
|
|
const localMainMode = process.env.PLAN_LOCAL_MAIN_MODE === 'true';
|
|
const skipWorkComplete = process.env.PLAN_SKIP_WORK_COMPLETE === 'true';
|
|
const gitUserName = process.env.PLAN_GIT_USER_NAME ?? 'how2ice';
|
|
const gitUserEmail = process.env.PLAN_GIT_USER_EMAIL ?? 'how2ice@naver.com';
|
|
const previewBaseUrl = process.env.PLAN_PREVIEW_BASE_URL?.trim() || '';
|
|
const previewUrlTemplate = process.env.PLAN_PREVIEW_URL_TEMPLATE?.trim() || '';
|
|
const STREAM_CAPTURE_LIMIT = 256 * 1024;
|
|
const PROMPT_REQUEST_SUMMARY_LIMIT = 72;
|
|
const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024;
|
|
const ERROR_SUMMARY_MAX_LENGTH = 500;
|
|
const SOURCE_SNAPSHOT_TEXT_PATTERN =
|
|
/\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i;
|
|
|
|
const ERROR_SUMMARY_LINE_PATTERN =
|
|
/(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
|
|
const ERROR_SUMMARY_NOISE_PATTERN =
|
|
/^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
|
|
const CODEX_EXEC_MAX_ATTEMPTS = 2;
|
|
const CODEX_EXEC_RETRY_DELAY_MS = 3000;
|
|
const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6;
|
|
const CODEX_EXEC_TRANSIENT_FAILURE_PATTERN =
|
|
/failed to record rollout items|failed to queue rollout items|channel closed/i;
|
|
const CODEX_HOME_PERMISSION_PATTERN =
|
|
/failed to record rollout items|failed to queue rollout items|channel closed|read-only file system|eacces|permission/i;
|
|
const CODEX_HOME_RUNTIME_PATHS = [
|
|
'auth.json',
|
|
'config.toml',
|
|
'rules',
|
|
'skills',
|
|
'vendor_imports',
|
|
'models_cache.json',
|
|
'version.json',
|
|
];
|
|
|
|
function parseCodexTokenUsage(output) {
|
|
const text = stripAnsi(String(output ?? ''));
|
|
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
|
|
const lines = text.split('\n').map((line) => line.trim());
|
|
const lastMatchedIndex = lines.reduce((foundIndex, line, index) => {
|
|
return /tokens?\s+used/i.test(line) ? index : foundIndex;
|
|
}, -1);
|
|
|
|
if (lastMatchedIndex < 0) {
|
|
return null;
|
|
}
|
|
|
|
const matchedLine = lines[lastMatchedIndex] ?? '';
|
|
const usageLines = [matchedLine];
|
|
|
|
if (!/\d/.test(matchedLine)) {
|
|
for (let index = lastMatchedIndex + 1; index < lines.length && usageLines.length < 5; index += 1) {
|
|
const nextLine = lines[index]?.trim() ?? '';
|
|
|
|
if (!nextLine) {
|
|
if (usageLines.length > 1) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (/^(?:\[plan-progress\]|done:|noop:|board_post:|recovered_commit:|git\s|diff --git|commit\s[0-9a-f]{7,})/i.test(nextLine)) {
|
|
break;
|
|
}
|
|
|
|
if (!/\d/.test(nextLine) && !/(input|output|total|cached|reasoning)/i.test(nextLine)) {
|
|
if (usageLines.length > 1) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
usageLines.push(nextLine);
|
|
}
|
|
}
|
|
|
|
return usageLines
|
|
.join(' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function reportProgress(message) {
|
|
const text = String(message ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
if (!text) {
|
|
return;
|
|
}
|
|
|
|
process.stderr.write(`[plan-progress] ${text}\n`);
|
|
}
|
|
|
|
function requiresSourceChange(note) {
|
|
const text = String(note ?? '');
|
|
return /(?:\/|\\).+\.[a-z0-9]+/i.test(text) || /(생성|만들어|추가|수정|변경|삭제|파일)/.test(text);
|
|
}
|
|
|
|
function extractRequestedPaths(note) {
|
|
const text = String(note ?? '');
|
|
const matches = text.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/g) ?? [];
|
|
return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))];
|
|
}
|
|
|
|
function isAutomationEnvironmentFailure(text) {
|
|
const message = String(text ?? '');
|
|
return /bwrap|namespace 오류|샌드박스 제약|파일 쓰기 실패|sandbox/i.test(message);
|
|
}
|
|
|
|
function isBoardDraftOnlyRequest(note) {
|
|
const text = String(note ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
const mentionsBoard = /(?:plan|플랜)?\s*게시판|게시글|board/i.test(text);
|
|
const asksToWrite = /작성만|등록만|글\s*작성|게시글\s*작성|작성해|작성해줘|올려줘|남겨줘/.test(text);
|
|
const keepsUnreceived = /미접수|접수\s*하지|접수하지|자동화\s*접수\s*(?:하지|금지|안)/.test(text);
|
|
|
|
return mentionsBoard && asksToWrite && keepsUnreceived;
|
|
}
|
|
|
|
function normalizeAutomationType(value) {
|
|
return value === 'plan' ||
|
|
value === 'command_execution' ||
|
|
value === 'non_source_work' ||
|
|
value === 'auto_worker'
|
|
? value
|
|
: value === 'plan_registration'
|
|
? 'plan'
|
|
: value === 'general_development'
|
|
? 'auto_worker'
|
|
: 'none';
|
|
}
|
|
|
|
function isErrorLogReviewRequest(note) {
|
|
const text = String(note ?? '').replace(/\s+/g, ' ').trim();
|
|
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
|
|
return /에러\s*로그|error\s*log/i.test(text) && /plan\s*게시판|plan게시판|플랜\s*게시판/i.test(text) && /등록|확인/.test(text);
|
|
}
|
|
|
|
function shouldProcessAsPlanDocument(item) {
|
|
return normalizeAutomationType(item?.automationType) === 'plan' && isErrorLogReviewRequest(item?.note);
|
|
}
|
|
|
|
function shouldForceNoSourceChange(item) {
|
|
const automationType = normalizeAutomationType(item?.automationType);
|
|
return automationType === 'command_execution' || automationType === 'non_source_work' || isErrorLogReviewRequest(item?.note);
|
|
}
|
|
|
|
function shouldSkipSourceChangeRequirement(item) {
|
|
const automationType = normalizeAutomationType(item?.automationType);
|
|
return automationType === 'command_execution' || automationType === 'non_source_work' || isErrorLogReviewRequest(item?.note);
|
|
}
|
|
|
|
function normalizeDateBoundary(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const date = value instanceof Date ? value : new Date(String(value));
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
return date;
|
|
}
|
|
|
|
function formatIsoTimestamp(value) {
|
|
const date = normalizeDateBoundary(value);
|
|
return date ? date.toISOString() : String(value ?? '');
|
|
}
|
|
|
|
function normalizeRequestPathGroup(requestPath) {
|
|
const normalized = String(requestPath ?? '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\?.*$/u, '')
|
|
.replace(/\/\d+(?=\/|$)/gu, '/:id')
|
|
.replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id');
|
|
|
|
if (!normalized) {
|
|
return 'unknown';
|
|
}
|
|
|
|
const segments = normalized
|
|
.split('/')
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean)
|
|
.slice(0, 3);
|
|
|
|
return segments.length > 0 ? `/${segments.join('/')}` : normalized;
|
|
}
|
|
|
|
function buildErrorLogPlanFingerprint(log) {
|
|
return createHash('sha1')
|
|
.update(
|
|
[
|
|
String(log.source ?? '').trim().toLowerCase(),
|
|
String(log.errorType ?? '').trim().toLowerCase(),
|
|
log.statusCode == null ? '' : String(log.statusCode),
|
|
].join('||'),
|
|
)
|
|
.digest('hex')
|
|
.slice(0, 12);
|
|
}
|
|
|
|
function buildErrorLogPlanWorkId(log) {
|
|
return `error-fix-${buildErrorLogPlanFingerprint(log)}`;
|
|
}
|
|
|
|
function buildErrorLogPlanCandidates(logs) {
|
|
const grouped = new Map();
|
|
|
|
for (const log of logs) {
|
|
const fingerprint = createHash('sha1')
|
|
.update(
|
|
[
|
|
String(log.source ?? '').trim().toLowerCase(),
|
|
String(log.errorType ?? '').trim().toLowerCase(),
|
|
String(log.errorName ?? '').trim().toLowerCase(),
|
|
log.statusCode == null ? '' : String(log.statusCode),
|
|
normalizeRequestPathGroup(log.requestPath),
|
|
].join('||'),
|
|
)
|
|
.digest('hex')
|
|
.slice(0, 12);
|
|
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime() ?? 0;
|
|
const existing = grouped.get(fingerprint);
|
|
const requestPathGroup = normalizeRequestPathGroup(log.requestPath);
|
|
const errorMessage = String(log.errorMessage ?? '').trim();
|
|
|
|
if (!existing) {
|
|
grouped.set(fingerprint, {
|
|
sample: log,
|
|
count: 1,
|
|
firstCreatedAt: log.createdAt,
|
|
firstTimeMs: createdAtMs,
|
|
lastCreatedAt: log.createdAt,
|
|
lastTimeMs: createdAtMs,
|
|
requestPathGroup,
|
|
requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(),
|
|
errorMessages: errorMessage ? new Set([errorMessage]) : new Set(),
|
|
errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(),
|
|
sampleLogIds: log.id != null ? [log.id] : [],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
existing.count += 1;
|
|
if (log.requestPath) {
|
|
existing.requestPaths.add(String(log.requestPath).trim());
|
|
}
|
|
if (errorMessage) {
|
|
existing.errorMessages.add(errorMessage);
|
|
}
|
|
if (log.errorName) {
|
|
existing.errorNames.add(String(log.errorName).trim());
|
|
}
|
|
if (log.id != null && existing.sampleLogIds.length < 5) {
|
|
existing.sampleLogIds.push(log.id);
|
|
}
|
|
|
|
if (createdAtMs <= existing.firstTimeMs) {
|
|
existing.firstCreatedAt = log.createdAt;
|
|
existing.firstTimeMs = createdAtMs;
|
|
}
|
|
|
|
if (createdAtMs >= existing.lastTimeMs) {
|
|
existing.sample = log;
|
|
existing.lastCreatedAt = log.createdAt;
|
|
existing.lastTimeMs = createdAtMs;
|
|
}
|
|
}
|
|
|
|
return [...grouped.entries()]
|
|
.map(([fingerprint, entry]) => ({
|
|
fingerprint,
|
|
workId: buildErrorLogPlanWorkId(entry.sample),
|
|
source: entry.sample.source,
|
|
sourceLabel: entry.sample.sourceLabel,
|
|
errorType: entry.sample.errorType,
|
|
errorName: entry.sample.errorName,
|
|
errorMessage: entry.sample.errorMessage,
|
|
requestPath: entry.sample.requestPath,
|
|
requestPathGroup: entry.requestPathGroup,
|
|
requestPaths: [...entry.requestPaths].filter(Boolean).slice(0, 5),
|
|
statusCode: entry.sample.statusCode,
|
|
count: entry.count,
|
|
firstCreatedAt: entry.firstCreatedAt,
|
|
lastCreatedAt: entry.lastCreatedAt,
|
|
sampleLogId: entry.sample.id,
|
|
sampleLogIds: entry.sampleLogIds,
|
|
errorNames: [...entry.errorNames].filter(Boolean).slice(0, 5),
|
|
representativeMessages: [...entry.errorMessages].filter(Boolean).slice(0, 5),
|
|
}))
|
|
.sort((left, right) => {
|
|
if (right.count !== left.count) {
|
|
return right.count - left.count;
|
|
}
|
|
|
|
const leftLastTime = normalizeDateBoundary(left.lastCreatedAt)?.getTime() ?? 0;
|
|
const rightLastTime = normalizeDateBoundary(right.lastCreatedAt)?.getTime() ?? 0;
|
|
|
|
if (rightLastTime !== leftLastTime) {
|
|
return rightLastTime - leftLastTime;
|
|
}
|
|
|
|
return Number(right.sampleLogId ?? 0) - Number(left.sampleLogId ?? 0);
|
|
});
|
|
}
|
|
|
|
function mergeErrorLogPlanCandidateBucket(bucket, bucketIndex) {
|
|
const sortedBucket = [...bucket].sort((left, right) => {
|
|
if (right.count !== left.count) {
|
|
return right.count - left.count;
|
|
}
|
|
|
|
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
|
|
});
|
|
const representative = sortedBucket[0];
|
|
const uniqueFingerprints = [...new Set(sortedBucket.map((candidate) => candidate.fingerprint).filter(Boolean))].sort();
|
|
const mergedFingerprint = createHash('sha1')
|
|
.update(uniqueFingerprints.join('||'))
|
|
.digest('hex')
|
|
.slice(0, 12);
|
|
const firstCreatedAt = sortedBucket
|
|
.map((candidate) => normalizeDateBoundary(candidate.firstCreatedAt)?.getTime() ?? Number.POSITIVE_INFINITY)
|
|
.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
|
|
const lastCreatedAt = sortedBucket
|
|
.map((candidate) => normalizeDateBoundary(candidate.lastCreatedAt)?.getTime() ?? 0)
|
|
.reduce((max, value) => Math.max(max, value), 0);
|
|
const requestPaths = [...new Set(sortedBucket.flatMap((candidate) => candidate.requestPaths ?? []).filter(Boolean))].slice(0, 8);
|
|
const representativeMessages = [...new Set(sortedBucket.flatMap((candidate) => candidate.representativeMessages ?? []).filter(Boolean))].slice(0, 8);
|
|
const errorNames = [...new Set(sortedBucket.flatMap((candidate) => candidate.errorNames ?? []).filter(Boolean))].slice(0, 8);
|
|
const groupedScopes = sortedBucket
|
|
.slice(0, 8)
|
|
.map((candidate) => {
|
|
const parts = [candidate.sourceLabel || candidate.source, candidate.errorType];
|
|
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
|
parts.push(candidate.requestPathGroup);
|
|
}
|
|
return parts.filter(Boolean).join(' / ');
|
|
})
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
fingerprint: mergedFingerprint,
|
|
workId: `error-fix-bundle-${mergedFingerprint}`,
|
|
source: representative.source,
|
|
sourceLabel: representative.sourceLabel,
|
|
errorType: sortedBucket.length > 1 ? '다중 에러 묶음' : representative.errorType,
|
|
errorName: representative.errorName,
|
|
errorMessage: representative.errorMessage,
|
|
requestPath: representative.requestPath,
|
|
requestPathGroup: representative.requestPathGroup,
|
|
requestPaths,
|
|
statusCode: representative.statusCode,
|
|
count: sortedBucket.reduce((sum, candidate) => sum + Number(candidate.count ?? 0), 0),
|
|
firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt,
|
|
lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt,
|
|
sampleLogId: representative.sampleLogId,
|
|
sampleLogIds: [...new Set(sortedBucket.flatMap((candidate) => candidate.sampleLogIds ?? []).filter((value) => value != null))].slice(0, 8),
|
|
errorNames,
|
|
representativeMessages,
|
|
groupedScopes,
|
|
groupedCandidateCount: sortedBucket.length,
|
|
bucketIndex,
|
|
};
|
|
}
|
|
|
|
function coalesceErrorLogPlanCandidates(candidates, maxGroups = MAX_ERROR_LOG_PLAN_REGISTRATIONS) {
|
|
const sortedCandidates = [...candidates].sort((left, right) => {
|
|
if (right.count !== left.count) {
|
|
return right.count - left.count;
|
|
}
|
|
|
|
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
|
|
});
|
|
|
|
if (sortedCandidates.length <= maxGroups) {
|
|
return sortedCandidates;
|
|
}
|
|
|
|
const bucketSize = Math.ceil(sortedCandidates.length / maxGroups);
|
|
const merged = [];
|
|
|
|
for (let index = 0; index < sortedCandidates.length; index += bucketSize) {
|
|
const bucket = sortedCandidates.slice(index, index + bucketSize);
|
|
if (bucket.length === 0) {
|
|
continue;
|
|
}
|
|
merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1));
|
|
}
|
|
|
|
return merged.slice(0, maxGroups);
|
|
}
|
|
|
|
function filterLogsWithinRange(logs, rangeStart, rangeEnd) {
|
|
const startTime = rangeStart.getTime();
|
|
const endTime = rangeEnd.getTime();
|
|
|
|
return logs.filter((log) => {
|
|
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime();
|
|
return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime;
|
|
});
|
|
}
|
|
|
|
function formatErrorLogPlanNote(candidate, rangeStart, rangeEnd) {
|
|
const lines = [
|
|
`조회 구간: ${formatIsoTimestamp(rangeStart)} ~ ${formatIsoTimestamp(rangeEnd)}`,
|
|
`발생 건수: ${candidate.count}건`,
|
|
`최근 발생: ${formatIsoTimestamp(candidate.lastCreatedAt)}`,
|
|
`최초 발생: ${formatIsoTimestamp(candidate.firstCreatedAt)}`,
|
|
`에러 유형: ${candidate.errorType}`,
|
|
];
|
|
|
|
if (candidate.errorName) {
|
|
lines.push(`에러 이름: ${candidate.errorName}`);
|
|
}
|
|
|
|
if (candidate.sourceLabel || candidate.source) {
|
|
lines.push(`발생 위치: ${candidate.sourceLabel || candidate.source}`);
|
|
}
|
|
|
|
if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) {
|
|
lines.push(`묶인 에러 그룹: ${candidate.groupedCandidateCount}개`);
|
|
}
|
|
|
|
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
|
lines.push(`주요 경로 그룹: ${candidate.requestPathGroup}`);
|
|
}
|
|
|
|
if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) {
|
|
lines.push(`대표 경로: ${candidate.requestPaths.join(', ')}`);
|
|
}
|
|
|
|
if (candidate.statusCode != null) {
|
|
lines.push(`상태 코드: ${candidate.statusCode}`);
|
|
}
|
|
|
|
if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) {
|
|
lines.push('대표 메시지:');
|
|
lines.push(...candidate.representativeMessages.map((message, index) => `${index + 1}. ${message}`));
|
|
} else {
|
|
lines.push(`대표 메시지: ${String(candidate.errorMessage ?? '').trim()}`);
|
|
}
|
|
|
|
if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) {
|
|
lines.push(`대표 로그 ID: ${candidate.sampleLogIds.join(', ')}`);
|
|
} else {
|
|
lines.push(`대표 로그 ID: ${candidate.sampleLogId}`);
|
|
}
|
|
|
|
if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) {
|
|
lines.push('묶인 에러 범위:');
|
|
lines.push(...candidate.groupedScopes.map((scope, index) => `${index + 1}. ${scope}`));
|
|
}
|
|
lines.push('');
|
|
lines.push('처리 요청:');
|
|
lines.push('1. 재현 경로와 영향 범위를 확인합니다.');
|
|
lines.push('2. 수정이 필요한 경우 별도 Plan으로 소스 작업을 진행합니다.');
|
|
lines.push('3. 테스트와 재발 방지 필요 여부를 검토합니다.');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function stripAnsi(text) {
|
|
return String(text ?? '').replace(/\u001B\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
function normalizeErrorSummaryLine(line) {
|
|
return stripAnsi(line)
|
|
.replace(/^\[plan-progress\]\s*/u, '')
|
|
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+ERROR\s+[^:]+:\s*/u, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function summarizeFailureOutput(output, fallback) {
|
|
const normalizedLines = String(output ?? '')
|
|
.split('\n')
|
|
.map((line) => normalizeErrorSummaryLine(line))
|
|
.filter(Boolean)
|
|
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line));
|
|
|
|
const bestLine =
|
|
normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
|
|
normalizedLines.at(-1) ??
|
|
String(fallback ?? '').trim();
|
|
|
|
const summary = bestLine.replace(/\s+/g, ' ').trim();
|
|
const normalizedSummary = summary.slice(0, ERROR_SUMMARY_MAX_LENGTH) || '자동 작업 실행에 실패했습니다.';
|
|
|
|
if (!CODEX_HOME_PERMISSION_PATTERN.test(normalizedSummary)) {
|
|
return normalizedSummary;
|
|
}
|
|
|
|
const codexHome = process.env.CODEX_HOME?.trim() || '(unset)';
|
|
const templateHome = process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || '(unset)';
|
|
return `${normalizedSummary} [hint: CODEX_HOME=${codexHome}, template=${templateHome}]`;
|
|
}
|
|
|
|
function isTransientCodexExecFailure(error) {
|
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
return CODEX_EXEC_TRANSIENT_FAILURE_PATTERN.test(message);
|
|
}
|
|
|
|
async function wait(ms) {
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
async function prepareWritableCodexHome(tempDir) {
|
|
const writableCodexHome = process.env.CODEX_HOME?.trim() || path.join(tempDir, '.codex');
|
|
const sourceCodexHome =
|
|
process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() ||
|
|
process.env.CODEX_HOME_TEMPLATE?.trim() ||
|
|
path.join(process.env.HOME ?? '/root', '.codex');
|
|
|
|
await mkdir(writableCodexHome, { recursive: true });
|
|
|
|
for (const relativePath of CODEX_HOME_RUNTIME_PATHS) {
|
|
const sourcePath = path.join(sourceCodexHome, relativePath);
|
|
const targetPath = path.join(writableCodexHome, relativePath);
|
|
|
|
try {
|
|
await access(sourcePath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
await cp(sourcePath, targetPath, { recursive: true, force: true });
|
|
}
|
|
|
|
return writableCodexHome;
|
|
}
|
|
function summarizeRequest(note, limit = PROMPT_REQUEST_SUMMARY_LIMIT) {
|
|
const text = String(note ?? '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
if (!text) {
|
|
return '요청 내용 없음';
|
|
}
|
|
|
|
if (text.length <= limit) {
|
|
return text;
|
|
}
|
|
|
|
return `${text.slice(0, Math.max(0, limit - 1)).trim()}…`;
|
|
}
|
|
|
|
function escapeTemplateValue(value) {
|
|
return encodeURIComponent(String(value ?? '').trim());
|
|
}
|
|
|
|
function buildPreviewUrl(item, commitHash = '') {
|
|
if (previewUrlTemplate) {
|
|
return previewUrlTemplate
|
|
.replaceAll('{branch}', escapeTemplateValue(item.assignedBranch ?? ''))
|
|
.replaceAll('{commit}', escapeTemplateValue(commitHash))
|
|
.replaceAll('{workId}', escapeTemplateValue(item.workId ?? ''))
|
|
.replaceAll('{planId}', escapeTemplateValue(item.id ?? ''));
|
|
}
|
|
|
|
if (previewBaseUrl) {
|
|
return previewBaseUrl;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function request(pathname, init) {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
...(accessToken ? { 'X-Access-Token': accessToken } : {}),
|
|
...(init?.headers ?? {}),
|
|
};
|
|
const response = await fetch(`${apiBaseUrl}${pathname}`, {
|
|
headers,
|
|
...init,
|
|
});
|
|
|
|
const data = await response.json().catch(() => ({}));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data?.message ?? `Plan API 요청에 실패했습니다: ${pathname}`);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async function fetchPlanActionHistories(planItemId) {
|
|
const data = await request(`/plan/items/${planItemId}/actions`);
|
|
return data.items ?? [];
|
|
}
|
|
|
|
async function fetchPlanIssueHistories(planItemId) {
|
|
const data = await request(`/plan/items/${planItemId}/issues`);
|
|
return data.items ?? [];
|
|
}
|
|
|
|
function formatRecentActionHistories(histories) {
|
|
if (!histories.length) {
|
|
return '없음';
|
|
}
|
|
|
|
return histories
|
|
.slice(0, 5)
|
|
.map(
|
|
(history, index) =>
|
|
`${index + 1}. [${history.actionType}] ${history.createdAt}\n${String(history.note ?? '').trim()}`,
|
|
)
|
|
.join('\n\n');
|
|
}
|
|
|
|
function formatRecentIssueHistories(histories) {
|
|
if (!histories.length) {
|
|
return '없음';
|
|
}
|
|
|
|
return histories
|
|
.slice(0, 5)
|
|
.map((history, index) => {
|
|
const actionNote = history.actionNote ? `\n조치이력:\n${String(history.actionNote).trim()}` : '';
|
|
return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`;
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
|
|
async function runCommand(command, args) {
|
|
if (command === 'git') {
|
|
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], {
|
|
encoding: 'utf8',
|
|
maxBuffer: EXEC_MAX_BUFFER,
|
|
});
|
|
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.email', gitUserEmail], {
|
|
encoding: 'utf8',
|
|
maxBuffer: EXEC_MAX_BUFFER,
|
|
});
|
|
return execFileAsync('git', ['-c', `safe.directory=${repoPath}`, ...args], {
|
|
cwd: repoPath,
|
|
encoding: 'utf8',
|
|
maxBuffer: EXEC_MAX_BUFFER,
|
|
});
|
|
}
|
|
|
|
return execFileAsync(command, args, {
|
|
cwd: repoPath,
|
|
encoding: 'utf8',
|
|
maxBuffer: EXEC_MAX_BUFFER,
|
|
});
|
|
}
|
|
|
|
async function hasGitChanges() {
|
|
const { stdout } = await runCommand('git', ['status', '--porcelain']);
|
|
return Boolean(stdout.trim());
|
|
}
|
|
|
|
async function getHeadCommit() {
|
|
const { stdout } = await runCommand('git', ['rev-parse', 'HEAD']);
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function getHeadCommitSubject() {
|
|
const { stdout } = await runCommand('git', ['show', '--format=%s', '--no-patch', 'HEAD']);
|
|
return stdout.trim();
|
|
}
|
|
|
|
async function listHeadChangedFiles() {
|
|
const { stdout } = await runCommand('git', ['show', '--format=', '--name-only', '--find-renames', 'HEAD']);
|
|
return stdout
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function listChangedFiles() {
|
|
const { stdout } = await runCommand('git', ['status', '--short']);
|
|
return stdout
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function normalizeChangedPaths(changedFiles) {
|
|
return changedFiles
|
|
.map((line) => line.replace(/^[A-Z?]{1,2}\s+/, '').trim())
|
|
.map((line) => line.split(' -> ').at(-1) ?? line)
|
|
.map((line) => line.replace(/\\/g, '/'));
|
|
}
|
|
|
|
function inferSourceLanguage(filePath) {
|
|
const normalizedPath = String(filePath ?? '').toLowerCase();
|
|
|
|
if (normalizedPath.endsWith('.tsx')) {
|
|
return 'tsx';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.ts')) {
|
|
return 'typescript';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.jsx')) {
|
|
return 'jsx';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.js')) {
|
|
return 'javascript';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.json') || normalizedPath.endsWith('.jsonl')) {
|
|
return 'json';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.yml') || normalizedPath.endsWith('.yaml')) {
|
|
return 'yaml';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.css') || normalizedPath.endsWith('.scss') || normalizedPath.endsWith('.less')) {
|
|
return 'css';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.html') || normalizedPath.endsWith('.htm')) {
|
|
return 'html';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.diff') || normalizedPath.endsWith('.patch')) {
|
|
return 'diff';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.sh') || normalizedPath.endsWith('.bash') || normalizedPath.endsWith('.zsh')) {
|
|
return 'bash';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.py')) {
|
|
return 'python';
|
|
}
|
|
|
|
if (normalizedPath.endsWith('.md') || normalizedPath.endsWith('.mdx')) {
|
|
return 'markdown';
|
|
}
|
|
|
|
return 'text';
|
|
}
|
|
|
|
async function collectSourceFileSnapshots() {
|
|
const { stdout } = await runCommand('git', ['show', '--format=', '--name-status', '--find-renames', 'HEAD']);
|
|
const lines = stdout
|
|
.split('\n')
|
|
.map((line) => line.trimEnd())
|
|
.filter(Boolean);
|
|
|
|
const snapshots = await Promise.all(
|
|
lines.map(async (line) => {
|
|
const columns = line.split('\t').filter(Boolean);
|
|
|
|
if (columns.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const rawStatus = columns[0] ?? '';
|
|
const statusCode = rawStatus.charAt(0).toUpperCase();
|
|
const isRename = statusCode === 'R';
|
|
const previousPath = isRename ? (columns[1] ?? null) : null;
|
|
const pathValue = isRename ? columns[2] : columns[1];
|
|
const normalizedPath = String(pathValue ?? '').replace(/\\/g, '/');
|
|
|
|
if (!normalizedPath) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedStatus =
|
|
statusCode === 'A'
|
|
? 'added'
|
|
: statusCode === 'M'
|
|
? 'modified'
|
|
: statusCode === 'D'
|
|
? 'deleted'
|
|
: statusCode === 'R'
|
|
? 'renamed'
|
|
: 'unknown';
|
|
|
|
if (normalizedStatus === 'deleted') {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath,
|
|
status: normalizedStatus,
|
|
language: 'text',
|
|
content: '삭제된 파일이라 본문 스냅샷이 없습니다.',
|
|
};
|
|
}
|
|
|
|
if (!SOURCE_SNAPSHOT_TEXT_PATTERN.test(normalizedPath)) {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath,
|
|
status: 'binary',
|
|
language: 'text',
|
|
content: '텍스트 미리보기를 지원하지 않는 파일입니다.',
|
|
};
|
|
}
|
|
|
|
const absolutePath = path.resolve(repoPath, normalizedPath);
|
|
|
|
try {
|
|
const content = await readFile(absolutePath, 'utf8');
|
|
|
|
if (Buffer.byteLength(content, 'utf8') > SOURCE_SNAPSHOT_MAX_BYTES) {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath,
|
|
status: normalizedStatus,
|
|
language: inferSourceLanguage(normalizedPath),
|
|
content: `파일 크기가 커서 전체 스냅샷을 저장하지 않았습니다. (${Math.round(
|
|
Buffer.byteLength(content, 'utf8') / 1024,
|
|
)}KB)`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath,
|
|
status: normalizedStatus,
|
|
language: inferSourceLanguage(normalizedPath),
|
|
content,
|
|
};
|
|
} catch {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath,
|
|
status: normalizedStatus,
|
|
language: inferSourceLanguage(normalizedPath),
|
|
content: '파일 스냅샷을 읽지 못했습니다.',
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
return snapshots.filter(Boolean);
|
|
}
|
|
|
|
async function collectWorkingTreeSourceSnapshots(changedPaths) {
|
|
const uniquePaths = [...new Set((changedPaths ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
|
|
|
const snapshots = await Promise.all(
|
|
uniquePaths.map(async (normalizedPath) => {
|
|
const absolutePath = path.resolve(repoPath, normalizedPath);
|
|
|
|
if (!SOURCE_SNAPSHOT_TEXT_PATTERN.test(normalizedPath)) {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath: null,
|
|
status: 'binary',
|
|
language: 'text',
|
|
content: '텍스트 미리보기를 지원하지 않는 파일입니다.',
|
|
};
|
|
}
|
|
|
|
try {
|
|
const content = await readFile(absolutePath, 'utf8');
|
|
|
|
if (Buffer.byteLength(content, 'utf8') > SOURCE_SNAPSHOT_MAX_BYTES) {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath: null,
|
|
status: 'modified',
|
|
language: inferSourceLanguage(normalizedPath),
|
|
content: `파일 크기가 커서 앞부분만 보관했습니다.\n\n${content.slice(0, SOURCE_SNAPSHOT_MAX_BYTES)}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath: null,
|
|
status: 'modified',
|
|
language: inferSourceLanguage(normalizedPath),
|
|
content,
|
|
};
|
|
} catch {
|
|
return {
|
|
path: normalizedPath,
|
|
previousPath: null,
|
|
status: 'deleted',
|
|
language: 'text',
|
|
content: '삭제된 파일이라 본문 스냅샷이 없습니다.',
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
return snapshots.filter(Boolean);
|
|
}
|
|
|
|
async function hasLocalBranch(branchName) {
|
|
try {
|
|
await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getGitDirPath() {
|
|
const { stdout } = await runCommand('git', ['rev-parse', '--git-dir']);
|
|
const gitDir = stdout.trim() || '.git';
|
|
return path.resolve(repoPath, gitDir);
|
|
}
|
|
|
|
async function exists(targetPath) {
|
|
try {
|
|
await access(targetPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function tryGitCleanup(args) {
|
|
try {
|
|
await runCommand('git', args);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function clearInterruptedGitOperations() {
|
|
const gitDirPath = await getGitDirPath();
|
|
const checks = await Promise.all([
|
|
exists(path.join(gitDirPath, 'MERGE_HEAD')),
|
|
exists(path.join(gitDirPath, 'rebase-merge')),
|
|
exists(path.join(gitDirPath, 'rebase-apply')),
|
|
exists(path.join(gitDirPath, 'CHERRY_PICK_HEAD')),
|
|
exists(path.join(gitDirPath, 'REVERT_HEAD')),
|
|
exists(path.join(gitDirPath, 'BISECT_LOG')),
|
|
]);
|
|
|
|
const [hasMergeHead, hasRebaseMerge, hasRebaseApply, hasCherryPickHead, hasRevertHead, hasBisectLog] = checks;
|
|
|
|
if (hasMergeHead) {
|
|
await tryGitCleanup(['merge', '--abort']);
|
|
}
|
|
|
|
if (hasRebaseMerge || hasRebaseApply) {
|
|
await tryGitCleanup(['rebase', '--abort']);
|
|
}
|
|
|
|
if (hasCherryPickHead) {
|
|
await tryGitCleanup(['cherry-pick', '--abort']);
|
|
}
|
|
|
|
if (hasRevertHead) {
|
|
await tryGitCleanup(['revert', '--abort']);
|
|
}
|
|
|
|
if (hasBisectLog) {
|
|
await tryGitCleanup(['bisect', 'reset']);
|
|
}
|
|
}
|
|
|
|
async function ensureCleanWorkingTree() {
|
|
if (localMainMode) {
|
|
reportProgress('로컬 main 직접 작업 모드라 git reset/clean은 건너뜁니다.');
|
|
return;
|
|
}
|
|
|
|
reportProgress('이전 git 중간 상태와 작업 트리를 정리하는 중입니다.');
|
|
await clearInterruptedGitOperations();
|
|
await runCommand('git', ['reset', '--hard']);
|
|
await runCommand('git', ['clean', '-fd']);
|
|
}
|
|
|
|
async function ensureWorkingBranch(item) {
|
|
if (localMainMode) {
|
|
reportProgress(`로컬 main 직접 작업 모드로 ${item.assignedBranch || 'main'} 작업본을 그대로 사용합니다.`);
|
|
return;
|
|
}
|
|
|
|
const branchName = item.assignedBranch;
|
|
const baseBranch = 'main';
|
|
|
|
if (await hasLocalBranch(branchName)) {
|
|
await runCommand('git', ['switch', branchName]);
|
|
return;
|
|
}
|
|
|
|
await runCommand('git', ['switch', baseBranch]);
|
|
await runCommand('git', ['switch', '-C', branchName, baseBranch]);
|
|
}
|
|
|
|
async function commitAndPushBranch(item, summary) {
|
|
if (localMainMode) {
|
|
throw new Error('로컬 main 직접 작업 모드에서는 commit/push를 수행하지 않습니다.');
|
|
}
|
|
|
|
const commitMessage = `plan(${item.workId}): ${summary}`.slice(0, 120);
|
|
await runCommand('git', ['add', '-A']);
|
|
await runCommand('git', ['commit', '-m', commitMessage]);
|
|
await runCommand('git', ['push', '-u', 'origin', item.assignedBranch]);
|
|
const { stdout } = await runCommand('git', ['rev-parse', '--short', 'HEAD']);
|
|
return {
|
|
commitHash: stdout.trim(),
|
|
commitMessage,
|
|
};
|
|
}
|
|
|
|
function parseBoardPostResult(result) {
|
|
const text = String(result ?? '').trim();
|
|
|
|
if (!text.startsWith('BOARD_POST:')) {
|
|
return null;
|
|
}
|
|
|
|
const rawPayload = text
|
|
.replace(/^BOARD_POST:\s*/u, '')
|
|
.replace(/^```(?:json)?\s*/u, '')
|
|
.replace(/\s*```$/u, '')
|
|
.trim();
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawPayload);
|
|
const title = String(parsed?.title ?? '').trim();
|
|
const content = String(parsed?.content ?? '').trim();
|
|
|
|
if (!title || !content) {
|
|
throw new Error('게시글 제목 또는 본문이 비어 있습니다.');
|
|
}
|
|
|
|
return { title, content };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'JSON 파싱 실패';
|
|
throw new Error(`게시판 작성 결과를 해석하지 못했습니다. ${message}`);
|
|
}
|
|
}
|
|
|
|
function parseRecoveredCommitResult(result) {
|
|
const text = String(result ?? '').trim();
|
|
|
|
if (!text.startsWith('RECOVERED_COMMIT:')) {
|
|
return null;
|
|
}
|
|
|
|
const lines = text.split('\n');
|
|
const commitHash = lines[0]?.replace(/^RECOVERED_COMMIT:\s*/u, '').trim() ?? '';
|
|
const summary = lines[1]?.trim() || 'Codex가 생성한 로컬 커밋을 복구했습니다.';
|
|
const originalError = lines.slice(2).join('\n').replace(/^Original error:\s*/u, '').trim();
|
|
|
|
return {
|
|
commitHash,
|
|
summary,
|
|
originalError,
|
|
};
|
|
}
|
|
|
|
async function runCodexForPlan(item) {
|
|
reportProgress('최근 조치 이력과 이슈 이력을 조회하는 중입니다.');
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), 'plan-codex-'));
|
|
const outputFile = path.join(tempDir, `plan-${item.id}.txt`);
|
|
const writableCodexHome = await prepareWritableCodexHome(tempDir);
|
|
const [recentActions, recentIssues] = await Promise.all([
|
|
fetchPlanActionHistories(item.id),
|
|
fetchPlanIssueHistories(item.id),
|
|
]);
|
|
const requestSummary = summarizeRequest(item.note);
|
|
|
|
const prompt = [
|
|
'당신은 현재 저장소에서 Plan 항목을 처리하는 Codex 실행기입니다.',
|
|
`작업 ID: ${item.workId}`,
|
|
`요청 요약: ${requestSummary}`,
|
|
`상세 요청:\n${item.note || '(비어 있음)'}`,
|
|
`최근 조치 이력:\n${formatRecentActionHistories(recentActions)}`,
|
|
`최근 이슈 이력:\n${formatRecentIssueHistories(recentIssues)}`,
|
|
'규칙:',
|
|
'0. git commit, git push, release/main merge는 직접 실행하지 마세요. 파일 변경만 남기면 이 실행기가 커밋/푸시/반영을 처리합니다.',
|
|
'1. 요청이 구체적이고 실제 코드 작업이 필요하면 저장소를 수정하고 최종 답변을 반드시 "DONE: "으로 시작하세요.',
|
|
'2. 요청이 테스트 수준이거나 별도 작업이 불필요하면 코드를 수정하지 말고 최종 답변을 반드시 "NOOP: "으로 시작하세요.',
|
|
'3. 최종 답변은 한국어 한두 문장으로 간단히 작성하세요.',
|
|
'4. 최근 조치 이력에 재처리나 보완 지시가 있으면 그 내용을 우선 반영해 다시 작업하세요.',
|
|
'5. 응답 톤은 CLI처럼 짧고 직접적으로 작성하세요. 두루뭉술한 설명이나 동문서답을 피하세요.',
|
|
'6. 미리보기 확인이 필요한 작업이면 preview 링크가 필요한지 판단하고, 이미 정해진 preview 주소 규칙이 있으면 그 링크를 최종 답변에도 함께 적으세요.',
|
|
shouldForceNoSourceChange(item)
|
|
? '7. 이번 요청은 선택된 자동화 처리 유형상 저장소 파일을 수정하면 안 됩니다. 필요한 조회/명령/정리만 수행하고 최종 답변은 반드시 "NOOP: "으로 시작하세요.'
|
|
: '',
|
|
isBoardDraftOnlyRequest(item.note)
|
|
? '8. 이번 요청은 게시판 작성 전용입니다. 저장소 파일을 수정하지 말고, 최종 답변을 반드시 `BOARD_POST:` 다음 줄에 `{"title":"...","content":"..."}` JSON 하나만 붙여서 작성하세요. 게시글 본문에는 실제 접수 후 작업자가 바로 실행할 내용만 작성하고, 검토용/미접수/자동화 접수 대기 같은 운영 상태 문구는 넣지 마세요.'
|
|
: '',
|
|
].join('\n');
|
|
|
|
const runCodexExecOnce = async (attempt) => {
|
|
if (attempt > 1) {
|
|
reportProgress(`Codex 내부 채널 오류로 자동 작업을 재시도합니다. (${attempt}/${CODEX_EXEC_MAX_ATTEMPTS})`);
|
|
}
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const child = spawn(
|
|
codexBin,
|
|
[
|
|
'exec',
|
|
'--dangerously-bypass-approvals-and-sandbox',
|
|
'-C',
|
|
repoPath,
|
|
'-o',
|
|
outputFile,
|
|
'-',
|
|
],
|
|
{
|
|
cwd: repoPath,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
CODEX_HOME: writableCodexHome,
|
|
},
|
|
},
|
|
);
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
const text = String(chunk);
|
|
stdout = (stdout + text).slice(-STREAM_CAPTURE_LIMIT);
|
|
process.stderr.write(text);
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk) => {
|
|
const text = String(chunk);
|
|
stderr = (stderr + text).slice(-STREAM_CAPTURE_LIMIT);
|
|
process.stderr.write(text);
|
|
});
|
|
|
|
child.stdin?.end(prompt);
|
|
|
|
child.on('error', (error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
reject(error);
|
|
});
|
|
child.on('close', (code, signal) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
|
|
settled = true;
|
|
if (code === 0) {
|
|
resolve({
|
|
stdout,
|
|
stderr,
|
|
tokenUsage: parseCodexTokenUsage(`${stderr}\n${stdout}`),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const details = summarizeFailureOutput(
|
|
`${stderr}\n${stdout}`,
|
|
`Codex exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`,
|
|
);
|
|
reject(new Error(details));
|
|
});
|
|
});
|
|
};
|
|
|
|
const runCodexExecWithRetry = async () => {
|
|
let lastError = null;
|
|
|
|
for (let attempt = 1; attempt <= CODEX_EXEC_MAX_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
return await runCodexExecOnce(attempt);
|
|
} catch (error) {
|
|
lastError = error;
|
|
|
|
if (attempt >= CODEX_EXEC_MAX_ATTEMPTS || !isTransientCodexExecFailure(error)) {
|
|
throw error;
|
|
}
|
|
|
|
const outputText = await readFile(outputFile, 'utf8').catch(() => '');
|
|
|
|
if (outputText.trim()) {
|
|
reportProgress('Codex 결과 파일이 남아 있어 내부 채널 오류를 성공으로 처리합니다.');
|
|
return;
|
|
}
|
|
|
|
if (await hasGitChanges()) {
|
|
throw error;
|
|
}
|
|
|
|
await wait(CODEX_EXEC_RETRY_DELAY_MS);
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('Codex 자동 작업 실행에 실패했습니다.');
|
|
};
|
|
|
|
try {
|
|
reportProgress(`작업 브랜치 ${item.assignedBranch ?? item.releaseTarget ?? 'release'} 상태를 준비하는 중입니다.`);
|
|
await ensureWorkingBranch(item);
|
|
const headBeforeCodex = await getHeadCommit();
|
|
reportProgress('Codex 자동 작업을 실행하는 중입니다.');
|
|
let codexRunMetadata = { tokenUsage: null };
|
|
try {
|
|
codexRunMetadata = (await runCodexExecWithRetry()) ?? codexRunMetadata;
|
|
} catch (error) {
|
|
const headAfterCodex = await getHeadCommit();
|
|
|
|
if (headAfterCodex && headAfterCodex !== headBeforeCodex) {
|
|
const summary = await getHeadCommitSubject();
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return [`RECOVERED_COMMIT: ${headAfterCodex}`, summary, `Original error: ${message}`].join('\n');
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
reportProgress('Codex 응답을 정리하는 중입니다.');
|
|
const message = (await readFile(outputFile, 'utf8')).trim();
|
|
return {
|
|
message: message || 'NOOP: 별도 작업 결과를 남기지 않았습니다.',
|
|
tokenUsage: codexRunMetadata.tokenUsage,
|
|
};
|
|
} finally {
|
|
await rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function processErrorLogReviewPlan(item) {
|
|
const rangeEnd = new Date();
|
|
const rangeStart = new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000);
|
|
|
|
reportProgress('에러 로그 기반 Plan 게시판 등록 요청을 감지해 최근 24시간 로그를 집계하는 중입니다.');
|
|
const result = await request('/plan/registrations/error-logs', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
rangeStart: rangeStart.toISOString(),
|
|
rangeEnd: rangeEnd.toISOString(),
|
|
}),
|
|
});
|
|
|
|
const createdBoardPosts = result?.createdBoardPosts ?? [];
|
|
const skippedBoardPosts = result?.skippedBoardPosts ?? [];
|
|
const recentLogCount = Number(result?.recentLogCount ?? 0);
|
|
const rawCandidateCount = Number(result?.rawCandidateCount ?? 0);
|
|
const candidateCount = Number(result?.candidateCount ?? 0);
|
|
|
|
const summaryLines = [
|
|
`조회 구간: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`,
|
|
`조회 로그: ${recentLogCount}건`,
|
|
`에러 후보 원본: ${rawCandidateCount}건`,
|
|
`에러 후보 등록용 묶음: ${candidateCount}건`,
|
|
`신규 게시글 등록: ${createdBoardPosts.length}건`,
|
|
`중복 제외: ${skippedBoardPosts.length}건`,
|
|
];
|
|
|
|
if (createdBoardPosts.length > 0) {
|
|
summaryLines.push('');
|
|
summaryLines.push('등록된 게시글:');
|
|
summaryLines.push(...createdBoardPosts.map((post) => `- 게시글 #${post.postId} ${post.workId} (${post.count}건)`));
|
|
}
|
|
|
|
if (skippedBoardPosts.length > 0) {
|
|
summaryLines.push('');
|
|
summaryLines.push('제외된 항목:');
|
|
summaryLines.push(...skippedBoardPosts.map((post) => `- ${post.workId}: ${post.reason}`));
|
|
}
|
|
|
|
const summary = summaryLines.join('\n');
|
|
|
|
await request(`/plan/items/${item.id}/actions/note`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
actionType: '에러로그점검',
|
|
actionNote: [
|
|
summary,
|
|
'',
|
|
'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.',
|
|
].join('\n'),
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/complete`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
note: createdBoardPosts.length > 0 ? `에러 로그 기반 Plan 게시글 ${createdBoardPosts.length}건을 등록했습니다.` : '등록할 신규 에러 Plan 게시글이 없었습니다.',
|
|
}),
|
|
});
|
|
|
|
return {
|
|
id: item.id,
|
|
outcome: 'error-log-review-complete',
|
|
createdBoardPosts,
|
|
skippedBoardPosts,
|
|
};
|
|
}
|
|
|
|
async function processPlan(item) {
|
|
reportProgress(`Plan ${item.workId} 요청 처리를 시작합니다.`);
|
|
if (shouldProcessAsPlanDocument(item)) {
|
|
return processErrorLogReviewPlan(item);
|
|
}
|
|
|
|
const baselineChangedFiles = localMainMode ? await listChangedFiles() : [];
|
|
const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles));
|
|
const codexResult = await runCodexForPlan(item);
|
|
const result = codexResult.message;
|
|
const tokenUsage = codexResult.tokenUsage;
|
|
const tokenUsageLine = tokenUsage ? `토큰 사용량: ${tokenUsage}` : null;
|
|
const summary =
|
|
result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.';
|
|
const boardPost = parseBoardPostResult(result);
|
|
const recoveredCommit = parseRecoveredCommitResult(result);
|
|
|
|
if (isAutomationEnvironmentFailure(summary) || isAutomationEnvironmentFailure(result)) {
|
|
throw new Error(
|
|
'자동화 실행 환경 문제로 실제 작업을 수행하지 못했습니다. 상세: ' + summary,
|
|
);
|
|
}
|
|
|
|
if (boardPost) {
|
|
if (!isBoardDraftOnlyRequest(item.note)) {
|
|
throw new Error('게시판 작성 전용 요청이 아닌데 BOARD_POST 결과가 반환되었습니다.');
|
|
}
|
|
|
|
reportProgress('게시판 작성 전용 요청을 Plan 게시판에 미접수 글로 등록하는 중입니다.');
|
|
const created = await request('/board/posts', {
|
|
method: 'POST',
|
|
body: JSON.stringify(boardPost),
|
|
});
|
|
const boardPostId = created?.item?.id ?? null;
|
|
const boardSummary = `Plan 게시판 미접수 글을 작성했습니다.${boardPostId ? ` 게시글 #${boardPostId}` : ''}`;
|
|
|
|
await request(`/plan/items/${item.id}/source-works`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
summary: boardSummary,
|
|
branchName: item.assignedBranch || item.releaseTarget || 'release',
|
|
commitHash: null,
|
|
changedFiles: [],
|
|
commandLog: [
|
|
`${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o <temp-output> "<plan prompt>"`,
|
|
tokenUsageLine,
|
|
`POST /api/board/posts${boardPostId ? ` -> #${boardPostId}` : ''}`,
|
|
'BOARD_POST: 저장소 파일 변경 없음',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
diffText: null,
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/note`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
actionType: '게시판작성',
|
|
actionNote: [
|
|
boardSummary,
|
|
tokenUsageLine,
|
|
`제목: ${boardPost.title}`,
|
|
'자동화 접수는 하지 않고 미접수 상태로 남겼습니다.',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/complete`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
note: boardSummary,
|
|
}),
|
|
});
|
|
|
|
return {
|
|
id: item.id,
|
|
outcome: 'board-post-complete',
|
|
boardPostId,
|
|
result,
|
|
};
|
|
}
|
|
|
|
if (recoveredCommit) {
|
|
reportProgress('Codex 비정상 종료 후 남은 로컬 커밋을 복구해 원격 브랜치와 Plan 이력에 기록하는 중입니다.');
|
|
await runCommand('git', ['push', '-u', 'origin', item.assignedBranch]);
|
|
const previewUrl = buildPreviewUrl(item, recoveredCommit.commitHash);
|
|
const changedPaths = await listHeadChangedFiles();
|
|
const [{ stdout: diffText }, sourceFiles] = await Promise.all([
|
|
runCommand('git', ['show', '--stat', '--patch', '--format=medium', 'HEAD']),
|
|
collectSourceFileSnapshots(),
|
|
]);
|
|
const recoveredSummary = [
|
|
recoveredCommit.summary,
|
|
recoveredCommit.originalError ? `Codex 종료 오류 복구: ${recoveredCommit.originalError}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
await request(`/plan/items/${item.id}/source-works`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
summary: previewUrl ? `${recoveredSummary}\nPreview: ${previewUrl}` : recoveredSummary,
|
|
branchName: item.assignedBranch,
|
|
commitHash: recoveredCommit.commitHash.slice(0, 12),
|
|
previewUrl,
|
|
changedFiles: changedPaths,
|
|
commandLog: [
|
|
`${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o <temp-output> "<plan prompt>"`,
|
|
tokenUsageLine,
|
|
`RECOVERED_COMMIT: ${recoveredCommit.commitHash}`,
|
|
`git push -u origin ${item.assignedBranch}`,
|
|
'git show --stat --patch --format=medium HEAD',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
diffText: diffText.trim(),
|
|
sourceFiles,
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/note`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
actionType: '소스작업복구',
|
|
actionNote:
|
|
[
|
|
recoveredSummary,
|
|
tokenUsageLine,
|
|
previewUrl ? `Preview: ${previewUrl}` : null,
|
|
changedPaths.length > 0 ? `변경 파일:\n${changedPaths.join('\n')}` : null,
|
|
`커밋: ${recoveredCommit.commitHash.slice(0, 12)}`,
|
|
`브랜치: ${item.assignedBranch}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n\n'),
|
|
}),
|
|
});
|
|
|
|
return {
|
|
id: item.id,
|
|
outcome: 'development-complete',
|
|
result,
|
|
};
|
|
}
|
|
|
|
if (isBoardDraftOnlyRequest(item.note)) {
|
|
throw new Error('게시판 작성 전용 요청인데 자동화가 BOARD_POST 결과를 반환하지 않았습니다. 저장소 파일 변경으로 처리하지 않습니다.');
|
|
}
|
|
|
|
const changed = await hasGitChanges();
|
|
|
|
if (result.startsWith('NOOP:')) {
|
|
reportProgress('코드 변경 없이 처리 가능한 요청인지 검증하는 중입니다.');
|
|
if (!shouldSkipSourceChangeRequirement(item) && requiresSourceChange(item.note)) {
|
|
throw new Error(
|
|
`소스 변경이 필요한 요청인데 자동화가 작업을 수행하지 못했습니다. 응답: ${summary || 'NOOP'}`
|
|
);
|
|
}
|
|
|
|
reportProgress('NOOP 결과를 Plan 이력에 기록하는 중입니다.');
|
|
await request(`/plan/items/${item.id}/source-works`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
summary: summary || '요청을 검토하고 별도 코드 변경 없이 처리했습니다.',
|
|
branchName: item.assignedBranch || item.releaseTarget || 'release',
|
|
commitHash: null,
|
|
changedFiles: [],
|
|
commandLog: [
|
|
`${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o <temp-output> "<plan prompt>"`,
|
|
tokenUsageLine,
|
|
'NOOP: 별도 코드 변경 없음',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
diffText: null,
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/note`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
actionType: '자동완료메모',
|
|
actionNote: [summary || '별도 코드 변경 없이 요청을 처리했습니다.', tokenUsageLine].filter(Boolean).join('\n'),
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/complete`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
note: summary || '별도 작업이 없어 완료 처리했습니다.',
|
|
}),
|
|
});
|
|
return {
|
|
id: item.id,
|
|
outcome: 'noop-complete',
|
|
result,
|
|
};
|
|
}
|
|
|
|
reportProgress('변경 파일과 요청 경로 일치 여부를 검증하는 중입니다.');
|
|
if (!changed) {
|
|
throw new Error(
|
|
`자동 작업 결과에 실제 변경 파일이 없습니다. 응답: ${summary || '작업 결과 요약이 비어 있습니다.'}`,
|
|
);
|
|
}
|
|
|
|
const changedFiles = await listChangedFiles();
|
|
const changedPaths = normalizeChangedPaths(changedFiles);
|
|
const effectiveChangedPaths = localMainMode
|
|
? changedPaths.filter((changedPath) => !baselineChangedPathSet.has(changedPath))
|
|
: changedPaths;
|
|
const requestedPaths = extractRequestedPaths(item.note);
|
|
|
|
if (requestedPaths.length > 0) {
|
|
const matched = requestedPaths.some((requestedPath) =>
|
|
effectiveChangedPaths.some((changedPath) => changedPath === requestedPath || changedPath.endsWith('/' + requestedPath)),
|
|
);
|
|
|
|
if (!matched) {
|
|
throw new Error(
|
|
'요청한 파일 경로가 실제 변경 목록에 없습니다. 요청 경로: ' +
|
|
requestedPaths.join(', ') +
|
|
' / 실제 변경: ' +
|
|
(effectiveChangedPaths.join(', ') || '없음'),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (localMainMode) {
|
|
reportProgress('로컬 main 직접 작업 결과를 이력과 조치 메모로 저장하는 중입니다.');
|
|
const sourceFiles = await collectWorkingTreeSourceSnapshots(effectiveChangedPaths);
|
|
const diffText =
|
|
effectiveChangedPaths.length > 0
|
|
? (await runCommand('git', ['diff', '--', ...effectiveChangedPaths]).then(({ stdout }) => stdout).catch(() => ''))
|
|
: '';
|
|
const commandLog = [
|
|
`${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o <temp-output> "<plan prompt>"`,
|
|
tokenUsageLine,
|
|
'local main mode: commit/push skipped',
|
|
effectiveChangedPaths.length > 0 ? `git diff -- ${effectiveChangedPaths.join(' ')}` : 'git diff -- 변경 파일 없음',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
await request(`/plan/items/${item.id}/source-works`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
summary: summary || 'Codex 로컬 main 소스 작업 결과',
|
|
branchName: item.assignedBranch || 'main',
|
|
commitHash: null,
|
|
previewUrl: null,
|
|
changedFiles: effectiveChangedPaths,
|
|
commandLog,
|
|
diffText: diffText.trim() || null,
|
|
sourceFiles,
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/note`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
actionType: '소스작업',
|
|
actionNote:
|
|
[
|
|
summary || 'Codex 로컬 main 작업 결과를 기록했습니다.',
|
|
tokenUsageLine,
|
|
effectiveChangedPaths.length > 0 ? `변경 파일:\n${effectiveChangedPaths.join('\n')}` : null,
|
|
`브랜치: ${item.assignedBranch || 'main'} (로컬 직접 작업)`,
|
|
'커밋과 원격 푸시는 수행하지 않았습니다.',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n\n'),
|
|
}),
|
|
});
|
|
|
|
if (!skipWorkComplete) {
|
|
await request(`/plan/items/${item.id}/actions/complete-development`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
reportProgress('Plan 상태를 로컬 main 작업 완료로 반영했습니다.');
|
|
return {
|
|
id: item.id,
|
|
outcome: 'development-complete',
|
|
result,
|
|
};
|
|
}
|
|
|
|
reportProgress('변경 내용을 커밋하고 원격 브랜치로 푸시하는 중입니다.');
|
|
const { commitHash, commitMessage } = await commitAndPushBranch(item, summary);
|
|
const previewUrl = buildPreviewUrl(item, commitHash);
|
|
const [{ stdout: diffText }, sourceFiles] = await Promise.all([
|
|
runCommand('git', ['show', '--stat', '--patch', '--format=medium', 'HEAD']),
|
|
collectSourceFileSnapshots(),
|
|
]);
|
|
const commandLog = [
|
|
`${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o <temp-output> "<plan prompt>"`,
|
|
tokenUsageLine,
|
|
'git add -A',
|
|
`git commit -m "${commitMessage}"`,
|
|
`git push -u origin ${item.assignedBranch}`,
|
|
'git show --stat --patch --format=medium HEAD',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
reportProgress('소스 작업 이력과 조치 메모를 저장하는 중입니다.');
|
|
await request(`/plan/items/${item.id}/source-works`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
summary: previewUrl ? `${summary || 'Codex 소스 작업 결과'}\nPreview: ${previewUrl}` : summary || 'Codex 소스 작업 결과',
|
|
branchName: item.assignedBranch,
|
|
commitHash,
|
|
previewUrl,
|
|
changedFiles: changedPaths,
|
|
commandLog,
|
|
diffText: diffText.trim(),
|
|
sourceFiles,
|
|
}),
|
|
});
|
|
|
|
await request(`/plan/items/${item.id}/actions/note`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
actionType: '소스작업',
|
|
actionNote:
|
|
[
|
|
summary || 'Codex 작업 결과를 기록했습니다.',
|
|
tokenUsageLine,
|
|
previewUrl ? `Preview: ${previewUrl}` : null,
|
|
changedFiles.length > 0 ? `변경 파일:\n${changedFiles.join('\n')}` : null,
|
|
commitHash ? `커밋: ${commitHash}` : null,
|
|
`브랜치: ${item.assignedBranch}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n\n'),
|
|
}),
|
|
});
|
|
|
|
if (!skipWorkComplete) {
|
|
await request(`/plan/items/${item.id}/actions/complete-development`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
});
|
|
}
|
|
|
|
reportProgress('Plan 상태를 개발 완료로 전환하는 중입니다.');
|
|
return {
|
|
id: item.id,
|
|
outcome: 'development-complete',
|
|
result,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const isExecutableTarget = (item) =>
|
|
item &&
|
|
item.status === '작업중' &&
|
|
Boolean(item.assignedBranch) &&
|
|
['브랜치준비', '자동작업중'].includes(item.workerStatus);
|
|
|
|
let targets = [];
|
|
|
|
if (planItemId) {
|
|
const data = await request(`/plan/items/${planItemId}`);
|
|
const item = data.item;
|
|
targets = isExecutableTarget(item) ? [item] : [];
|
|
} else {
|
|
const data = await request('/plan/items');
|
|
targets = (data.items ?? []).filter((item) => isExecutableTarget(item));
|
|
}
|
|
|
|
if (targets.length === 0) {
|
|
console.log('처리할 Plan 항목이 없습니다.');
|
|
return;
|
|
}
|
|
|
|
for (const item of targets) {
|
|
await ensureCleanWorkingTree();
|
|
const processed = await processPlan(item);
|
|
console.log(JSON.stringify(processed, null, 2));
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exitCode = 1;
|
|
});
|