361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { db } from '../db/client.js';
|
|
|
|
const AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
|
|
|
|
export type AutomationContextRecord = {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
enabled: boolean;
|
|
defaultSelected: boolean;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
|
|
{
|
|
id: 'general-inquiry-default',
|
|
title: '기본 확인',
|
|
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
|
enabled: true,
|
|
defaultSelected: true,
|
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'none-default',
|
|
title: '기본 처리',
|
|
content:
|
|
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
|
enabled: true,
|
|
defaultSelected: true,
|
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'plan-default',
|
|
title: '문서형 처리',
|
|
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
|
enabled: true,
|
|
defaultSelected: false,
|
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'command-execution-default',
|
|
title: '명령 실행',
|
|
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
|
enabled: true,
|
|
defaultSelected: false,
|
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'non-source-work-default',
|
|
title: '비소스 작업',
|
|
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
|
enabled: true,
|
|
defaultSelected: false,
|
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
|
},
|
|
{
|
|
id: 'auto-worker-default',
|
|
title: '자동화 기본 규칙',
|
|
content:
|
|
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
|
enabled: true,
|
|
defaultSelected: true,
|
|
updatedAt: '2026-04-29T00:00:00.000Z',
|
|
},
|
|
];
|
|
|
|
function normalizeText(value: unknown) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function normalizeEnabled(value: unknown) {
|
|
if (typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === 'number') {
|
|
return value !== 0;
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
const normalizedValue = value.trim().toLowerCase();
|
|
|
|
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
|
return false;
|
|
}
|
|
|
|
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return value !== false;
|
|
}
|
|
|
|
function buildContextTitleKey(value: string) {
|
|
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
|
}
|
|
|
|
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
|
|
const leftTime = Date.parse(left.updatedAt);
|
|
const rightTime = Date.parse(right.updatedAt);
|
|
|
|
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
|
return leftTime - rightTime;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
|
|
const title = normalizeText(record.title);
|
|
const content = normalizeText(record.content);
|
|
|
|
if (!title && !content) {
|
|
return null;
|
|
}
|
|
|
|
const rawId = normalizeText(record.id);
|
|
const normalizedId =
|
|
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
return {
|
|
id: normalizedId,
|
|
title: title || 'Context',
|
|
content,
|
|
enabled: normalizeEnabled(record.enabled),
|
|
defaultSelected: normalizeEnabled(record.defaultSelected),
|
|
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
|
|
const byId = new Map<string, AutomationContextRecord>();
|
|
const bySemanticKey = new Map<string, AutomationContextRecord>();
|
|
|
|
(items ?? [])
|
|
.map((item) => normalizeAutomationContext(item))
|
|
.filter((item): item is AutomationContextRecord => Boolean(item))
|
|
.forEach((item) => {
|
|
const currentById = byId.get(item.id);
|
|
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
|
byId.set(item.id, item);
|
|
}
|
|
});
|
|
|
|
for (const item of byId.values()) {
|
|
const semanticKey = buildContextTitleKey(item.title);
|
|
const current = bySemanticKey.get(semanticKey);
|
|
|
|
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
|
bySemanticKey.set(semanticKey, item);
|
|
}
|
|
}
|
|
|
|
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
|
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
|
|
}
|
|
|
|
async function ensureAutomationContextsTable() {
|
|
const hasTable = await db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE);
|
|
|
|
if (!hasTable) {
|
|
await db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
|
|
table.string('id').primary();
|
|
table.string('title').notNullable();
|
|
table.text('content').notNullable().defaultTo('');
|
|
table.boolean('enabled').notNullable().defaultTo(true);
|
|
table.boolean('default_selected').notNullable().defaultTo(false);
|
|
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
|
});
|
|
return;
|
|
}
|
|
|
|
const requiredColumns: Array<[string, (table: any) => void]> = [
|
|
['title', (table) => table.string('title').notNullable().defaultTo('')],
|
|
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
|
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
|
['default_selected', (table) => table.boolean('default_selected').notNullable().defaultTo(false)],
|
|
['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(AUTOMATION_CONTEXTS_TABLE, columnName);
|
|
|
|
if (!hasColumn) {
|
|
await db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
|
|
createColumn(table);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseContextsFromLegacyValue(value: unknown) {
|
|
if (typeof value !== 'string') {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function toAutomationContextRecord(row: Record<string, unknown>) {
|
|
return normalizeAutomationContext({
|
|
id: typeof row.id === 'string' ? row.id : undefined,
|
|
title: typeof row.title === 'string' ? row.title : undefined,
|
|
content: typeof row.content === 'string' ? row.content : undefined,
|
|
enabled: normalizeEnabled(row.enabled),
|
|
defaultSelected: normalizeEnabled(row.default_selected),
|
|
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
|
});
|
|
}
|
|
|
|
async function replaceAutomationContextsInTable(items: AutomationContextRecord[]) {
|
|
await ensureAutomationContextsTable();
|
|
|
|
const nextItems = sanitizeAutomationContexts(items);
|
|
|
|
await db.transaction(async (trx) => {
|
|
await trx(AUTOMATION_CONTEXTS_TABLE).del();
|
|
await trx(AUTOMATION_CONTEXTS_TABLE).insert(
|
|
nextItems.map((item) => ({
|
|
id: item.id,
|
|
title: item.title,
|
|
content: item.content,
|
|
enabled: item.enabled,
|
|
default_selected: item.defaultSelected,
|
|
updated_at: item.updatedAt,
|
|
})),
|
|
);
|
|
});
|
|
|
|
return nextItems;
|
|
}
|
|
|
|
async function seedAutomationContextsFromLegacySources() {
|
|
const seededItems: Partial<AutomationContextRecord>[] = [...DEFAULT_AUTOMATION_CONTEXTS];
|
|
|
|
const hasAutomationTypesTable = await db.schema.hasTable('automation_types');
|
|
if (hasAutomationTypesTable) {
|
|
const rows = await db('automation_types').select('contexts_json');
|
|
for (const row of rows) {
|
|
seededItems.push(...parseContextsFromLegacyValue((row as Record<string, unknown>).contexts_json));
|
|
}
|
|
}
|
|
|
|
return replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems));
|
|
}
|
|
|
|
function isSameAutomationContextList(left: AutomationContextRecord[], right: AutomationContextRecord[]) {
|
|
if (left.length !== right.length) {
|
|
return false;
|
|
}
|
|
|
|
return left.every((item, index) => {
|
|
const target = right[index];
|
|
return (
|
|
target &&
|
|
item.id === target.id &&
|
|
item.title === target.title &&
|
|
item.content === target.content &&
|
|
item.enabled === target.enabled &&
|
|
item.defaultSelected === target.defaultSelected &&
|
|
item.updatedAt === target.updatedAt
|
|
);
|
|
});
|
|
}
|
|
|
|
function mergeDefaultAutomationContexts(items: AutomationContextRecord[]) {
|
|
const byId = new Map(items.map((item) => [item.id, item] as const));
|
|
|
|
for (const defaultItem of DEFAULT_AUTOMATION_CONTEXTS) {
|
|
const existingItem = byId.get(defaultItem.id);
|
|
|
|
if (!existingItem) {
|
|
byId.set(defaultItem.id, defaultItem);
|
|
continue;
|
|
}
|
|
|
|
byId.set(defaultItem.id, {
|
|
...existingItem,
|
|
title: defaultItem.title,
|
|
content: existingItem.content || defaultItem.content,
|
|
});
|
|
}
|
|
|
|
return sanitizeAutomationContexts(Array.from(byId.values()));
|
|
}
|
|
|
|
async function readAutomationContextsFromTable() {
|
|
await ensureAutomationContextsTable();
|
|
|
|
const rows = await db(AUTOMATION_CONTEXTS_TABLE)
|
|
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
|
|
.orderBy('title', 'asc');
|
|
|
|
const savedItems = rows
|
|
.map((row) => toAutomationContextRecord(row as Record<string, unknown>))
|
|
.filter((item): item is AutomationContextRecord => Boolean(item));
|
|
|
|
return sanitizeAutomationContexts(savedItems);
|
|
}
|
|
|
|
export async function getAutomationContextsConfig() {
|
|
const savedContexts = await readAutomationContextsFromTable();
|
|
|
|
if (savedContexts.length === 0 || savedContexts === DEFAULT_AUTOMATION_CONTEXTS) {
|
|
return seedAutomationContextsFromLegacySources();
|
|
}
|
|
|
|
const mergedContexts = mergeDefaultAutomationContexts(savedContexts);
|
|
if (!isSameAutomationContextList(savedContexts, mergedContexts)) {
|
|
await replaceAutomationContextsInTable(mergedContexts);
|
|
}
|
|
|
|
return mergedContexts;
|
|
}
|
|
|
|
export async function upsertAutomationContextsConfig(items: unknown[]) {
|
|
const nextContexts = mergeDefaultAutomationContexts(
|
|
sanitizeAutomationContexts(Array.isArray(items) ? (items as Partial<AutomationContextRecord>[]) : []),
|
|
);
|
|
|
|
return replaceAutomationContextsInTable(nextContexts);
|
|
}
|
|
|
|
export function normalizeAutomationContextSelection(value: unknown) {
|
|
const rawValues = Array.isArray(value)
|
|
? value
|
|
: typeof value === 'string'
|
|
? value
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
|
|
}
|
|
|
|
export function resolveAutomationContexts(
|
|
contexts: AutomationContextRecord[] | null | undefined,
|
|
selectedContextIds?: unknown,
|
|
) {
|
|
const normalizedContexts = sanitizeAutomationContexts(contexts);
|
|
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
|
|
|
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
|
return [];
|
|
}
|
|
|
|
if (requestedIds.length === 0) {
|
|
return normalizedContexts.filter((item) => item.enabled && item.defaultSelected);
|
|
}
|
|
|
|
const requestedIdSet = new Set(requestedIds);
|
|
return normalizedContexts.filter((item) => requestedIdSet.has(item.id));
|
|
}
|