feat: refine codex live chat context flows

This commit is contained in:
2026-05-08 21:15:51 +09:00
parent 82c0d8a197
commit 442879313f
92 changed files with 14815 additions and 7314 deletions

View File

@@ -1,8 +1,8 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
getAppConfig,
getChatContextSettingsConfig,
getAppConfigSnapshot,
getChatTypesConfig,
normalizeAppConfigSnapshot,
upsertAppConfig,
@@ -52,20 +52,20 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async (request) => {
const appOrigin = getRequestAppOrigin(request);
const config = await getAppConfig(appOrigin);
const config = await getAppConfigSnapshot(appOrigin);
return {
ok: true,
config: normalizeAppConfigSnapshot(config),
config,
};
});
app.get('/api/chat-types', async (request) => {
const chatTypes = await getChatTypesConfig(getRequestAppOrigin(request));
const chatTypeConfig = await getChatTypesConfig(getRequestAppOrigin(request));
return {
ok: true,
chatTypes,
...chatTypeConfig,
};
});
@@ -108,17 +108,21 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
}
const parsed = z.object({
chatTypes: z.array(z.unknown()),
}).parse(payload ?? {});
const parsed = z
.object({
chatTypes: z.array(z.unknown()).optional(),
customChatTypes: z.array(z.unknown()).optional(),
})
.parse(payload ?? {});
const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes, appOrigin, appDomain);
const targetChatTypes = parsed.customChatTypes ?? parsed.chatTypes ?? [];
const savedChatTypeConfig = await upsertChatTypesConfig(targetChatTypes, appOrigin, appDomain);
return {
ok: true,
chatTypes: savedChatTypes,
...savedChatTypeConfig,
};
} catch (error) {
return reply.code(409).send({

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveStaticContentType } from './chat.js';
test('resolveStaticContentType returns html content type for chat resource html files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
});
test('resolveStaticContentType keeps plain text content type for code resources', () => {
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
});

View File

@@ -10,6 +10,7 @@ import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRunt
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
clearChatConversationData,
createChatConversation,
deleteUnansweredChatConversationRequest,
deleteChatConversation,
@@ -22,13 +23,14 @@ import {
updateChatConversationContext,
} from '../services/chat-room-service.js';
import { chatRuntimeService } from '../services/chat-runtime-service.js';
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
function resolveStaticContentType(filePath: string) {
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
@@ -40,10 +42,12 @@ function resolveStaticContentType(filePath: string) {
case '.cjs':
case '.json':
case '.css':
case '.html':
case '.txt':
case '.diff':
return 'text/plain; charset=utf-8';
case '.html':
case '.htm':
return 'text/html; charset=utf-8';
case '.md':
case '.markdown':
return 'text/markdown; charset=utf-8';
@@ -139,7 +143,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
}
function resolveChatAttachmentRepoPath() {
return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH);
return resolveMainProjectRoot();
}
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
@@ -421,7 +425,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const messageLimit = query.limit ?? 6;
const messageLimit = query.limit ?? 8;
const detailPage = await listChatConversationDetailPage(params.sessionId, {
limit: messageLimit,
beforeMessageId: query.beforeMessageId ?? null,
@@ -562,4 +566,34 @@ export async function registerChatRoutes(app: FastifyInstance) {
sessionId: params.sessionId,
};
});
app.post('/api/chat/conversations/:sessionId/clear', async (request, reply) => {
const params = z.object({
sessionId: z.string().trim().min(1).max(120),
}).parse(request.params ?? {});
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
const current = await getChatConversation(params.sessionId, clientId || null);
if (!current) {
return reply.code(404).send({
message: '초기화할 채팅방을 찾을 수 없습니다.',
});
}
getActiveChatService()?.resetSessionData(params.sessionId);
chatRuntimeService.clearSession(params.sessionId);
const item = await clearChatConversationData(params.sessionId, clientId || null);
if (!item) {
return reply.code(404).send({
message: '채팅방 데이터 초기화 후 다시 불러오지 못했습니다.',
});
}
return {
ok: true,
item,
};
});
}

View File

@@ -6,6 +6,7 @@ import {
cancelServerRestartReservation,
confirmServerRestartReservation,
getRestartReservationWorkloadSummary,
requestImmediateRestartRecovery,
getServerRestartReservation,
scheduleServerRestartReservation,
} from '../services/server-restart-reservation-service.js';
@@ -90,14 +91,40 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
}
}
const result = await restartServerCommand(key);
try {
const result = await restartServerCommand(key);
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
};
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
};
} catch (error) {
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
if (key !== 'test' && key !== 'work-server') {
throw error;
}
if (!/(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message)) {
throw error;
}
await requestImmediateRestartRecovery(app.log, key, message);
const server = (await listServerCommands()).find((item) => item.key === key);
if (!server) {
throw new Error(`${key} 서버 상태를 다시 읽지 못했습니다.`);
}
return {
ok: true,
item: server,
commandOutput: `${message}\n\n빌드 실패를 감지해 Codex 자동 개선과 재기동 재시도를 시작했습니다.`,
restartState: 'accepted' as const,
};
}
});
app.get('/api/server-commands/restart-reservation', async (request, reply) => {

View File

@@ -1,6 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mergeDefaultChatTypes, resolveAppConfigByOrigin } from './app-config-service.js';
import {
mergeDefaultChatTypes,
migrateLegacyChatTypeContexts,
stripBuiltInChatTypes,
resolveAppConfigByOrigin,
resolveCanonicalChatTypesFromConfig,
resolveCanonicalChatContextSettingsFromConfig,
stripChatContextSettingsFromScopedAppConfigs,
} from './app-config-service.js';
test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => {
const merged = mergeDefaultChatTypes([
@@ -64,9 +72,74 @@ test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
assert.ok(merged.some((item) => item.id === 'layout-editor-execution'));
assert.ok(merged.some((item) => item.id === 'api-request-template'));
assert.ok(merged.some((item) => item.id === 'general-inquiry'));
assert.ok(!merged.some((item) => item.id === 'plan-checklist-execution'));
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
});
test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () => {
const stripped = stripBuiltInChatTypes([
{
id: 'general-request',
name: '일반 요청',
description: 'builtin',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'plan-checklist-execution',
name: 'Plan 체크리스트 실행',
description: 'custom-seeded',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'custom-support-flow',
name: '운영 문의 전용',
description: 'custom',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
]);
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'plan-checklist-execution']);
});
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
const migrated = migrateLegacyChatTypeContexts(
{
defaultContexts: [],
chatTypeDefaults: [
{
chatTypeId: 'plan-checklist-execution',
defaultContextIds: ['legacy-linked-context'],
updatedAt: '2026-05-08T00:00:00.000Z',
},
],
roomContexts: [],
},
[
{
id: 'plan-checklist-execution',
name: 'Plan 체크리스트 실행',
description: 'legacy plan context',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
],
);
assert.equal(migrated.defaultContexts.some((item) => item.id === 'chat-default-plan-checklist-execution'), true);
assert.equal(
migrated.defaultContexts.find((item) => item.id === 'chat-default-plan-checklist-execution')?.content,
'legacy plan context',
);
assert.equal(migrated.chatTypeDefaults.some((item) => item.chatTypeId === 'plan-checklist-execution'), false);
});
test('resolveAppConfigByOrigin prefers scoped app config over legacy global config', () => {
const resolved = resolveAppConfigByOrigin(
{
@@ -112,3 +185,149 @@ test('resolveAppConfigByOrigin falls back to legacy global config when scoped co
assert.equal(resolved.chat?.receiveRoomNotifications, true);
});
test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context settings over stale scoped entries', () => {
const resolved = resolveCanonicalChatContextSettingsFromConfig(
{
chatContextSettings: {
defaultContexts: [
{
id: 'global-a',
title: '전역 A',
content: 'global',
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'global-b',
title: '전역 B',
content: 'global',
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
],
},
scopedAppConfigs: {
'https://test.sm-home.cloud': {
config: {
chatContextSettings: {
defaultContexts: [
{
id: 'scoped-a',
title: '스코프 A',
content: 'scoped',
enabled: true,
updatedAt: '2026-05-01T00:00:00.000Z',
},
],
},
},
},
},
},
'https://test.sm-home.cloud',
);
assert.deepEqual(
resolved.defaultContexts.map((item) => item.id),
['global-a', 'global-b'],
);
});
test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped settings only when global settings are empty', () => {
const resolved = resolveCanonicalChatContextSettingsFromConfig(
{
scopedAppConfigs: {
'https://test.sm-home.cloud': {
config: {
chatContextSettings: {
defaultContexts: [
{
id: 'scoped-a',
title: '스코프 A',
content: 'scoped',
enabled: true,
updatedAt: '2026-05-01T00:00:00.000Z',
},
],
},
},
},
},
},
'https://test.sm-home.cloud',
);
assert.deepEqual(
resolved.defaultContexts.map((item) => item.id),
['scoped-a'],
);
});
test('resolveCanonicalChatTypesFromConfig merges global chat types with stale scoped entries', () => {
const resolved = resolveCanonicalChatTypesFromConfig(
{
chatTypes: [
{
id: 'verification-test-generation',
name: '검증 밑 테스트 생성',
description: 'global',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T08:15:18.440Z',
},
],
scopedAppConfigs: {
'https://test.sm-home.cloud': {
config: {
chatTypes: [
{
id: 'general-request',
name: '일반 요청',
description: 'scoped',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-01T00:00:00.000Z',
},
],
},
},
},
},
'https://test.sm-home.cloud',
);
assert.ok(resolved);
assert.equal(resolved.some((item) => item.id === 'verification-test-generation'), true);
assert.equal(resolved.some((item) => item.id === 'general-request'), true);
});
test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat context settings only', () => {
const stripped = stripChatContextSettingsFromScopedAppConfigs({
scopedAppConfigs: {
'https://test.sm-home.cloud': {
config: {
chatContextSettings: {
defaultContexts: [{ id: 'legacy', title: 'legacy', content: 'legacy', enabled: true }],
},
chat: {
receiveRoomNotifications: false,
},
},
updatedAt: '2026-05-08T00:00:00.000Z',
},
},
});
assert.equal(stripped.changed, true);
assert.deepEqual(stripped.scopedConfigs, {
'https://test.sm-home.cloud': {
config: {
chat: {
receiveRoomNotifications: false,
},
},
updatedAt: '2026-05-08T00:00:00.000Z',
},
});
});

View File

