feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { getAppConfig, upsertAppConfig } from '../services/app-config-service.js';
import { z } from 'zod';
import { getAppConfig, getChatTypesConfig, upsertAppConfig, upsertChatTypesConfig } from '../services/app-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => {
@@ -11,6 +12,44 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/chat-types', async () => {
const chatTypes = await getChatTypesConfig();
return {
ok: true,
chatTypes,
};
});
app.put('/api/chat-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
chatTypes: z.array(z.unknown()),
}).parse(payload ?? {});
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes);
return {
ok: true,
chatTypes: savedChatTypes,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.',
});
}
});
app.put('/api/app-config', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};

View File

@@ -6,16 +6,14 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import { getChatRuntimeController } from '../services/chat-service.js';
import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
import {
createChatConversation,
deleteUnansweredChatConversationRequest,
deleteChatConversation,
ensureChatConversationTables,
getChatConversation,
listChatConversationActivityLogs,
listChatConversationMessages,
listChatConversationRequests,
listChatConversationDetailPage,
listChatConversations,
markChatConversationResponsesRead,
updateChatConversationContext,
@@ -136,7 +134,7 @@ function sanitizeChatAttachmentFileName(fileName: string) {
}
function resolveChatAttachmentRepoPath() {
return path.resolve(env.PLAN_MAIN_PROJECT_REPO_PATH ?? env.PLAN_GIT_REPO_PATH);
return path.resolve(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH);
}
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
@@ -314,6 +312,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const payload = z.object({
sessionId: z.string().trim().min(1).max(120),
title: z.string().trim().max(200).optional(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
contextLabel: z.string().trim().max(200).optional(),
contextDescription: z.string().trim().max(2000).optional(),
notifyOffline: z.boolean().optional(),
@@ -324,6 +323,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
sessionId: payload.sessionId,
clientId: clientId || null,
title: payload.title ?? '새 대화',
chatTypeId: payload.chatTypeId ?? null,
contextLabel: payload.contextLabel ?? null,
contextDescription: payload.contextDescription ?? null,
notifyOffline: payload.notifyOffline ?? true,
@@ -353,30 +353,20 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const messageLimit = query.limit ?? 500;
const messages = await listChatConversationMessages(params.sessionId, {
const messageLimit = query.limit ?? 6;
const detailPage = await listChatConversationDetailPage(params.sessionId, {
limit: messageLimit,
beforeMessageId: query.beforeMessageId ?? null,
});
const requests = await listChatConversationRequests(params.sessionId, 500);
const activityLogs = await listChatConversationActivityLogs(params.sessionId, 500);
const oldestLoadedMessageId = messages[0]?.id ?? null;
const hasOlderMessages =
oldestLoadedMessageId != null
? (await listChatConversationMessages(params.sessionId, {
limit: 1,
beforeMessageId: oldestLoadedMessageId,
})).length > 0
: false;
return {
ok: true,
item,
messages,
requests,
activityLogs,
oldestLoadedMessageId,
hasOlderMessages,
messages: detailPage.messages,
requests: detailPage.requests,
activityLogs: detailPage.activityLogs,
oldestLoadedMessageId: detailPage.oldestLoadedMessageId,
hasOlderMessages: detailPage.hasOlderMessages,
};
});
@@ -447,6 +437,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}).parse(request.params ?? {});
const payload = z.object({
title: z.string().trim().min(1).max(200).optional(),
chatTypeId: z.string().trim().max(120).optional().nullable(),
contextLabel: z.string().trim().max(200).optional().nullable(),
contextDescription: z.string().trim().max(2000).optional().nullable(),
notifyOffline: z.boolean().optional(),
@@ -464,6 +455,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const item = await updateChatConversationContext(params.sessionId, {
title: payload.title ?? current.title,
clientId: current.clientId,
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
contextLabel: payload.contextLabel ?? current.contextLabel,
contextDescription: payload.contextDescription ?? current.contextDescription,
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
@@ -489,6 +481,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
await getActiveChatService()?.forgetSession(params.sessionId);
const deleted = await deleteChatConversation(params.sessionId);
return {

View File

@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { maskCrudRowSensitiveFields } from './crud.js';
test('maskCrudRowSensitiveFields masks password and related login identifier fields in credential-like rows', () => {
const rows = [
{
service_name: 'legacy-admin',
login_id: 'admin-master',
password: 'super-secret-password',
note: 'keep this as-is',
},
];
const masked = maskCrudRowSensitiveFields(rows);
assert.deepEqual(masked, [
{
service_name: 'legacy-admin',
login_id: 'ad********er',
password: 'su*****************rd',
note: 'keep this as-is',
},
]);
});
test('maskCrudRowSensitiveFields keeps generic ids unchanged when no credential secret field exists', () => {
const rows = [
{
id: 12,
user_id: 'owner-01',
title: 'normal business row',
},
];
const masked = maskCrudRowSensitiveFields(rows);
assert.deepEqual(masked, rows);
});
test('maskCrudRowSensitiveFields masks nested secret values recursively', () => {
const payload = {
profile: {
username: 'how2ice',
access_token: 'tok_1234567890',
},
};
assert.deepEqual(maskCrudRowSensitiveFields(payload), {
profile: {
username: 'ho***ce',
access_token: 'to**********90',
},
});
});

View File

@@ -39,6 +39,61 @@ const deleteSchema = z.object({
});
const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']);
const secretFieldPattern = /(?:^|_|-)(?:password|passwd|pwd|passcode|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)(?:$|_|-)/i;
const loginFieldPattern =
/(?:^|_|-)(?:login[_-]?id|login[_-]?name|username|user[_-]?name|user[_-]?id|account[_-]?id|account[_-]?name|admin[_-]?id|admin[_-]?name|member[_-]?id|member[_-]?name|email)(?:$|_|-)/i;
function maskCredentialValue(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return value;
}
if (trimmed.length <= 2) {
return '*'.repeat(trimmed.length);
}
if (trimmed.length <= 4) {
return `${trimmed[0]}${'*'.repeat(trimmed.length - 2)}${trimmed.at(-1) ?? ''}`;
}
return `${trimmed.slice(0, 2)}${'*'.repeat(Math.max(2, trimmed.length - 4))}${trimmed.slice(-2)}`;
}
function shouldMaskLoginField(fieldName: string, row: Record<string, unknown>) {
if (!loginFieldPattern.test(fieldName)) {
return false;
}
return Object.keys(row).some((candidateField) => secretFieldPattern.test(candidateField));
}
export function maskCrudRowSensitiveFields<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => maskCrudRowSensitiveFields(item)) as T;
}
if (!value || typeof value !== 'object') {
return value;
}
const row = value as Record<string, unknown>;
const result: Record<string, unknown> = {};
Object.entries(row).forEach(([fieldName, fieldValue]) => {
if (typeof fieldValue === 'string') {
if (secretFieldPattern.test(fieldName) || shouldMaskLoginField(fieldName, row)) {
result[fieldName] = maskCredentialValue(fieldValue);
return;
}
}
result[fieldName] = maskCrudRowSensitiveFields(fieldValue);
});
return result as T;
}
function applyFilters(query: Knex.QueryBuilder, filters: z.infer<typeof filterSchema>[] = []) {
filters.forEach((filter) => {
@@ -138,7 +193,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true,
table,
count: rows.length,
rows,
rows: maskCrudRowSensitiveFields(rows),
};
});
@@ -150,7 +205,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
return {
ok: true,
table,
rows: inserted,
rows: maskCrudRowSensitiveFields(inserted),
};
});
@@ -197,7 +252,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true,
table,
count: rows.length,
rows,
rows: maskCrudRowSensitiveFields(rows),
};
});
@@ -214,7 +269,7 @@ export async function registerCrudRoutes(app: FastifyInstance) {
ok: true,
table,
count: rows.length,
rows,
rows: maskCrudRowSensitiveFields(rows),
};
});
}

