feat: update codex live chat workflow
This commit is contained in:
@@ -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 ?? {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
55
etc/servers/work-server/src/routes/crud.test.ts
Normal file
55
etc/servers/work-server/src/routes/crud.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 '실행이 완료되었습니다.';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user