@@ -1,5 +1,10 @@
import { db } from '../db/client.js';
import { DEFAULT_CHAT_TYPES } from './chat-type-defaults.js';
import {
DEFAULT_CHAT_TYPES,
PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT,
PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
} from './chat-type-defaults.js';
export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
@@ -25,6 +30,14 @@ type ChatTypeRecord = {
updatedAt: string;
};
const LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID = 'plan-checklist-execution';
export type ChatTypesConfigSnapshot = {
builtInChatTypes: ChatTypeRecord[];
customChatTypes: ChatTypeRecord[];
chatTypes: ChatTypeRecord[];
};
type ChatDefaultContextRecord = {
id: string;
title: string;
@@ -53,25 +66,6 @@ export type ChatContextSettingsSnapshot = {
roomContexts: ChatRoomContextSettings[];
};
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
{
id: 'chat-default-mobile-verification',
title: '모바일 검증',
content:
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
enabled: true,
updatedAt: '2026-05-03T00:00:00.000Z',
},
{
id: 'chat-default-resource-output',
title: '리소스 출력',
content:
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
enabled: true,
updatedAt: '2026-05-03T00:00:00.000Z',
},
];
async function ensureAppConfigTable() {
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
@@ -154,6 +148,82 @@ function getScopedAppConfigsRecord(value: unknown) {
return normalizeConfigRecord(normalizeConfigRecord(value)[SCOPED_APP_CONFIGS_KEY]);
}
function getScopedAppConfigEntryRecord(value: unknown) {
return normalizeConfigRecord(value);
}
function hasChatContextSettingsSnapshot(value: ChatContextSettingsSnapshot) {
return (
value.defaultContexts.length > 0 ||
value.chatTypeDefaults.length > 0 ||
value.roomContexts.length > 0
);
}
export function stripChatContextSettingsFromScopedAppConfigs(value: unknown) {
const scopedConfigs = getScopedAppConfigsRecord(value);
let changed = false;
const sanitizedScopedConfigs = Object.fromEntries(
Object.entries(scopedConfigs).map(([origin, entry]) => {
const normalizedEntry = getScopedAppConfigEntryRecord(entry);
const normalizedConfig = normalizeConfigRecord(normalizedEntry.config);
if (!(CHAT_CONTEXT_SETTINGS_CONFIG_KEY in normalizedConfig)) {
return [origin, normalizedEntry];
}
const { [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: _removed, ...nextConfig } = normalizedConfig;
changed = true;
return [
origin,
{
...normalizedEntry,
config: nextConfig,
},
];
}),
);
return {
changed,
scopedConfigs: sanitizedScopedConfigs,
};
}
export function resolveCanonicalChatContextSettingsFromConfig(value: unknown, appOrigin?: string | null) {
const normalized = normalizeConfigRecord(value);
const globalSettings = sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
if (hasChatContextSettingsSnapshot(globalSettings)) {
return globalSettings;
}
const scopedSettings = sanitizeChatContextSettings(
normalizeConfigRecord(resolveAppConfigByOrigin(normalized, appOrigin))[CHAT_CONTEXT_SETTINGS_CONFIG_KEY],
);
return hasChatContextSettingsSnapshot(scopedSettings) ? scopedSettings : globalSettings;
}
export function resolveCanonicalChatTypesFromConfig(value: unknown, appOrigin?: string | null) {
const normalized = normalizeConfigRecord(value);
const globalChatTypes = Array.isArray(normalized[CHAT_TYPES_CONFIG_KEY])
? sanitizeChatTypes(normalized[CHAT_TYPES_CONFIG_KEY])
: [];
const scopedConfig = resolveScopedAppConfig(normalized, appOrigin);
const scopedChatTypes = Array.isArray(normalizeConfigRecord(scopedConfig)[CHAT_TYPES_CONFIG_KEY])
? sanitizeChatTypes(normalizeConfigRecord(scopedConfig)[CHAT_TYPES_CONFIG_KEY] as unknown[])
: [];
if (globalChatTypes.length === 0 && scopedChatTypes.length === 0) {
return null;
}
return mergeDefaultChatTypes([...globalChatTypes, ...scopedChatTypes]);
}
function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) {
const normalizedAppOrigin = normalizeAppOrigin(appOrigin);
@@ -229,6 +299,26 @@ export async function getAppConfig(appOrigin?: string | null) {
return resolveAppConfigByOrigin(row.config_json ?? {}, appOrigin);
}
async function getRawAppConfigRecord() {
await ensureAppConfigTable();
const row = await db(APP_CONFIG_TABLE).first();
if (!row) {
return {} as Record<string, unknown>;
}
if (typeof row.config_json === 'string') {
try {
return normalizeConfigRecord(JSON.parse(row.config_json));
} catch {
return {} as Record<string, unknown>;
}
}
return normalizeConfigRecord(row.config_json);
}
function normalizeConfigRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
@@ -299,7 +389,7 @@ function sanitizeDefaultContexts(items: unknown) {
const byId = new Map<string, ChatDefaultContextRecord>();
const sourceItems = Array.isArray(items) ? items : [];
[...sourceItems, ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
sourceItems
.map((item) => normalizeDefaultContextRecord(item))
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
.forEach((item) => {
@@ -420,6 +510,14 @@ function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function isBuiltInChatTypeId(chatTypeId: string) {
return DEFAULT_CHAT_TYPES.some((item) => item.id === chatTypeId);
}
function isLegacyMigratedChatTypeId(chatTypeId: string) {
return chatTypeId === LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID;
}
function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
@@ -473,6 +571,56 @@ export function mergeDefaultChatTypes(items: unknown[]) {
return sanitizeChatTypes(Array.from(byId.values()));
}
export function stripBuiltInChatTypes(items: unknown[]) {
return sanitizeChatTypes(items).filter((item) => !isBuiltInChatTypeId(item.id));
}
function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) {
return items.filter((item) => !isLegacyMigratedChatTypeId(item.id));
}
function buildPlanChecklistDefaultContext(record?: ChatTypeRecord | null, existing?: ChatDefaultContextRecord | null) {
const content = normalizeText(record?.description) || normalizeText(existing?.content) || PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT;
return normalizeDefaultContextRecord({
id: PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
title: normalizeText(record?.name) || normalizeText(existing?.title) || PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
content,
enabled: existing?.enabled ?? record?.enabled ?? true,
updatedAt: normalizeText(record?.updatedAt) || normalizeText(existing?.updatedAt) || new Date().toISOString(),
});
}
export function migrateLegacyChatTypeContexts(
settings: ChatContextSettingsSnapshot,
chatTypes: ChatTypeRecord[],
): ChatContextSettingsSnapshot {
const legacyPlanChecklistChatType = chatTypes.find((item) => item.id === LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID);
if (!legacyPlanChecklistChatType) {
return settings;
}
const existingContext =
settings.defaultContexts.find((item) => item.id === PLAN_CHECKLIST_DEFAULT_CONTEXT_ID) ?? null;
const migratedContext = buildPlanChecklistDefaultContext(legacyPlanChecklistChatType, existingContext);
const nextDefaultContexts = migratedContext
? sanitizeDefaultContexts([
...settings.defaultContexts.filter((item) => item.id !== PLAN_CHECKLIST_DEFAULT_CONTEXT_ID),
migratedContext,
])
: settings.defaultContexts;
const nextChatTypeDefaults = sanitizeChatTypeDefaultSelections(
settings.chatTypeDefaults.filter((item) => item.chatTypeId !== LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID),
);
return {
defaultContexts: nextDefaultContexts,
chatTypeDefaults: nextChatTypeDefaults,
roomContexts: settings.roomContexts,
};
}
function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
if (left.length !== right.length) {
return false;
@@ -585,7 +733,18 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
}
export async function getAppConfigSnapshot(appOrigin?: string | null): Promise<AppConfigSnapshot> {
return normalizeAppConfigSnapshot(await getAppConfig(appOrigin));
const config = normalizeConfigRecord(await getAppConfig(appOrigin));
const rawConfig = await getRawAppConfigRecord();
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin);
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(rawConfig);
return normalizeAppConfigSnapshot({
...config,
...(canonicalChatTypes ? { [CHAT_TYPES_CONFIG_KEY]: canonicalChatTypes } : null),
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: canonicalChatContextSettings,
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
});
}
export async function upsertAppConfig(
@@ -626,42 +785,83 @@ export async function upsertAppConfig(
return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin);
}
export async function getChatTypesConfig(appOrigin?: string | null) {
const config = await getAppConfig(appOrigin);
const normalized = normalizeConfigRecord(config);
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
if (chatTypes == null) {
return null;
}
export async function getChatTypesConfig(appOrigin?: string | null): Promise<ChatTypesConfigSnapshot> {
const rawConfig = await getRawAppConfigRecord();
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
const builtInChatTypes = sanitizeChatTypes(DEFAULT_CHAT_TYPES);
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
const customChatTypes = stripBuiltInChatTypes(migratedChatTypeList);
const mergedChatTypes = mergeDefaultChatTypes(customChatTypes);
const migratedSettings = migrateLegacyChatTypeContexts(
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
canonicalChatTypes ?? [],
);
const savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
const mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
: [];
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
if (!isSameChatTypeList(savedChatTypes, mergedChatTypes)) {
if (!isSameChatTypeList(resolvedCustomChatTypes, customChatTypes)) {
await upsertAppConfig({
[CHAT_TYPES_CONFIG_KEY]: mergedChatTypes,
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
}, appOrigin);
}
return mergedChatTypes;
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
await upsertChatContextSettingsConfig(migratedSettings);
}
return {
builtInChatTypes,
customChatTypes,
chatTypes: mergedChatTypes,
};
}
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
const resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
const customChatTypes = stripBuiltInChatTypes(chatTypes);
const nextConfig = {
...current,
[CHAT_TYPES_CONFIG_KEY]: resolvedChatTypes,
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
};
await upsertAppConfig(nextConfig, appOrigin, appDomain);
return resolvedChatTypes;
return {
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
customChatTypes,
chatTypes: mergeDefaultChatTypes(customChatTypes),
};
}
export async function getChatContextSettingsConfig(appOrigin?: string | null) {
const config = await getAppConfig(appOrigin);
const normalized = normalizeConfigRecord(config);
return sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
const rawConfig = await getRawAppConfigRecord();
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin) ?? [];
const migratedSettings = migrateLegacyChatTypeContexts(
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
canonicalChatTypes,
);
const migratedChatTypes = stripLegacyMigratedChatTypes(canonicalChatTypes);
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
: [];
const nextCustomChatTypes = stripBuiltInChatTypes(migratedChatTypes);
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
if (!isSameChatTypeList(resolvedCustomChatTypes, nextCustomChatTypes)) {
await upsertAppConfig({
[CHAT_TYPES_CONFIG_KEY]: nextCustomChatTypes,
}, appOrigin);
}
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
await upsertChatContextSettingsConfig(migratedSettings);
}
return migratedSettings;
}
export async function upsertChatContextSettingsConfig(
@@ -669,13 +869,17 @@ export async function upsertChatContextSettingsConfig(
appOrigin?: string | null,
appDomain?: string | null,
) {
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
const current = await getRawAppConfigRecord();
const nextSettings = sanitizeChatContextSettings(settings);
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(current);
const nextConfig = {
...current,
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: nextSettings,
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
};
await upsertAppConfig(nextConfig, appOrigin, appDomain);
void appOrigin;
void appDomain;
await upsertAppConfig(nextConfig);
return nextSettings;
}

View File

@@ -4,12 +4,79 @@ export type ChatMessagePart =
title: string;
url: string;
actionLabel?: string | null;
}
| {
type: 'prompt';
title: string;
description?: string | null;
submitLabel?: string | null;
mode?: 'queue' | 'direct' | null;
multiple?: boolean;
responseTemplate?: string | null;
freeTextLabel?: string | null;
freeTextPlaceholder?: string | null;
currentStepKey?: string | null;
steps?: Array<{
key: string;
title: string;
description?: string | null;
submitLabel?: string | null;
mode?: 'queue' | 'direct' | null;
multiple?: boolean;
optional?: boolean;
responseTemplate?: string | null;
freeTextLabel?: string | null;
freeTextPlaceholder?: string | null;
selectedValues?: string[];
options: Array<{
value: string;
label: string;
description?: string | null;
preview?:
| {
type: 'image' | 'markdown' | 'html' | 'resource';
url?: string | null;
content?: string | null;
alt?: string | null;
title?: string | null;
}
| null;
}>;
}>;
readOnly?: boolean;
selectedValues?: string[];
resolvedBy?: 'user' | 'timeout' | 'system' | null;
resolvedAt?: string | null;
resultText?: string | null;
options: Array<{
value: string;
label: string;
description?: string | null;
preview?:
| {
type: 'image' | 'markdown' | 'html' | 'resource';
url?: string | null;
content?: string | null;
alt?: string | null;
title?: string | null;
}
| null;
}>;
};
type PromptPart = Extract<ChatMessagePart, { type: 'prompt' }>;
type PromptOption = PromptPart['options'][number];
type PromptPreview = NonNullable<PromptOption['preview']>;
type PromptStep = NonNullable<PromptPart['steps']>[number];
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
function normalizeText(value: unknown) {
return String(value ?? '').trim();
@@ -27,6 +94,25 @@ function normalizeUrl(value: string) {
return `/${malformedResourceMatch[1]}`;
}
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
if (apiMarkerIndex >= 0) {
const apiPath = normalized.slice(apiMarkerIndex);
const dotCodexIndex = apiPath.indexOf(CHAT_DOT_CODEX_MARKER);
return dotCodexIndex >= 0
? `${CHAT_API_RESOURCE_MARKER}${apiPath.slice(dotCodexIndex + 1)}`
: apiPath;
}
const publicDotCodexIndex = normalized.lastIndexOf(CHAT_PUBLIC_DOT_CODEX_MARKER);
if (publicDotCodexIndex >= 0) {
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(publicDotCodexIndex + 8)}`;
}
const dotCodexIndex = normalized.lastIndexOf(CHAT_DOT_CODEX_MARKER);
if (dotCodexIndex >= 0) {
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
@@ -34,6 +120,114 @@ function normalizeUrl(value: string) {
return '';
}
function normalizePromptPreview(value: unknown): PromptPreview | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const type: 'image' | 'markdown' | 'html' | 'resource' | null =
record.type === 'image' || record.type === 'markdown' || record.type === 'html' || record.type === 'resource'
? record.type
: null;
const url = normalizeUrl(normalizeText(record.url));
const content = String(record.content ?? '').trim() || null;
const alt = normalizeText(record.alt) || null;
const title = normalizeText(record.title) || null;
if (!type) {
return null;
}
if (type === 'image' || type === 'resource') {
if (!url) {
return null;
}
} else if (!content && !url) {
return null;
}
return {
type,
url: url || null,
content,
alt,
title,
};
}
function normalizePromptOption(value: unknown): PromptOption | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const optionValue = normalizeText(record.value);
const label = normalizeText(record.label);
if (!optionValue || !label) {
return null;
}
return {
value: optionValue,
label,
description: normalizeText(record.description) || null,
preview: normalizePromptPreview(record.preview),
};
}
function normalizePromptSelectedValues(value: unknown) {
return [
...(Array.isArray(value) ? value : []),
]
.map((item) => normalizeText(item))
.filter(Boolean)
.filter((item, index, array) => array.indexOf(item) === index);
}
function normalizePromptSteps(value: unknown): PromptStep[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return [];
}
const record = item as Record<string, unknown>;
const key = normalizeText(record.key) || `step-${index + 1}`;
const title = normalizeText(record.title);
const options = Array.isArray(record.options)
? record.options
.map((option) => normalizePromptOption(option))
.filter((option): option is PromptOption => Boolean(option))
: [];
if (!title || options.length === 0) {
return [];
}
return [
{
key,
title,
description: normalizeText(record.description) || null,
submitLabel: normalizeText(record.submitLabel) || null,
mode: record.mode === 'direct' || record.mode === 'queue' ? record.mode : null,
multiple: record.multiple === true,
optional: record.optional === true,
responseTemplate: normalizeText(record.responseTemplate) || null,
freeTextLabel: normalizeText(record.freeTextLabel) || null,
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
selectedValues: normalizePromptSelectedValues(record.selectedValues),
options,
},
];
});
}
function decodeUrlComponentSafely(value: string) {
try {
return decodeURIComponent(value);
@@ -141,6 +335,66 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
};
}
function buildPromptPart(rawBody: string): ChatMessagePart | null {
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const record = parsed as Record<string, unknown>;
const title = normalizeText(record.title);
const options = Array.isArray(record.options)
? record.options
.map((item) => normalizePromptOption(item))
.filter((option): option is PromptOption => Boolean(option))
: [];
const steps = normalizePromptSteps(record.steps);
if (!title || (options.length === 0 && steps.length === 0)) {
return null;
}
const mode = record.mode === 'direct' || record.mode === 'queue' ? record.mode : null;
const selectedValues = [
...normalizePromptSelectedValues(record.selectedValues),
...(record.selectedValue != null ? [record.selectedValue] : []),
]
.map((item) => normalizeText(item))
.filter(Boolean)
.filter((value, index, values) => values.indexOf(value) === index);
const resolvedBy =
record.resolvedBy === 'user' || record.resolvedBy === 'timeout' || record.resolvedBy === 'system'
? record.resolvedBy
: null;
return {
type: 'prompt',
title,
description: normalizeText(record.description) || null,
submitLabel: normalizeText(record.submitLabel) || null,
mode,
multiple: record.multiple === true,
responseTemplate: normalizeText(record.responseTemplate) || null,
freeTextLabel: normalizeText(record.freeTextLabel) || null,
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
currentStepKey: normalizeText(record.currentStepKey) || null,
steps: steps.length > 0 ? steps : undefined,
readOnly: record.readOnly === true || selectedValues.length > 0,
selectedValues,
resolvedBy,
resolvedAt: normalizeText(record.resolvedAt) || null,
resultText: normalizeText(record.resultText) || null,
options,
};
}
export function extractChatMessageParts(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
@@ -151,7 +405,38 @@ export function extractChatMessageParts(text: string) {
return false;
}
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
const dedupeKey =
nextPart.type === 'link_card'
? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`
: [
nextPart.type,
nextPart.title,
nextPart.options
.map((option) =>
[
option.value,
option.label,
option.preview?.type ?? '',
option.preview?.url ?? '',
option.preview?.content ?? '',
option.preview?.title ?? '',
].join('|'),
)
.join(','),
(nextPart.steps ?? [])
.map((step) =>
[
step.key,
step.title,
step.options.map((option) => `${option.value}:${option.label}`).join(','),
].join('|'),
)
.join(','),
nextPart.selectedValues?.join(',') ?? '',
nextPart.resolvedBy ?? '',
nextPart.resultText ?? '',
nextPart.readOnly === true ? 'readonly' : '',
].join(':');
if (seenLinkKeys.has(dedupeKey)) {
return true;
@@ -163,6 +448,15 @@ export function extractChatMessageParts(text: string) {
};
for (const line of lines) {
const promptMatched = line.match(PROMPT_LINE_PATTERN);
if (promptMatched) {
if (!pushPart(buildPromptPart(promptMatched[1] ?? ''))) {
keptLines.push(line);
}
continue;
}
const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) {
@@ -196,7 +490,7 @@ export function extractChatMessageParts(text: string) {
}
const latestPart = parts.at(-1);
if (latestPart && isInternalResourceUrl(latestPart.url)) {
if (latestPart?.type === 'link_card' && isInternalResourceUrl(latestPart.url)) {
parts.pop();
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
keptLines.push(latestPart.url);
@@ -222,24 +516,29 @@ export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
}
const record = item as Record<string, unknown>;
if (record.type !== 'link_card') {
return null;
if (record.type === 'link_card') {
const title = normalizeText(record.title);
const url = normalizeUrl(String(record.url ?? ''));
const actionLabel = normalizeText(record.actionLabel) || null;
if (!title || !url) {
return null;
}
return {
type: 'link_card' as const,
title,
url,
actionLabel,
};
}
const title = normalizeText(record.title);
const url = normalizeUrl(String(record.url ?? ''));
const actionLabel = normalizeText(record.actionLabel) || null;
if (!title || !url) {
return null;
if (record.type === 'prompt') {
const promptPart = buildPromptPart(JSON.stringify(record));
return promptPart;
}
return {
type: 'link_card' as const,
title,
url,
actionLabel,
};
return null;
})
.filter(Boolean) as ChatMessagePart[];
}

View File

@@ -50,6 +50,8 @@ export type ChatConversationItem = {
currentJobMessage: string | null;
currentQueueSize: number;
currentStatusUpdatedAt: string | null;
isPendingWork: boolean;
pendingWorkReason: 'prompt' | 'analysis' | 'design' | null;
lastRequestPreview: string;
lastMessagePreview: string;
lastResponsePreview: string;
@@ -173,6 +175,160 @@ function createPreview(text: string) {
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
}
const PENDING_WORK_ANALYSIS_PATTERNS = [
//u,
//u,
//u,
//u,
//u,
/\banalysis\b/i,
/\binvestigat(?:e|ion)\b/i,
] as const;
const PENDING_WORK_DESIGN_PATTERNS = [
//u,
//u,
//u,
//u,
//u,
//u,
//u,
//u,
/\bdesign\b/i,
/\barchitecture\b/i,
] as const;
const PENDING_WORK_IMPLEMENTATION_PATTERNS = [
//u,
//u,
//u,
//u,
//u,
//u,
//u,
//u,
/.*/u,
/.*/u,
//u,
/preview/iu,
/ /u,
/diff/u,
/\bimplement(?:ed|ation)?\b/i,
/\bfix(?:ed)?\b/i,
/\bverified?\b/i,
/\btested?\b/i,
] as const;
const PENDING_WORK_RESPONSE_HOLD_PATTERNS = [
//u,
//u,
/(?:|)/u,
/ /u,
//u,
//u,
//u,
/\bif you want\b/i,
/\bnext step\b/i,
] as const;
function normalizePendingWorkText(text: string | null | undefined) {
return String(text ?? '').replace(/\s+/g, ' ').trim();
}
function hasPendingWorkPattern(text: string, patterns: readonly RegExp[]) {
return patterns.some((pattern) => pattern.test(text));
}
function resolvePendingWorkReasonFromText(text: string) {
if (!text) {
return null;
}
if (hasPendingWorkPattern(text, PENDING_WORK_DESIGN_PATTERNS)) {
return 'design' as const;
}
if (hasPendingWorkPattern(text, PENDING_WORK_ANALYSIS_PATTERNS)) {
return 'analysis' as const;
}
return null;
}
function hasOpenPromptParts(parts: ChatMessagePart[] | undefined) {
return (parts ?? []).some((part) => {
if (part.type !== 'prompt' || part.readOnly === true) {
return false;
}
if ((part.selectedValues?.length ?? 0) > 0) {
return false;
}
if ((part.resultText?.trim() ?? '').length > 0) {
return false;
}
if ((part.resolvedAt?.trim() ?? '').length > 0 || part.resolvedBy != null) {
return false;
}
return true;
});
}
function resolvePendingWorkState(args: {
requestText?: string | null;
responseText?: string | null;
latestCodexParts?: ChatMessagePart[] | undefined;
}) {
if (hasOpenPromptParts(args.latestCodexParts)) {
return {
isPendingWork: true,
pendingWorkReason: 'prompt' as const,
};
}
const requestText = normalizePendingWorkText(args.requestText);
const responseText = normalizePendingWorkText(args.responseText);
const requestReason = resolvePendingWorkReasonFromText(requestText);
if (!requestReason) {
return {
isPendingWork: false,
pendingWorkReason: null,
};
}
if (hasPendingWorkPattern(responseText, PENDING_WORK_IMPLEMENTATION_PATTERNS)) {
return {
isPendingWork: false,
pendingWorkReason: null,
};
}
if (!responseText) {
return {
isPendingWork: true,
pendingWorkReason: requestReason,
};
}
const responseReason = resolvePendingWorkReasonFromText(responseText);
if (responseReason || hasPendingWorkPattern(responseText, PENDING_WORK_RESPONSE_HOLD_PATTERNS)) {
return {
isPendingWork: true,
pendingWorkReason: responseReason ?? requestReason,
};
}
return {
isPendingWork: false,
pendingWorkReason: null,
};
}
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
/\s*(||)/u,
/\s*/u,
@@ -279,6 +435,8 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
currentQueueSize: Number(row.current_queue_size ?? 0),
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
isPendingWork: false,
pendingWorkReason: null,
lastRequestPreview: '',
lastMessagePreview: String(row.last_message_preview ?? ''),
lastResponsePreview: '',
@@ -876,6 +1034,40 @@ async function getLatestResponseMessageIdMap(sessionIds: string[]) {
return responseMap;
}
async function getLatestCodexPromptPartsMap(sessionIds: string[]) {
const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)));
if (normalizedSessionIds.length === 0) {
return new Map<string, ChatMessagePart[]>();
}
const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
.select('session_id', 'parts_json', 'created_at', 'message_id')
.whereIn('session_id', normalizedSessionIds)
.andWhere('author', 'codex')
.orderBy('session_id', 'asc')
.orderBy('created_at', 'desc')
.orderBy('message_id', 'desc');
const promptPartMap = new Map<string, ChatMessagePart[]>();
for (const row of rows) {
const sessionId = String(row.session_id ?? '').trim();
if (!sessionId || promptPartMap.has(sessionId)) {
continue;
}
const parts = parseChatMessageParts(row.parts_json);
if ((parts ?? []).some((part) => part.type === 'prompt')) {
promptPartMap.set(sessionId, parts ?? []);
}
}
return promptPartMap;
}
async function getLatestResponseMessageId(sessionId: string) {
const responseMap = await getLatestResponseMessageIdMap([sessionId]);
return responseMap.get(sessionId.trim()) ?? null;
@@ -1444,17 +1636,26 @@ export async function listChatConversations(
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
rows.map((row) => String(row.session_id ?? '')),
);
const latestCodexPromptPartsMap = await getLatestCodexPromptPartsMap(
rows.map((row) => String(row.session_id ?? '')),
);
if (!normalizedUnreadStateClientId) {
return rows
.map((row) => {
const mapped = mapConversationRow(row);
const pendingWorkState = resolvePendingWorkState({
requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
});
return {
...resolveConversationPreviewOverride(
mapped,
latestPreviewMessageMap.get(mapped.sessionId),
latestRequestPreviewMap.get(mapped.sessionId),
),
...pendingWorkState,
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
hasUnreadResponse: false,
@@ -1489,6 +1690,11 @@ export async function listChatConversations(
const mapped = mapConversationRow(row);
const preference = preferenceMap.get(mapped.sessionId);
const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId);
const pendingWorkState = resolvePendingWorkState({
requestText: latestRequestPreviewMap.get(mapped.sessionId)?.text ?? '',
responseText: latestResponsePreviewMap.get(mapped.sessionId)?.text ?? '',
latestCodexParts: latestCodexPromptPartsMap.get(mapped.sessionId),
});
return {
...resolveConversationPreviewOverride(
@@ -1496,6 +1702,7 @@ export async function listChatConversations(
latestPreviewMessage,
latestRequestPreviewMap.get(mapped.sessionId),
),
...pendingWorkState,
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
clientId: normalizedUnreadStateClientId,
@@ -1654,7 +1861,7 @@ export async function listChatConversationDetailPage(
): Promise<ChatConversationDetailPage> {
const normalizedSessionId = sessionId.trim();
const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 6)));
const normalizedLimit = Math.max(1, Math.min(100, Math.round(options.limit ?? 8)));
const normalizedBeforeMessageId =
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
? Math.trunc(options.beforeMessageId as number)
@@ -2435,6 +2642,36 @@ export async function deleteChatConversation(sessionId: string) {
});
}
export async function clearChatConversationData(sessionId: string, clientId?: string | null) {
const normalizedSessionId = sessionId.trim();
await db.transaction(async (trx) => {
await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del();
await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del();
await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del();
await trx(CHAT_CONVERSATION_CLIENT_TABLE)
.where({ session_id: normalizedSessionId })
.update({
last_read_response_message_id: null,
updated_at: db.fn.now(),
});
await trx(CHAT_CONVERSATION_TABLE)
.where({ session_id: normalizedSessionId })
.update({
current_request_id: null,
current_job_status: null,
current_job_message: null,
current_queue_size: 0,
current_status_updated_at: null,
last_message_preview: '',
last_message_at: null,
updated_at: db.fn.now(),
});
});
return getChatConversation(normalizedSessionId, clientId);
}
export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
.where({

View File

@@ -149,13 +149,17 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
assert.match(prompt, /`steps` 배열을 추가해/);
assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
});
test('ensureChatSessionReferenceResource creates a persistent per-room markdown resource and preserves manual notes', async () => {
test('ensureChatSessionReferenceResource creates a minimal per-room markdown resource without chat memo accumulation', async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-'));
const resourcePath = await ensureChatSessionReferenceResource({
@@ -182,13 +186,9 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
const firstContent = await readFile(absolutePath, 'utf8');
assert.match(firstContent, /# 채팅방 참고 리소스/);
assert.match(firstContent, /## 자동 갱신 문맥/);
assert.match(firstContent, /## 수동 메모/);
const manuallyEditedContent = firstContent.replace(
'- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.',
'- 유지 메모: 이 줄은 보존되어야 합니다.',
);
await writeFile(absolutePath, manuallyEditedContent, 'utf8');
assert.doesNotMatch(firstContent, /## 수동 메모/);
assert.doesNotMatch(firstContent, /## 최신 사용자 요청/);
assert.doesNotMatch(firstContent, /## 최근 대화 요약/);
await ensureChatSessionReferenceResource({
repoPath: tempDir,
@@ -210,9 +210,8 @@ test('ensureChatSessionReferenceResource creates a persistent per-room markdown
const updatedContent = await readFile(absolutePath, 'utf8');
assert.match(updatedContent, /request-2/);
assert.match(updatedContent, /둘째 요청/);
assert.match(updatedContent, /이전 1개 메시지는 제외되었습니다\./);
assert.match(updatedContent, /유지 메모: 이 줄은 보존되어야 합니다\./);
assert.doesNotMatch(updatedContent, /둘째 요청/);
assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/);
});
test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => {
@@ -249,9 +248,6 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
"이전 응답 조각'; @@ +export async function ensureChatSessionReferenceResource(args: { ... }) {",
'<!-- codex-live:auto:end -->',
'',
'## 수동 메모',
'- 유지 메모',
'',
].join('\n'),
'utf8',
);
@@ -277,8 +273,8 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
const rebuiltContent = await readFile(absolutePath, 'utf8');
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:start -->/g) ?? []).length, 1);
assert.equal((rebuiltContent.match(/<!-- codex-live:auto:end -->/g) ?? []).length, 1);
assert.match(rebuiltContent, /셋째 요청/);
assert.match(rebuiltContent, /## 수동 메모\n- 유지 메모/);
assert.doesNotMatch(rebuiltContent, /셋째 요청/);
assert.doesNotMatch(rebuiltContent, /## 수동 메모/);
assert.doesNotMatch(rebuiltContent, /이전 응답 조각/);
});
@@ -299,6 +295,282 @@ test('extractChatMessageParts strips link-card markers into structured parts', (
);
});
test('extractChatMessageParts strips prompt markers into structured parts', () => {
assert.deepEqual(
extractChatMessageParts(
[
'단계형 선택지를 준비했습니다.',
'[[prompt:{"title":"다음 단계 선택","description":"원하는 작업 흐름을 고르세요.","submitLabel":"계속","mode":"queue","options":[{"label":"요약 먼저","value":"summary-first","description":"현황 요약 후 구현합니다."},{"label":"바로 구현","value":"implement-now","description":"확인 없이 바로 수정합니다."}],"responseTemplate":"사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요."}]]',
].join('\n'),
),
{
strippedText: '단계형 선택지를 준비했습니다.',
parts: [
{
type: 'prompt',
title: '다음 단계 선택',
description: '원하는 작업 흐름을 고르세요.',
submitLabel: '계속',
mode: 'queue',
multiple: false,
responseTemplate: '사용자가 {{selection_label}}을 선택했습니다. 이 기준으로 이어서 진행해 주세요.',
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: '요약 먼저',
value: 'summary-first',
description: '현황 요약 후 구현합니다.',
preview: null,
},
{
label: '바로 구현',
value: 'implement-now',
description: '확인 없이 바로 수정합니다.',
preview: null,
},
],
},
],
},
);
});
test('extractChatMessageParts keeps readonly auto-selected prompt state', () => {
assert.deepEqual(
extractChatMessageParts(
[
'시안 3개 중 자동 선택 결과입니다.',
'[[prompt:{"title":"UI 시안 선택","description":"시간 안에 응답이 없어 자동 선택되었습니다.","readOnly":true,"selectedValues":["option-b"],"resolvedBy":"timeout","resultText":"B안이 기본 시안으로 채택되었습니다.","options":[{"label":"A안","value":"option-a","description":"카드 레이아웃 중심"},{"label":"B안","value":"option-b","description":"탭과 요약 헤더 중심"},{"label":"C안","value":"option-c","description":"하단 플로팅 액션 중심"}]}]]',
].join('\n'),
),
{
strippedText: '시안 3개 중 자동 선택 결과입니다.',
parts: [
{
type: 'prompt',
title: 'UI 시안 선택',
description: '시간 안에 응답이 없어 자동 선택되었습니다.',
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: true,
selectedValues: ['option-b'],
resolvedBy: 'timeout',
resolvedAt: null,
resultText: 'B안이 기본 시안으로 채택되었습니다.',
steps: undefined,
options: [
{
label: 'A안',
value: 'option-a',
description: '카드 레이아웃 중심',
preview: null,
},
{
label: 'B안',
value: 'option-b',
description: '탭과 요약 헤더 중심',
preview: null,
},
{
label: 'C안',
value: 'option-c',
description: '하단 플로팅 액션 중심',
preview: null,
},
],
},
],
},
);
});
test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
assert.deepEqual(
extractChatMessageParts(
[
'시안 미리보기 선택입니다.',
'[[prompt:{"title":"시안 선택","options":[{"label":"이미지안","value":"image-a","preview":{"type":"image","url":"https://example.com/a.png","alt":"A"}},{"label":"마크다운안","value":"markdown-b","preview":{"type":"markdown","content":"## B안\\n- 설명"}},{"label":"HTML안","value":"html-c","preview":{"type":"html","content":"<section>C</section>","title":"HTML C"}},{"label":"리소스안","value":"resource-d","preview":{"type":"resource","url":"/api/chat/resources/sample.html","title":"리소스"}}]}]]',
].join('\n'),
),
{
strippedText: '시안 미리보기 선택입니다.',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: '이미지안',
value: 'image-a',
description: null,
preview: {
type: 'image',
url: 'https://example.com/a.png',
content: null,
alt: 'A',
title: null,
},
},
{
label: '마크다운안',
value: 'markdown-b',
description: null,
preview: {
type: 'markdown',
url: null,
content: '## B안\n- 설명',
alt: null,
title: null,
},
},
{
label: 'HTML안',
value: 'html-c',
description: null,
preview: {
type: 'html',
url: null,
content: '<section>C</section>',
alt: null,
title: 'HTML C',
},
},
{
label: '리소스안',
value: 'resource-d',
description: null,
preview: {
type: 'resource',
url: '/api/chat/resources/sample.html',
content: null,
alt: null,
title: '리소스',
},
},
],
},
],
},
);
});
test('extractChatMessageParts supports stepper prompt steps', () => {
assert.deepEqual(
extractChatMessageParts(
[
'단계형 stepper prompt입니다.',
'[[prompt:{"title":"구현 흐름 선택","description":"단계별로 실행 범위를 고릅니다.","steps":[{"key":"layout","title":"시안 선택","options":[{"label":"A안","value":"layout-a","description":"기본 레이아웃"},{"label":"B안","value":"layout-b","description":"탭 중심 레이아웃"}]},{"key":"scope","title":"후속 범위","optional":true,"multiple":true,"freeTextLabel":"세부 요청","options":[{"label":"모바일 정리","value":"mobile-cleanup"},{"label":"상태 요약 추가","value":"summary-card"}]}],"responseTemplate":"{{step_summaries}}"}]]',
].join('\n'),
),
{
strippedText: '단계형 stepper prompt입니다.',
parts: [
{
type: 'prompt',
title: '구현 흐름 선택',
description: '단계별로 실행 범위를 고릅니다.',
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: '{{step_summaries}}',
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
steps: [
{
key: 'layout',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
optional: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
selectedValues: [],
options: [
{
label: 'A안',
value: 'layout-a',
description: '기본 레이아웃',
preview: null,
},
{
label: 'B안',
value: 'layout-b',
description: '탭 중심 레이아웃',
preview: null,
},
],
},
{
key: 'scope',
title: '후속 범위',
description: null,
submitLabel: null,
mode: null,
multiple: true,
optional: true,
responseTemplate: null,
freeTextLabel: '세부 요청',
freeTextPlaceholder: null,
selectedValues: [],
options: [
{
label: '모바일 정리',
value: 'mobile-cleanup',
description: null,
preview: null,
},
{
label: '상태 요약 추가',
value: 'summary-card',
description: null,
preview: null,
},
],
},
],
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
options: [],
},
],
},
);
});
test('extractChatMessageParts repairs malformed resource link-card urls and encoded action labels', () => {
assert.deepEqual(
extractChatMessageParts(
@@ -317,6 +589,75 @@ test('extractChatMessageParts repairs malformed resource link-card urls and enco
);
});
test('extractChatMessageParts canonicalizes prompt preview resource urls from public paths and absolute filesystem paths', () => {
assert.deepEqual(
extractChatMessageParts(
'[[prompt:{"title":"시안 선택","options":[{"label":"공개경로","value":"public-path","preview":{"type":"resource","url":"public/.codex_chat/chat-room/resource/sample-a.html"}},{"label":"절대경로","value":"absolute-path","preview":{"type":"resource","url":"/home/how2ice/project/ai-code-app/public/.codex_chat/chat-room/resource/sample-b.html"}},{"label":"닷경로","value":"dot-path","preview":{"type":"resource","url":"/.codex_chat/chat-room/resource/sample-c.html"}}]}]]',
),
{
strippedText: '',
parts: [
{
type: 'prompt',
title: '시안 선택',
description: null,
submitLabel: null,
mode: null,
multiple: false,
responseTemplate: null,
freeTextLabel: null,
freeTextPlaceholder: null,
currentStepKey: null,
readOnly: false,
selectedValues: [],
resolvedBy: null,
resolvedAt: null,
resultText: null,
steps: undefined,
options: [
{
label: '공개경로',
value: 'public-path',
description: null,
preview: {
type: 'resource',
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-a.html',
content: null,
alt: null,
title: null,
},
},
{
label: '절대경로',
value: 'absolute-path',
description: null,
preview: {
type: 'resource',
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-b.html',
content: null,
alt: null,
title: null,
},
},
{
label: '닷경로',
value: 'dot-path',
description: null,
preview: {
type: 'resource',
url: '/api/chat/resources/.codex_chat/chat-room/resource/sample-c.html',
content: null,
alt: null,
title: null,
},
},
],
},
],
},
);
});
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
assert.deepEqual(
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),

View File

@@ -27,6 +27,7 @@ import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
findLatestPlanItem,
findPlanItemByPreviewUrl,
@@ -329,6 +330,26 @@ function createChatQuestionAnswerNotificationBody(args: {
return args.fallback;
}
function normalizeStructuredChatMessage(message: ChatMessage): ChatMessage {
if (message.author === 'user') {
return message;
}
const existingParts = Array.isArray(message.parts) ? message.parts.filter(Boolean) : [];
const extracted = extractChatMessageParts(message.text);
const nextParts = existingParts.length > 0 ? existingParts : extracted.parts;
if (nextParts.length === 0) {
return existingParts.length === 0 ? message : { ...message, parts: existingParts };
}
return {
...message,
text: extracted.strippedText,
parts: nextParts,
};
}
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
const questionPreview = createChatNotificationPreview(questionText ?? '');
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
@@ -1584,9 +1605,6 @@ function buildChatSessionReferenceAutoSection(args: {
context: ChatContext | null;
sessionId: string;
requestId: string;
input: string;
recentHistoryLines: string[];
omittedHistoryCount: number;
}) {
const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청';
const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음';
@@ -1594,14 +1612,6 @@ function buildChatSessionReferenceAutoSection(args: {
const topMenu = args.context?.topMenu?.trim() || '없음';
const pageUrl = args.context?.pageUrl?.trim() || '없음';
const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음';
const historyLines =
args.recentHistoryLines.length > 0
? args.recentHistoryLines.map((line) => `- ${line}`)
: ['- 최근 대화 없음'];
if (args.omittedHistoryCount > 0) {
historyLines.push(`- 최근 문맥 일부만 포함했습니다. 이전 ${args.omittedHistoryCount}개 메시지는 제외되었습니다.`);
}
return [
CHAT_SESSION_REFERENCE_AUTO_START,
@@ -1617,12 +1627,6 @@ function buildChatSessionReferenceAutoSection(args: {
'',
'## 현재 채팅 유형 context',
chatTypeDescription,
'',
'## 최신 사용자 요청',
args.input.trim() || '없음',
'',
'## 최근 대화 요약',
...historyLines,
CHAT_SESSION_REFERENCE_AUTO_END,
].join('\n');
}
@@ -1635,30 +1639,19 @@ function mergeChatSessionReferenceContent(existingContent: string, autoSection:
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
'사용자 요청으로 수정할 수 있고, 필요 시 Codex가 계속 갱신합니다.',
].join('\n');
const defaultManualSection = ['## 수동 메모', '- 사용자 요청으로 유지해야 하는 참고사항을 이 섹션에 정리합니다.'].join('\n');
if (!trimmedExisting) {
return [
defaultHeader,
'',
autoSection,
'',
defaultManualSection,
'',
].join('\n');
return `${defaultHeader}\n\n${autoSection}\n`;
}
const firstAutoStartIndex = existingContent.indexOf(CHAT_SESSION_REFERENCE_AUTO_START);
const manualSectionMatch = existingContent.match(/(^|\n)(## 수동 메모[\s\S]*)$/m);
const preservedManualSection = manualSectionMatch?.[2]?.trim() || defaultManualSection;
if (firstAutoStartIndex >= 0) {
const preservedHeader = existingContent.slice(0, firstAutoStartIndex).trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
return `${preservedHeader}\n\n${autoSection}\n`;
}
const preservedHeader = existingContent.replace(/(^|\n)(## 수동 메모[\s\S]*)$/m, '').trim() || defaultHeader;
return `${preservedHeader}\n\n${autoSection}\n\n${preservedManualSection}\n`;
return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`;
}
export async function ensureChatSessionReferenceResource(args: {
@@ -1677,9 +1670,6 @@ export async function ensureChatSessionReferenceResource(args: {
context: args.context,
sessionId: args.sessionId,
requestId: args.requestId,
input: args.input,
recentHistoryLines: args.recentHistoryLines,
omittedHistoryCount: args.omittedHistoryCount,
});
let existingContent = '';
@@ -1765,7 +1755,7 @@ export function buildAgenticCodexPrompt(
'- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.',
'- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.',
'- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.',
'- 이 채팅방에서 참고사항이나 설계 메모를 유지해야 하면 위 참고 문서를 계속 갱신하세요.',
'- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.',
'- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.',
...buildChatTypeInstructionBlock(context),
'',
@@ -1776,6 +1766,7 @@ export function buildAgenticCodexPrompt(
'- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.',
'- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
'- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 세션 리소스 아래 실제 `.html` 파일을 만든 뒤 기본값으로 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 형태를 사용하세요. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
@@ -1947,7 +1938,7 @@ async function runAgenticCodexReply(
onActivity?: (line: string) => void,
isCancellationRequested?: () => boolean,
) {
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
const repoPath = resolveMainProjectRoot();
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const appConfig = await getAppConfigSnapshot();
const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, {
@@ -2856,19 +2847,33 @@ export class ChatService {
},
) {
if (session.isDeleted) {
return this.createSessionEnvelope(session, message);
const normalizedDeletedMessage =
message.type === 'chat:message'
? {
...message,
payload: normalizeStructuredChatMessage(message.payload),
}
: message;
return this.createSessionEnvelope(session, normalizedDeletedMessage);
}
const envelope = this.createSessionEnvelope(session, message);
const normalizedMessage =
message.type === 'chat:message'
? {
...message,
payload: normalizeStructuredChatMessage(message.payload),
}
: message;
const envelope = this.createSessionEnvelope(session, normalizedMessage);
this.retainEnvelopeForReplay(session, envelope);
sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope');
if (message.type === 'chat:message') {
this.persistConversationMessage(session, message.payload);
if (normalizedMessage.type === 'chat:message') {
this.persistConversationMessage(session, normalizedMessage.payload);
if (message.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationIfNeeded(session, message.payload).catch((error: unknown) => {
if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) {
void this.sendOfflineNotificationIfNeeded(session, normalizedMessage.payload).catch((error: unknown) => {
this.logger.error(error, 'failed to send offline chat notification');
});
}
@@ -2878,9 +2883,10 @@ export class ChatService {
}
private updateMessageInSession(session: ChatSessionState, message: ChatMessage) {
const normalizedMessage = normalizeStructuredChatMessage(message);
const envelope = this.createSessionEnvelope(session, {
type: 'chat:message:update',
payload: message,
payload: normalizedMessage,
});
this.retainEnvelopeForReplay(session, envelope);
@@ -2889,8 +2895,8 @@ export class ChatService {
// Streaming codex deltas and synthesized activity summaries are transient UI state.
// Persist only the final chat message / activity rows to avoid long DB tails that
// can keep a finished request looking "running" until every intermediate update flushes.
if (shouldPersistMessageUpdate(message)) {
this.persistConversationMessage(session, message);
if (shouldPersistMessageUpdate(normalizedMessage)) {
this.persistConversationMessage(session, normalizedMessage);
}
return envelope;
@@ -3465,6 +3471,26 @@ export class ChatService {
chatRuntimeService.clearSession(normalizedSessionId);
}
resetSessionData(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const session = this.sessions.get(normalizedSessionId);
if (!session) {
return;
}
session.queue = [];
session.eventHistory = [];
session.pendingQueueReleaseEventId = null;
session.watchedRuntimeRequestId = null;
session.activeRequestCount = 0;
}
private handleMessage(socket: WebSocket, raw: RawData) {
try {
const message = JSON.parse(raw.toString()) as ChatInboundMessage;

View File

@@ -1,14 +1,31 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_CHAT_TYPES = void 0;
exports.SEEDED_CUSTOM_CHAT_TYPES = exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = exports.DEFAULT_CHAT_TYPES = void 0;
exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = {
id: 'plan-checklist-execution',
name: 'Plan 체크리스트 실행',
description: '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
};
exports.SEEDED_CUSTOM_CHAT_TYPES = [exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE];
exports.DEFAULT_CHAT_TYPES = [
{
id: 'general-request',
name: '일반 요청',
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'md-context-managed',
name: 'MD 기준 관리',
description: '## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'layout-editor-execution',

View File

@@ -7,15 +7,38 @@ export type DefaultChatTypeRecord = {
updatedAt: string;
};
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT =
'## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
{
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'md-context-managed',
name: 'MD 기준 관리',
description:
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'chat-maximized-bottom-safe',
name: '채팅 최대화 하단 안전영역',
description:
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-08T00:00:00.000Z',
},
{
id: 'layout-editor-execution',

View File

@@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../config/env.js';
function normalizeCandidatePath(value: string | null | undefined) {
const normalized = String(value ?? '').trim();
return normalized ? path.resolve(normalized) : null;
}
function getCandidateScore(candidatePath: string) {
let score = 0;
if (fs.existsSync(path.join(candidatePath, 'AGENTS.md'))) {
score += 4;
}
if (fs.existsSync(path.join(candidatePath, 'package.json'))) {
score += 2;
}
if (fs.existsSync(path.join(candidatePath, 'etc', 'servers', 'work-server'))) {
score += 1;
}
return score;
}
export function resolveMainProjectRoot() {
const candidates = [
env.SERVER_COMMAND_MAIN_PROJECT_ROOT,
env.PLAN_MAIN_PROJECT_REPO_PATH,
env.PLAN_GIT_REPO_PATH,
env.SERVER_COMMAND_PROJECT_ROOT,
path.resolve(process.cwd(), '../../..'),
process.cwd(),
'/workspace/main-project',
]
.map((value) => normalizeCandidatePath(value))
.filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
const existingCandidates = candidates.filter((candidate) => {
try {
return fs.statSync(candidate).isDirectory();
} catch {
return false;
}
});
if (existingCandidates.length === 0) {
return candidates[0] ?? path.resolve(process.cwd(), '../../..');
}
return existingCandidates
.slice()
.sort((left, right) => getCandidateScore(right) - getCandidateScore(left))[0];
}

View File

@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveStaticContentType } from './resource-manager-service.js';
test('resolveStaticContentType returns html content type for resource manager html files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
assert.equal(resolveStaticContentType('/tmp/sample.htm'), 'text/html; charset=utf-8');
});
test('resolveStaticContentType keeps markdown and text files unchanged', () => {
assert.equal(resolveStaticContentType('/tmp/sample.md'), 'text/markdown; charset=utf-8');
assert.equal(resolveStaticContentType('/tmp/sample.log'), 'text/plain; charset=utf-8');
});

View File

@@ -86,7 +86,7 @@ const TEXT_FILE_EXTENSIONS = new Set([
'.diff',
]);
function resolveStaticContentType(filePath: string) {
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
switch (extension) {
@@ -98,7 +98,6 @@ function resolveStaticContentType(filePath: string) {
case '.cjs':
case '.json':
case '.css':
case '.html':
case '.txt':
case '.diff':
case '.log':
@@ -107,6 +106,9 @@ function resolveStaticContentType(filePath: string) {
case '.yml':
case '.xml':
return 'text/plain; charset=utf-8';
case '.html':
case '.htm':
return 'text/html; charset=utf-8';
case '.md':
case '.markdown':
return 'text/markdown; charset=utf-8';

View File

@@ -5,6 +5,7 @@ import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { env } from '../config/env.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
getRuntimeWorkServerBuildInfo,
readLatestWorkServerBuildInfo,
@@ -243,7 +244,7 @@ async function findLatestSourceChangeInPath(rootPath: string, targetPath: string
}
async function readLatestAppSourceChange() {
const projectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
const projectRoot = normalizePath(resolveMainProjectRoot());
let latest: SourceChangeInfo | null = null;
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
@@ -575,7 +576,7 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
function getServerDefinitions(): ServerDefinition[] {
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
const mainProjectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT);
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
return [
{

View File

@@ -1,14 +1,18 @@
import type { FastifyBaseLogger } from 'fastify';
import path from 'node:path';
import { env } from '../config/env.js';
import { db } from '../db/client.js';
import { getAppConfigSnapshot } from './app-config-service.js';
import { listBoardPosts, type BoardPostItem, type BoardPostRequestItem } from './board-service.js';
import { getActiveChatService } from './chat-service.js';
import { chatRuntimeService, type ChatRuntimeJobItem } from './chat-runtime-service.js';
import { createNotificationMessage, deleteOlderNotificationMessagesBySource } from './notification-message-service.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
listServerCommands,
restartServerCommand,
type ServerCommandSnapshot,
type ServerCommandKey,
} from './server-command-service.js';
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
@@ -19,9 +23,12 @@ const ACTIVE_CLIENT_WINDOW_MS = 3 * 60 * 1000;
const TEST_TO_WORK_SERVER_DELAY_MS = 5_000;
const RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservation';
const RESERVED_RESTART_AUTO_FIX_SESSION_ID = 'server-restart-reservation';
const RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS = 600;
const RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS = 180;
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'completed' | 'cancelled' | 'failed';
type RestartReservationTarget = 'all';
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'recovering' | 'completed' | 'cancelled' | 'failed';
type RestartReservationTarget = 'all' | 'test' | 'work-server';
type RestartReservationWorkloadSummary = {
codexRunningCount: number;
@@ -30,6 +37,31 @@ type RestartReservationWorkloadSummary = {
automationQueuedCount: number;
};
type RestartReservationWorkItem = {
kind: 'codex' | 'automation';
status: 'running' | 'queued' | 'waiting';
title: string;
detail: string | null;
requestId: string | null;
sessionId: string | null;
};
type RestartReservationAutoFixStatus = 'idle' | 'queued' | 'running' | 'completed' | 'failed';
type RestartReservationAutoFix = {
enabled: boolean;
targetKey: 'test' | 'work-server' | null;
requestId: string | null;
sessionId: string | null;
status: RestartReservationAutoFixStatus;
summary: string | null;
detail: string | null;
requestedAt: string | null;
startedAt: string | null;
completedAt: string | null;
failedAt: string | null;
};
type RestartReservationRow = {
id: number;
enabled: boolean;
@@ -50,6 +82,7 @@ type RestartReservationRow = {
auto_execute_at: string | null;
auto_execute_delay_seconds: number | null;
updated_at: string | null;
auto_fix_json: RestartReservationAutoFix | string | null;
};
export type ServerRestartReservationSnapshot = {
@@ -72,6 +105,8 @@ export type ServerRestartReservationSnapshot = {
autoExecuteAt: string | null;
autoExecuteDelaySeconds: number;
updatedAt: string | null;
workItems: RestartReservationWorkItem[];
autoFix: RestartReservationAutoFix;
};
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
@@ -83,6 +118,22 @@ function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
};
}
function getDefaultAutoFixState(): RestartReservationAutoFix {
return {
enabled: false,
targetKey: null,
requestId: null,
sessionId: null,
status: 'idle',
summary: null,
detail: null,
requestedAt: null,
startedAt: null,
completedAt: null,
failedAt: null,
};
}
function hasAcceptedAutomationRequest(requestItem: Pick<BoardPostRequestItem, 'planItemId' | 'automationReceivedAt' | 'workflowState'>) {
return Boolean(requestItem.planItemId) || Boolean(requestItem.automationReceivedAt) || requestItem.workflowState !== 'pending';
}
@@ -168,7 +219,48 @@ function buildNextCheckAt(row: RestartReservationRow | null | undefined) {
return new Date(baseTime + SERVER_RESTART_RESERVATION_POLL_INTERVAL_MS).toISOString();
}
function mapReservationRow(row: RestartReservationRow | null | undefined): ServerRestartReservationSnapshot {
function parseAutoFixState(rawValue: RestartReservationRow['auto_fix_json']): RestartReservationAutoFix {
if (!rawValue) {
return getDefaultAutoFixState();
}
if (typeof rawValue === 'string') {
try {
return parseAutoFixState(JSON.parse(rawValue) as RestartReservationAutoFix);
} catch {
return getDefaultAutoFixState();
}
}
const value = rawValue as Partial<RestartReservationAutoFix>;
return {
enabled: value.enabled === true,
targetKey: value.targetKey === 'test' || value.targetKey === 'work-server' ? value.targetKey : null,
requestId: typeof value.requestId === 'string' ? value.requestId : null,
sessionId: typeof value.sessionId === 'string' ? value.sessionId : null,
status:
value.status === 'queued'
|| value.status === 'running'
|| value.status === 'completed'
|| value.status === 'failed'
? value.status
: 'idle',
summary: typeof value.summary === 'string' ? value.summary : null,
detail: typeof value.detail === 'string' ? value.detail : null,
requestedAt: typeof value.requestedAt === 'string' ? value.requestedAt : null,
startedAt: typeof value.startedAt === 'string' ? value.startedAt : null,
completedAt: typeof value.completedAt === 'string' ? value.completedAt : null,
failedAt: typeof value.failedAt === 'string' ? value.failedAt : null,
};
}
function mapReservationRow(
row: RestartReservationRow | null | undefined,
options?: {
workItems?: RestartReservationWorkItem[];
},
): ServerRestartReservationSnapshot {
const rawSummary = row?.workload_summary_json;
let workloadSummary = getDefaultWorkloadSummary();
@@ -193,6 +285,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
};
}
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
return {
enabled: Boolean(row?.enabled),
target: row?.target === 'all' ? 'all' : 'all',
@@ -213,6 +307,8 @@ function mapReservationRow(row: RestartReservationRow | null | undefined): Serve
autoExecuteAt: row?.auto_execute_at ?? null,
autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)),
updatedAt: row?.updated_at ?? null,
workItems: options?.workItems ?? [],
autoFix,
};
}
@@ -239,6 +335,7 @@ async function ensureServerRestartReservationTable() {
table.string('app_origin', 255).nullable();
table.timestamp('auto_execute_at', { useTz: true }).nullable();
table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10);
table.jsonb('auto_fix_json').notNullable().defaultTo('{}');
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
}
@@ -247,6 +344,7 @@ async function ensureServerRestartReservationTable() {
['app_origin', (table) => table.string('app_origin', 255).nullable()],
['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()],
['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)],
['auto_fix_json', (table) => table.jsonb('auto_fix_json').notNullable().defaultTo('{}')],
];
for (const [columnName, addColumn] of requiredColumns) {
@@ -268,6 +366,7 @@ async function ensureServerRestartReservationTable() {
status: 'idle',
workload_summary_json: getDefaultWorkloadSummary(),
active_client_count: 0,
auto_fix_json: getDefaultAutoFixState(),
updated_at: db.fn.now(),
});
}
@@ -295,6 +394,115 @@ async function countPendingAutomationWork() {
return summarizeRestartReservationAutomationWork(await listBoardPosts());
}
async function listRestartReservationWorkItems(): Promise<RestartReservationWorkItem[]> {
const runtimeSnapshot = chatRuntimeService.getSnapshot();
const codexRunningItems = runtimeSnapshot.running.slice(0, 4).map((item) => ({
kind: 'codex' as const,
status: 'running' as const,
title: item.summary || 'Codex Live 요청',
detail: item.startedAt ? `실행 시작 ${item.startedAt}` : null,
requestId: item.requestId,
sessionId: item.sessionId,
}));
const codexQueuedItems = runtimeSnapshot.queued.slice(0, 4).map((item) => ({
kind: 'codex' as const,
status: 'queued' as const,
title: item.summary || 'Codex Live 요청',
detail: item.enqueuedAt ? `대기 등록 ${item.enqueuedAt}` : null,
requestId: item.requestId,
sessionId: item.sessionId,
}));
const boardPosts = await listBoardPosts();
const automationItems = boardPosts.flatMap((post) =>
post.requestItems
.filter((requestItem) => requestItem.status === 'in_progress' || requestItem.status === 'queued' || requestItem.status === 'waiting')
.slice(0, 4)
.map((requestItem) => ({
kind: 'automation' as const,
status:
requestItem.status === 'in_progress'
? 'running' as const
: requestItem.status === 'queued'
? 'queued' as const
: 'waiting' as const,
title: requestItem.title.trim() || post.title.trim() || `자동화 요청 #${requestItem.id}`,
detail: [post.title.trim() || null, requestItem.statusLabel.trim() || null].filter(Boolean).join(' · ') || null,
requestId: requestItem.planItemId ? String(requestItem.planItemId) : null,
sessionId: null,
})),
);
return [...codexRunningItems, ...codexQueuedItems, ...automationItems.slice(0, 6)];
}
function normalizeRunnerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
function buildCommandRunnerApiCandidates(requestPath: string) {
const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health';
let parsedUrl: URL;
try {
parsedUrl = new URL(configuredHealthUrl);
} catch {
return [];
}
const hostVariants =
parsedUrl.hostname === 'host.docker.internal'
? ['host.docker.internal', '127.0.0.1', 'localhost']
: parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost'
? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal']
: [parsedUrl.hostname];
const deduped: string[] = [];
for (const hostname of hostVariants) {
const candidate = new URL(parsedUrl.toString());
candidate.hostname = hostname;
candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
candidate.search = '';
candidate.hash = '';
const serialized = normalizeRunnerUrl(candidate.toString());
if (!deduped.includes(serialized)) {
deduped.push(serialized);
}
}
return deduped;
}
async function requestCommandRunner(requestPath: string, init?: RequestInit) {
const headers = new Headers(init?.headers);
if (init?.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim());
}
let lastError: Error | null = null;
for (const url of buildCommandRunnerApiCandidates(requestPath)) {
try {
return await fetch(url, {
...init,
headers,
});
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
}
}
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
}
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
const reasons: string[] = [];
@@ -312,6 +520,224 @@ function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
return reasons.length > 0 ? `${reasons.join(', ')} 진행 중이라 재기동을 대기합니다.` : null;
}
function isRestartBuildFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error ?? '');
return /(build|vite|tsc|typescript|npm run build|pnpm build|rollup|esbuild|compilation|compile|exit:\d+)/i.test(message);
}
function buildReservedRestartAutoFixPrompt(args: {
targetKey: 'test' | 'work-server';
failureMessage: string;
}) {
const repoPath = resolveMainProjectRoot();
const targetLabel = args.targetKey === 'test' ? 'TEST 앱' : 'WORK-SERVER';
return [
`당신은 ${repoPath} 저장소에서 ${targetLabel} 재기동 빌드 실패를 자동 복구하는 Codex 실행기입니다.`,
'반드시 저장소 루트의 AGENTS.md를 먼저 읽고 그 규칙을 따르세요.',
'목표는 현재 로컬 main 기준으로 재기동을 막는 빌드 오류를 직접 수정하는 것입니다.',
'필요한 범위만 수정하고, 불필요한 Git 작업은 하지 마세요.',
`현재 실패 대상: ${targetLabel}`,
'실패 로그:',
args.failureMessage,
'작업 지시:',
'1. 빌드 실패 원인을 확인합니다.',
'2. 현재 저장소에서 직접 수정합니다.',
'3. 대상 서버 재기동을 막는 빌드 오류가 해결되었는지 관련 빌드/검증 명령으로 확인합니다.',
'4. 최종 답변은 한국어로 간결하게 작성합니다.',
].join('\n');
}
async function updateReservationAutoFixState(patch: Partial<RestartReservationAutoFix>) {
const row = await readReservationRow();
const current = parseAutoFixState(row?.auto_fix_json ?? null);
const nextState: RestartReservationAutoFix = {
...current,
...patch,
};
return updateReservationRow({
auto_fix_json: nextState,
});
}
async function runReservedRestartAutoFix(
logger: FastifyBaseLogger,
args: {
targetKey: 'test' | 'work-server';
failureMessage: string;
},
) {
const repoPath = resolveMainProjectRoot();
const requestId = `server-restart-fix-${args.targetKey}-${Date.now().toString(36)}`;
const sessionId = RESERVED_RESTART_AUTO_FIX_SESSION_ID;
const prompt = buildReservedRestartAutoFixPrompt(args);
await updateReservationAutoFixState({
enabled: true,
targetKey: args.targetKey,
requestId,
sessionId,
status: 'queued',
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선 요청을 준비 중입니다.`,
detail: args.failureMessage,
requestedAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
failedAt: null,
});
const response = await requestCommandRunner('/api/codex-live/execute', {
method: 'POST',
body: JSON.stringify({
requestId,
sessionId,
repoPath,
prompt,
resourceDir: path.join(
repoPath,
'public',
'.codex_chat',
sessionId,
'resource',
),
uploadDir: path.join(
repoPath,
'public',
'.codex_chat',
sessionId,
'resource',
'uploads',
),
maxExecutionSeconds: RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS,
idleTimeoutSeconds: RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS,
}),
});
if (!response.ok || !response.body) {
const message = (await response.text().catch(() => '')) || 'Codex 자동 개선 요청을 시작하지 못했습니다.';
await updateReservationAutoFixState({
enabled: true,
targetKey: args.targetKey,
requestId,
sessionId,
status: 'failed',
summary: `${args.targetKey.toUpperCase()} 자동 개선 요청 시작 실패`,
detail: message,
failedAt: new Date().toISOString(),
});
throw new Error(message);
}
const decoder = new TextDecoder();
const reader = response.body.getReader();
let buffer = '';
let completedText = '';
let remoteError = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
let event: Record<string, unknown>;
try {
event = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
const type = typeof event.type === 'string' ? event.type : '';
if (type === 'started') {
await updateReservationAutoFixState({
enabled: true,
targetKey: args.targetKey,
requestId,
sessionId,
status: 'running',
summary: `${args.targetKey.toUpperCase()} 빌드 오류를 Codex가 분석 중입니다.`,
startedAt: new Date().toISOString(),
});
continue;
}
if (type === 'activity' || type === 'stdout' || type === 'stderr') {
const lineText = String(event.line ?? '').trim();
if (lineText) {
await updateReservationAutoFixState({
enabled: true,
targetKey: args.targetKey,
requestId,
sessionId,
status: 'running',
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선 진행 중`,
detail: lineText,
});
}
continue;
}
if (type === 'completed') {
completedText = String(event.text ?? '').trim();
continue;
}
if (type === 'error') {
remoteError = String(event.message ?? '').trim();
}
}
}
if (remoteError) {
await updateReservationAutoFixState({
enabled: true,
targetKey: args.targetKey,
requestId,
sessionId,
status: 'failed',
summary: `${args.targetKey.toUpperCase()} 자동 개선 실패`,
detail: remoteError,
failedAt: new Date().toISOString(),
});
throw new Error(remoteError);
}
await updateReservationAutoFixState({
enabled: true,
targetKey: args.targetKey,
requestId,
sessionId,
status: 'completed',
summary: `${args.targetKey.toUpperCase()} 빌드 오류 자동 개선을 마쳤습니다.`,
detail: completedText || 'Codex 자동 개선이 완료되었습니다. 재기동을 다시 시도합니다.',
completedAt: new Date().toISOString(),
});
logger.info({ requestId, targetKey: args.targetKey }, 'Reserved restart auto fix completed');
return {
requestId,
sessionId,
completedText,
};
}
async function listActiveClients() {
await ensureVisitorHistoryTables();
const visitors = await listVisitorClients(50);
@@ -452,6 +878,129 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
return mapReservationRow(nextRow);
}
async function restartReservedTargetWithRecovery(
logger: FastifyBaseLogger,
targetKey: 'test' | 'work-server',
startMessage: string,
) {
await updateReservationRow({
enabled: true,
status: 'executing',
waiting_reason: startMessage,
last_checked_at: db.fn.now(),
});
try {
await restartServerCommand(targetKey);
return;
} catch (error) {
const message = error instanceof Error ? error.message : '재기동에 실패했습니다.';
if (!isRestartBuildFailure(error)) {
throw error;
}
logger.warn({ err: error, targetKey }, 'Reserved restart build failure detected, requesting Codex auto fix');
await updateReservationRow({
enabled: true,
status: 'recovering',
waiting_reason: `${targetKey.toUpperCase()} 재기동 빌드 실패를 감지해 Codex 자동 개선을 시작합니다.`,
last_checked_at: db.fn.now(),
last_error: message,
});
await runReservedRestartAutoFix(logger, {
targetKey,
failureMessage: message,
});
await updateReservationRow({
enabled: true,
status: 'executing',
waiting_reason: `${targetKey.toUpperCase()} 빌드 오류를 수정해 재기동을 다시 시도합니다.`,
last_checked_at: db.fn.now(),
last_error: null,
});
await restartServerCommand(targetKey);
}
}
async function finalizeSingleServerRestart(targetKey: 'test' | 'work-server') {
const nextRow = await updateReservationRow({
enabled: false,
target: targetKey,
status: 'completed',
completed_at: db.fn.now(),
waiting_reason: null,
workload_summary_json: getDefaultWorkloadSummary(),
last_error: null,
last_checked_at: db.fn.now(),
auto_execute_at: null,
});
return mapReservationRow(nextRow);
}
let immediateRecoveryPromise: Promise<void> | null = null;
export async function requestImmediateRestartRecovery(
logger: FastifyBaseLogger,
targetKey: 'test' | 'work-server',
failureMessage: string,
) {
await updateReservationRow({
enabled: true,
target: targetKey,
status: 'recovering',
requested_at: db.fn.now(),
requested_by_client_id: null,
waiting_reason: `${targetKey.toUpperCase()} 재기동 빌드 실패를 감지해 Codex 자동 개선을 시작합니다.`,
workload_summary_json: getDefaultWorkloadSummary(),
started_at: db.fn.now(),
completed_at: null,
cancelled_at: null,
last_error: failureMessage,
active_client_count: 0,
notified_active_clients_at: null,
app_origin: null,
auto_execute_at: null,
auto_execute_delay_seconds: 10,
last_checked_at: db.fn.now(),
auto_fix_json: getDefaultAutoFixState(),
});
if (immediateRecoveryPromise) {
return getServerRestartReservation();
}
immediateRecoveryPromise = (async () => {
try {
await restartReservedTargetWithRecovery(
logger,
targetKey,
`${targetKey.toUpperCase()} 빌드 오류를 자동 수정한 뒤 재기동을 다시 시도합니다.`,
);
await finalizeSingleServerRestart(targetKey);
} catch (error) {
const message = error instanceof Error ? error.message : '즉시 재기동 자동 복구에 실패했습니다.';
logger.error({ err: error, targetKey }, 'Immediate restart recovery failed');
await updateReservationRow({
enabled: false,
target: targetKey,
status: 'failed',
waiting_reason: null,
last_error: message,
last_checked_at: db.fn.now(),
}).catch(() => undefined);
} finally {
immediateRecoveryPromise = null;
}
})();
return getServerRestartReservation();
}
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
const activeClients = await listActiveClients();
await updateReservationRow({
@@ -494,7 +1043,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
'Executing reserved restart',
);
await restartServerCommand('test');
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
await updateReservationRow({
@@ -504,11 +1053,21 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
last_checked_at: db.fn.now(),
});
await restartServerCommand('work-server');
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
}
export async function getServerRestartReservation() {
return mapReservationRow(await readReservationRow());
const row = await readReservationRow();
const autoFix = parseAutoFixState(row?.auto_fix_json ?? null);
const shouldExposeWorkItems =
Boolean(row?.enabled)
|| row?.status === 'waiting'
|| row?.status === 'ready'
|| row?.status === 'executing'
|| row?.status === 'recovering'
|| autoFix.enabled;
const workItems = shouldExposeWorkItems ? await listRestartReservationWorkItems() : [];
return mapReservationRow(row, { workItems });
}
export async function scheduleServerRestartReservation(options?: {
@@ -535,6 +1094,7 @@ export async function scheduleServerRestartReservation(options?: {
app_origin: options?.appOrigin?.trim() || null,
auto_execute_at: null,
auto_execute_delay_seconds: autoExecuteDelaySeconds,
auto_fix_json: getDefaultAutoFixState(),
});
return mapReservationRow(row);
@@ -550,6 +1110,7 @@ export async function cancelServerRestartReservation() {
active_client_count: 0,
last_error: null,
auto_execute_at: null,
auto_fix_json: getDefaultAutoFixState(),
});
return mapReservationRow(row);
@@ -580,6 +1141,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.',
last_error: null,
auto_execute_at: null,
auto_fix_json: getDefaultAutoFixState(),
});
if (!nextRow) {
@@ -638,6 +1200,10 @@ export class ServerRestartReservationWorker {
return;
}
if (row.status === 'recovering') {
return;
}
if (row.status === 'executing' && row.started_at) {
await finalizeReservedRestart(row);
return;

File diff suppressed because it is too large Load Diff

View File

@@ -205,7 +205,7 @@ test('buildChangeRateThresholdStockAlertLines includes every registered stock th
]);
});
test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a 300%+ volume jump over the previous snapshot', () => {
test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks whose current jump beats the recent 5-record max by the configured ratio', () => {
const items: StockAlertItem[] = [
{
id: '290550',
@@ -300,6 +300,59 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
},
],
]),
new Map([
[
'290550',
[
{
id: '290550:1',
stockCode: '290550',
stockName: '디케이티',
baselineVolume: 100000,
currentVolume: 160000,
volumeIncreasePercent: 60,
currentPrice: 25000,
changeRate: 3,
quotedAt: '2026-05-05T00:00:00.000Z',
createdAt: '2026-05-05T00:00:00.000Z',
},
],
],
[
'005930',
[
{
id: '005930:1',
stockCode: '005930',
stockName: '삼성전자',
baselineVolume: 130000,
currentVolume: 200000,
volumeIncreasePercent: 53.85,
currentPrice: 205000,
changeRate: 2,
quotedAt: '2026-05-05T00:00:00.000Z',
createdAt: '2026-05-05T00:00:00.000Z',
},
],
],
[
'035420',
[
{
id: '035420:1',
stockCode: '035420',
stockName: 'NAVER',
baselineVolume: 120000,
currentVolume: 190000,
volumeIncreasePercent: 58.33,
currentPrice: 240000,
changeRate: -3,
quotedAt: '2026-05-05T00:00:00.000Z',
createdAt: '2026-05-05T00:00:00.000Z',
},
],
],
]),
{
thresholdPercent: 3,
minVolumeIncreasePercent: 300,
@@ -315,7 +368,8 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates keeps only stocks with a
currentVolume: 400000,
previousVolume: 100000,
volumeIncreasePercent: 300,
volumeAmplificationPercent: 300,
recentMaxVolumeIncreasePercent: 60,
volumeAmplificationGrowthPercent: 400,
quotedAt: '2026-05-06T00:30:00.000Z',
},
]);
@@ -358,6 +412,25 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
},
],
]),
new Map([
[
'290550',
[
{
id: '290550:1',
stockCode: '290550',
stockName: '디케이티',
baselineVolume: 100000,
currentVolume: 150000,
volumeIncreasePercent: 50,
currentPrice: 25000,
changeRate: 3,
quotedAt: '2026-05-05T00:00:00.000Z',
createdAt: '2026-05-05T00:00:00.000Z',
},
],
],
]),
{
thresholdPercent: 3,
minVolumeIncreasePercent: 50,
@@ -373,13 +446,14 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates treats 100% as a doubled
currentVolume: 350000,
previousVolume: 200000,
volumeIncreasePercent: 75,
volumeAmplificationPercent: 50,
recentMaxVolumeIncreasePercent: 50,
volumeAmplificationGrowthPercent: 50,
quotedAt: '2026-05-06T00:30:00.000Z',
},
]);
});
test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day average volume when no previous snapshot exists', () => {
test('buildChangeRateAndVolumeSpikeStockAlertCandidates skips stocks without a previous batch snapshot baseline', () => {
const items: StockAlertItem[] = [
{
id: '290550',
@@ -411,24 +485,35 @@ test('buildChangeRateAndVolumeSpikeStockAlertCandidates falls back to the 5-day
},
];
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(items, new Map(), {
thresholdPercent: 3,
minVolumeIncreasePercent: 300,
});
assert.deepEqual(candidates, [
const candidates = buildChangeRateAndVolumeSpikeStockAlertCandidates(
items,
new Map(),
new Map([
[
'290550',
[
{
id: '290550:1',
stockCode: '290550',
stockName: '디케이티',
baselineVolume: 100000,
currentVolume: 175000,
volumeIncreasePercent: 75,
currentPrice: 25000,
changeRate: 3,
quotedAt: '2026-05-05T00:00:00.000Z',
createdAt: '2026-05-05T00:00:00.000Z',
},
],
],
]),
{
stockCode: '290550',
stockName: '디케이티',
currentPrice: 26500,
changeRate: 11.11,
currentVolume: 400000,
previousVolume: 100000,
volumeIncreasePercent: 300,
volumeAmplificationPercent: 300,
quotedAt: '2026-05-06T00:30:00.000Z',
thresholdPercent: 3,
minVolumeIncreasePercent: 300,
},
]);
);
assert.deepEqual(candidates, []);
});
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {

View File

@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
export const STOCK_ALERT_TABLE = 'stock_alerts';
export const STOCK_ALERT_VOLUME_SNAPSHOT_TABLE = 'stock_alert_volume_snapshots';
export const STOCK_ALERT_VOLUME_HISTORY_TABLE = 'stock_alert_volume_histories';
export const STOCK_ALERT_LAYOUT_NAME = 'stock알림';
export const STOCK_ALERT_TYPE_OPTIONS = [
@@ -73,6 +74,32 @@ export type StockAlertVolumeSnapshot = {
updatedAt: string;
};
export type StockAlertVolumeHistoryRow = {
id: string;
stock_code: string;
stock_name: string;
baseline_volume: number | string | null;
current_volume: number | string | null;
volume_increase_percent: number | string | null;
current_price: number | string | null;
change_rate: number | string | null;
quoted_at: string | null;
created_at: string;
};
export type StockAlertVolumeHistory = {
id: string;
stockCode: string;
stockName: string;
baselineVolume: number | null;
currentVolume: number | null;
volumeIncreasePercent: number | null;
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string;
};
export type StockAlertVolumeSpikeCandidate = {
stockCode: string;
stockName: string;
@@ -81,7 +108,8 @@ export type StockAlertVolumeSpikeCandidate = {
currentVolume: number | null;
previousVolume: number | null;
volumeIncreasePercent: number | null;
volumeAmplificationPercent: number | null;
recentMaxVolumeIncreasePercent: number | null;
volumeAmplificationGrowthPercent: number | null;
quotedAt: string | null;
};
@@ -345,50 +373,17 @@ function calculateVolumeIncreasePercent(currentVolume: number | null, previousVo
return ((currentVolume - previousVolume) / previousVolume) * 100;
}
function calculateVolumeAmplificationPercent(
currentVolume: number | null,
previousSnapshot: StockAlertVolumeSnapshot | null,
fallbackPercent: number | null,
) {
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
const previousCurrentVolume = normalizeNonNegativeVolume(previousSnapshot?.currentVolume);
const previousBaselineVolume = normalizeNonNegativeVolume(previousSnapshot?.previousVolume);
function calculateVolumeAmplificationGrowthPercent(currentIncreasePercent: number | null, recentMaxIncreasePercent: number | null) {
if (
normalizedCurrentVolume === null
|| previousCurrentVolume === null
|| previousBaselineVolume === null
|| previousCurrentVolume <= previousBaselineVolume
|| normalizedCurrentVolume < previousCurrentVolume
!isFiniteNumber(currentIncreasePercent)
|| !isFiniteNumber(recentMaxIncreasePercent)
|| recentMaxIncreasePercent <= 0
|| currentIncreasePercent < recentMaxIncreasePercent
) {
return fallbackPercent;
}
const previousRiseAmount = previousCurrentVolume - previousBaselineVolume;
const currentRiseAmount = normalizedCurrentVolume - previousCurrentVolume;
if (previousRiseAmount <= 0) {
return fallbackPercent;
}
return ((currentRiseAmount - previousRiseAmount) / previousRiseAmount) * 100;
}
function deriveVolumeBaselineFromRate5d(item: StockAlertItem) {
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
const volumeRate5d = isFiniteNumber(item.volumeRate5d) ? item.volumeRate5d : null;
if (currentVolume === null || volumeRate5d === null || volumeRate5d <= 0) {
return null;
}
const baseline = currentVolume / (volumeRate5d / 100);
if (!Number.isFinite(baseline) || baseline <= 0) {
return null;
}
return Math.max(1, Math.round(baseline));
return ((currentIncreasePercent - recentMaxIncreasePercent) / recentMaxIncreasePercent) * 100;
}
function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot: StockAlertVolumeSnapshot | null) {
@@ -398,7 +393,7 @@ function resolveComparableVolumeBaseline(item: StockAlertItem, previousSnapshot:
return snapshotBaseline;
}
return deriveVolumeBaselineFromRate5d(item);
return null;
}
function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow): StockAlertVolumeSnapshot {
@@ -416,6 +411,21 @@ function normalizeStockAlertVolumeSnapshotRow(row: StockAlertVolumeSnapshotRow):
};
}
function normalizeStockAlertVolumeHistoryRow(row: StockAlertVolumeHistoryRow): StockAlertVolumeHistory {
return {
id: String(row.id ?? '').trim(),
stockCode: normalizeStockCode(row.stock_code),
stockName: String(row.stock_name ?? '').trim() || normalizeStockCode(row.stock_code),
baselineVolume: normalizeNonNegativeVolume(row.baseline_volume),
currentVolume: normalizeNonNegativeVolume(row.current_volume),
volumeIncreasePercent: parseLooseNumber(row.volume_increase_percent),
currentPrice: parseLooseNumber(row.current_price),
changeRate: parseLooseNumber(row.change_rate),
quotedAt: row.quoted_at ? normalizeTimestamp(row.quoted_at) : null,
createdAt: normalizeTimestamp(row.created_at),
};
}
function buildStockAlertVolumeSnapshotRecord(
item: StockAlertItem,
currentVolume: number | null,
@@ -451,6 +461,30 @@ function buildStockAlertVolumeSnapshotRecord(
} satisfies Omit<StockAlertVolumeSnapshotRow, 'quoted_at'> & { quoted_at: string | null };
}
function buildStockAlertVolumeHistoryRecord(
item: StockAlertItem,
baselineVolume: number | null,
currentVolume: number | null,
) {
const now = new Date().toISOString();
const normalizedBaselineVolume = normalizeNonNegativeVolume(baselineVolume);
const normalizedCurrentVolume = normalizeNonNegativeVolume(currentVolume);
const quotedAt = item.quotedAt ?? now;
return {
id: `${item.stockCode}:${quotedAt}`,
stock_code: item.stockCode,
stock_name: item.stockName,
baseline_volume: normalizedBaselineVolume,
current_volume: normalizedCurrentVolume,
volume_increase_percent: calculateVolumeIncreasePercent(normalizedCurrentVolume, normalizedBaselineVolume),
current_price: item.currentPrice,
change_rate: item.changeRate,
quoted_at: item.quotedAt,
created_at: now,
} satisfies Omit<StockAlertVolumeHistoryRow, 'quoted_at'> & { quoted_at: string | null };
}
function buildStockSymbols(stockCode: string) {
const normalizedCode = normalizeStockCode(stockCode);
@@ -532,6 +566,27 @@ async function ensureStockAlertVolumeSnapshotTable() {
});
}
async function ensureStockAlertVolumeHistoryTable() {
const exists = await db.schema.hasTable(STOCK_ALERT_VOLUME_HISTORY_TABLE);
if (exists) {
return;
}
await db.schema.createTable(STOCK_ALERT_VOLUME_HISTORY_TABLE, (table) => {
table.text('id').primary();
table.text('stock_code').notNullable();
table.text('stock_name').notNullable();
table.bigInteger('baseline_volume').nullable();
table.bigInteger('current_volume').nullable();
table.decimal('volume_increase_percent', 10, 2).nullable();
table.decimal('current_price', 14, 2).nullable();
table.decimal('change_rate', 10, 4).nullable();
table.timestamp('quoted_at', { useTz: true }).nullable();
table.timestamp('created_at', { useTz: true }).notNullable();
});
}
async function fetchJson<T>(url: URL, init?: RequestInit) {
const response = await fetch(url, {
...init,
@@ -1494,6 +1549,34 @@ async function listStockAlertVolumeSnapshots() {
);
}
async function listRecentStockAlertVolumeHistories(limitPerStock = 5) {
await ensureStockAlertVolumeHistoryTable();
const rows = (await db<StockAlertVolumeHistoryRow>(STOCK_ALERT_VOLUME_HISTORY_TABLE)
.select('*')
.orderBy('created_at', 'desc')) as StockAlertVolumeHistoryRow[];
const historyMap = new Map<string, StockAlertVolumeHistory[]>();
rows.forEach((row) => {
const normalized = normalizeStockAlertVolumeHistoryRow(row);
if (!normalized.stockCode) {
return;
}
const items = historyMap.get(normalized.stockCode) ?? [];
if (items.length >= limitPerStock) {
return;
}
items.push(normalized);
historyMap.set(normalized.stockCode, items);
});
return historyMap;
}
async function upsertStockAlertVolumeSnapshots(
items: StockAlertItem[],
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
@@ -1523,9 +1606,35 @@ async function upsertStockAlertVolumeSnapshots(
});
}
async function insertStockAlertVolumeHistories(
items: StockAlertItem[],
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
) {
await ensureStockAlertVolumeHistoryTable();
if (!items.length) {
return;
}
const records = items
.map((item) => {
const previousSnapshot = previousSnapshots.get(item.stockCode) ?? null;
const baselineVolume = resolveComparableVolumeBaseline(item, previousSnapshot);
return buildStockAlertVolumeHistoryRecord(item, baselineVolume, item.currentVolume);
})
.filter((record) => record.current_volume !== null);
if (!records.length) {
return;
}
await db(STOCK_ALERT_VOLUME_HISTORY_TABLE).insert(records).onConflict('id').ignore();
}
export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
items: StockAlertItem[],
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
recentHistories: Map<string, StockAlertVolumeHistory[]>,
options: {
thresholdPercent: number;
minVolumeIncreasePercent: number;
@@ -1541,16 +1650,23 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
const previousVolume = resolveComparableVolumeBaseline(item, previousSnapshot ?? null);
const currentVolume = normalizeNonNegativeVolume(item.currentVolume);
const volumeIncreasePercent = calculateVolumeIncreasePercent(currentVolume, previousVolume);
const volumeAmplificationPercent = calculateVolumeAmplificationPercent(
currentVolume,
previousSnapshot ?? null,
const recentMaxVolumeIncreasePercent = Math.max(
...((recentHistories.get(item.stockCode) ?? [])
.map((history) => history.volumeIncreasePercent)
.filter((value): value is number => isFiniteNumber(value))),
);
const normalizedRecentMaxVolumeIncreasePercent = Number.isFinite(recentMaxVolumeIncreasePercent)
? recentMaxVolumeIncreasePercent
: null;
const volumeAmplificationGrowthPercent = calculateVolumeAmplificationGrowthPercent(
volumeIncreasePercent,
normalizedRecentMaxVolumeIncreasePercent,
);
if (
volumeAmplificationPercent === null ||
volumeAmplificationGrowthPercent === null ||
Math.abs(item.changeRate ?? 0) < options.thresholdPercent ||
volumeAmplificationPercent < options.minVolumeIncreasePercent
volumeAmplificationGrowthPercent < options.minVolumeIncreasePercent
) {
return [];
}
@@ -1564,7 +1680,8 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
currentVolume,
previousVolume,
volumeIncreasePercent,
volumeAmplificationPercent,
recentMaxVolumeIncreasePercent: normalizedRecentMaxVolumeIncreasePercent,
volumeAmplificationGrowthPercent,
quotedAt: item.quotedAt,
} satisfies StockAlertVolumeSpikeCandidate,
];
@@ -1576,7 +1693,7 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
return changeRateGap;
}
const volumeGap = (right.volumeAmplificationPercent ?? 0) - (left.volumeAmplificationPercent ?? 0);
const volumeGap = (right.volumeAmplificationGrowthPercent ?? 0) - (left.volumeAmplificationGrowthPercent ?? 0);
if (volumeGap !== 0) {
return volumeGap;
@@ -1589,12 +1706,13 @@ export function buildChangeRateAndVolumeSpikeStockAlertCandidates(
export function buildChangeRateAndVolumeSpikeStockAlertLines(
items: StockAlertItem[],
previousSnapshots: Map<string, StockAlertVolumeSnapshot>,
recentHistories: Map<string, StockAlertVolumeHistory[]>,
options: {
thresholdPercent: number;
minVolumeIncreasePercent: number;
},
) {
return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, options).map(
return buildChangeRateAndVolumeSpikeStockAlertCandidates(items, previousSnapshots, recentHistories, options).map(
(item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`,
);
}
@@ -1664,12 +1782,14 @@ export async function sendManagedStockAlertWebPush(options: {
const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all');
const previousSnapshots =
options.mode === 'change-threshold-volume-spike' ? await listStockAlertVolumeSnapshots() : new Map<string, StockAlertVolumeSnapshot>();
const recentHistories =
options.mode === 'change-threshold-volume-spike' ? await listRecentStockAlertVolumeHistories() : new Map<string, StockAlertVolumeHistory[]>();
const lines =
options.mode === 'price'
? buildCurrentPriceStockAlertLines(items)
: options.mode === 'change-threshold'
? buildChangeRateThresholdStockAlertLines(items, thresholdPercent)
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, {
: buildChangeRateAndVolumeSpikeStockAlertLines(items, previousSnapshots, recentHistories, {
thresholdPercent,
minVolumeIncreasePercent,
});
@@ -1681,6 +1801,12 @@ export async function sendManagedStockAlertWebPush(options: {
const previousSnapshot = previousSnapshots.get(item.stockCode);
return resolveComparableVolumeBaseline(item, previousSnapshot ?? null) !== null;
});
const hasRecentVolumeHistory =
options.mode !== 'change-threshold-volume-spike'
? false
: items.some((item) =>
(recentHistories.get(item.stockCode) ?? []).some((history) => isFiniteNumber(history.volumeIncreasePercent) && history.volumeIncreasePercent > 0),
);
const skippedReason =
options.mode === 'price'
? hasRegisteredTargets
@@ -1692,13 +1818,16 @@ export async function sendManagedStockAlertWebPush(options: {
: '등록된 종목이 없습니다.'
: !hasRegisteredTargets
? '등록된 종목이 없습니다.'
: !hasComparableVolumeBaseline
? '이전 거래량 또는 5영업일 평균 거래량 비교 기준이 없어 스냅샷만 갱신했습니다.'
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 직전 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
: !hasComparableVolumeBaseline
? '이전 배치 실행 row 기준 거래량이 없어 스냅샷만 갱신했습니다.'
: !hasRecentVolumeHistory
? '배치 실행 거래량 히스토리 row 5건 비교 기준이 없어 히스토리만 적재했습니다.'
: `등락률 ${thresholdPercent}% 이상이면서 이번 거래량 증폭수가 최근 배치 실행 row 5건 최대값 대비 ${minVolumeIncreasePercent}% 이상 증가한 종목이 없습니다.`;
const skippedResult = createSkippedNotificationResult(skippedReason);
if (options.mode === 'change-threshold-volume-spike') {
await upsertStockAlertVolumeSnapshots(items, previousSnapshots);
await insertStockAlertVolumeHistories(items, previousSnapshots);
}
if (!lines.length) {
@@ -1810,7 +1939,7 @@ export async function updateStockAlertLayoutFeatureDescription() {
'알림유형의 경우 멀티선택 가능하게 해주세요.',
].join('\n');
const nextNotes =
'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량은 네이버 실시간 누적거래량을 현재값으로 받고, Yahoo Finance 일봉 최근 5영업일 평균 대비 비율(%)로 계산해 제공합니다.';
'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다. 거래량 비교는 배치 실행 때마다 적재되는 종목별 거래량 스냅샷과 최근 히스토리 row 5건 기준으로 계산해 제공합니다.';
if (interaction.description !== nextDescription || interaction.implementationNotes !== nextNotes) {
changed = true;

View File

@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from '../config/env.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
export type WorkServerBuildInfo = {
version: string;
@@ -26,7 +27,7 @@ function normalizeRootPath(value: string | null | undefined) {
function resolveSourceTargetRoots() {
const roots = [WORK_SERVER_ROOT_PATH];
const mainProjectRoot = normalizeRootPath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
const mainProjectRoot = normalizeRootPath(resolveMainProjectRoot());
if (mainProjectRoot) {
const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server');
@@ -51,7 +52,7 @@ export function resolveWorkServerBuildInfoFilePaths(options?: {
const workServerRootPath = path.resolve(options?.workServerRootPath ?? WORK_SERVER_ROOT_PATH);
const configuredDistDir = String(options?.configuredDistDir ?? env.WORK_SERVER_DIST_DIR ?? 'dist').trim() || 'dist';
const mainProjectRoot = normalizeRootPath(
options?.mainProjectRoot ?? env.SERVER_COMMAND_MAIN_PROJECT_ROOT ?? env.SERVER_COMMAND_PROJECT_ROOT,
options?.mainProjectRoot ?? resolveMainProjectRoot(),
);
const candidates = [
path.join(resolveBuildInfoDirectoryPath(workServerRootPath, configuredDistDir), 'build-info.json'),