View File

@@ -2,7 +2,7 @@ import { env } from './config/env.js';
import { db } from './db/client.js';
import { createApp } from './app.js';
import { ChatService } from './services/chat-service.js';
import { clearAllChatConversationJobStates } from './services/chat-room-service.js';
import { clearAllChatConversationJobStates, ensureChatConversationTables } from './services/chat-room-service.js';
import { shutdownNotificationProvider } from './services/notification-service.js';
import { PlanWorker } from './workers/plan-worker.js';
@@ -13,6 +13,7 @@ app.server.on('upgrade', chatService.attachUpgradeHandler());
async function start() {
try {
await ensureChatConversationTables();
await clearAllChatConversationJobStates();
await app.listen({
host: '0.0.0.0',

View File

@@ -1,6 +1,7 @@
import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
async function ensureAppConfigTable() {
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
@@ -49,6 +50,14 @@ export async function getAppConfig() {
return row.config_json ?? {};
}
function normalizeConfigRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
}
return value as Record<string, unknown>;
}
export type AppConfigSnapshot = {
chat?: {
maxContextMessages?: number;
@@ -107,25 +116,49 @@ export async function getAppConfigSnapshot(): Promise<AppConfigSnapshot> {
export async function upsertAppConfig(config: Record<string, unknown>) {
await ensureAppConfigTable();
const nextConfig = normalizeConfigRecord(config);
const existing = await db(APP_CONFIG_TABLE).first();
if (!existing) {
const rows = await db(APP_CONFIG_TABLE)
.insert({
config_json: config,
config_json: nextConfig,
updated_at: db.fn.now(),
})
.returning('*');
return rows[0]?.config_json ?? config;
return rows[0]?.config_json ?? nextConfig;
}
const mergedConfig = {
...normalizeConfigRecord(existing.config_json),
...nextConfig,
};
const rows = await db(APP_CONFIG_TABLE)
.update({
config_json: config,
config_json: mergedConfig,
updated_at: db.fn.now(),
})
.returning('*');
return rows[0]?.config_json ?? config;
return rows[0]?.config_json ?? mergedConfig;
}
export async function getChatTypesConfig() {
const config = await getAppConfig();
const normalized = normalizeConfigRecord(config);
const chatTypes = normalized[CHAT_TYPES_CONFIG_KEY];
return Array.isArray(chatTypes) ? chatTypes : null;
}
export async function upsertChatTypesConfig(chatTypes: unknown[]) {
const current = normalizeConfigRecord(await getAppConfig());
const nextConfig = {
...current,
[CHAT_TYPES_CONFIG_KEY]: Array.isArray(chatTypes) ? chatTypes : [],
};
await upsertAppConfig(nextConfig);
return nextConfig[CHAT_TYPES_CONFIG_KEY] as unknown[];
}

View File

@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildChatConversationRequestPatchFromMessage,
isVisibleConversationMessage,
mergeChatConversationRequestStatus,
shouldClearConversationJobState,
selectChatConversationResponseCandidate,
@@ -26,6 +27,28 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa
);
});
test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => {
assert.equal(
isVisibleConversationMessage({
id: 1,
author: 'system',
text: '응답을 준비하고 있습니다.',
timestamp: '2026-04-22 10:00:00',
}),
false,
);
assert.equal(
isVisibleConversationMessage({
id: 2,
author: 'system',
text: '[[activity-log]]\n작업을 시작했습니다.',
timestamp: '2026-04-22 10:00:01',
}),
true,
);
});
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
assert.deepEqual(
buildChatConversationRequestPatchFromMessage({

View File

@@ -14,6 +14,7 @@ const conversationPayloadSchema = z.object({
sessionId: z.string().trim().min(1).max(120),
clientId: z.string().trim().max(120).nullable().optional(),
title: z.string().trim().max(200).nullable().optional(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
contextLabel: z.string().trim().max(200).nullable().optional(),
contextDescription: z.string().trim().max(2000).nullable().optional(),
notifyOffline: z.boolean().optional(),
@@ -32,6 +33,7 @@ export type ChatConversationItem = {
sessionId: string;
clientId: string | null;
title: string;
chatTypeId: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
@@ -88,6 +90,14 @@ export type ChatConversationActivityLogItem = {
updatedAt: string | null;
};
export type ChatConversationDetailPage = {
messages: StoredChatMessage[];
requests: ChatConversationRequestItem[];
activityLogs: ChatConversationActivityLogItem[];
oldestLoadedMessageId: number | null;
hasOlderMessages: boolean;
};
type ChatConversationRequestStatusPatch = {
requestId: string;
status?: ChatConversationRequestStatus;
@@ -113,6 +123,25 @@ type ChatConversationClientPreference = {
lastReadResponseMessageId: number | null;
};
function normalizeDateTimeValue(value: unknown) {
if (value == null) {
return null;
}
if (value instanceof Date) {
return value.toISOString();
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString();
}
function createPreview(text: string) {
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
@@ -143,6 +172,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
sessionId: String(row.session_id ?? ''),
clientId: row.client_id == null ? null : String(row.client_id),
title: String(row.title ?? '새 대화'),
chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id),
contextLabel: row.context_label == null ? null : String(row.context_label),
contextDescription: row.context_description == null ? null : String(row.context_description),
notifyOffline: Boolean(row.notify_offline),
@@ -151,11 +181,11 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
currentQueueSize: Number(row.current_queue_size ?? 0),
currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at),
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
lastMessagePreview: String(row.last_message_preview ?? ''),
createdAt: String(row.created_at ?? ''),
updatedAt: String(row.updated_at ?? ''),
lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at),
createdAt: normalizeDateTimeValue(row.created_at) ?? '',
updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
lastMessageAt: normalizeDateTimeValue(row.last_message_at),
};
}
@@ -169,7 +199,7 @@ function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
};
}
function isVisibleConversationMessage(message: StoredChatMessage) {
export function isVisibleConversationMessage(message: StoredChatMessage) {
if (message.author !== 'system') {
return true;
}
@@ -177,6 +207,12 @@ function isVisibleConversationMessage(message: StoredChatMessage) {
return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
function applyVisibleConversationMessageCondition(builder: any) {
builder.whereNot('author', 'system').orWhere((nestedBuilder: any) => {
nestedBuilder.where('author', '=', 'system').andWhere('text', 'like', `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n%`);
});
}
function mapClientPreferenceRow(row: Record<string, unknown>): ChatConversationClientPreference {
return {
sessionId: String(row.session_id ?? ''),
@@ -203,10 +239,10 @@ function mapRequestRow(row: Record<string, unknown>): ChatConversationRequestIte
responseText: String(row.response_text ?? ''),
hasResponse,
canDelete,
createdAt: String(row.created_at ?? ''),
updatedAt: String(row.updated_at ?? ''),
answeredAt: row.answered_at == null ? null : String(row.answered_at),
terminalAt: row.terminal_at == null ? null : String(row.terminal_at),
createdAt: normalizeDateTimeValue(row.created_at) ?? '',
updatedAt: normalizeDateTimeValue(row.updated_at) ?? '',
answeredAt: normalizeDateTimeValue(row.answered_at),
terminalAt: normalizeDateTimeValue(row.terminal_at),
};
}
@@ -560,7 +596,7 @@ async function getLatestPreviewableMessageMap(sessionIds: string[]) {
messageMap.set(sessionId, {
text: String(row.text ?? ''),
createdAt: row.created_at == null ? null : String(row.created_at),
createdAt: normalizeDateTimeValue(row.created_at),
});
}
@@ -594,7 +630,7 @@ async function getLatestRequestPreviewMap(sessionIds: string[]) {
requestMap.set(sessionId, {
text: userText,
createdAt: row.created_at == null ? null : String(row.created_at),
createdAt: normalizeDateTimeValue(row.created_at),
});
}
@@ -672,6 +708,7 @@ export async function ensureChatConversationTables() {
table.string('session_id', 120).primary();
table.string('client_id', 120).nullable().index();
table.string('title', 200).notNullable().defaultTo('새 대화');
table.string('chat_type_id', 120).nullable();
table.string('context_label', 200).nullable();
table.text('context_description').nullable();
table.boolean('notify_offline').notNullable().defaultTo(false);
@@ -690,6 +727,7 @@ export async function ensureChatConversationTables() {
const requiredConversationColumns: Array<[string, (table: any) => void]> = [
['client_id', (table) => table.string('client_id', 120).nullable().index()],
['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')],
['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()],
['context_label', (table) => table.string('context_label', 200).nullable()],
['context_description', (table) => table.text('context_description').nullable()],
['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)],
@@ -864,7 +902,6 @@ export async function ensureChatConversationTables() {
}
export async function getChatConversation(sessionId: string, clientId?: string | null) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
@@ -878,7 +915,7 @@ export async function getChatConversation(sessionId: string, clientId?: string |
shouldClearConversationJobState({
currentRequestId,
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at),
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
runtimeActive: isRuntimeRequestActive(currentRequestId),
request: currentRequestId
? await db(CHAT_CONVERSATION_REQUEST_TABLE)
@@ -894,8 +931,8 @@ export async function getChatConversation(sessionId: string, clientId?: string |
status: String(requestRow.status ?? '') as ChatConversationRequestStatus,
responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
responseText: String(requestRow.response_text ?? ''),
terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at),
updatedAt: requestRow.updated_at == null ? null : String(requestRow.updated_at),
terminalAt: normalizeDateTimeValue(requestRow.terminal_at),
updatedAt: normalizeDateTimeValue(requestRow.updated_at),
}
: null,
)
@@ -966,7 +1003,6 @@ export async function getChatConversation(sessionId: string, clientId?: string |
}
export async function createChatConversation(payload: z.input<typeof conversationPayloadSchema>) {
await ensureChatConversationTables();
const parsed = conversationPayloadSchema.parse(payload);
const normalizedClientId = normalizeClientId(parsed.clientId);
const notifyOffline = parsed.notifyOffline ?? true;
@@ -975,6 +1011,7 @@ export async function createChatConversation(payload: z.input<typeof conversatio
session_id: parsed.sessionId,
client_id: normalizedClientId,
title: parsed.title?.trim() || '새 대화',
chat_type_id: parsed.chatTypeId?.trim() || null,
context_label: parsed.contextLabel?.trim() || null,
context_description: parsed.contextDescription?.trim() || null,
notify_offline: notifyOffline,
@@ -1013,12 +1050,12 @@ export async function updateChatConversationContext(
payload: {
title?: string | null;
clientId?: string | null;
chatTypeId?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean | null;
},
) {
await ensureChatConversationTables();
const normalizedClientId = normalizeClientId(payload.clientId);
const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();
@@ -1031,6 +1068,7 @@ export async function updateChatConversationContext(
.update({
title: payload.title?.trim() || current.title || '새 대화',
client_id: normalizedClientId || current.client_id || null,
chat_type_id: payload.chatTypeId?.trim() || null,
context_label: payload.contextLabel?.trim() || null,
context_description: payload.contextDescription?.trim() || null,
notify_offline:
@@ -1052,32 +1090,44 @@ export async function listChatConversations(
limit = 50,
unreadStateClientId?: string | null,
) {
await ensureChatConversationTables();
const normalizedClientId = normalizeClientId(clientId);
const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId);
const query = db(CHAT_CONVERSATION_TABLE)
.select('*')
.orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST')
.orderByRaw('last_message_at DESC NULLS LAST')
.orderByRaw('updated_at DESC NULLS LAST')
.orderByRaw('created_at DESC NULLS LAST')
.limit(Math.max(1, Math.min(200, Math.round(limit))));
const normalizedLimit = Math.max(1, Math.min(200, Math.round(limit)));
let conversationListScopeClientId = normalizedClientId;
const buildConversationListQuery = (targetClientId?: string | null) => {
const query = db(CHAT_CONVERSATION_TABLE)
.select('*')
.orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST')
.orderByRaw('last_message_at DESC NULLS LAST')
.orderByRaw('updated_at DESC NULLS LAST')
.orderByRaw('created_at DESC NULLS LAST')
.limit(normalizedLimit);
if (normalizedClientId) {
query.where((builder) => {
builder
.where({ client_id: normalizedClientId })
.orWhereExists(
db(CHAT_CONVERSATION_CLIENT_TABLE)
.select(db.raw('1'))
.whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`)
.andWhere({ client_id: normalizedClientId }),
);
});
if (targetClientId) {
query.where((builder) => {
builder
.where({ client_id: targetClientId })
.orWhereExists(
db(CHAT_CONVERSATION_CLIENT_TABLE)
.select(db.raw('1'))
.whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`)
.andWhere({ client_id: targetClientId }),
);
});
}
return query;
};
let rows = await buildConversationListQuery(normalizedClientId);
// Browser storage reset can regenerate client_id and hide existing rooms.
// When that happens, fall back to the recent global list so the user can recover.
if (normalizedClientId && rows.length === 0) {
conversationListScopeClientId = null;
rows = await buildConversationListQuery(null);
}
let rows = await query;
const sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean);
const currentRequestIds = Array.from(
new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)),
@@ -1096,7 +1146,7 @@ export async function listChatConversations(
status: String(requestRow.status ?? '') as ChatConversationRequestStatus,
responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
responseText: String(requestRow.response_text ?? ''),
terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at),
terminalAt: normalizeDateTimeValue(requestRow.terminal_at),
},
]),
);
@@ -1105,7 +1155,7 @@ export async function listChatConversations(
shouldClearConversationJobState({
currentRequestId: String(row.current_request_id ?? ''),
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at),
currentStatusUpdatedAt: normalizeDateTimeValue(row.current_status_updated_at),
runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')),
request:
requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null,
@@ -1149,7 +1199,7 @@ export async function listChatConversations(
current_status_updated_at: db.fn.now(),
updated_at: db.fn.now(),
});
rows = await query.clone();
rows = await buildConversationListQuery(conversationListScopeClientId);
}
}
@@ -1231,7 +1281,6 @@ export async function listChatConversationMessages(
beforeMessageId?: number | null;
} = {},
) {
await ensureChatConversationTables();
const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200)));
const normalizedBeforeMessageId =
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
@@ -1244,20 +1293,226 @@ export async function listChatConversationMessages(
if (normalizedBeforeMessageId !== null) {
builder.where('message_id', '<', normalizedBeforeMessageId);
}
builder.andWhere((visibilityBuilder) => {
applyVisibleConversationMessageCondition(visibilityBuilder);
});
})
.orderBy('message_id', 'desc')
.orderBy('id', 'desc')
.limit(normalizedLimit);
const latestRows = await query;
return latestRows
.reverse()
.map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row))
.filter((message: ReturnType<typeof mapMessageRow>) => isVisibleConversationMessage(message));
return latestRows.reverse().map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row));
}
async function listChatConversationActivityLogsByRequestIds(
sessionId: string,
requestIds: string[],
): Promise<ChatConversationActivityLogItem[]> {
const normalizedSessionId = sessionId.trim();
const normalizedRequestIds = Array.from(new Set(requestIds.map((item) => item.trim()).filter(Boolean)));
if (!normalizedSessionId || normalizedRequestIds.length === 0) {
return [];
}
const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE)
.select('session_id', 'request_id', 'text', 'line_no', 'created_at')
.where({ session_id: normalizedSessionId })
.whereIn('request_id', normalizedRequestIds)
.orderBy('request_id', 'asc')
.orderBy('line_no', 'asc')
.orderBy('id', 'asc');
const activityMap = new Map<string, ChatConversationActivityLogItem>();
for (const row of rows) {
const requestId = String(row.request_id ?? '').trim();
if (!requestId) {
continue;
}
const existing = activityMap.get(requestId);
if (existing) {
existing.lines.push(String(row.text ?? ''));
existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt;
continue;
}
activityMap.set(requestId, {
sessionId: String(row.session_id ?? normalizedSessionId),
requestId,
lines: [String(row.text ?? '')],
updatedAt: normalizeDateTimeValue(row.created_at),
});
}
return normalizedRequestIds
.map((requestId) => activityMap.get(requestId))
.filter(Boolean) as ChatConversationActivityLogItem[];
}
async function resolveConversationRequestCursor(sessionId: string, beforeMessageId: number) {
const normalizedSessionId = sessionId.trim();
const normalizedBeforeMessageId = Math.trunc(beforeMessageId);
if (!normalizedSessionId || normalizedBeforeMessageId <= 0) {
return null;
}
const directRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.select('request_id', 'created_at')
.where({ session_id: normalizedSessionId })
.andWhere((builder) => {
builder.where('user_message_id', normalizedBeforeMessageId).orWhere('response_message_id', normalizedBeforeMessageId);
})
.first();
if (directRequestRow) {
return {
requestId: String(directRequestRow.request_id ?? '').trim(),
createdAt: normalizeDateTimeValue(directRequestRow.created_at) ?? '',
};
}
const messageRow = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
.select('client_request_id')
.where({
session_id: normalizedSessionId,
message_id: normalizedBeforeMessageId,
})
.first();
const linkedRequestId = String(messageRow?.client_request_id ?? '').trim();
if (!linkedRequestId) {
return null;
}
const linkedRequestRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.select('request_id', 'created_at')
.where({
session_id: normalizedSessionId,
request_id: linkedRequestId,
})
.first();
if (!linkedRequestRow) {
return null;
}
return {
requestId: String(linkedRequestRow.request_id ?? '').trim(),
createdAt: normalizeDateTimeValue(linkedRequestRow.created_at) ?? '',
};
}
export async function listChatConversationDetailPage(
sessionId: string,
options: {
limit?: number;
beforeMessageId?: number | null;
} = {},
): 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 normalizedBeforeMessageId =
Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0
? Math.trunc(options.beforeMessageId as number)
: null;
const requestCursor =
normalizedBeforeMessageId == null
? null
: await resolveConversationRequestCursor(normalizedSessionId, normalizedBeforeMessageId);
const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.where({ session_id: normalizedSessionId })
.whereNot('status', 'removed')
.modify((builder) => {
if (!requestCursor) {
return;
}
builder.andWhere((cursorBuilder) => {
cursorBuilder
.where('created_at', '<', requestCursor.createdAt)
.orWhere((sameTimeBuilder) => {
sameTimeBuilder.where('created_at', '=', requestCursor.createdAt).andWhere('request_id', '<', requestCursor.requestId);
});
});
})
.orderBy('created_at', 'desc')
.orderBy('request_id', 'desc')
.limit(normalizedLimit);
const orderedRequestRows = [...requestRows].reverse();
const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean);
if (requestIds.length === 0) {
return {
messages: [],
requests,
activityLogs: [],
oldestLoadedMessageId: null,
hasOlderMessages: false,
};
}
const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
.select('*')
.where({ session_id: normalizedSessionId })
.whereIn('client_request_id', requestIds)
.andWhere((builder) => {
applyVisibleConversationMessageCondition(builder);
})
.orderBy('message_id', 'asc')
.orderBy('id', 'asc');
const messages = messageRows.map((row: Parameters<typeof mapMessageRow>[0]) => mapMessageRow(row));
const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, requestIds);
const oldestLoadedMessageId =
requests.reduce<number | null>((oldestId, request) => {
const candidateIds = [request.userMessageId, request.responseMessageId].filter(
(value): value is number => typeof value === 'number' && Number.isInteger(value) && value > 0,
);
if (candidateIds.length === 0) {
return oldestId;
}
const nextCandidateId = Math.min(...candidateIds);
return oldestId == null ? nextCandidateId : Math.min(oldestId, nextCandidateId);
}, null) ?? messages[0]?.id ?? null;
const oldestRequest = requests[0] ?? null;
const hasOlderMessages = oldestRequest
? Boolean(
await db(CHAT_CONVERSATION_REQUEST_TABLE)
.where({ session_id: normalizedSessionId })
.whereNot('status', 'removed')
.andWhere((builder) => {
builder
.where('created_at', '<', oldestRequest.createdAt)
.orWhere((sameTimeBuilder) => {
sameTimeBuilder.where('created_at', '=', oldestRequest.createdAt).andWhere('request_id', '<', oldestRequest.requestId);
});
})
.first(),
)
: false;
return {
messages,
requests,
activityLogs,
oldestLoadedMessageId,
hasOlderMessages,
};
}
export async function listChatConversationRequests(sessionId: string, limit = 200) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first();
@@ -1270,8 +1525,6 @@ export async function listChatConversationRequests(sessionId: string, limit = 20
}
export async function getChatConversationRequest(sessionId: string, requestId: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim();
@@ -1313,7 +1566,6 @@ export async function appendChatConversationMessage(
conversationPayload: z.input<typeof conversationPayloadSchema>,
messagePayload: z.input<typeof conversationMessagePayloadSchema>,
) {
await ensureChatConversationTables();
const conversation = conversationPayloadSchema.parse(conversationPayload);
const message = conversationMessagePayloadSchema.parse(messagePayload);
@@ -1353,6 +1605,7 @@ export async function appendChatConversationMessage(
.update({
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
title: nextTitle,
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
notify_offline:
@@ -1390,7 +1643,6 @@ export async function appendChatConversationMessage(
}
export async function appendChatConversationActivityLine(sessionId: string, requestId: string, line: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim();
const normalizedLine = line.trim();
@@ -1426,7 +1678,6 @@ export async function listChatConversationActivityLogs(
sessionId: string,
limitRequests = 500,
): Promise<ChatConversationActivityLogItem[]> {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
@@ -1469,7 +1720,7 @@ export async function listChatConversationActivityLogs(
if (existing) {
existing.lines.push(String(row.text ?? ''));
existing.updatedAt = row.created_at == null ? existing.updatedAt : String(row.created_at);
existing.updatedAt = normalizeDateTimeValue(row.created_at) ?? existing.updatedAt;
continue;
}
@@ -1477,7 +1728,7 @@ export async function listChatConversationActivityLogs(
sessionId: String(row.session_id ?? normalizedSessionId),
requestId,
lines: [String(row.text ?? '')],
updatedAt: row.created_at == null ? null : String(row.created_at),
updatedAt: normalizeDateTimeValue(row.created_at),
});
}
@@ -1494,8 +1745,6 @@ export async function updateChatConversationJobState(
clear?: boolean;
},
) {
await ensureChatConversationTables();
const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first();
if (!current) {
@@ -1547,8 +1796,6 @@ export async function upsertChatConversationRequest(
responseText?: string | null;
},
) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const normalizedRequestId = payload.requestId.trim();
@@ -1698,7 +1945,7 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
author: String(row.author ?? 'codex') as StoredChatMessage['author'],
text: String(row.text ?? ''),
clientRequestId: row.client_request_id == null ? null : String(row.client_request_id),
createdAt: row.created_at == null ? null : String(row.created_at),
createdAt: normalizeDateTimeValue(row.created_at),
}));
for (let index = 0; index < requestRows.length; index += 1) {
@@ -1713,12 +1960,12 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
const candidate = selectChatConversationResponseCandidate(
{
requestId,
createdAt: String(requestRow.created_at ?? ''),
createdAt: normalizeDateTimeValue(requestRow.created_at) ?? '',
responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id),
},
nextRequestRow
? {
createdAt: String(nextRequestRow.created_at ?? ''),
createdAt: normalizeDateTimeValue(nextRequestRow.created_at) ?? '',
}
: undefined,
responseMessages,
@@ -1785,8 +2032,6 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
}
export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim();
const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
@@ -1866,8 +2111,6 @@ export async function clearAllChatConversationJobStates() {
}
export async function deleteChatConversation(sessionId: string) {
await ensureChatConversationTables();
return db.transaction(async (trx) => {
await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del();
await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: sessionId.trim() }).del();
@@ -1878,7 +2121,6 @@ export async function deleteChatConversation(sessionId: string) {
}
export async function getChatConversationClientPreference(sessionId: string, clientId: string) {
await ensureChatConversationTables();
const row = await db(CHAT_CONVERSATION_CLIENT_TABLE)
.where({
session_id: sessionId.trim(),
@@ -1890,8 +2132,6 @@ export async function getChatConversationClientPreference(sessionId: string, cli
}
export async function listChatConversationOfflineNotificationClientIds(sessionId: string) {
await ensureChatConversationTables();
const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE)
.where({
session_id: sessionId.trim(),
@@ -1905,7 +2145,6 @@ export async function listChatConversationOfflineNotificationClientIds(sessionId
}
export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const normalizedClientId = clientId.trim();
await db(CHAT_CONVERSATION_CLIENT_TABLE)
@@ -1927,8 +2166,6 @@ export async function upsertChatConversationClientPreference(sessionId: string,
}
export async function markChatConversationResponsesRead(sessionId: string, clientId: string) {
await ensureChatConversationTables();
const normalizedSessionId = sessionId.trim();
const normalizedClientId = clientId.trim();

View File

@@ -352,6 +352,50 @@ class ChatRuntimeService {
this.emit();
}
clearSession(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
let changed = false;
for (const [requestId, item] of this.runningJobs.entries()) {
if (item.sessionId !== normalizedSessionId) {
continue;
}
this.runningJobs.delete(requestId);
this.controls.delete(requestId);
changed = true;
}
for (const [requestId, item] of this.queuedJobs.entries()) {
if (item.sessionId !== normalizedSessionId) {
continue;
}
this.queuedJobs.delete(requestId);
this.controls.delete(requestId);
changed = true;
}
for (const [requestId, item] of this.archivedJobs.entries()) {
if (item.sessionId !== normalizedSessionId) {
continue;
}
this.archivedJobs.delete(requestId);
this.controls.delete(requestId);
changed = true;
}
if (changed) {
this.emit();
}
}
private buildTerminalLog(status: ChatRuntimeTerminalStatus) {
if (status === 'completed') {
return '실행이 완료되었습니다.';

View File

@@ -150,7 +150,8 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources',
'response.diff',
);
assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
assert.match(rewritten, new RegExp(`\\[\\[preview:${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]\\]$`, 'm'));
assert.doesNotMatch(rewritten, /diff 리소스 경로:/);
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
});

View File

@@ -22,6 +22,7 @@ import {
updateChatConversationContext,
} from './chat-room-service.js';
import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js';
import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import {
@@ -162,6 +163,7 @@ type ChatSessionState = {
clientId: string | null;
socket: WebSocket | null;
lastSeenAt: number;
isDeleted: boolean;
context: ChatContext | null;
queue: Array<{
requestId: string;
@@ -188,12 +190,17 @@ type ActiveChatExecution = {
};
let activeRuntimeController: ChatRuntimeController | null = null;
let activeChatService: ChatService | null = null;
const activeChatProcessRegistry = new Map<string, ActiveChatExecution>();
export function getChatRuntimeController() {
return activeRuntimeController;
}
export function getActiveChatService() {
return activeChatService;
}
const SOCKET_PATH = '/ws/chat';
const KST_TIME_ZONE = 'Asia/Seoul';
const STREAM_CAPTURE_LIMIT = 256 * 1024;
@@ -275,8 +282,11 @@ function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId:
try {
const targetUrl = new URL(pageUrl);
targetUrl.pathname = '/chat/live';
targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat');
targetUrl.searchParams.set('sessionId', sessionId);
targetUrl.searchParams.delete('chatView');
targetUrl.searchParams.delete('runtimeRequestId');
return targetUrl.toString();
} catch {
return fallbackUrl.toString();
@@ -397,6 +407,15 @@ function createRequestId() {
return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function hasAuthorizedChatSocketAccess(request: IncomingMessage, url: URL) {
const queryToken = url.searchParams.get('accessToken')?.trim();
const headerToken = Array.isArray(request.headers['x-access-token'])
? String(request.headers['x-access-token'][0] ?? '').trim()
: String(request.headers['x-access-token'] ?? '').trim();
return hasErrorLogViewAccessToken(queryToken || headerToken);
}
function hashRequestId(value: string) {
let hash = 0;
@@ -454,13 +473,19 @@ function isSocketOpen(socket: WebSocket | null | undefined) {
return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN);
}
function closeSocketSafely(logger: FastifyBaseLogger, socket: WebSocket | null | undefined, message: string) {
function closeSocketSafely(
logger: FastifyBaseLogger,
socket: WebSocket | null | undefined,
message: string,
code = 1000,
reason = 'replaced',
) {
if (!socket) {
return;
}
try {
socket.close();
socket.close(code, reason);
} catch (error) {
logger.warn(error, message);
}
@@ -1293,15 +1318,8 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) {
return output;
}
const lines = ['diff 리소스 경로:'];
if (uniqueUrls.length === 1) {
lines.push(uniqueUrls[0]!);
} else {
lines.push(...uniqueUrls.map((url, index) => `${index + 1}. ${url}`));
}
return `${output}\n\n${lines.join('\n')}`;
const hiddenPreviewTags = uniqueUrls.map((url) => `[[preview:${url}]]`).join('\n');
return `${output}\n\n${hiddenPreviewTags}`;
}
export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) {
@@ -1452,18 +1470,22 @@ function buildAgenticCodexPrompt(
'응답 규칙:',
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
'',
'현재 화면 문맥:',
'채팅 유형 문맥(우선 적용):',
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.',
'',
'참고 화면 정보:',
`- pageTitle: ${context?.pageTitle ?? '없음'}`,
`- topMenu: ${context?.topMenu ?? '없음'}`,
`- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`,
`- pageUrl: ${context?.pageUrl ?? '없음'}`,
`- chatTypeLabel: ${context?.chatTypeLabel ?? '없음'}`,
`- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`,
`- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`,
'',
isTemplateRequest ? '템플릿 요청 규칙:' : '최근 대화 문맥:',
...(isTemplateRequest
@@ -1627,7 +1649,7 @@ async function runAgenticCodexReply(
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
) {
const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN);
const appConfig = await getAppConfigSnapshot();
const recentHistory =
@@ -2109,60 +2131,7 @@ async function buildCodexReply(
onProgress?: (text: string) => void,
onActivity?: (line: string) => void,
) {
const normalized = input.toLowerCase();
if (isAutomationRegistrationCountRequest(input)) {
return buildAutomationRegistrationCountReply();
}
if (isAutomationRegistrationDefinitionRequest(input)) {
return buildAutomationRegistrationDefinitionReply();
}
if (shouldUseAgenticCodexReply(input)) {
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
}
const requestsPlanContext =
isWorklogRequest(input) ||
isPlanDetailRequest(input) ||
normalized.includes('preview') ||
normalized.includes('링크') ||
normalized.includes('url') ||
input.includes('변경') ||
input.includes('스크린샷');
const parsedPlanContext = parsePlanContext(context, input);
let snapshot = parsedPlanContext.planId ? await loadPlanSnapshot(parsedPlanContext.planId) : null;
if (!snapshot && parsedPlanContext.workId) {
const planItem = await findPlanItemByWorkId(parsedPlanContext.workId);
snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null;
}
if (!snapshot && parsedPlanContext.previewUrl) {
const planItem = await findPlanItemByPreviewUrl(parsedPlanContext.previewUrl);
snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null;
}
if (!snapshot && requestsPlanContext) {
const latestPlanItem = await findLatestPlanItem();
snapshot = latestPlanItem?.id ? await loadPlanSnapshot(Number(latestPlanItem.id)) : null;
}
const isPlanPage =
context?.topMenu === 'plans' ||
context?.pageId?.startsWith('plans:') ||
isPlanDetailRequest(input);
if (isPlanPage && snapshot) {
return buildPlanReply(context, input, snapshot);
}
if (!shouldUseTemplateMacroReply(context, input)) {
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
}
return buildGenericReply(context, input, snapshot);
return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity);
}
export class ChatService {
@@ -2174,6 +2143,7 @@ export class ChatService {
private readonly unsubscribeRuntimeBroadcast: () => void;
constructor(private readonly logger: FastifyBaseLogger) {
activeChatService = this;
activeRuntimeController = {
getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId),
cancelJob: (requestId) => this.cancelRuntimeJob(requestId),
@@ -2215,6 +2185,9 @@ export class ChatService {
close() {
activeRuntimeController = null;
if (activeChatService === this) {
activeChatService = null;
}
this.unsubscribeRuntimeBroadcast();
for (const execution of activeChatProcessRegistry.values()) {
@@ -2248,6 +2221,7 @@ export class ChatService {
clientId: clientId?.trim() || null,
socket: null,
lastSeenAt: Date.now(),
isDeleted: false,
context: null,
queue: [],
activeRequestCount: 0,
@@ -2282,6 +2256,10 @@ export class ChatService {
}
private persistConversationMessage(session: ChatSessionState, message: ChatMessage) {
if (session.isDeleted) {
return Promise.resolve();
}
const nextPersistence = session.messagePersistenceTail
.catch(() => undefined)
.then(() =>
@@ -2319,6 +2297,10 @@ export class ChatService {
skipOfflineNotification?: boolean;
},
) {
if (session.isDeleted) {
return this.createSessionEnvelope(session, message);
}
const envelope = this.createSessionEnvelope(session, message);
this.retainEnvelopeForReplay(session, envelope);
@@ -2739,7 +2721,7 @@ export class ChatService {
}
private replaySessionHistory(session: ChatSessionState, lastEventId: number) {
if (!Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
if (session.isDeleted || !Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) {
return;
}
@@ -2751,6 +2733,10 @@ export class ChatService {
}
private async initializeSession(session: ChatSessionState) {
if (session.isDeleted) {
return;
}
await session.messagePersistenceTail.catch(() => undefined);
const messages = await listChatConversationMessages(session.sessionId, { limit: 500 });
@@ -2782,6 +2768,12 @@ export class ChatService {
private async handleConnection(socket: WebSocket, request: IncomingMessage) {
const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost';
const url = new URL(request.url ?? '/', origin);
if (!hasAuthorizedChatSocketAccess(request, url)) {
closeSocketSafely(this.logger, socket, 'failed to close unauthorized chat websocket session', 1008, 'unauthorized');
return;
}
const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId();
const clientId = url.searchParams.get('clientId')?.trim() || null;
const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0);
@@ -2819,6 +2811,62 @@ export class ChatService {
this.replaySessionHistory(session, lastEventId);
}
async forgetSession(sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const session = this.sessions.get(normalizedSessionId);
const runtimeSnapshot = chatRuntimeService.getSnapshot();
const runtimeRequestIds = new Set(
[...runtimeSnapshot.running, ...runtimeSnapshot.queued, ...runtimeSnapshot.recent]
.filter((item) => item.sessionId === normalizedSessionId)
.map((item) => item.requestId),
);
if (session) {
session.isDeleted = true;
session.queue = [];
session.eventHistory = [];
session.pendingQueueReleaseEventId = null;
session.watchedRuntimeRequestId = null;
session.activeRequestCount = 0;
if (session.socket) {
this.clientStates.delete(session.socket);
closeSocketSafely(this.logger, session.socket, 'failed to close deleted chat websocket session');
session.socket = null;
}
this.sessions.delete(normalizedSessionId);
}
for (const requestId of runtimeRequestIds) {
const detail = chatRuntimeService.getJobDetail(requestId);
if (detail.availableActions.cancel) {
try {
await this.cancelRuntimeJob(requestId);
} catch {
// ignore and hard-clear runtime state below
}
} else if (detail.availableActions.remove) {
try {
await this.removeQueuedRuntimeJob(requestId);
} catch {
// ignore and hard-clear runtime state below
}
}
activeChatProcessRegistry.delete(requestId);
this.cancelledRequestIds.delete(requestId);
}
chatRuntimeService.clearSession(normalizedSessionId);
}
private handleMessage(socket: WebSocket, raw: RawData) {
try {
const message = JSON.parse(raw.toString()) as ChatInboundMessage;

View File

@@ -1,5 +1,6 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import { readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
@@ -118,6 +119,259 @@ const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
const DEFERRED_RESTART_DELAY_MS = 2_000;
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
const DEFERRED_RESTART_POLL_INTERVAL_MS = 150;
const APP_SOURCE_TARGET_PATHS = [
'src',
'public',
'index.html',
'package.json',
'tsconfig.json',
'tsconfig.app.json',
'vite.config.ts',
'scripts',
] as const;
const APP_BUILD_INFO_FILE_CANDIDATES = [
'/tmp/ai-code-test-app-dist/index.html',
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
'/tmp/ai-code-test-app-dist/assets',
] as const;
async function readLocalBuildTimestamp(targetPath: string) {
try {
const targetStat = await stat(targetPath);
return normalizeDateTimeValue(targetStat.mtime.toISOString());
} catch {
return null;
}
}
async function readContainerBuildTimestamp(definition: ServerDefinition, targetPath: string) {
try {
const { stdout } = await execFileAsync(
'docker',
['exec', definition.containerName, 'sh', '-lc', `if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`],
{
cwd: definition.commandWorkingDirectory,
timeout: 8000,
maxBuffer: 1024 * 1024,
},
);
return normalizeDateTimeValue(stdout.trim());
} catch (error) {
if (!shouldRetryWithDockerSocket(error)) {
return null;
}
return readContainerBuildTimestampViaSocket(definition, targetPath);
}
}
type SourceChangeInfo = {
changedAt: string;
path: string;
};
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
try {
const targetStat = await stat(targetPath);
if (targetStat.isFile()) {
return {
changedAt: normalizeDateTimeValue(targetStat.mtime.toISOString()) ?? targetStat.mtime.toISOString(),
path: path.relative(rootPath, targetPath) || path.basename(targetPath),
};
}
if (!targetStat.isDirectory()) {
return null;
}
const entries = await fs.promises.readdir(targetPath, { withFileTypes: true });
let latest: SourceChangeInfo | null = null;
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git' || entry.name === '.docker') {
continue;
}
const childPath = path.join(targetPath, entry.name);
const candidate = await findLatestSourceChangeInPath(rootPath, childPath);
if (!candidate) {
continue;
}
if (!latest || candidate.changedAt > latest.changedAt) {
latest = candidate;
}
}
return latest;
} catch {
return null;
}
}
async function readLatestAppSourceChange() {
const projectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.SERVER_COMMAND_PROJECT_ROOT);
let latest: SourceChangeInfo | null = null;
for (const relativePath of APP_SOURCE_TARGET_PATHS) {
const candidate = await findLatestSourceChangeInPath(projectRoot, path.join(projectRoot, relativePath));
if (!candidate) {
continue;
}
if (!latest || candidate.changedAt > latest.changedAt) {
latest = candidate;
}
}
return latest;
}
async function requestDockerEngine<T = unknown>(
method: string,
requestPath: string,
payload?: unknown,
): Promise<T> {
const socketPath = resolveDockerSocketPath(process.env);
const body = payload == null ? null : JSON.stringify(payload);
return await new Promise<T>((resolve, reject) => {
const request = http.request(
{
socketPath,
path: requestPath,
method,
headers: body
? {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
}
: undefined,
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on('end', () => {
const responseText = Buffer.concat(chunks).toString('utf8');
if ((response.statusCode ?? 500) >= 400) {
reject(
new Error(
trimPreview(`Docker API ${method} ${requestPath} failed: ${response.statusCode} ${responseText}`, 400) ??
`Docker API ${method} ${requestPath} failed: ${response.statusCode}`,
),
);
return;
}
if (!responseText.trim()) {
resolve(undefined as T);
return;
}
try {
resolve(JSON.parse(responseText) as T);
} catch (error) {
reject(error);
}
});
},
);
request.once('error', reject);
request.setTimeout(8000, () => {
request.destroy(new Error(`Docker API timeout: ${method} ${requestPath}`));
});
if (body) {
request.write(body);
}
request.end();
});
}
type DockerContainerInspect = {
Id?: string;
Name?: string;
State?: {
StartedAt?: string;
Status?: string;
};
};
async function inspectContainerViaSocket(containerName: string) {
return requestDockerEngine<DockerContainerInspect>('GET', `/containers/${encodeURIComponent(containerName)}/json`);
}
async function execContainerCommandViaSocket(containerName: string, command: string[]) {
const execCreated = await requestDockerEngine<{ Id?: string }>('POST', `/containers/${encodeURIComponent(containerName)}/exec`, {
AttachStdout: true,
AttachStderr: true,
Cmd: command,
});
const execId = execCreated.Id?.trim();
if (!execId) {
return null;
}
const output = await new Promise<string>((resolve, reject) => {
const request = http.request(
{
socketPath: resolveDockerSocketPath(process.env),
path: `/exec/${encodeURIComponent(execId)}/start`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on('end', () => {
resolve(decodeDockerExecStream(Buffer.concat(chunks)));
});
},
);
request.once('error', reject);
request.setTimeout(8000, () => {
request.destroy(new Error(`Docker exec timeout: ${containerName}`));
});
request.write(JSON.stringify({ Detach: false, Tty: false }));
request.end();
});
const execState = await requestDockerEngine<{ ExitCode?: number | null }>('GET', `/exec/${encodeURIComponent(execId)}/json`);
if ((execState.ExitCode ?? 1) !== 0) {
return null;
}
return output.trim();
}
async function readContainerBuildTimestampViaSocket(definition: ServerDefinition, targetPath: string) {
try {
const output = await execContainerCommandViaSocket(definition.containerName, [
'sh',
'-lc',
`if [ -e ${JSON.stringify(targetPath)} ]; then stat -c '%y' ${JSON.stringify(targetPath)}; fi`,
]);
return normalizeDateTimeValue(output);
} catch {
return null;
}
}
function normalizeUrl(value: string) {
return value.trim().replace(/\/+$/, '');
@@ -181,7 +435,27 @@ function shouldRetryWithDockerSocket(error: unknown) {
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
return failure?.code === 127 || /docker CLI not found/i.test(detail);
return failure?.code === 127 || failure?.code === 'ENOENT' || /docker CLI not found|spawn docker ENOENT/i.test(detail);
}
function decodeDockerExecStream(buffer: Buffer) {
let offset = 0;
const chunks: Buffer[] = [];
while (offset + 8 <= buffer.length) {
const frameLength = buffer.readUInt32BE(offset + 4);
const frameStart = offset + 8;
const frameEnd = frameStart + frameLength;
if (frameEnd > buffer.length) {
break;
}
chunks.push(buffer.subarray(frameStart, frameEnd));
offset = frameEnd;
}
return (chunks.length > 0 ? Buffer.concat(chunks) : buffer).toString('utf8');
}
export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) {
@@ -794,6 +1068,19 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null),
};
} catch (error) {
if (shouldRetryWithDockerSocket(error)) {
try {
const inspected = await inspectContainerViaSocket(definition.containerName);
return {
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
composeStatus: inspected.State?.Status?.trim() || null,
composeDetails: trimPreview(inspected.Name?.trim() ? `name:${inspected.Name.trim().replace(/^\//, '')}` : null),
};
} catch {
// fall through to compose inspection
}
}
return inspectComposeStatus(definition);
}
}
@@ -935,8 +1222,61 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
return inspectContainerRuntime(definition);
}
async function inspectAppContainerBuild(definition: ServerDefinition): Promise<BuildInspectionResult | null> {
if (definition.key !== 'test') {
return null;
}
const latestSourceChange = await readLatestAppSourceChange();
const latestSourceChangedAt = latestSourceChange?.changedAt ?? null;
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
const builtAt =
(await readLocalBuildTimestamp(targetPath)) ?? (await readContainerBuildTimestamp(definition, targetPath));
if (!builtAt) {
continue;
}
return {
runningVersion: null,
runningBuiltAt: builtAt,
latestVersion: null,
latestBuiltAt: builtAt,
latestSourceChangeAt: latestSourceChangedAt,
latestSourceChangePath: latestSourceChange?.path ?? null,
buildRequired: Boolean(latestSourceChangedAt && latestSourceChangedAt > builtAt),
updateAvailable: false,
updateSummary:
latestSourceChangedAt && latestSourceChangedAt > builtAt
? `수정된 소스가 테스트 빌드보다 새롭습니다.${latestSourceChange?.path ? ` (${latestSourceChange.path})` : ''} 테스트 앱을 다시 빌드해야 합니다.`
: `테스트 빌드 기준: ${builtAt}`,
};
}
return {
runningVersion: null,
runningBuiltAt: null,
latestVersion: null,
latestBuiltAt: null,
latestSourceChangeAt: latestSourceChangedAt,
latestSourceChangePath: latestSourceChange?.path ?? null,
buildRequired: Boolean(latestSourceChangedAt),
updateAvailable: false,
updateSummary: latestSourceChangedAt
? `테스트 빌드 시각을 읽지 못했습니다.${latestSourceChange?.path ? ` 최근 소스 변경: ${latestSourceChange.path}` : ''}`
: '테스트 빌드 시각을 읽지 못했습니다.',
};
}
async function inspectBuild(definition: ServerDefinition): Promise<BuildInspectionResult> {
if (definition.key !== 'work-server') {
const appBuildInfo = await inspectAppContainerBuild(definition);
if (appBuildInfo) {
return appBuildInfo;
}
return {
runningVersion: null,
runningBuiltAt: null,