feat: update codex live automation and plan flows
This commit is contained in:
@@ -57,15 +57,7 @@ npm run server-command:runner
|
||||
|
||||
소스 수정이 필요하면 **현재 실행 환경에서 실제로 연결된 `main_project` 저장소 루트의 `AGENTS.md` 규칙을 먼저 확인한 뒤**, 그 작업 트리에서 바로 수정합니다.
|
||||
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다.
|
||||
|
||||
단, 자동화 작업메모(`auto_worker`)는 예외적으로 아래 Git 흐름을 기본 동작으로 사용합니다.
|
||||
|
||||
- 신규 `feature/*` 브랜치 생성
|
||||
- 자동 작업 수행
|
||||
- `release` 브랜치 반영
|
||||
- `main` 일괄반영
|
||||
- `PLAN_MAIN_PROJECT_REPO_PATH` 기준 프로젝트 루트에서 `pull --ff-only`
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다.
|
||||
|
||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
@@ -77,15 +69,7 @@ npm run server-command:runner
|
||||
|
||||
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
|
||||
|
||||
현재 운영 기준에서 자동화 작업메모는 아래 브랜치 흐름을 기본 동작으로 사용합니다.
|
||||
|
||||
- `등록` 상태: worker가 읽어서 신규 `feature/plan-{id}-{workId}` 브랜치 생성
|
||||
- 성공 시: `작업중`, `브랜치준비`
|
||||
- 실패 시: `이슈`, 최근 오류 기록
|
||||
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도
|
||||
- 병합 성공 시: `완료`
|
||||
- 병합 실패 시: `이슈`
|
||||
- `main` 반영 성공 시: `PLAN_MAIN_PROJECT_REPO_PATH`에서 `main` 브랜치 `pull --ff-only`까지 수행
|
||||
현재 운영 기준에서 자동화 작업메모의 세부 Git 절차는 이 문서에 고정하지 않습니다. 상태 전이와 실제 처리 흐름은 worker 구현, 환경 변수, 현재 운영 정책을 함께 확인해야 합니다.
|
||||
|
||||
안전 조건:
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ services:
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
cpus: 1.5
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js';
|
||||
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async () => {
|
||||
@@ -21,6 +22,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/automation-types', async () => {
|
||||
const automationTypes = await getAutomationTypesConfig();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automationTypes,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/chat-types', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
@@ -50,6 +60,35 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/automation-types', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
automationTypes: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
|
||||
const savedAutomationTypes = await upsertAutomationTypesConfig(parsed.automationTypes);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automationTypes: savedAutomationTypes,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
@@ -9,6 +9,8 @@ import { PlanWorker } from './workers/plan-worker.js';
|
||||
const app = createApp();
|
||||
const planWorker = new PlanWorker(app.log);
|
||||
const chatService = new ChatService(app.log);
|
||||
const startedAt = Date.now();
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
app.server.on('upgrade', chatService.attachUpgradeHandler());
|
||||
|
||||
async function start() {
|
||||
@@ -27,14 +29,32 @@ async function start() {
|
||||
}
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
app.log.info(`Received ${signal}, closing server`);
|
||||
if (shutdownPromise) {
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
await planWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
await shutdownNotificationProvider();
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
shutdownPromise = (async () => {
|
||||
app.log.warn({
|
||||
signal,
|
||||
pid: process.pid,
|
||||
uptimeSeconds: Math.round((Date.now() - startedAt) / 1000),
|
||||
rssBytes: process.memoryUsage().rss,
|
||||
}, 'Received shutdown signal');
|
||||
|
||||
try {
|
||||
await planWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
await shutdownNotificationProvider();
|
||||
await db.destroy();
|
||||
process.exitCode = 0;
|
||||
} catch (error) {
|
||||
app.log.error({ error, signal }, 'Failed to shut down cleanly');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
})();
|
||||
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
|
||||
@@ -3,6 +3,37 @@ import { db } from '../db/client.js';
|
||||
export const APP_CONFIG_TABLE = 'app_configs';
|
||||
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
||||
|
||||
type ChatPermissionRole = 'guest' | 'token-user';
|
||||
|
||||
type ChatTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'api-request-template',
|
||||
name: 'API요청',
|
||||
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureAppConfigTable() {
|
||||
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
||||
|
||||
@@ -58,6 +89,134 @@ function normalizeConfigRecord(value: unknown) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizePermissions(value: unknown): ChatPermissionRole[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return ['token-user'];
|
||||
}
|
||||
|
||||
const permissions = Array.from(
|
||||
new Set(
|
||||
value.filter(
|
||||
(item): item is ChatPermissionRole => item === 'guest' || item === 'token-user',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return permissions.length > 0 ? permissions : ['token-user'];
|
||||
}
|
||||
|
||||
function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Partial<ChatTypeRecord>;
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: normalizeText(record.id) || `chat-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
|
||||
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
|
||||
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 sanitizeChatTypes(items: unknown[]) {
|
||||
const normalized = items
|
||||
.map((item) => normalizeChatTypeRecord(item))
|
||||
.filter((item): item is ChatTypeRecord => Boolean(item));
|
||||
|
||||
const byId = new Map<string, ChatTypeRecord>();
|
||||
const bySemanticKey = new Map<string, ChatTypeRecord>();
|
||||
|
||||
for (const item of normalized) {
|
||||
const current = byId.get(item.id);
|
||||
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = buildChatTypeSemanticKey(item);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
}
|
||||
|
||||
function mergeDefaultChatTypes(items: unknown[]) {
|
||||
const savedItems = sanitizeChatTypes(items);
|
||||
const byId = new Map(savedItems.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_CHAT_TYPES) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
name: defaultItem.name,
|
||||
description: defaultItem.description,
|
||||
permissions: defaultItem.permissions,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt &&
|
||||
item.permissions.length === target.permissions.length &&
|
||||
item.permissions.every((permission, permissionIndex) => permission === target.permissions[permissionIndex])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export type AppConfigSnapshot = {
|
||||
chat?: {
|
||||
maxContextMessages?: number;
|
||||
@@ -149,16 +308,30 @@ export async function getChatTypesConfig() {
|
||||
const config = await getAppConfig();
|
||||
const normalized = normalizeConfigRecord(config);
|
||||
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
|
||||
return Array.isArray(chatTypes) ? chatTypes : null;
|
||||
if (chatTypes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
|
||||
const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
|
||||
|
||||
if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) {
|
||||
await upsertAppConfig({
|
||||
[CHAT_TYPES_CONFIG_KEY]: mergedChatTypes,
|
||||
});
|
||||
}
|
||||
|
||||
return mergedChatTypes;
|
||||
}
|
||||
|
||||
export async function upsertChatTypesConfig(chatTypes: unknown[]) {
|
||||
const current = normalizeConfigRecord(await getAppConfig());
|
||||
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
|
||||
const nextConfig = {
|
||||
...current,
|
||||
[CHAT_TYPES_CONFIG_KEY]: Array.isArray(chatTypes) ? chatTypes : [],
|
||||
[CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
|
||||
};
|
||||
|
||||
await upsertAppConfig(nextConfig);
|
||||
return nextConfig[CHAT_TYPES_CONFIG_KEY] as unknown[];
|
||||
return resolvedChatTypes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { db } from '../db/client.js';
|
||||
import { getAppConfig } from './app-config-service.js';
|
||||
|
||||
const AUTOMATION_TYPES_TABLE = 'automation_types';
|
||||
const AUTOMATION_TYPES_CONFIG_KEY = 'automationTypes';
|
||||
|
||||
export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
'none',
|
||||
'plan',
|
||||
'command_execution',
|
||||
'non_source_work',
|
||||
'auto_worker',
|
||||
] as const;
|
||||
|
||||
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
|
||||
|
||||
export type AutomationTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
behaviorType: 'auto_worker',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
const byId = new Map(items.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
name: defaultItem.name,
|
||||
description: defaultItem.description,
|
||||
behaviorType: defaultItem.behaviorType,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeAutomationTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
item.behaviorType === target.behaviorType &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function ensureAutomationTypesTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.string('behavior_type').notNullable();
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['name', (table) => table.string('name').notNullable().defaultTo('')],
|
||||
['description', (table) => table.text('description').notNullable().defaultTo('')],
|
||||
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['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_TYPES_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
|
||||
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
return AUTOMATION_BEHAVIOR_TYPES.includes(normalizedValue as AutomationBehaviorType)
|
||||
? (normalizedValue as AutomationBehaviorType)
|
||||
: 'none';
|
||||
}
|
||||
|
||||
export function normalizeLegacyAutomationBehaviorType(value: unknown): string {
|
||||
const normalizedValue = normalizeText(value);
|
||||
|
||||
if (normalizedValue === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
|
||||
if (normalizedValue === 'general_development') {
|
||||
return 'auto_worker';
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function buildNameKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId ||
|
||||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecord) {
|
||||
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 dedupeAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
const byId = new Map<string, AutomationTypeRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationTypeRecord>();
|
||||
|
||||
for (const item of items) {
|
||||
const currentById = byId.get(item.id);
|
||||
|
||||
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = `${item.behaviorType}:${buildNameKey(item.name)}`;
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
}
|
||||
|
||||
export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] | null | undefined) {
|
||||
const normalized = (items ?? [])
|
||||
.map((item) => normalizeAutomationType(item))
|
||||
.filter((item): item is AutomationTypeRecord => Boolean(item));
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return DEFAULT_AUTOMATION_TYPES;
|
||||
}
|
||||
|
||||
return dedupeAutomationTypes(normalized);
|
||||
}
|
||||
|
||||
function normalizeConfigRecord(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toAutomationTypeRecord(row: Record<string, unknown>) {
|
||||
return normalizeAutomationType({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAutomationTypesFromLegacyConfig() {
|
||||
const config = normalizeConfigRecord(await getAppConfig());
|
||||
const raw = config[AUTOMATION_TYPES_CONFIG_KEY];
|
||||
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return mergeDefaultAutomationTypes(DEFAULT_AUTOMATION_TYPES);
|
||||
}
|
||||
|
||||
const legacyItems = mergeDefaultAutomationTypes(
|
||||
sanitizeAutomationTypes(raw as Partial<AutomationTypeRecord>[]),
|
||||
);
|
||||
await replaceAutomationTypesInTable(legacyItems);
|
||||
return legacyItems;
|
||||
}
|
||||
|
||||
async function readAutomationTypesFromTable() {
|
||||
await ensureAutomationTypesTable();
|
||||
|
||||
const rows = await db(AUTOMATION_TYPES_TABLE)
|
||||
.select('id', 'name', 'description', 'behavior_type', 'enabled', 'updated_at')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const savedItems = rows
|
||||
.map((row) => toAutomationTypeRecord(row as Record<string, unknown>))
|
||||
.filter((item): item is AutomationTypeRecord => Boolean(item));
|
||||
|
||||
return sanitizeAutomationTypes(savedItems);
|
||||
}
|
||||
|
||||
async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
|
||||
await ensureAutomationTypesTable();
|
||||
|
||||
const nextItems = sanitizeAutomationTypes(items);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(AUTOMATION_TYPES_TABLE).del();
|
||||
|
||||
await trx(AUTOMATION_TYPES_TABLE).insert(
|
||||
nextItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
behavior_type: item.behaviorType,
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
export async function getAutomationTypesConfig() {
|
||||
const savedAutomationTypes = await readAutomationTypesFromTable();
|
||||
const automationTypes = mergeDefaultAutomationTypes(savedAutomationTypes);
|
||||
|
||||
if (automationTypes.length === 0 || automationTypes === DEFAULT_AUTOMATION_TYPES) {
|
||||
return seedAutomationTypesFromLegacyConfig();
|
||||
}
|
||||
|
||||
if (!isSameAutomationTypeList(savedAutomationTypes, automationTypes)) {
|
||||
await replaceAutomationTypesInTable(automationTypes);
|
||||
}
|
||||
|
||||
return automationTypes;
|
||||
}
|
||||
|
||||
export async function upsertAutomationTypesConfig(items: unknown[]) {
|
||||
const nextAutomationTypes = mergeDefaultAutomationTypes(
|
||||
sanitizeAutomationTypes(Array.isArray(items) ? (items as Partial<AutomationTypeRecord>[]) : []),
|
||||
);
|
||||
return replaceAutomationTypesInTable(nextAutomationTypes);
|
||||
}
|
||||
|
||||
export async function resolveAutomationType(input: unknown) {
|
||||
const requestedId = normalizeLegacyAutomationBehaviorType(input);
|
||||
const automationTypes = await getAutomationTypesConfig();
|
||||
const matched = automationTypes.find((item) => item.id === requestedId);
|
||||
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
const matchedByBehavior = automationTypes.find((item) => item.behaviorType === normalizeBehaviorType(requestedId));
|
||||
|
||||
if (matchedByBehavior) {
|
||||
return matchedByBehavior;
|
||||
}
|
||||
|
||||
return (
|
||||
DEFAULT_AUTOMATION_TYPES.find((item) => item.id === normalizeBehaviorType(requestedId)) ??
|
||||
DEFAULT_AUTOMATION_TYPES[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveStoredAutomationTypeId(row: Record<string, unknown>) {
|
||||
const automationTypeId = normalizeText(row.automation_type_id);
|
||||
|
||||
if (automationTypeId) {
|
||||
return normalizeLegacyAutomationBehaviorType(automationTypeId);
|
||||
}
|
||||
|
||||
return normalizeLegacyAutomationBehaviorType(row.automation_type) || 'none';
|
||||
}
|
||||
@@ -4,12 +4,19 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
|
||||
|
||||
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n'),
|
||||
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
|
||||
name: '자동화 메모',
|
||||
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||
}),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
'- 게시판 제목: 알림 개선',
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'- 선택 자동화 유형: 자동화 메모',
|
||||
'',
|
||||
'## 선택된 유형 context',
|
||||
'## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
'본문 첫 줄\n본문 둘째 줄',
|
||||
@@ -17,6 +24,28 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote('작업', '본문', {
|
||||
name: '빈 context 유형',
|
||||
description: ' ',
|
||||
}),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
'- 게시판 제목: 작업',
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'- 선택 자동화 유형: 빈 context 유형',
|
||||
'',
|
||||
'## 선택된 유형 context',
|
||||
'선택된 자동화 유형 context 없음',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
'본문',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
|
||||
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
|
||||
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
PLAN_TABLE,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-service.js';
|
||||
import {
|
||||
resolveAutomationType,
|
||||
resolveStoredAutomationTypeId,
|
||||
type AutomationTypeRecord,
|
||||
} from './automation-type-config-service.js';
|
||||
|
||||
export const BOARD_POSTS_TABLE = 'board_posts';
|
||||
|
||||
@@ -58,7 +63,7 @@ function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
||||
title: String(row.title ?? ''),
|
||||
content,
|
||||
preview: createPreview(content),
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
automationType: resolveStoredAutomationTypeId(row),
|
||||
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
|
||||
? null
|
||||
: Number(row.automation_plan_item_id),
|
||||
@@ -74,19 +79,27 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
|
||||
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(title: string, content: string) {
|
||||
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null) {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedContent = content.trim();
|
||||
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
|
||||
const normalizedAutomationContext = String(automationType?.description ?? '').trim();
|
||||
|
||||
return [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
`- 게시판 제목: ${normalizedTitle}`,
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null,
|
||||
'',
|
||||
'## 선택된 유형 context',
|
||||
normalizedAutomationContext || '선택된 자동화 유형 context 없음',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
].join('\n');
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function resolveInsertedId(result: unknown): number | null {
|
||||
@@ -159,6 +172,7 @@ export async function ensureBoardPostsTable() {
|
||||
['title', (table) => table.string('title', 200).notNullable().defaultTo('제목 없음')],
|
||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
|
||||
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
||||
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
@@ -187,6 +201,11 @@ export async function ensureBoardPostsTable() {
|
||||
await db(BOARD_POSTS_TABLE)
|
||||
.where({ automation_type: 'general_development' })
|
||||
.update({ automation_type: 'auto_worker' });
|
||||
await db(BOARD_POSTS_TABLE)
|
||||
.whereNull('automation_type_id')
|
||||
.update({
|
||||
automation_type_id: db.raw('automation_type'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBoardPosts() {
|
||||
@@ -206,10 +225,12 @@ export async function getBoardPost(id: number) {
|
||||
export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSchema>) {
|
||||
await ensureBoardPostsTable();
|
||||
const parsedPayload = boardPostPayloadSchema.parse(payload);
|
||||
const automationType = await resolveAutomationType(parsedPayload.automationType);
|
||||
const insertQuery = db(BOARD_POSTS_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
@@ -255,10 +276,12 @@ export async function receiveBoardPostAutomation(id: number) {
|
||||
const title = String(currentRow.title ?? '').trim();
|
||||
const content = String(currentRow.content ?? '').trim();
|
||||
const workId = `board-post-${id}`;
|
||||
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
|
||||
const insertQuery = trx(PLAN_TABLE).insert({
|
||||
work_id: workId,
|
||||
note: buildBoardPostPlanNote(title, content),
|
||||
note: buildBoardPostPlanNote(title, content, automationType),
|
||||
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
||||
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
|
||||
status: '등록',
|
||||
release_target: 'release',
|
||||
jangsing_processing_required: true,
|
||||
@@ -303,6 +326,7 @@ export async function receiveBoardPostAutomation(id: number) {
|
||||
export async function updateBoardPost(id: number, payload: z.infer<typeof boardPostPayloadSchema>) {
|
||||
await ensureBoardPostsTable();
|
||||
const parsedPayload = boardPostPayloadSchema.parse(payload);
|
||||
const automationType = await resolveAutomationType(parsedPayload.automationType);
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
@@ -319,7 +343,8 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
|
||||
.update({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ test('shouldUseAgenticCodexReply keeps fast-path responses for automation regist
|
||||
assert.equal(shouldUseAgenticCodexReply('오늘 자동화 등록 총 건수'), false);
|
||||
});
|
||||
|
||||
test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => {
|
||||
test('shouldUseTemplateMacroReply is disabled for chat types', () => {
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
@@ -49,13 +49,12 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
chatTypeLabel: 'API요청',
|
||||
chatTypeDescription: 'API 요청 본문을 정리합니다.',
|
||||
},
|
||||
'이 템플릿 예시 보여줘',
|
||||
),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
@@ -66,9 +65,8 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
chatTypeLabel: 'API요청',
|
||||
chatTypeDescription: 'API 요청 본문을 정리합니다.',
|
||||
},
|
||||
'아이패드 말풍선 폰트 조금 줄여줘',
|
||||
),
|
||||
@@ -85,7 +83,6 @@ test('shouldUseTemplateMacroReply only matches template chats and template-scope
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청',
|
||||
chatTypeIsTemplate: false,
|
||||
},
|
||||
'템플릿 예시 보여줘',
|
||||
),
|
||||
|
||||
@@ -60,7 +60,6 @@ type ChatContext = {
|
||||
chatTypeId?: string | null;
|
||||
chatTypeLabel?: string;
|
||||
chatTypeDescription?: string;
|
||||
chatTypeIsTemplate?: boolean;
|
||||
};
|
||||
|
||||
type ChatInboundMessage =
|
||||
@@ -89,7 +88,6 @@ type ChatInboundMessage =
|
||||
chatTypeId?: string | null;
|
||||
chatTypeLabel?: string;
|
||||
chatTypeDescription?: string;
|
||||
chatTypeIsTemplate?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -875,45 +873,10 @@ export function shouldUseAgenticCodexReply(input: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function extractTemplateKeywords(context: ChatContext | null) {
|
||||
const raw = `${context?.chatTypeLabel ?? ''} ${context?.chatTypeDescription ?? ''}`;
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
raw
|
||||
.split(/[^0-9A-Za-z가-힣]+/)
|
||||
.map((keyword) => keyword.trim())
|
||||
.filter((keyword) => keyword.length >= 2)
|
||||
.filter(
|
||||
(keyword) =>
|
||||
!['템플릿', 'template', 'chat', 'codex', 'live', '요청', '일반', '기본', '유형'].includes(keyword.toLowerCase()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) {
|
||||
if (context?.chatTypeIsTemplate !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = input.toLowerCase();
|
||||
|
||||
if (
|
||||
input.includes('템플릿') ||
|
||||
input.includes('양식') ||
|
||||
input.includes('포맷') ||
|
||||
input.includes('형식') ||
|
||||
input.includes('예시') ||
|
||||
input.includes('샘플') ||
|
||||
normalized.includes('template') ||
|
||||
normalized.includes('format') ||
|
||||
normalized.includes('sample')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return extractTemplateKeywords(context).some((keyword) => normalized.includes(keyword.toLowerCase()));
|
||||
void context;
|
||||
void input;
|
||||
return false;
|
||||
}
|
||||
|
||||
function summarizeCodexOutput(output: string) {
|
||||
@@ -1466,7 +1429,6 @@ function buildAgenticCodexPrompt(
|
||||
const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`;
|
||||
const recentHistoryLines = promptContext?.recentHistoryLines ?? [];
|
||||
const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0);
|
||||
const isTemplateRequest = context?.chatTypeIsTemplate === true;
|
||||
|
||||
return [
|
||||
'당신은 이 저장소에서 Codex Live 요청을 처리하는 실제 Codex 실행기입니다.',
|
||||
@@ -1477,7 +1439,7 @@ function buildAgenticCodexPrompt(
|
||||
'- 필요 시 DB 직접 조회',
|
||||
'- 필요 시 로컬 API 응답 확인',
|
||||
'- 사용자가 요청했거나 해결에 필요하면 소스 코드 수정',
|
||||
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md의 브랜치 전략을 먼저 확인하고 그 규칙 안에서만 작업하세요.',
|
||||
'- Codex Live에서 소스 수정이 필요하면 현재 프로젝트 환경의 main_project 저장소 루트에서 바로 수정하세요. 단, AGENTS.md를 먼저 확인하고 그 규칙 안에서만 작업하세요.',
|
||||
`- 현재 채팅 세션 리소스 기본 경로: ${chatSessionResourceDir}/`,
|
||||
`- 현재 채팅 첨부 업로드 경로: ${chatSessionUploadDir}/`,
|
||||
'- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.',
|
||||
@@ -1494,7 +1456,6 @@ function buildAgenticCodexPrompt(
|
||||
'채팅 유형 문맥(우선 적용):',
|
||||
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
|
||||
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
|
||||
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
|
||||
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
|
||||
'',
|
||||
'참고 화면 정보:',
|
||||
@@ -1503,20 +1464,15 @@ function buildAgenticCodexPrompt(
|
||||
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
|
||||
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
|
||||
'',
|
||||
isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:',
|
||||
...(isTemplateRequest
|
||||
'최근 대화 문맥:',
|
||||
...(recentHistoryLines.length > 0
|
||||
? [
|
||||
'- 이 요청은 템플릿 유형입니다.',
|
||||
'- 이전 채팅방 내용은 참조하지 말고, 현재 화면 문맥/유형 설명/사용자 요청만 기준으로 처리하세요.',
|
||||
...recentHistoryLines.map((line) => `- ${line}`),
|
||||
...(omittedHistoryCount > 0
|
||||
? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
|
||||
: []),
|
||||
]
|
||||
: recentHistoryLines.length > 0
|
||||
? [
|
||||
...recentHistoryLines.map((line) => `- ${line}`),
|
||||
...(omittedHistoryCount > 0
|
||||
? [`- 최근 문맥 일부만 포함했습니다. 이전 ${omittedHistoryCount}개 메시지는 제외되었습니다.`]
|
||||
: []),
|
||||
]
|
||||
: ['- 참조할 최근 대화가 없습니다.']),
|
||||
: ['- 참조할 최근 대화가 없습니다.']),
|
||||
'',
|
||||
'사용자 요청:',
|
||||
input,
|
||||
@@ -1669,13 +1625,10 @@ async function runAgenticCodexReply(
|
||||
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
|
||||
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
|
||||
const appConfig = await getAppConfigSnapshot();
|
||||
const recentHistory =
|
||||
context?.chatTypeIsTemplate === true
|
||||
? { items: [] as string[], omittedCount: 0 }
|
||||
: await buildRecentChatPromptHistory(sessionId, requestId, {
|
||||
maxMessages: appConfig.chat?.maxContextMessages,
|
||||
maxChars: appConfig.chat?.maxContextChars,
|
||||
});
|
||||
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
|
||||
maxMessages: appConfig.chat?.maxContextMessages,
|
||||
maxChars: appConfig.chat?.maxContextChars,
|
||||
});
|
||||
const prompt = buildAgenticCodexPrompt(context, input, sessionId, {
|
||||
recentHistoryLines: recentHistory.items,
|
||||
omittedHistoryCount: recentHistory.omittedCount,
|
||||
@@ -2980,7 +2933,6 @@ export class ChatService {
|
||||
chatTypeId: message.payload.chatTypeId ?? null,
|
||||
chatTypeLabel: message.payload.chatTypeLabel,
|
||||
chatTypeDescription: message.payload.chatTypeDescription,
|
||||
chatTypeIsTemplate: message.payload.chatTypeIsTemplate,
|
||||
},
|
||||
).catch((error: unknown) => {
|
||||
this.logger.error(error, 'chat reply build failed');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { resolveAutomationType, resolveStoredAutomationTypeId } from './automation-type-config-service.js';
|
||||
import {
|
||||
createCompletedPlanExecutionLogItem,
|
||||
createPlanActionHistory,
|
||||
@@ -186,7 +187,7 @@ export function mapPlanScheduledTaskRow(row: Record<string, unknown>) {
|
||||
id: row.id,
|
||||
workId: row.work_id,
|
||||
note: row.note,
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
automationType: resolveStoredAutomationTypeId(row),
|
||||
releaseTarget: row.release_target,
|
||||
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
@@ -229,6 +230,7 @@ export async function ensurePlanScheduledTaskTable() {
|
||||
table.string('work_id', 120).notNullable().defaultTo('반복작업');
|
||||
table.text('note').notNullable().defaultTo('');
|
||||
table.string('automation_type', 40).notNullable().defaultTo('none');
|
||||
table.string('automation_type_id', 120).nullable();
|
||||
table.string('release_target', 120).notNullable().defaultTo('release');
|
||||
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
|
||||
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
|
||||
@@ -252,6 +254,9 @@ export async function ensurePlanScheduledTaskTable() {
|
||||
await ensurePlanScheduledTaskColumn('automation_type', (table) => {
|
||||
table.string('automation_type', 40).notNullable().defaultTo('none');
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('automation_type_id', (table) => {
|
||||
table.string('automation_type_id', 120).nullable();
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => {
|
||||
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
|
||||
});
|
||||
@@ -295,6 +300,11 @@ export async function ensurePlanScheduledTaskTable() {
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ automation_type: 'general_development' })
|
||||
.update({ automation_type: 'auto_worker' });
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.whereNull('automation_type_id')
|
||||
.update({
|
||||
automation_type_id: db.raw('automation_type'),
|
||||
});
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ repeat_interval_unit: 'minute' })
|
||||
@@ -320,12 +330,14 @@ export async function createPlanScheduledTask(payload: z.infer<typeof createPlan
|
||||
const scheduleMode = normalizeScheduleMode(payload.scheduleMode);
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue);
|
||||
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit);
|
||||
const automationType = await resolveAutomationType(payload.automationType);
|
||||
|
||||
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.insert({
|
||||
work_id: normalizeScheduledWorkId(payload.workId),
|
||||
note: payload.note,
|
||||
automation_type: normalizePlanAutomationType(payload.automationType),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
@@ -358,13 +370,17 @@ export async function updatePlanScheduledTask(id: number, payload: z.infer<typeo
|
||||
payload.repeatIntervalValue ?? currentRow.repeat_interval_value ?? currentRow.repeat_interval_minutes ?? 60,
|
||||
);
|
||||
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit ?? currentRow.repeat_interval_unit);
|
||||
const automationType = await resolveAutomationType(
|
||||
payload.automationType ?? currentRow.automation_type_id ?? currentRow.automation_type,
|
||||
);
|
||||
|
||||
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId),
|
||||
note: payload.note ?? currentRow.note,
|
||||
automation_type: normalizePlanAutomationType(payload.automationType ?? currentRow.automation_type),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
release_target: payload.releaseTarget ?? currentRow.release_target ?? 'release',
|
||||
jangsing_processing_required:
|
||||
payload.jangsingProcessingRequired ?? currentRow.jangsing_processing_required ?? true,
|
||||
@@ -458,7 +474,7 @@ async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: D
|
||||
const executionLog = await createCompletedPlanExecutionLogItem({
|
||||
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
|
||||
note: executionLogNoteLines.join('\n'),
|
||||
automationType: 'plan',
|
||||
automationType: String(row.automation_type_id ?? row.automation_type ?? 'plan'),
|
||||
releaseTarget: String(row.release_target ?? 'release'),
|
||||
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
@@ -500,7 +516,7 @@ async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: D
|
||||
const createdPlan = await createPlanItem({
|
||||
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
|
||||
note: String(row.note ?? ''),
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
automationType: String(row.automation_type_id ?? row.automation_type ?? 'none'),
|
||||
releaseTarget: String(row.release_target ?? 'release'),
|
||||
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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';
|
||||
@@ -53,6 +59,7 @@ type PlanRowOptions = {
|
||||
maskNote?: boolean;
|
||||
noteMasked?: boolean;
|
||||
releaseReviewNote?: string;
|
||||
exposeConfiguredAutomationType?: boolean;
|
||||
};
|
||||
|
||||
export const statusSchema = z.enum(planStatuses);
|
||||
@@ -62,32 +69,15 @@ export const setupSchema = z.object({
|
||||
});
|
||||
|
||||
function resolvePlanAutomationTypeAlias(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalizedValue = value.trim();
|
||||
|
||||
if (normalizedValue === 'plan_registration') {
|
||||
return 'plan';
|
||||
}
|
||||
|
||||
if (normalizedValue === 'general_development') {
|
||||
return 'auto_worker';
|
||||
}
|
||||
|
||||
return normalizedValue;
|
||||
return normalizeLegacyAutomationBehaviorType(value);
|
||||
}
|
||||
|
||||
export const planAutomationTypeSchema = z.preprocess(
|
||||
resolvePlanAutomationTypeAlias,
|
||||
z.enum(planAutomationTypes),
|
||||
);
|
||||
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.enum(planAutomationTypes).default('none')),
|
||||
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),
|
||||
@@ -98,7 +88,7 @@ export const createPlanSchema = z.object({
|
||||
export const updatePlanSchema = z.object({
|
||||
workId: z.string().trim().optional(),
|
||||
note: z.string().optional(),
|
||||
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).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(),
|
||||
@@ -135,7 +125,7 @@ export const listPlanQuerySchema = z.object({
|
||||
});
|
||||
|
||||
export type PlanStatus = (typeof planStatuses)[number];
|
||||
export type PlanAutomationType = (typeof planAutomationTypes)[number];
|
||||
export type PlanAutomationType = string;
|
||||
export type PlanWorkerStatus = (typeof planWorkerStatuses)[number];
|
||||
export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number];
|
||||
|
||||
@@ -191,8 +181,8 @@ function normalizePlanWorkId(value?: string | null) {
|
||||
|
||||
export function normalizePlanAutomationType(value: unknown): PlanAutomationType {
|
||||
const normalizedValue = resolvePlanAutomationTypeAlias(value);
|
||||
return planAutomationTypes.includes(normalizedValue as PlanAutomationType)
|
||||
? (normalizedValue as PlanAutomationType)
|
||||
return planAutomationTypes.includes(normalizedValue as AutomationBehaviorType)
|
||||
? (normalizedValue as AutomationBehaviorType)
|
||||
: 'none';
|
||||
}
|
||||
|
||||
@@ -281,7 +271,8 @@ export function mapPlanRow(
|
||||
id: row.id,
|
||||
workId: row.work_id,
|
||||
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
|
||||
automationBehaviorType: normalizePlanAutomationType(row.automation_type),
|
||||
releaseReviewNote: options?.releaseReviewNote ?? '',
|
||||
noteMasked: Boolean(options?.noteMasked),
|
||||
status: row.status,
|
||||
@@ -825,6 +816,9 @@ async function syncPlanColumns() {
|
||||
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);
|
||||
});
|
||||
@@ -871,6 +865,11 @@ async function syncPlanColumns() {
|
||||
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() {
|
||||
@@ -1090,13 +1089,15 @@ export async function ensurePlanTable() {
|
||||
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: normalizePlanAutomationType(payload.automationType),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
@@ -1114,13 +1115,15 @@ export async function createPlanItem(payload: z.infer<typeof createPlanSchema>)
|
||||
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: normalizePlanAutomationType(payload.automationType),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
@@ -1159,11 +1162,12 @@ export async function upsertAutoPlanItem(args: {
|
||||
.first();
|
||||
|
||||
if (!existingRow) {
|
||||
const automationType = await resolveAutomationType(args.automationType);
|
||||
return {
|
||||
row: await createPlanItem({
|
||||
workId,
|
||||
note: args.note,
|
||||
automationType: normalizePlanAutomationType(args.automationType),
|
||||
automationType: automationType.id,
|
||||
releaseTarget: args.releaseTarget,
|
||||
jangsingProcessingRequired: args.jangsingProcessingRequired,
|
||||
autoDeployToMain: args.autoDeployToMain,
|
||||
@@ -1175,7 +1179,7 @@ export async function upsertAutoPlanItem(args: {
|
||||
}
|
||||
|
||||
const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release';
|
||||
const nextAutomationType = normalizePlanAutomationType(args.automationType ?? existingRow.automation_type);
|
||||
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;
|
||||
@@ -1185,7 +1189,7 @@ export async function upsertAutoPlanItem(args: {
|
||||
: existingRow.normal_processing_level === '상';
|
||||
const hasPayloadChange =
|
||||
existingRow.note !== nextNote ||
|
||||
normalizePlanAutomationType(existingRow.automation_type) !== nextAutomationType ||
|
||||
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;
|
||||
@@ -1202,7 +1206,8 @@ export async function upsertAutoPlanItem(args: {
|
||||
.where({ id: existingRow.id })
|
||||
.update({
|
||||
note: nextNote,
|
||||
automation_type: nextAutomationType,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
@@ -1223,7 +1228,8 @@ export async function upsertAutoPlanItem(args: {
|
||||
note: nextNote,
|
||||
status: '등록',
|
||||
assigned_branch: null,
|
||||
automation_type: nextAutomationType,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
@@ -1262,7 +1268,9 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
|
||||
const nextWorkId =
|
||||
payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId);
|
||||
const nextAutomationType = payload.automationType ?? normalizePlanAutomationType(currentRow.automation_type);
|
||||
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 ??
|
||||
@@ -1276,7 +1284,7 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
|
||||
const isOnlyJangsingUpdate =
|
||||
nextWorkId === currentRow.work_id &&
|
||||
nextAutomationType === normalizePlanAutomationType(currentRow.automation_type) &&
|
||||
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) &&
|
||||
@@ -1297,7 +1305,8 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
work_id: nextWorkId,
|
||||
note: nextNote,
|
||||
status: currentRow.status,
|
||||
automation_type: nextAutomationType,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
@@ -2261,6 +2270,7 @@ export async function listPlanReleaseReviewBoardItems(options?: Pick<PlanRowOpti
|
||||
...(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);
|
||||
@@ -2818,6 +2828,7 @@ export async function listPlanItems(status?: PlanStatus, options?: Pick<PlanRowO
|
||||
maskNote: options?.maskNote,
|
||||
noteMasked: options?.maskNote,
|
||||
releaseReviewNote: releaseReviewNoteMap.get(Number(row.id)) ?? '',
|
||||
exposeConfiguredAutomationType: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -2843,6 +2854,7 @@ export async function getPlanItemById(id: number, options?: Pick<PlanRowOptions,
|
||||
maskNote: options?.maskNote,
|
||||
noteMasked: options?.maskNote,
|
||||
releaseReviewNote: releaseReviewNoteMap.get(id) ?? '',
|
||||
exposeConfiguredAutomationType: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user