Files
ai-code-app/etc/servers/work-server/src/services/plan-service.ts

2952 lines
88 KiB
TypeScript
Executable File

import { z } from 'zod';
import { getEnv } from '../config/env.js';
import { db } from '../db/client.js';
import {
normalizeLegacyAutomationBehaviorType,
resolveAutomationType,
resolveStoredAutomationTypeId,
type AutomationBehaviorType,
} from './automation-type-config-service.js';
import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js';
export const PLAN_TABLE = 'plan_items';
export const PLAN_ISSUE_TABLE = 'plan_issue_histories';
export const PLAN_ACTION_TABLE = 'plan_action_histories';
export const PLAN_SOURCE_WORK_TABLE = 'plan_source_work_histories';
export const PLAN_RELEASE_REVIEW_TABLE = 'plan_release_reviews';
export const planStatuses = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'] as const;
export const planAutomationTypes = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const;
export const planWorkerStatuses = [
'대기',
'브랜치생성중',
'브랜치준비',
'자동작업중',
'release반영대기',
'release반영중',
'release반영완료',
'main반영대기',
'main반영중',
'main반영완료',
'자동완료',
'브랜치실패',
'release반영실패',
'main반영실패',
'자동작업실패',
'작업취소',
] as const;
export const planReleaseReviewStatuses = ['pending', 'reviewing', 'approved', 'changes-requested'] as const;
export type PlanAutomationUsageSnapshot = {
tokenTotals: {
total: number;
input: number;
output: number;
cached: number;
reasoning: number;
};
totalTokens: number;
retryCount: number;
sourceWorkCount: number;
processingStartedAt: string | null;
processingEndedAt: string | null;
processingEndedAtSource: string | null;
processingDurationSeconds: number | null;
};
type PlanRowOptions = {
issueTags?: string[];
hasOpenIssues?: boolean;
maskNote?: boolean;
noteMasked?: boolean;
releaseReviewNote?: string;
exposeConfiguredAutomationType?: boolean;
};
export const statusSchema = z.enum(planStatuses);
export const setupSchema = z.object({
recreate: z.boolean().optional(),
});
function resolvePlanAutomationTypeAlias(value: unknown) {
return normalizeLegacyAutomationBehaviorType(value);
}
export const planAutomationTypeSchema = z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120));
export const createPlanSchema = z.object({
workId: z.string().trim().optional().default('작업ID'),
note: z.string().default(''),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')),
releaseTarget: z.string().trim().min(1).default('release'),
jangsingProcessingRequired: z.boolean().default(true),
autoDeployToMain: z.boolean().default(true),
repeatRequestEnabled: z.boolean().default(false),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60),
});
export const updatePlanSchema = z.object({
workId: z.string().trim().optional(),
note: z.string().optional(),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()),
releaseTarget: z.string().trim().min(1).optional(),
jangsingProcessingRequired: z.boolean().optional(),
autoDeployToMain: z.boolean().optional(),
repeatRequestEnabled: z.boolean().optional(),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(),
});
export const updatePlanJangsingProcessingSchema = z.object({
jangsingProcessingRequired: z.boolean(),
});
export const issueActionSchema = z.object({
actionNote: z.string().trim().min(1),
resolve: z.boolean().default(false),
retry: z.boolean().default(false),
});
const planListStatusFilterAliases = new Set(['all', 'in-progress', 'done', 'error']);
export const listPlanQuerySchema = z.object({
status: z.preprocess((value) => {
if (typeof value !== 'string') {
return value;
}
const normalizedValue = value.trim();
if (!normalizedValue || planListStatusFilterAliases.has(normalizedValue)) {
return undefined;
}
return normalizedValue;
}, statusSchema.optional()),
});
export type PlanStatus = (typeof planStatuses)[number];
export type PlanAutomationType = string;
export type PlanWorkerStatus = (typeof planWorkerStatuses)[number];
export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number];
export const planReleaseReviewStatusSchema = z.enum(planReleaseReviewStatuses);
const planReleaseReviewMetadataSchema = z.object({
summary: z.string().trim().min(1).max(500).optional(),
pageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
checkedPageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
docIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
componentIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
widgetIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
});
export const updatePlanReleaseReviewSchema = z.object({
status: planReleaseReviewStatusSchema.optional(),
reviewNote: z.string().max(4000).optional(),
metadata: planReleaseReviewMetadataSchema.optional(),
});
export type PlanReleaseReviewMetadata = z.infer<typeof planReleaseReviewMetadataSchema>;
const planFailureWorkerStatuses = new Set<PlanWorkerStatus>([
'브랜치실패',
'자동작업실패',
'release반영실패',
'main반영실패',
]);
const functionCheckEditableStatuses = new Set<PlanStatus>(['작업완료', '릴리즈완료', '완료']);
const automationIssueTags = ['#브랜치실패', '#자동작업실패', '#release반영실패', '#main반영실패'] as const;
const WORKLOG_EVIDENCE_PATH_PATTERN = /^docs\/(?:worklogs\/.+\.md|assets\/worklogs\/.+)/i;
function sanitizeBranchToken(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
function normalizePlanWorkId(value?: string | null) {
const workId = String(value ?? '').trim();
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') {
return '작업ID';
}
return workId;
}
export function normalizePlanAutomationType(value: unknown): PlanAutomationType {
const normalizedValue = resolvePlanAutomationTypeAlias(value);
return planAutomationTypes.includes(normalizedValue as AutomationBehaviorType)
? (normalizedValue as AutomationBehaviorType)
: 'none';
}
function shouldSkipLifecycleSourceWork(row: Record<string, unknown> | undefined | null) {
return normalizePlanAutomationType(row?.automation_type) === 'plan';
}
export function formatPlanNotificationLabel(workId: string | null | undefined, id: number) {
const normalizedWorkId = normalizePlanWorkId(workId);
return normalizedWorkId === '작업ID' ? `#${id}` : normalizedWorkId;
}
export function maskPlanNote(value: unknown) {
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return '';
}
const words = normalized.split(' ').filter(Boolean);
if (words.length === 1) {
const [word] = words;
if (word.length <= 1) {
return '*';
}
return `${word[0]}${'*'.repeat(word.length - 1)}`;
}
return words
.map((word, index) => {
if (word.length <= 1) {
return '*';
}
if (index === 0) {
return `${word[0]}${'*'.repeat(word.length - 1)}`;
}
if (index === words.length - 1) {
return `${'*'.repeat(word.length - 1)}${word[word.length - 1]}`;
}
return word;
})
.join(' ');
}
export function normalizeChangedFiles(rawChangedFiles: string[] | null | undefined) {
return [...new Set((rawChangedFiles ?? []).map((file) => String(file ?? '').trim()).filter(Boolean))];
}
export function filterRetryWorklogEvidencePaths(
changedFiles: string[],
existingChangedFilesList: string[][],
) {
const normalizedChangedFiles = normalizeChangedFiles(changedFiles);
const existingEvidencePaths = new Set(
existingChangedFilesList
.flatMap((files) => normalizeChangedFiles(files))
.filter((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file)),
);
return normalizedChangedFiles.filter(
(file) => !WORKLOG_EVIDENCE_PATH_PATTERN.test(file) || !existingEvidencePaths.has(file),
);
}
export function buildPlanBranchName(workId: string, id: number) {
const token = sanitizeBranchToken(workId) || `plan-${id}`;
const prefix = /^auto-worklog-\d{4}-\d{2}-\d{2}$/i.test(String(workId ?? '').trim()) ? 'hotfix' : 'feature';
return `${prefix}/plan-${id}-${token}`;
}
export function shouldUseLocalMainPlanMode(automationType: unknown) {
const env = getEnv();
return Boolean(env.PLAN_LOCAL_MAIN_MODE) && normalizePlanAutomationType(automationType) !== 'auto_worker';
}
export function mapPlanRow(
row: Record<string, unknown>,
options?: PlanRowOptions,
) {
return {
id: row.id,
workId: row.work_id,
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
automationBehaviorType: normalizePlanAutomationType(row.automation_type),
releaseReviewNote: options?.releaseReviewNote ?? '',
noteMasked: Boolean(options?.noteMasked),
status: row.status,
jangsingProcessingRequired:
typeof row.jangsing_processing_required === 'boolean'
? row.jangsing_processing_required
: row.normal_processing_level === '상',
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false),
repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60),
assignedBranch: row.assigned_branch,
releaseTarget: row.release_target,
workerStatus: row.worker_status,
lastError: row.last_error,
issueTags: options?.issueTags ?? [],
hasOpenIssues: options?.hasOpenIssues ?? false,
startedAt: row.started_at,
completedAt: row.completed_at,
mergedAt: row.merged_at,
usageSnapshot: mapPlanAutomationUsageSnapshot(row.usage_snapshot),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function mapPlanIssueRow(row: Record<string, unknown>) {
return {
id: row.id,
planItemId: row.plan_item_id,
issueTag: row.issue_tag,
message: row.message,
actionNote: row.action_note,
resolved: row.resolved,
resolvedAt: row.resolved_at,
createdAt: row.created_at,
};
}
export function mapPlanActionRow(row: Record<string, unknown>) {
return {
id: row.id,
planItemId: row.plan_item_id,
actionType: row.action_type,
note: row.note,
createdAt: row.created_at,
};
}
export function mapPlanSourceWorkRow(row: Record<string, unknown>) {
const changedFilesText = String(row.changed_files ?? '[]');
let changedFiles: string[] = [];
const sourceFilesText = String(row.source_files ?? '[]');
type SourceFileRow = {
path: string;
previousPath: string | null;
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown';
language: string;
content: string;
};
let sourceFiles: SourceFileRow[] = [];
try {
changedFiles = normalizeChangedFiles(JSON.parse(changedFilesText) as string[]);
} catch {
changedFiles = [];
}
try {
sourceFiles = JSON.parse(sourceFilesText) as SourceFileRow[];
} catch {
sourceFiles = [];
}
return {
id: row.id,
planItemId: row.plan_item_id,
summary: row.summary,
branchName: row.branch_name,
commitHash: row.commit_hash,
previewUrl: row.preview_url ?? null,
changedFiles,
commandLog: row.command_log,
diffText: row.diff_text,
sourceFiles,
createdAt: row.created_at,
};
}
function normalizePlanAutomationUsageSnapshotMetric(value: unknown) {
const normalized = Number(value ?? 0);
return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0;
}
export function mapPlanAutomationUsageSnapshot(value: unknown): PlanAutomationUsageSnapshot | null {
let parsedValue = value;
if (typeof value === 'string') {
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
try {
parsedValue = JSON.parse(trimmedValue);
} catch {
return null;
}
}
if (!parsedValue || typeof parsedValue !== 'object') {
return null;
}
const snapshot = parsedValue as Partial<PlanAutomationUsageSnapshot> & {
tokenTotals?: Partial<PlanAutomationUsageSnapshot['tokenTotals']>;
};
const tokenTotals: Partial<PlanAutomationUsageSnapshot['tokenTotals']> = snapshot.tokenTotals ?? {};
return {
tokenTotals: {
total: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.total),
input: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.input),
output: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.output),
cached: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.cached),
reasoning: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.reasoning),
},
totalTokens: normalizePlanAutomationUsageSnapshotMetric(snapshot.totalTokens),
retryCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.retryCount),
sourceWorkCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.sourceWorkCount),
processingStartedAt:
typeof snapshot.processingStartedAt === 'string' && snapshot.processingStartedAt.trim()
? snapshot.processingStartedAt
: null,
processingEndedAt:
typeof snapshot.processingEndedAt === 'string' && snapshot.processingEndedAt.trim()
? snapshot.processingEndedAt
: null,
processingEndedAtSource:
typeof snapshot.processingEndedAtSource === 'string' && snapshot.processingEndedAtSource.trim()
? snapshot.processingEndedAtSource
: null,
processingDurationSeconds: Number.isFinite(Number(snapshot.processingDurationSeconds))
? Math.max(0, Math.round(Number(snapshot.processingDurationSeconds)))
: null,
};
}
export function mapPlanReleaseReviewMetadata(value: unknown): PlanReleaseReviewMetadata {
if (typeof value === 'string' && value.trim()) {
try {
return planReleaseReviewMetadataSchema.parse(JSON.parse(value));
} catch {
return {};
}
}
if (value && typeof value === 'object') {
try {
return planReleaseReviewMetadataSchema.parse(value);
} catch {
return {};
}
}
return {};
}
export function mapPlanReleaseReviewRow(row: Record<string, unknown> | null | undefined, planItemId: number) {
const metadata = mapPlanReleaseReviewMetadata(row?.metadata ?? null);
return {
id: row?.id ? Number(row.id) : null,
planItemId,
status: (row?.status ? String(row.status) : 'pending') as PlanReleaseReviewStatus,
reviewNote: row?.review_note ? String(row.review_note) : '',
checkedByClientId: row?.checked_by_client_id ? String(row.checked_by_client_id) : null,
checkedByNickname: row?.checked_by_nickname ? String(row.checked_by_nickname) : null,
checkedAt: row?.checked_at ? String(row.checked_at) : null,
metadata,
createdAt: row?.created_at ? String(row.created_at) : null,
updatedAt: row?.updated_at ? String(row.updated_at) : null,
};
}
function extractAutomationTokenUsageText(sourceWork: Pick<ReturnType<typeof mapPlanSourceWorkRow>, 'summary' | 'commandLog'>) {
const candidates = [
typeof sourceWork.commandLog === 'string' ? sourceWork.commandLog : null,
typeof sourceWork.summary === 'string' ? sourceWork.summary : null,
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const line = candidate
.split('\n')
.map((entry) => entry.trim())
.find((entry) => /^토큰 사용량:\s*/.test(entry));
if (line) {
return line.replace(/^ :\s*/u, '').trim();
}
}
return null;
}
function parseTokenMetricValue(valueText: string, unitText: string | undefined) {
const normalizedValue = Number(String(valueText ?? '').replace(/,/g, ''));
if (!Number.isFinite(normalizedValue)) {
return null;
}
const unit = String(unitText ?? '').trim().toLowerCase();
if (unit === 'k') {
return Math.round(normalizedValue * 1_000);
}
if (unit === 'm') {
return Math.round(normalizedValue * 1_000_000);
}
return Math.round(normalizedValue);
}
function parseAutomationTokenUsageMetrics(tokenUsageText: string) {
const normalizedText = tokenUsageText
.replace(/^tokens?\s+used\s*:?\s*/iu, '')
.replace(/\(([^)]+)\)/g, ', $1')
.trim();
const metrics = new Map<string, number>();
for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) {
const label = match[3]?.toLowerCase() ?? '';
const value = parseTokenMetricValue(match[1] ?? '', match[2]);
if (!label || value === null) {
continue;
}
metrics.set(label, value);
}
for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) {
const label = match[1]?.toLowerCase() ?? '';
const value = parseTokenMetricValue(match[2] ?? '', match[3]);
if (!label || value === null) {
continue;
}
metrics.set(label, value);
}
if (metrics.size > 0) {
return metrics;
}
const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i);
if (!fallbackMatch) {
return null;
}
const fallbackValue = parseTokenMetricValue(fallbackMatch[1], fallbackMatch[2]);
if (fallbackValue === null) {
return null;
}
return new Map([['total', fallbackValue]]);
}
function getAutomationTotalTokenCount(metrics: Map<string, number>) {
const total = metrics.get('total');
if (typeof total === 'number' && Number.isFinite(total)) {
return total;
}
return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0);
}
function isPlanAutomationWorkerActive(workerStatus: unknown) {
return [
'브랜치생성중',
'브랜치준비',
'자동작업중',
'release반영대기',
'release반영중',
'main반영대기',
'main반영중',
].includes(String(workerStatus ?? '').trim());
}
async function getLatestPlanReviewCheckedAt(planItemId: number) {
const row = await db(PLAN_RELEASE_REVIEW_TABLE)
.select('checked_at', 'updated_at', 'id')
.where({ plan_item_id: planItemId })
.orderBy([
{ column: 'updated_at', order: 'desc' },
{ column: 'id', order: 'desc' },
])
.first();
return row?.checked_at ? String(row.checked_at) : null;
}
function resolvePlanProcessingEndedAt(
row: Record<string, unknown>,
reviewCheckedAt: string | null,
): { endedAt: string | null; source: string | null } {
if (reviewCheckedAt) {
return {
endedAt: reviewCheckedAt,
source: 'review_checked_at',
};
}
if (row.merged_at) {
return {
endedAt: String(row.merged_at),
source: 'merged_at',
};
}
if (row.completed_at) {
return {
endedAt: String(row.completed_at),
source: 'completed_at',
};
}
if (row.updated_at && row.started_at && !isPlanAutomationWorkerActive(row.worker_status)) {
return {
endedAt: String(row.updated_at),
source: 'updated_at',
};
}
if (row.started_at && isPlanAutomationWorkerActive(row.worker_status)) {
return {
endedAt: new Date().toISOString(),
source: 'in_progress',
};
}
return {
endedAt: null,
source: null,
};
}
function buildPlanAutomationUsageSnapshot(
sourceWorks: Array<Pick<ReturnType<typeof mapPlanSourceWorkRow>, 'summary' | 'commandLog' | 'createdAt'>>,
row: Record<string, unknown>,
reviewCheckedAt: string | null,
): PlanAutomationUsageSnapshot | null {
const totals = new Map<string, number>();
sourceWorks.forEach((sourceWork) => {
const tokenUsageText = extractAutomationTokenUsageText(sourceWork);
if (!tokenUsageText) {
return;
}
const metrics = parseAutomationTokenUsageMetrics(tokenUsageText);
if (!metrics) {
return;
}
metrics.forEach((value, key) => {
totals.set(key, (totals.get(key) ?? 0) + value);
});
});
const sourceWorkCount = sourceWorks.length;
const processingStartedAt =
row.started_at ? String(row.started_at) : sourceWorks[0]?.createdAt ? String(sourceWorks[0].createdAt) : null;
const { endedAt, source } = resolvePlanProcessingEndedAt(row, reviewCheckedAt);
const processingDurationSeconds =
processingStartedAt && endedAt
? Math.max(
0,
Math.round((new Date(endedAt).getTime() - new Date(processingStartedAt).getTime()) / 1_000),
)
: null;
const totalTokens = getAutomationTotalTokenCount(totals);
if (sourceWorkCount === 0 && !processingStartedAt && totalTokens === 0) {
return null;
}
return {
tokenTotals: {
total: Math.max(0, totals.get('total') ?? 0),
input: Math.max(0, totals.get('input') ?? 0),
output: Math.max(0, totals.get('output') ?? 0),
cached: Math.max(0, totals.get('cached') ?? 0),
reasoning: Math.max(0, totals.get('reasoning') ?? 0),
},
totalTokens,
retryCount: Math.max(0, sourceWorkCount - 1),
sourceWorkCount,
processingStartedAt,
processingEndedAt: endedAt,
processingEndedAtSource: source,
processingDurationSeconds,
};
}
export async function syncPlanAutomationUsageSnapshot(planItemId: number) {
await ensurePlanTable();
const row = await db(PLAN_TABLE).where({ id: planItemId }).first();
if (!row) {
return null;
}
const sourceWorkRows = await db(PLAN_SOURCE_WORK_TABLE)
.select('summary', 'command_log', 'created_at')
.where({ plan_item_id: planItemId })
.orderBy('created_at', 'asc')
.orderBy('id', 'asc');
const reviewCheckedAt = await getLatestPlanReviewCheckedAt(planItemId);
const snapshot = buildPlanAutomationUsageSnapshot(
sourceWorkRows.map((sourceWorkRow) => ({
summary: sourceWorkRow.summary,
commandLog: sourceWorkRow.command_log,
createdAt: String(sourceWorkRow.created_at),
})),
row,
reviewCheckedAt,
);
await db(PLAN_TABLE)
.where({ id: planItemId })
.update({
usage_snapshot: snapshot ? JSON.stringify(snapshot) : null,
});
return snapshot;
}
async function listPlanReleaseReviewNoteMap(planItemIds: number[]) {
if (planItemIds.length === 0) {
return new Map<number, string>();
}
const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
.select('plan_item_id', 'review_note', 'updated_at', 'id')
.whereIn('plan_item_id', planItemIds)
.orderBy([
{ column: 'updated_at', order: 'desc' },
{ column: 'id', order: 'desc' },
]);
const noteMap = new Map<number, string>();
for (const row of rows) {
const planItemId = Number(row.plan_item_id);
if (!Number.isFinite(planItemId) || noteMap.has(planItemId)) {
continue;
}
noteMap.set(planItemId, row.review_note ? String(row.review_note) : '');
}
return noteMap;
}
async function ensurePlanFailureIssueHistory(planItemId: number) {
const planRow = await db(PLAN_TABLE).where({ id: planItemId }).first();
if (!planRow || !planRow.worker_status || !planFailureWorkerStatuses.has(planRow.worker_status)) {
return;
}
if (!planRow.last_error) {
return;
}
const existingIssue = await db(PLAN_ISSUE_TABLE)
.where({
plan_item_id: planItemId,
issue_tag: `#${planRow.worker_status}`,
message: planRow.last_error,
})
.first();
if (existingIssue) {
return;
}
await db(PLAN_ISSUE_TABLE).insert({
plan_item_id: planItemId,
issue_tag: `#${planRow.worker_status}`,
message: planRow.last_error,
resolved: false,
});
}
async function ensureColumn(
columnName: string,
addColumn: (table: any) => void,
) {
const hasColumn = await db.schema.hasColumn(PLAN_TABLE, columnName);
if (hasColumn) {
return;
}
await db.schema.alterTable(PLAN_TABLE, (table) => {
addColumn(table);
});
}
async function syncPlanColumns() {
const hasIssueNoteColumn = await db.schema.hasColumn(PLAN_TABLE, 'issue_note');
if (hasIssueNoteColumn) {
await db.schema.alterTable(PLAN_TABLE, (table) => {
table.dropColumn('issue_note');
});
}
await ensureColumn('assigned_branch', (table) => {
table.string('assigned_branch', 200).nullable();
});
await ensureColumn('release_target', (table) => {
table.string('release_target', 120).notNullable().defaultTo('release');
});
await ensureColumn('automation_type', (table) => {
table.string('automation_type', 40).notNullable().defaultTo('none');
});
await ensureColumn('automation_type_id', (table) => {
table.string('automation_type_id', 120).nullable();
});
await ensureColumn('auto_deploy_to_main', (table) => {
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
});
await ensureColumn('repeat_request_enabled', (table) => {
table.boolean('repeat_request_enabled').notNullable().defaultTo(false);
});
await ensureColumn('repeat_interval_minutes', (table) => {
table.integer('repeat_interval_minutes').notNullable().defaultTo(60);
});
await ensureColumn('normal_processing_level', (table) => {
table.string('normal_processing_level', 20).notNullable().defaultTo('중');
});
await ensureColumn('jangsing_processing_required', (table) => {
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
});
await ensureColumn('worker_status', (table) => {
table.string('worker_status', 80).nullable();
});
await ensureColumn('last_error', (table) => {
table.text('last_error').nullable();
});
await ensureColumn('locked_by', (table) => {
table.string('locked_by', 120).nullable();
});
await ensureColumn('locked_at', (table) => {
table.timestamp('locked_at', { useTz: true }).nullable();
});
await ensureColumn('started_at', (table) => {
table.timestamp('started_at', { useTz: true }).nullable();
});
await ensureColumn('completed_at', (table) => {
table.timestamp('completed_at', { useTz: true }).nullable();
});
await ensureColumn('merged_at', (table) => {
table.timestamp('merged_at', { useTz: true }).nullable();
});
await ensureColumn('usage_snapshot', (table) => {
table.text('usage_snapshot').nullable();
});
await db(PLAN_TABLE)
.where({ automation_type: 'plan_registration' })
.update({ automation_type: 'plan' });
await db(PLAN_TABLE)
.where({ automation_type: 'general_development' })
.update({ automation_type: 'auto_worker' });
await db(PLAN_TABLE)
.whereNull('automation_type_id')
.update({
automation_type_id: db.raw('automation_type'),
});
}
async function dropPlanWorkIdUniqueConstraint() {
await db.raw(
`
do $$
declare constraint_name text;
begin
for constraint_name in
select con.conname
from pg_constraint con
join pg_class rel on rel.oid = con.conrelid
join pg_namespace nsp on nsp.oid = rel.relnamespace
join pg_attribute att on att.attrelid = rel.oid and att.attnum = any(con.conkey)
where nsp.nspname = current_schema()
and rel.relname = '${PLAN_TABLE}'
and con.contype = 'u'
and att.attname = 'work_id'
loop
execute format('alter table %I.%I drop constraint %I', current_schema(), '${PLAN_TABLE}', constraint_name);
end loop;
end
$$;
`,
);
}
async function ensurePlanIssueTable() {
const exists = await db.schema.hasTable(PLAN_ISSUE_TABLE);
if (exists) {
return;
}
await db.schema.createTable(PLAN_ISSUE_TABLE, (table) => {
table.increments('id').primary();
table.integer('plan_item_id').notNullable().index();
table.string('issue_tag', 120).notNullable();
table.text('message').notNullable();
table.text('action_note').nullable();
table.boolean('resolved').notNullable().defaultTo(false);
table.timestamp('resolved_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
}
async function ensurePlanActionTable() {
const exists = await db.schema.hasTable(PLAN_ACTION_TABLE);
if (exists) {
return;
}
await db.schema.createTable(PLAN_ACTION_TABLE, (table) => {
table.increments('id').primary();
table.integer('plan_item_id').notNullable().index();
table.string('action_type', 120).notNullable();
table.text('note').notNullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
}
async function ensurePlanSourceWorkTable() {
const exists = await db.schema.hasTable(PLAN_SOURCE_WORK_TABLE);
if (exists) {
const hasPreviewUrlColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'preview_url');
if (!hasPreviewUrlColumn) {
await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => {
table.string('preview_url', 2000).nullable();
});
}
const hasSourceFilesColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'source_files');
if (!hasSourceFilesColumn) {
await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => {
table.text('source_files').notNullable().defaultTo('[]');
});
}
return;
}
await db.schema.createTable(PLAN_SOURCE_WORK_TABLE, (table) => {
table.increments('id').primary();
table.integer('plan_item_id').notNullable().index();
table.text('summary').notNullable();
table.string('branch_name', 200).notNullable();
table.string('commit_hash', 80).nullable();
table.string('preview_url', 2000).nullable();
table.text('changed_files').notNullable().defaultTo('[]');
table.text('command_log').nullable();
table.text('diff_text').nullable();
table.text('source_files').notNullable().defaultTo('[]');
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
}
async function ensurePlanReleaseReviewTable() {
const exists = await db.schema.hasTable(PLAN_RELEASE_REVIEW_TABLE);
if (!exists) {
await db.schema.createTable(PLAN_RELEASE_REVIEW_TABLE, (table) => {
table.increments('id').primary();
table.integer('plan_item_id').notNullable().unique().index();
table.string('status', 40).notNullable().defaultTo('pending');
table.text('review_note').notNullable().defaultTo('');
table.string('checked_by_client_id', 120).nullable();
table.string('checked_by_nickname', 80).nullable();
table.timestamp('checked_at', { useTz: true }).nullable();
table.text('metadata').notNullable().defaultTo('{}');
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['plan_item_id', (table) => table.integer('plan_item_id').notNullable().unique().index()],
['status', (table) => table.string('status', 40).notNullable().defaultTo('pending')],
['review_note', (table) => table.text('review_note').notNullable().defaultTo('')],
['checked_by_client_id', (table) => table.string('checked_by_client_id', 120).nullable()],
['checked_by_nickname', (table) => table.string('checked_by_nickname', 80).nullable()],
['checked_at', (table) => table.timestamp('checked_at', { useTz: true }).nullable()],
['metadata', (table) => table.text('metadata').notNullable().defaultTo('{}')],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(PLAN_RELEASE_REVIEW_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(PLAN_RELEASE_REVIEW_TABLE, (table) => {
createColumn(table);
});
}
}
}
export async function ensurePlanTable() {
const exists = await db.schema.hasTable(PLAN_TABLE);
if (!exists) {
try {
await db.schema.createTable(PLAN_TABLE, (table) => {
table.increments('id').primary();
table.string('work_id', 120).notNullable().unique();
table.text('note').notNullable().defaultTo('');
table.string('status', 40).notNullable().defaultTo('등록');
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
table.string('assigned_branch', 200).nullable();
table.string('release_target', 120).notNullable().defaultTo('release');
table.string('worker_status', 80).nullable();
table.text('last_error').nullable();
table.string('locked_by', 120).nullable();
table.timestamp('locked_at', { useTz: true }).nullable();
table.timestamp('started_at', { useTz: true }).nullable();
table.timestamp('completed_at', { useTz: true }).nullable();
table.timestamp('merged_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
} catch (error) {
const dbError = error as { code?: string };
if (dbError.code !== '42P07' && dbError.code !== '23505') {
throw error;
}
}
}
await syncPlanColumns();
await dropPlanWorkIdUniqueConstraint();
await ensurePlanIssueTable();
await ensurePlanActionTable();
await ensurePlanSourceWorkTable();
await ensurePlanReleaseReviewTable();
await db(PLAN_TABLE)
.whereNull('jangsing_processing_required')
.update({
jangsing_processing_required: true,
});
await db(PLAN_TABLE)
.whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[])
.whereNull('completed_at')
.update({
completed_at: db.raw('coalesce(merged_at, updated_at, created_at)'),
updated_at: db.fn.now(),
});
await db(PLAN_TABLE)
.where({ status: '이슈' as never })
.update({
status: '등록',
worker_status: db.raw("coalesce(worker_status, '브랜치실패')"),
updated_at: db.fn.now(),
});
await db(PLAN_TABLE)
.where({ status: '개발완료' as never })
.update({
status: '작업완료',
worker_status: db.raw("case when worker_status in ('병합대기','병합중') then 'release반영대기' when worker_status='병합완료' then 'main반영완료' else worker_status end"),
updated_at: db.fn.now(),
});
await db(PLAN_TABLE)
.whereIn('worker_status', ['release반영완료', 'main반영대기', 'main반영중', 'main반영실패'] as never[])
.whereNot({ status: '완료' as never })
.update({
status: '릴리즈완료',
updated_at: db.fn.now(),
});
}
export async function createPlanItem(payload: z.infer<typeof createPlanSchema>) {
await ensurePlanTable();
const workId = normalizePlanWorkId(payload.workId);
const automationType = await resolveAutomationType(payload.automationType);
const rows = await db(PLAN_TABLE)
.insert({
work_id: workId,
note: payload.note,
status: '등록',
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
repeat_request_enabled: payload.repeatRequestEnabled,
repeat_interval_minutes: payload.repeatIntervalMinutes,
worker_status: '대기',
last_error: null,
updated_at: db.fn.now(),
})
.returning('*');
return rows[0];
}
export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeof createPlanSchema>) {
await ensurePlanTable();
const workId = normalizePlanWorkId(payload.workId);
const automationType = await resolveAutomationType(payload.automationType);
const rows = await db(PLAN_TABLE)
.insert({
work_id: workId,
note: payload.note,
status: '완료',
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
repeat_request_enabled: payload.repeatRequestEnabled,
repeat_interval_minutes: payload.repeatIntervalMinutes,
worker_status: '자동완료',
last_error: null,
started_at: db.fn.now(),
completed_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
if (rows[0]?.id) {
await syncPlanAutomationUsageSnapshot(Number(rows[0].id));
}
return rows[0];
}
export async function upsertAutoPlanItem(args: {
workId: string;
note: string;
releaseTarget: string;
jangsingProcessingRequired: boolean;
autoDeployToMain: boolean;
automationType?: PlanAutomationType;
requeue: boolean;
}) {
await ensurePlanTable();
const workId = normalizePlanWorkId(args.workId);
const existingRow = await db(PLAN_TABLE)
.where({ work_id: workId })
.orderBy('id', 'desc')
.first();
if (!existingRow) {
const automationType = await resolveAutomationType(args.automationType);
return {
row: await createPlanItem({
workId,
note: args.note,
automationType: automationType.id,
releaseTarget: args.releaseTarget,
jangsingProcessingRequired: args.jangsingProcessingRequired,
autoDeployToMain: args.autoDeployToMain,
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
}),
action: 'created' as const,
};
}
const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release';
const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type);
const nextJangsingProcessingRequired = args.jangsingProcessingRequired;
const nextAutoDeployToMain = args.autoDeployToMain;
const nextNote = args.note;
const currentJangsingProcessingRequired =
typeof existingRow.jangsing_processing_required === 'boolean'
? existingRow.jangsing_processing_required
: existingRow.normal_processing_level === '상';
const hasPayloadChange =
existingRow.note !== nextNote ||
String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
(existingRow.release_target ?? 'release') !== nextReleaseTarget ||
Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain ||
Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired;
if (!args.requeue) {
if (!hasPayloadChange || existingRow.status !== '등록') {
return {
row: existingRow,
action: 'unchanged' as const,
};
}
const rows = await db(PLAN_TABLE)
.where({ id: existingRow.id })
.update({
note: nextNote,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
updated_at: db.fn.now(),
})
.returning('*');
return {
row: rows[0],
action: 'updated' as const,
};
}
const rows = await db(PLAN_TABLE)
.where({ id: existingRow.id })
.update({
work_id: workId,
note: nextNote,
status: '등록',
assigned_branch: null,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
worker_status: '대기',
last_error: null,
locked_by: null,
locked_at: null,
started_at: null,
completed_at: null,
merged_at: null,
updated_at: db.fn.now(),
})
.returning('*');
await createPlanActionHistory(
Number(existingRow.id),
'자동갱신',
'업무일지 자동화 설정 기준으로 Plan 항목을 최신 상태로 갱신했습니다.',
);
await syncPlanAutomationUsageSnapshot(Number(existingRow.id));
return {
row: rows[0],
action: 'requeued' as const,
};
}
export async function updatePlanItem(id: number, payload: z.infer<typeof updatePlanSchema>) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const nextWorkId =
payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId);
const nextAutomationType = await resolveAutomationType(
payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type,
);
const nextReleaseTarget = payload.releaseTarget ?? currentRow.release_target ?? 'release';
const nextJangsingProcessingRequired =
payload.jangsingProcessingRequired ??
(typeof currentRow.jangsing_processing_required === 'boolean'
? currentRow.jangsing_processing_required
: currentRow.normal_processing_level === '상');
const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true;
const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false;
const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60;
const nextNote = payload.note ?? currentRow.note;
const isOnlyJangsingUpdate =
nextWorkId === currentRow.work_id &&
nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
nextReleaseTarget === (currentRow.release_target ?? 'release') &&
nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) &&
nextNote === currentRow.note;
if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) {
return updatePlanItemJangsingProcessingRequired(id, nextJangsingProcessingRequired);
}
if (currentRow.started_at || currentRow.status !== '등록') {
throw new Error('작업시작 이후에는 원본 요청을 수정할 수 없습니다. 추가 조치사항은 이력에 기록해 주세요.');
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
work_id: nextWorkId,
note: nextNote,
status: currentRow.status,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
repeat_request_enabled: nextRepeatRequestEnabled,
repeat_interval_minutes: nextRepeatIntervalMinutes,
worker_status: currentRow.worker_status,
last_error: currentRow.last_error,
completed_at: currentRow.completed_at,
updated_at: db.fn.now(),
})
.returning('*');
return rows[0];
}
export async function updatePlanItemJangsingProcessingRequired(
id: number,
jangsingProcessingRequired: boolean,
) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
if (!functionCheckEditableStatuses.has(currentRow.status)) {
throw new Error('기능동작확인은 작업완료 건만 수정할 수 있습니다.');
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
jangsing_processing_required: jangsingProcessingRequired,
updated_at: db.fn.now(),
})
.returning('*');
return rows[0] ?? null;
}
export async function deletePlanItem(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
await db.transaction(async (trx) => {
await trx(PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete();
await trx(PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete();
await trx(PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete();
await trx(PLAN_TABLE).where({ id }).delete();
});
return currentRow;
}
export async function getBoardPostLinkedToPlanItem(planItemId: number) {
const boardPostsTable = 'board_posts';
const hasBoardPostsTable = await db.schema.hasTable(boardPostsTable);
if (!hasBoardPostsTable) {
return null;
}
return db(boardPostsTable).select('id', 'title').where({ automation_plan_item_id: planItemId }).first();
}
export async function markPlanAsDevelopmentComplete(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: '작업완료',
jangsing_processing_required: true,
worker_status: 'release반영대기',
last_error: null,
locked_by: null,
locked_at: null,
completed_at: currentRow.completed_at ?? db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
await createPlanLifecycleSourceWorkHistory(
id,
'작업완료 처리로 release 반영 대기 상태로 전환했습니다.',
currentRow.assigned_branch ?? currentRow.release_target ?? 'release',
currentRow.assigned_branch
? `현재 작업 브랜치: ${currentRow.assigned_branch}`
: `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`,
);
await createPlanActionHistory(id, '작업완료', '작업완료 처리');
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function markPlanAsCompleted(
id: number,
note?: string,
workerStatus: Extract<PlanWorkerStatus, '자동완료' | 'main반영완료'> = '자동완료',
) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: '완료',
worker_status: workerStatus,
last_error: null,
locked_by: null,
locked_at: null,
completed_at: currentRow.completed_at ?? db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
const sourceWorkCountRow = await db(PLAN_SOURCE_WORK_TABLE)
.where({ plan_item_id: id })
.count<{ count: string }>('id as count')
.first();
const sourceWorkCount = Number(sourceWorkCountRow?.count ?? 0);
if (sourceWorkCount === 0 && !shouldSkipLifecycleSourceWork(currentRow)) {
await createPlanLifecycleSourceWorkHistory(
id,
note ?? '작업 결과를 검토하고 완료 처리했습니다.',
currentRow.assigned_branch ?? currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH,
currentRow.assigned_branch
? `완료 기준 브랜치: ${currentRow.assigned_branch}`
: `완료 기준 브랜치: ${currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH}`,
);
}
await createPlanActionHistory(id, '완료처리', note ?? '작업을 완료 처리했습니다.');
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function markPlanAsStarted(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: '작업중',
worker_status: currentRow.worker_status === '대기' ? null : currentRow.worker_status,
last_error: currentRow.last_error,
started_at: currentRow.started_at ?? db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
await createPlanActionHistory(id, '작업시작', '작업을 시작했습니다.');
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function retryPlanBranch(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: '등록',
worker_status: '대기',
last_error: null,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
await createPlanActionHistory(id, '브랜치재시도', '브랜치 재시도를 요청했습니다.');
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function retryPlanMerge(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const isMainRetry =
currentRow.worker_status === 'main반영실패' ||
currentRow.worker_status === 'main반영대기' ||
currentRow.worker_status === 'main반영중' ||
currentRow.status === '릴리즈완료';
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: isMainRetry ? '릴리즈완료' : '작업완료',
worker_status: isMainRetry ? 'main반영대기' : 'release반영대기',
last_error: null,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
await createPlanActionHistory(
id,
isMainRetry ? 'main반영재시도' : 'release반영재시도',
isMainRetry ? 'main 일괄 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
);
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function requestPlanMainMerge(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
if (currentRow.status !== '릴리즈완료') {
return {
item: await getPlanItemById(id),
message: 'main 반영 요청은 release 반영이 완료된 이후에만 가능합니다.',
};
}
const releaseTarget = String(currentRow.release_target ?? 'release');
const pendingRows = await db(PLAN_TABLE)
.select('id')
.where({ status: '릴리즈완료', release_target: releaseTarget });
const targetIds = pendingRows.map((row) => Number(row.id)).filter(Number.isFinite);
await db(PLAN_TABLE)
.whereIn('id', targetIds.length > 0 ? targetIds : [id])
.update({
last_error: null,
locked_by: null,
locked_at: null,
worker_status: 'main반영대기',
updated_at: db.fn.now(),
});
await createPlanActionHistory(
id,
'main반영요청',
`${releaseTarget} 브랜치 기준 main 일괄 반영을 요청했습니다.`,
);
await Promise.all((targetIds.length > 0 ? targetIds : [id]).map((targetId) => syncPlanAutomationUsageSnapshot(targetId)));
return {
item: await getPlanItemById(id),
message: `${releaseTarget} 브랜치 기준으로 ${Math.max(targetIds.length, 1)}건 main 일괄 반영을 요청했습니다.`,
};
}
export async function retryPlanWork(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: '작업중',
worker_status: '브랜치준비',
last_error: null,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
await createPlanActionHistory(id, '작업재처리', '자동 작업 재처리를 요청했습니다.');
return rows[0];
}
export async function isPlanLockedByWorker(id: number, workerId: string) {
await ensurePlanTable();
const row = await db(PLAN_TABLE)
.select('id')
.where({ id, locked_by: workerId })
.first();
return Boolean(row);
}
export async function resumePlanDevelopmentFromRelease(id: number, actionNote: string) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
if (currentRow.status !== '릴리즈완료') {
return {
didScheduleRetry: false,
item: await getPlanItemById(id),
message: null,
};
}
const rows = await db(PLAN_TABLE)
.where({ id })
.update({
status: '작업중',
worker_status: '브랜치준비',
last_error: null,
locked_by: null,
locked_at: null,
merged_at: null,
updated_at: db.fn.now(),
})
.returning('*');
await syncPlanAutomationUsageSnapshot(id);
await createPlanActionHistory(
id,
'릴리즈추가조치',
`release 상태 추가 조치로 개발을 재개했습니다.\n${actionNote}`,
);
return {
didScheduleRetry: false,
item: await getPlanItemById(id),
message: 'release 상태의 추가 조치를 반영해 추가 개발로 전환했습니다.',
row: rows[0],
};
}
export async function queuePlanRetryFromFailure(id: number, actionNote?: string) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
return {
didScheduleRetry: false,
item: await getPlanItemById(id),
message: shouldTriggerRetryFromActionNote(actionNote ?? '')
? '조치 이력을 저장했습니다. 자동 재시작은 하지 않았습니다. 필요하면 재시도 액션을 직접 실행해 주세요.'
: '조치 이력을 저장했습니다. 자동화는 중단된 상태로 유지합니다.',
};
}
export function shouldResumePlanDevelopmentFromIssueAction(
status: PlanStatus | string | null | undefined,
retry: boolean,
) {
return retry && status === '릴리즈완료';
}
async function queuePlanRetryForCurrentStatus(id: number, workerStatus: string | null | undefined) {
if (workerStatus === '자동작업중') {
await retryPlanWork(id);
return {
didScheduleRetry: true,
item: await getPlanItemById(id),
message: '실행 중인 자동 작업을 정리하고 이슈 조치를 반영하도록 다시 실행합니다.',
};
}
if (workerStatus === '브랜치실패') {
await retryPlanBranch(id);
return {
didScheduleRetry: true,
item: await getPlanItemById(id),
message: '이슈 조치를 반영해 브랜치 재시도를 예약했습니다.',
};
}
if (workerStatus === '자동작업실패') {
await retryPlanWork(id);
return {
didScheduleRetry: true,
item: await getPlanItemById(id),
message: '이슈 조치를 반영해 자동 작업 재처리를 예약했습니다.',
};
}
if (workerStatus === 'release반영실패') {
await retryPlanMerge(id);
return {
didScheduleRetry: true,
item: await getPlanItemById(id),
message: '이슈 조치를 반영해 release 반영 재처리를 예약했습니다.',
};
}
if (workerStatus === 'main반영실패') {
return {
didScheduleRetry: true,
...(await requestPlanMainMerge(id)),
};
}
return {
didScheduleRetry: false,
item: await getPlanItemById(id),
message: '이슈 조치 이력을 저장했고 자동 재처리 대상은 없습니다.',
};
}
export async function queuePlanRetryFromIssueAction(id: number, actionNote: string, retry = false) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
if (!retry) {
return {
didScheduleRetry: false,
item: await getPlanItemById(id),
message: '이슈 조치 이력을 저장했습니다. 재처리는 요청하지 않았습니다.',
};
}
if (shouldResumePlanDevelopmentFromIssueAction(currentRow.status, retry)) {
const resumeResult = await resumePlanDevelopmentFromRelease(id, actionNote);
return {
...resumeResult,
didScheduleRetry: Boolean(resumeResult?.message),
};
}
return queuePlanRetryForCurrentStatus(id, currentRow.worker_status);
}
export async function cancelPlanRelease(id: number) {
await ensurePlanTable();
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const releaseTarget = currentRow.release_target ?? 'release';
const isReleaseMergeFailure = currentRow.status === '작업완료' && currentRow.worker_status === 'release반영실패';
const targetRows = isReleaseMergeFailure
? []
: await db(PLAN_TABLE)
.select('*')
.where({ release_target: releaseTarget, status: '릴리즈완료' });
const targetIds = isReleaseMergeFailure
? [id]
: [...new Set(targetRows.map((row) => Number(row.id)).concat(id))];
const historyMessage = isReleaseMergeFailure
? `release(${releaseTarget}) 반영 실패 상태에서 작업을 취소 처리했습니다.`
: `release(${releaseTarget}) 배포 내역을 롤백하고 작업을 취소 처리했습니다.`;
const resultMessage = isReleaseMergeFailure
? `release(${releaseTarget}) 반영 실패 상태에서 작업취소로 완료 처리했습니다.`
: `release(${releaseTarget}) 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.`;
if (targetIds.length === 0) {
return {
item: await getPlanItemById(id),
message: '취소할 release 반영 이력이 없습니다.',
};
}
await db(PLAN_TABLE)
.whereIn('id', targetIds)
.update({
status: '완료',
worker_status: '작업취소',
last_error: null,
locked_by: null,
locked_at: null,
completed_at: db.fn.now(),
updated_at: db.fn.now(),
});
for (const targetId of targetIds) {
await createPlanActionHistory(
targetId,
'작업취소',
historyMessage,
);
const issueRow = await createPlanIssueHistory(
targetId,
'작업취소',
historyMessage,
);
await db(PLAN_ISSUE_TABLE)
.where({ id: issueRow.id })
.update({
resolved: true,
resolved_at: db.fn.now(),
});
await syncPlanAutomationUsageSnapshot(targetId);
}
return {
item: await getPlanItemById(id),
message: resultMessage,
};
}
export async function createPlanActionHistory(planItemId: number, actionType: string, note: string) {
await ensurePlanTable();
const rows = await db(PLAN_ACTION_TABLE)
.insert({
plan_item_id: planItemId,
action_type: actionType,
note,
})
.returning('*');
return rows[0];
}
export async function resolveAutomationIssueHistories(planItemId: number) {
await ensurePlanTable();
await db(PLAN_ISSUE_TABLE)
.where({ plan_item_id: planItemId, resolved: false })
.whereIn('issue_tag', [...automationIssueTags])
.update({
resolved: true,
resolved_at: db.fn.now(),
});
}
export async function listPlanActionHistories(planItemId: number) {
await ensurePlanTable();
return db(PLAN_ACTION_TABLE)
.select('*')
.where({ plan_item_id: planItemId })
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
}
export async function createPlanSourceWorkHistory(
planItemId: number,
payload: {
summary: string;
branchName: string;
commitHash?: string | null;
previewUrl?: string | null;
changedFiles?: string[];
commandLog?: string | null;
diffText?: string | null;
sourceFiles?: Array<{
path: string;
previousPath?: string | null;
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown';
language: string;
content: string;
}>;
},
) {
await ensurePlanTable();
const rawChangedFiles = payload.changedFiles ?? [];
const changedFiles = normalizeChangedFiles(rawChangedFiles);
let filteredChangedFiles = changedFiles;
if (changedFiles.some((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file))) {
const rows = await db(PLAN_SOURCE_WORK_TABLE)
.select('changed_files')
.where({ plan_item_id: planItemId })
.orderBy('created_at', 'asc')
.orderBy('id', 'asc');
const existingChangedFilesList = rows.map((row) => {
try {
return JSON.parse(String(row.changed_files ?? '[]')) as string[];
} catch {
return [];
}
});
filteredChangedFiles = filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList);
}
const rows = await db(PLAN_SOURCE_WORK_TABLE)
.insert({
plan_item_id: planItemId,
summary: payload.summary,
branch_name: payload.branchName,
commit_hash: payload.commitHash ?? null,
preview_url: payload.previewUrl ?? null,
changed_files: JSON.stringify(filteredChangedFiles),
command_log: payload.commandLog ?? null,
diff_text: payload.diffText ?? null,
source_files: JSON.stringify(payload.sourceFiles ?? []),
})
.returning('*');
await syncPlanAutomationUsageSnapshot(planItemId);
return rows[0];
}
async function createPlanLifecycleSourceWorkHistory(
planItemId: number,
summary: string,
branchName: string,
commandLog?: string | null,
previewUrl?: string | null,
) {
return createPlanSourceWorkHistory(planItemId, {
summary,
branchName,
previewUrl,
changedFiles: [],
commandLog: commandLog ?? null,
diffText: null,
sourceFiles: [],
});
}
export async function listPlanSourceWorkHistories(planItemId: number) {
await ensurePlanTable();
return db(PLAN_SOURCE_WORK_TABLE)
.select('*')
.where({ plan_item_id: planItemId })
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
}
export async function getPlanSourceWorkHistory(planItemId: number, sourceWorkId: number) {
await ensurePlanTable();
return db(PLAN_SOURCE_WORK_TABLE)
.select('*')
.where({
id: sourceWorkId,
plan_item_id: planItemId,
})
.first();
}
export async function listLatestPlanSourceWorkMap(planItemIds: number[]) {
if (planItemIds.length === 0) {
return new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();
}
const rows = await db(PLAN_SOURCE_WORK_TABLE)
.select('*')
.whereIn('plan_item_id', planItemIds)
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
const latestMap = new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();
const fallbackMap = new Map<number, ReturnType<typeof mapPlanSourceWorkRow>>();
rows.forEach((row) => {
const planItemId = Number(row.plan_item_id);
const mappedRow = mapPlanSourceWorkRow(row);
const hasMeaningfulChanges = mappedRow.changedFiles.length > 0 || mappedRow.sourceFiles.length > 0;
if (!fallbackMap.has(planItemId)) {
fallbackMap.set(planItemId, mappedRow);
}
if (!latestMap.has(planItemId) && hasMeaningfulChanges) {
latestMap.set(planItemId, mappedRow);
}
});
planItemIds.forEach((planItemId) => {
if (!latestMap.has(planItemId) && fallbackMap.has(planItemId)) {
latestMap.set(planItemId, fallbackMap.get(planItemId)!);
}
});
return latestMap;
}
async function listPlanReleaseReviewRowMap(planItemIds: number[]) {
if (planItemIds.length === 0) {
return new Map<number, Record<string, unknown>>();
}
const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
.select('*')
.whereIn('plan_item_id', planItemIds)
.orderBy('updated_at', 'desc')
.orderBy('id', 'desc');
const reviewMap = new Map<number, Record<string, unknown>>();
rows.forEach((row) => {
const planItemId = Number(row.plan_item_id);
if (!reviewMap.has(planItemId)) {
reviewMap.set(planItemId, row);
}
});
return reviewMap;
}
function summarizePlanReviewText(value: string) {
return String(value ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 220);
}
function inferReleaseReviewPageSelectionIds(changedFiles: string[]) {
const selectionIds = new Set<string>(['page:plans:release-review', 'page:plans:release']);
changedFiles.forEach((file) => {
const normalized = String(file ?? '').trim();
if (!normalized) {
return;
}
if (normalized.startsWith('src/features/board/') || normalized.includes('/BoardPage.tsx')) {
selectionIds.add('page:plans:board');
}
if (normalized.startsWith('src/features/history/')) {
selectionIds.add('page:plans:history');
}
if (normalized.startsWith('src/components/') || normalized.startsWith('src/features/layout/')) {
selectionIds.add('page:apis:components');
}
if (normalized.startsWith('src/widgets/')) {
selectionIds.add('page:apis:widgets');
}
});
return Array.from(selectionIds);
}
function inferReleaseReviewDocIds(changedFiles: string[]) {
return Array.from(
new Set(
changedFiles
.map((file) => {
const normalized = String(file ?? '').trim();
return normalized.startsWith('docs/') ? normalized.replace(/[^\w-]+/g, '-') : '';
})
.filter(Boolean),
),
);
}
function toKebabCase(value: string) {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.replace(/[_\s]+/g, '-')
.toLowerCase();
}
function extractSampleTargetIds(
sourceFiles: Array<{ path: string; content: string }> | undefined,
changedFiles: string[],
pathPrefix: string,
) {
const targetIds = new Set<string>();
(sourceFiles ?? []).forEach((sourceFile) => {
const normalizedPath = String(sourceFile.path ?? '').trim();
if (!normalizedPath.startsWith(pathPrefix)) {
return;
}
const matches = String(sourceFile.content ?? '').matchAll(/componentId\s*:\s*['"]([^'"]+)['"]/g);
for (const match of matches) {
const targetId = String(match[1] ?? '').trim();
if (targetId) {
targetIds.add(targetId);
}
}
});
changedFiles.forEach((file) => {
const normalized = String(file ?? '').trim();
if (!normalized.startsWith(pathPrefix)) {
return;
}
const relativePath = normalized.slice(pathPrefix.length);
const firstSegment = relativePath.split('/').filter(Boolean)[0];
if (firstSegment) {
targetIds.add(toKebabCase(firstSegment));
}
});
return Array.from(targetIds);
}
function inferReleaseReviewComponentIds(
sourceFiles: Array<{ path: string; content: string }> | undefined,
changedFiles: string[],
) {
return extractSampleTargetIds(sourceFiles, changedFiles, 'src/components/');
}
function inferReleaseReviewWidgetIds(
sourceFiles: Array<{ path: string; content: string }> | undefined,
changedFiles: string[],
) {
return extractSampleTargetIds(sourceFiles, changedFiles, 'src/widgets/');
}
function buildReleaseReviewSummary(
row: Record<string, unknown>,
latestSourceWork: ReturnType<typeof mapPlanSourceWorkRow> | undefined,
reviewRow: Record<string, unknown> | undefined,
) {
const metadata = mapPlanReleaseReviewMetadata(reviewRow?.metadata ?? null);
const candidateSummary =
metadata.summary ??
summarizePlanReviewText(String(latestSourceWork?.summary ?? '')) ??
summarizePlanReviewText(String(row.note ?? ''));
return candidateSummary || summarizePlanReviewText(String(row.note ?? ''));
}
export async function upsertPlanReleaseReview(
planItemId: number,
payload: z.infer<typeof updatePlanReleaseReviewSchema>,
actor?: {
clientId?: string | null;
nickname?: string | null;
},
) {
await ensurePlanTable();
const currentItem = await db(PLAN_TABLE).where({ id: planItemId }).first();
if (!currentItem) {
return null;
}
const currentRow = await db(PLAN_RELEASE_REVIEW_TABLE).where({ plan_item_id: planItemId }).first();
const currentMetadata = mapPlanReleaseReviewMetadata(currentRow?.metadata ?? null);
const nextStatus = payload.status ?? ((currentRow?.status ? String(currentRow.status) : 'pending') as PlanReleaseReviewStatus);
const nextReviewNote = payload.reviewNote ?? String(currentRow?.review_note ?? '');
const nextMetadata = payload.metadata ? { ...currentMetadata, ...payload.metadata } : currentMetadata;
const hasReviewerTrace = nextStatus !== 'pending' || nextReviewNote.trim().length > 0;
const nextRow = {
plan_item_id: planItemId,
status: nextStatus,
review_note: nextReviewNote,
checked_by_client_id: hasReviewerTrace ? actor?.clientId?.trim() || null : null,
checked_by_nickname: hasReviewerTrace ? actor?.nickname?.trim() || actor?.clientId?.trim() || null : null,
checked_at: hasReviewerTrace ? db.fn.now() : null,
metadata: JSON.stringify(nextMetadata),
updated_at: db.fn.now(),
};
if (currentRow) {
const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
.where({ plan_item_id: planItemId })
.update(nextRow)
.returning('*');
await syncPlanAutomationUsageSnapshot(planItemId);
return mapPlanReleaseReviewRow(rows[0], planItemId);
}
const rows = await db(PLAN_RELEASE_REVIEW_TABLE)
.insert({
...nextRow,
created_at: db.fn.now(),
})
.returning('*');
await syncPlanAutomationUsageSnapshot(planItemId);
return mapPlanReleaseReviewRow(rows[0], planItemId);
}
export async function listPlanReleaseReviewBoardItems(options?: Pick<PlanRowOptions, 'maskNote'>) {
await ensurePlanTable();
const rows = await db(PLAN_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc');
const releaseRows = rows.filter((row) => {
const workerStatus = String(row.worker_status ?? '');
return (
row.status === '릴리즈완료' ||
row.status === '완료' ||
['release반영중', 'main반영대기', 'main반영중', 'main반영실패', 'release반영완료'].includes(workerStatus)
);
});
const planItemIds = releaseRows.map((row) => Number(row.id));
const issueSummaryMap = await listPlanIssueSummaries(planItemIds);
const latestSourceWorkMap = await listLatestPlanSourceWorkMap(planItemIds);
const reviewRowMap = await listPlanReleaseReviewRowMap(planItemIds);
return releaseRows.map((row) => {
const planItemId = Number(row.id);
const planItem = mapPlanRow(row, {
...(issueSummaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }),
maskNote: options?.maskNote,
noteMasked: options?.maskNote,
exposeConfiguredAutomationType: true,
});
const latestSourceWork = latestSourceWorkMap.get(planItemId) ?? null;
const reviewRow = reviewRowMap.get(planItemId);
const review = mapPlanReleaseReviewRow(reviewRow, planItemId);
const changedFiles = latestSourceWork?.changedFiles ?? [];
const sourceFiles = latestSourceWork?.sourceFiles ?? [];
const metadata = {
...review.metadata,
summary: review.metadata.summary ?? buildReleaseReviewSummary(row, latestSourceWork ?? undefined, reviewRow),
pageSelectionIds: review.metadata.pageSelectionIds ?? inferReleaseReviewPageSelectionIds(changedFiles),
docIds: review.metadata.docIds ?? inferReleaseReviewDocIds(changedFiles),
componentIds: review.metadata.componentIds ?? inferReleaseReviewComponentIds(sourceFiles, changedFiles),
widgetIds: review.metadata.widgetIds ?? inferReleaseReviewWidgetIds(sourceFiles, changedFiles),
};
return {
planItem,
review: {
...review,
metadata,
},
latestSourceWork,
};
});
}
export async function createPlanIssueHistory(
planItemId: number,
issueTag: string,
message: string,
) {
await ensurePlanTable();
const normalizedIssueTag = issueTag.startsWith('#') ? issueTag : `#${issueTag}`;
const previousIssueWithAction = await db(PLAN_ISSUE_TABLE)
.select('*')
.where({
plan_item_id: planItemId,
issue_tag: normalizedIssueTag,
message,
})
.whereNotNull('action_note')
.orderBy('created_at', 'desc')
.orderBy('id', 'desc')
.first();
const rows = await db(PLAN_ISSUE_TABLE)
.insert({
plan_item_id: planItemId,
issue_tag: normalizedIssueTag,
message,
action_note: previousIssueWithAction?.action_note ?? null,
})
.returning('*');
return rows[0];
}
export async function appendLatestIssueAction(planItemId: number, actionNote: string, resolve = false) {
await ensurePlanTable();
const issueRow = await db(PLAN_ISSUE_TABLE)
.where({ plan_item_id: planItemId })
.orderBy('resolved', 'asc')
.orderBy('created_at', 'desc')
.first();
if (!issueRow) {
throw new Error('기록할 이슈 이력이 없습니다.');
}
const nextActionNote = issueRow.action_note
? `${issueRow.action_note}\n[${new Date().toISOString()}] ${actionNote}`
: `[${new Date().toISOString()}] ${actionNote}`;
const rows = await db(PLAN_ISSUE_TABLE)
.where({ id: issueRow.id })
.update({
action_note: nextActionNote,
resolved: resolve ? true : issueRow.resolved,
resolved_at: resolve ? db.fn.now() : issueRow.resolved_at,
})
.returning('*');
await createPlanActionHistory(
planItemId,
resolve ? '이슈해결조치' : '이슈조치',
actionNote,
);
return rows[0];
}
export async function listPlanIssueHistories(planItemId: number) {
await ensurePlanTable();
await ensurePlanFailureIssueHistory(planItemId);
return db(PLAN_ISSUE_TABLE)
.select('*')
.where({ plan_item_id: planItemId })
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
}
export async function listPlanIssueSummaries(planItemIds: number[]) {
await ensurePlanTable();
if (planItemIds.length === 0) {
return new Map<number, { issueTags: string[]; hasOpenIssues: boolean }>();
}
for (const planItemId of planItemIds) {
await ensurePlanFailureIssueHistory(planItemId);
}
const rows = await db(PLAN_ISSUE_TABLE)
.select('*')
.whereIn('plan_item_id', planItemIds)
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
const summaryMap = new Map<number, { issueTags: string[]; hasOpenIssues: boolean }>();
rows.forEach((row) => {
const planItemId = Number(row.plan_item_id);
const current = summaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false };
if (!row.resolved && typeof row.issue_tag === 'string' && !current.issueTags.includes(row.issue_tag)) {
current.issueTags.push(row.issue_tag);
}
if (!row.resolved) {
current.hasOpenIssues = true;
}
summaryMap.set(planItemId, current);
});
return summaryMap;
}
export async function claimNextPlanForBranch(workerId: string) {
await ensurePlanTable();
return db.transaction(async (trx) => {
const row = await trx(PLAN_TABLE)
.select('*')
.where({ status: '등록' })
.where((builder) => {
builder.whereNull('worker_status').orWhere('worker_status', '대기');
})
.orderBy('created_at', 'asc')
.forUpdate()
.skipLocked()
.first();
if (!row) {
return null;
}
const assignedBranch = shouldUseLocalMainPlanMode(row.automation_type)
? String(getEnv().PLAN_MAIN_BRANCH)
: buildPlanBranchName(String(row.work_id), Number(row.id));
const rows = await trx(PLAN_TABLE)
.where({ id: row.id })
.update({
status: '작업중',
assigned_branch: assignedBranch,
worker_status: '브랜치생성중',
last_error: null,
locked_by: workerId,
locked_at: db.fn.now(),
started_at: row.started_at ?? db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
return rows[0];
});
}
export async function claimNextPlanForExecution(workerId: string) {
await ensurePlanTable();
return db.transaction(async (trx) => {
const row = await trx(PLAN_TABLE)
.select('*')
.where({ status: '작업중', worker_status: '브랜치준비' })
.whereNotNull('assigned_branch')
.orderBy('updated_at', 'asc')
.forUpdate()
.skipLocked()
.first();
if (!row) {
return null;
}
const rows = await trx(PLAN_TABLE)
.where({ id: row.id })
.update({
worker_status: '자동작업중',
last_error: null,
locked_by: workerId,
locked_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
return rows[0];
});
}
export async function claimNextPlanForMerge(workerId: string) {
await ensurePlanTable();
return db.transaction(async (trx) => {
const row = await trx(PLAN_TABLE)
.select('*')
.where({ status: '작업완료' })
.whereNotNull('assigned_branch')
.where((builder) => {
builder.whereNull('worker_status').orWhere('worker_status', 'release반영대기');
})
.orderBy('updated_at', 'asc')
.forUpdate()
.skipLocked()
.first();
if (!row) {
return null;
}
const rows = await trx(PLAN_TABLE)
.where({ id: row.id })
.update({
worker_status: 'release반영중',
last_error: null,
locked_by: workerId,
locked_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
return rows[0];
});
}
export async function claimNextPlanForMainMerge(workerId: string) {
await ensurePlanTable();
return db.transaction(async (trx) => {
const candidateRows = await trx(PLAN_TABLE)
.select('*')
.where({ status: '릴리즈완료', worker_status: 'main반영대기' })
.whereNotNull('assigned_branch')
.orderBy('updated_at', 'asc')
.forUpdate();
const row = candidateRows[0];
if (!row) {
return null;
}
const pendingReleaseRow = await trx(PLAN_TABLE)
.select('id')
.where({ release_target: row.release_target, status: '작업완료' })
.first();
if (pendingReleaseRow) {
return null;
}
const batchRows = await trx(PLAN_TABLE)
.select('id')
.where({ status: '릴리즈완료', release_target: row.release_target })
.whereIn('worker_status', ['main반영대기', 'main반영실패'] as never[]);
const batchIds = batchRows.map((candidate) => Number(candidate.id)).filter(Number.isFinite);
const rows = await trx(PLAN_TABLE)
.whereIn('id', batchIds.length > 0 ? batchIds : [Number(row.id)])
.update({
worker_status: 'main반영중',
last_error: null,
locked_by: workerId,
locked_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
return rows[0] ?? null;
});
}
export async function markPlanBranchReady(id: number, workerId: string) {
const rows = await db(PLAN_TABLE)
.where({ id })
.where({ locked_by: workerId })
.update({
worker_status: '브랜치준비',
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
if (!rows[0]) {
return null;
}
return rows[0];
}
export async function markPlanWorkCompleted(id: number, workerId: string, note?: string) {
const currentRow = await db(PLAN_TABLE).where({ id }).first();
const rows = await db(PLAN_TABLE)
.where({ id })
.where({ locked_by: workerId })
.update({
status: '작업완료',
jangsing_processing_required: true,
worker_status: 'release반영대기',
last_error: null,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
if (!rows[0]) {
return null;
}
if (currentRow) {
await createPlanLifecycleSourceWorkHistory(
id,
'자동 작업 완료로 release 반영 대기 상태로 전환했습니다.',
currentRow.assigned_branch ?? currentRow.release_target ?? 'release',
currentRow.assigned_branch
? `현재 작업 브랜치: ${currentRow.assigned_branch}`
: `release 대기 브랜치: ${currentRow.release_target ?? 'release'}`,
);
}
await createPlanActionHistory(id, '작업완료', note ?? '자동 작업을 마치고 release 반영을 대기합니다.');
await resolveAutomationIssueHistories(id);
return rows[0];
}
export async function markPlanReleaseMerged(id: number, workerId: string) {
const currentRow = await db(PLAN_TABLE).where({ id }).first();
const autoDeployToMain = Boolean(currentRow?.auto_deploy_to_main ?? true);
const rows = await db(PLAN_TABLE)
.where({ id })
.where({ locked_by: workerId })
.update({
status: '릴리즈완료',
worker_status: autoDeployToMain ? 'main반영대기' : 'release반영완료',
last_error: null,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
if (!rows[0]) {
return null;
}
if (currentRow) {
await createPlanLifecycleSourceWorkHistory(
id,
'release 브랜치 반영을 완료했습니다.',
currentRow.release_target ?? 'release',
`release 브랜치: ${currentRow.release_target ?? 'release'}`,
);
}
await createPlanActionHistory(
id,
'release반영완료',
autoDeployToMain
? 'release 브랜치 반영을 완료했고 main 자동 반영을 예약했습니다.'
: 'release 브랜치 반영을 완료했습니다.',
);
await resolveAutomationIssueHistories(id);
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function markPlanMerged(id: number, workerId: string) {
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow || currentRow.locked_by !== workerId) {
return null;
}
const batchRows = await db(PLAN_TABLE)
.select('*')
.where({ status: '릴리즈완료', release_target: currentRow.release_target })
.whereIn('worker_status', ['main반영대기', 'main반영중', 'main반영실패'] as never[]);
const batchIds = batchRows.map((row) => Number(row.id)).filter(Number.isFinite);
const targetRows = batchRows.length > 0 ? batchRows : [currentRow];
const rows = await db(PLAN_TABLE)
.whereIn('id', batchIds.length > 0 ? batchIds : [id])
.update({
status: '완료',
worker_status: 'main반영완료',
last_error: null,
locked_by: null,
locked_at: null,
merged_at: db.fn.now(),
completed_at: db.fn.now(),
updated_at: db.fn.now(),
})
.returning('*');
for (const row of targetRows) {
const planId = Number(row.id);
await createPlanLifecycleSourceWorkHistory(
planId,
`${getEnv().PLAN_MAIN_BRANCH} 브랜치 반영을 완료했습니다.`,
getEnv().PLAN_MAIN_BRANCH,
`${row.release_target ?? 'release'} -> ${getEnv().PLAN_MAIN_BRANCH}`,
);
await createPlanActionHistory(
planId,
'main반영완료',
`${row.release_target ?? 'release'} 브랜치를 ${getEnv().PLAN_MAIN_BRANCH} 브랜치에 일괄 반영 완료했습니다.`,
);
await resolveAutomationIssueHistories(planId);
await syncPlanAutomationUsageSnapshot(planId);
}
return {
mergedRow: rows[0] ?? null,
notificationRows: targetRows,
};
}
export async function markPlanMainMergeFailure(releaseTarget: string, workerId: string, errorMessage: string) {
await ensurePlanTable();
const rows = await db(PLAN_TABLE)
.where({ status: '릴리즈완료', release_target: releaseTarget, locked_by: workerId })
.whereIn('worker_status', ['main반영대기', 'main반영중'] as never[])
.update({
worker_status: 'main반영실패',
last_error: errorMessage,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
for (const row of rows) {
await createPlanActionHistory(
Number(row.id),
'main반영실패',
`${releaseTarget} 브랜치 기준 main 일괄 반영에 실패했습니다.`,
);
await syncPlanAutomationUsageSnapshot(Number(row.id));
}
return rows;
}
export async function markPlanAutomationFailure(
id: number,
workerId: string,
workerStatus: Extract<PlanWorkerStatus, '브랜치실패' | '자동작업실패' | 'release반영실패' | 'main반영실패'>,
errorMessage: string,
) {
const currentRow = await db(PLAN_TABLE).where({ id }).first();
if (!currentRow) {
return null;
}
const nextStatus =
workerStatus === '브랜치실패'
? '등록'
: workerStatus === '자동작업실패'
? '작업중'
: workerStatus === 'main반영실패'
? '릴리즈완료'
: '작업완료';
const rows = await db(PLAN_TABLE)
.where({ id })
.where({ locked_by: workerId })
.update({
status: nextStatus,
worker_status: workerStatus,
last_error: errorMessage,
locked_by: null,
locked_at: null,
updated_at: db.fn.now(),
})
.returning('*');
if (!rows[0]) {
return null;
}
const issueHistory = await createPlanIssueHistory(id, workerStatus, errorMessage);
if (issueHistory?.action_note) {
await createPlanActionHistory(
id,
'오류재발',
`동일 오류가 다시 발생했습니다.\n기존 조치내역:\n${issueHistory.action_note}`,
);
}
await syncPlanAutomationUsageSnapshot(id);
return rows[0];
}
export async function listPlanItems(status?: PlanStatus, options?: Pick<PlanRowOptions, 'maskNote'>) {
await ensurePlanTable();
const buildListQuery = () => {
const builder = db(PLAN_TABLE).select('*').orderBy('id', 'desc');
if (status) {
builder.where('status', status);
}
return builder;
};
let rows = await buildListQuery();
const missingSnapshotIds = rows
.filter((row) => row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '')
.map((row) => Number(row.id))
.filter(Number.isFinite);
if (missingSnapshotIds.length > 0) {
await Promise.all(missingSnapshotIds.map((planItemId) => syncPlanAutomationUsageSnapshot(planItemId)));
rows = await buildListQuery();
}
const planItemIds = rows.map((row) => Number(row.id));
const issueSummaryMap = await listPlanIssueSummaries(planItemIds);
const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap(planItemIds);
return rows.map((row) =>
mapPlanRow(row, {
...(issueSummaryMap.get(Number(row.id)) ?? { issueTags: [], hasOpenIssues: false }),
maskNote: options?.maskNote,
noteMasked: options?.maskNote,
releaseReviewNote: releaseReviewNoteMap.get(Number(row.id)) ?? '',
exposeConfiguredAutomationType: true,
}),
);
}
export async function getPlanItemById(id: number, options?: Pick<PlanRowOptions, 'maskNote'>) {
await ensurePlanTable();
await ensurePlanFailureIssueHistory(id);
let row = await db(PLAN_TABLE).where({ id }).first();
if (!row) {
return null;
}
if (row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '') {
await syncPlanAutomationUsageSnapshot(id);
row = await db(PLAN_TABLE).where({ id }).first();
}
const issueSummaryMap = await listPlanIssueSummaries([id]);
const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap([id]);
return mapPlanRow(row, {
...(issueSummaryMap.get(id) ?? { issueTags: [], hasOpenIssues: false }),
maskNote: options?.maskNote,
noteMasked: options?.maskNote,
releaseReviewNote: releaseReviewNoteMap.get(id) ?? '',
exposeConfiguredAutomationType: true,
});
}
function normalizePreviewLookupValue(value: string | null | undefined, stripSearch = false) {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
url.hash = '';
if (stripSearch) {
url.search = '';
}
return url.toString().replace(/\/+$/, '');
} catch {
const withoutHash = trimmed.replace(/#.*$/, '');
const withoutSearch = stripSearch ? withoutHash.replace(/\?.*$/, '') : withoutHash;
return withoutSearch.replace(/\/+$/, '');
}
}
function buildPreviewLookupCandidates(value: string | null | undefined) {
return Array.from(
new Set([
normalizePreviewLookupValue(value, false),
normalizePreviewLookupValue(value, true),
].filter(Boolean)),
);
}
export async function findLatestPlanItem() {
await ensurePlanTable();
const row = await db(PLAN_TABLE).select('id').orderBy('id', 'desc').first();
if (!row?.id) {
return null;
}
return getPlanItemById(Number(row.id));
}
export async function findPlanItemByWorkId(workId: string) {
await ensurePlanTable();
const normalizedWorkId = normalizePlanWorkId(workId);
if (normalizedWorkId === '작업ID') {
return null;
}
const rows = await db(PLAN_TABLE)
.select('id', 'work_id')
.orderBy('id', 'desc');
const matchedRow = rows.find((row) => normalizePlanWorkId(String(row.work_id ?? '')) === normalizedWorkId);
if (!matchedRow?.id) {
return null;
}
return getPlanItemById(Number(matchedRow.id));
}
export async function findPlanItemByPreviewUrl(previewUrl: string) {
await ensurePlanTable();
const lookupCandidates = new Set(buildPreviewLookupCandidates(previewUrl));
if (lookupCandidates.size === 0) {
return null;
}
const rows = await db(PLAN_SOURCE_WORK_TABLE)
.select('plan_item_id', 'preview_url')
.whereNotNull('preview_url')
.orderBy('created_at', 'desc')
.orderBy('id', 'desc');
const matchedRow = rows.find((row) =>
buildPreviewLookupCandidates(String(row.preview_url ?? '')).some((candidate) => lookupCandidates.has(candidate)),
);
if (!matchedRow?.plan_item_id) {
return null;
}
return getPlanItemById(Number(matchedRow.plan_item_id));
}