chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -2,9 +2,11 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
getAppConfig,
getChatContextSettingsConfig,
getChatTypesConfig,
normalizeAppConfigSnapshot,
upsertAppConfig,
upsertChatContextSettingsConfig,
upsertChatTypesConfig,
} from '../services/app-config-service.js';
import {
@@ -13,9 +15,44 @@ import {
} from '../services/automation-context-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
function getRequestAppOrigin(request: { headers: Record<string, string | string[] | undefined> }) {
const rawAppOrigin = request.headers['x-app-origin'];
const appOrigin = Array.isArray(rawAppOrigin) ? rawAppOrigin[0] : rawAppOrigin;
if (appOrigin?.trim()) {
return appOrigin.trim();
}
const rawOrigin = request.headers.origin;
const origin = Array.isArray(rawOrigin) ? rawOrigin[0] : rawOrigin;
return origin?.trim() ?? '';
}
function getRequestAppDomain(request: { headers: Record<string, string | string[] | undefined> }) {
const rawAppDomain = request.headers['x-app-domain'];
const appDomain = Array.isArray(rawAppDomain) ? rawAppDomain[0] : rawAppDomain;
if (appDomain?.trim()) {
return appDomain.trim();
}
const appOrigin = getRequestAppOrigin(request);
if (!appOrigin) {
return '';
}
try {
return new URL(appOrigin).hostname;
} catch {
return '';
}
}
export async function registerAppConfigRoutes(app: FastifyInstance) {
app.get('/api/app-config', async () => {
const config = await getAppConfig();
app.get('/api/app-config', async (request) => {
const appOrigin = getRequestAppOrigin(request);
const config = await getAppConfig(appOrigin);
return {
ok: true,
@@ -23,8 +60,8 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/chat-types', async () => {
const chatTypes = await getChatTypesConfig();
app.get('/api/chat-types', async (request) => {
const chatTypes = await getChatTypesConfig(getRequestAppOrigin(request));
return {
ok: true,
@@ -32,6 +69,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/chat-context-settings', async (request) => {
const settings = await getChatContextSettingsConfig(getRequestAppOrigin(request));
return {
ok: true,
settings,
};
});
app.get('/api/automation-types', async () => {
const automationTypes = await getAutomationTypesConfig();
@@ -66,7 +112,9 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
chatTypes: z.array(z.unknown()),
}).parse(payload ?? {});
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes);
const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const savedChatTypes = await upsertChatTypesConfig(parsed.chatTypes, appOrigin, appDomain);
return {
ok: true,
@@ -79,6 +127,37 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
});
app.put('/api/chat-context-settings', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
settings: z.unknown(),
}).parse(payload ?? {});
const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const savedSettings = await upsertChatContextSettingsConfig(parsed.settings, appOrigin, appDomain);
return {
ok: true,
settings: savedSettings,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '채팅 Context 설정 저장에 실패했습니다.',
});
}
});
app.put('/api/automation-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
@@ -159,7 +238,9 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
throw new Error('설정 값 형식이 올바르지 않습니다.');
}
const savedConfig = await upsertAppConfig(config as Record<string, unknown>);
const appOrigin = getRequestAppOrigin(request);
const appDomain = getRequestAppDomain(request);
const savedConfig = await upsertAppConfig(config as Record<string, unknown>, appOrigin, appDomain);
return {
ok: true,

View File

@@ -109,7 +109,7 @@ export async function registerBoardRoutes(app: FastifyInstance) {
return {
ok: true,
item: result.item,
planItemId: result.planItemId,
planItemIds: result.planItemIds,
alreadyReceived: result.alreadyReceived,
};
});

View File

@@ -6,9 +6,10 @@ 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 { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
import { ensureChatSessionResourceDirectories, getActiveChatService, getChatRuntimeController } from '../services/chat-service.js';
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
createChatConversation,
deleteUnansweredChatConversationRequest,
deleteChatConversation,
@@ -40,10 +41,12 @@ function resolveStaticContentType(filePath: string) {
case '.json':
case '.css':
case '.html':
case '.md':
case '.txt':
case '.diff':
return 'text/plain; charset=utf-8';
case '.md':
case '.markdown':
return 'text/markdown; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
@@ -190,7 +193,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
const viewerClientId = getClientIdHeader(request);
const clientId = canViewAllConversations(request) ? null : viewerClientId;
const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null);
const items = await listChatConversations(clientId, query.limit ?? 200, viewerClientId || null);
return {
ok: true,
@@ -239,7 +242,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
);
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
await mkdir(path.dirname(absolutePath), { recursive: true });
await ensureChatSessionResourceDirectories(resolveChatAttachmentRepoPath(), payload.sessionId);
await writeFile(absolutePath, buffer);
return {
@@ -375,8 +378,9 @@ export async function registerChatRoutes(app: FastifyInstance) {
title: z.string().trim().max(200).optional(),
chatTypeId: z.string().trim().max(120).nullable().optional(),
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
generalSectionName: z.string().trim().max(120).optional().nullable(),
contextLabel: z.string().trim().max(200).optional(),
contextDescription: z.string().trim().max(2000).optional(),
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional(),
notifyOffline: z.boolean().optional(),
}).parse(request.body ?? {});
@@ -387,6 +391,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
title: payload.title ?? '새 대화',
chatTypeId: payload.chatTypeId ?? null,
lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null,
generalSectionName: payload.generalSectionName ?? null,
contextLabel: payload.contextLabel ?? null,
contextDescription: payload.contextDescription ?? null,
notifyOffline: payload.notifyOffline ?? true,
@@ -502,8 +507,9 @@ export async function registerChatRoutes(app: FastifyInstance) {
title: z.string().trim().min(1).max(200).optional(),
chatTypeId: z.string().trim().max(120).optional().nullable(),
lastChatTypeId: z.string().trim().max(120).optional().nullable(),
generalSectionName: z.string().trim().max(120).optional().nullable(),
contextLabel: z.string().trim().max(200).optional().nullable(),
contextDescription: z.string().trim().max(2000).optional().nullable(),
contextDescription: z.string().trim().max(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH).optional().nullable(),
notifyOffline: z.boolean().optional(),
}).parse(request.body ?? {});
@@ -521,6 +527,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
clientId: current.clientId,
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId,
generalSectionName: payload.generalSectionName ?? current.generalSectionName,
contextLabel: payload.contextLabel ?? current.contextLabel,
contextDescription: payload.contextDescription ?? current.contextDescription,
notifyOffline: payload.notifyOffline ?? current.notifyOffline,

File diff suppressed because it is too large Load Diff

View File

@@ -64,10 +64,12 @@ import {
listPlanScheduledTasks,
mapPlanScheduledTaskRow,
registerPlanScheduledTaskNow,
syncManagedServiceGenerationCompletion,
updatePlanScheduledTask,
updatePlanScheduledTaskSchema,
} from '../services/plan-schedule-service.js';
import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js';
const completeActionSchema = z.object({
note: z.string().trim().min(1).optional(),
@@ -164,7 +166,10 @@ export async function registerPlanRoutes(app: FastifyInstance) {
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
const row = await createPlanScheduledTask(payload);
const ignoreScheduleDueForImmediateRegistration = !payload.repeatWindowStartTime;
const ignoreScheduleDueForImmediateRegistration =
payload.repeatWindows.length === 0
&& payload.scheduleWeekdays.length === 0
&& payload.scheduleDateRanges.length === 0;
const immediateRegistration =
payload.executionMode === 'managed-service' && payload.recreateManagedServiceOnNextSave
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
@@ -231,16 +236,26 @@ export async function registerPlanRoutes(app: FastifyInstance) {
&& Boolean(row.immediate_run_enabled ?? true)
&& payload.enabled !== false
);
const effectiveRepeatWindowStartTime =
payload.repeatWindowStartTime !== undefined
? payload.repeatWindowStartTime
: (typeof row?.repeat_window_start_time === 'string' ? row.repeat_window_start_time : null);
const effectiveRepeatWindows =
payload.repeatWindows !== undefined
? payload.repeatWindows
: (row ? mapPlanScheduledTaskRow(row).repeatWindows : []);
const effectiveScheduleDateRanges =
payload.scheduleDateRanges !== undefined
? payload.scheduleDateRanges
: (row ? mapPlanScheduledTaskRow(row).scheduleDateRanges : []);
const effectiveScheduleWeekdays =
payload.scheduleWeekdays !== undefined
? payload.scheduleWeekdays
: (row ? mapPlanScheduledTaskRow(row).scheduleWeekdays : []);
const immediateRegistration = shouldTriggerImmediateRegistration
? await registerPlanScheduledTaskNow(id, new Date(), {
? await registerPlanScheduledTaskNow(id, new Date(), {
ignoreScheduleDue:
payload.recreateManagedServiceOnNextSave === true
? false
: !effectiveRepeatWindowStartTime,
: effectiveRepeatWindows.length === 0
&& effectiveScheduleWeekdays.length === 0
&& effectiveScheduleDateRanges.length === 0,
forceManagedServiceGeneration: payload.recreateManagedServiceOnNextSave === true,
})
: null;
@@ -576,6 +591,8 @@ export async function registerPlanRoutes(app: FastifyInstance) {
});
}
await syncManagedServiceGenerationCompletion(id);
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
@@ -583,6 +600,7 @@ export async function registerPlanRoutes(app: FastifyInstance) {
'수동 작업완료로 release 반영 대기 상태가 되었습니다.',
'development-completed',
);
await progressBoardPostAutomationByPlanResult(id, 'completed');
return {
ok: true,
@@ -605,6 +623,8 @@ export async function registerPlanRoutes(app: FastifyInstance) {
});
}
await syncManagedServiceGenerationCompletion(id);
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
await notifyPlanEvent(
id,
@@ -612,6 +632,7 @@ export async function registerPlanRoutes(app: FastifyInstance) {
payload.note ?? '작업이 완료 처리되었습니다.',
'plan-completed',
);
await progressBoardPostAutomationByPlanResult(id, 'completed');
return {
ok: true,
@@ -748,8 +769,12 @@ export async function registerPlanRoutes(app: FastifyInstance) {
try {
const env = getEnv();
const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패';
const sourceWorkCount = Math.max(0, Number(item.usageSnapshot?.sourceWorkCount ?? 0) || 0);
const requiresRollbackBeforeCancel =
sourceWorkCount > 0 &&
(item.status === '릴리즈완료' || item.workerStatus === 'main반영실패');
if (!isReleaseMergeFailure) {
if (!isReleaseMergeFailure && requiresRollbackBeforeCancel) {
await recreateReleaseBranchFromMain(
{
repoPath: env.PLAN_GIT_REPO_PATH,

View File

@@ -0,0 +1,239 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import {
copyResourceManagerItem,
createResourceManagerDirectory,
createResourceManagerFile,
deleteResourceManagerItem,
ensureResourceManagerRoot,
getResourceManagerTree,
listResourceManagerDirectory,
moveResourceManagerItem,
openResourceManagerPreviewStream,
readResourceManagerFile,
saveResourceManagerFile,
uploadResourceManagerFile,
} from '../services/resource-manager-service.js';
const queryPathSchema = z.object({
path: z.string().trim().optional().default(''),
});
const createDirectoryBodySchema = z.object({
parentPath: z.string().trim().optional().default(''),
name: z.string().trim().min(1).max(255),
});
const createFileBodySchema = z.object({
parentPath: z.string().trim().optional().default(''),
name: z.string().trim().min(1).max(255),
content: z.string().optional().default(''),
});
const saveFileBodySchema = z.object({
path: z.string().trim().min(1),
content: z.string(),
});
const uploadFileBodySchema = z.object({
parentPath: z.string().trim().optional().default(''),
fileName: z.string().trim().min(1).max(255),
contentBase64: z.string().trim().min(1),
});
const copyMoveBodySchema = z.object({
path: z.string().trim().min(1),
targetDirectoryPath: z.string().trim().optional().default(''),
nextName: z.string().trim().max(255).optional().nullable(),
});
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply, tokenOverride?: string | null) {
if ((tokenOverride ?? getRequestAccessToken(request)) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
}
reply.status(403);
void reply.send({
message: '권한 토큰이 필요합니다.',
});
return false;
}
function resolveRepoRootPath() {
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH;
}
export async function registerResourceManagerRoutes(app: FastifyInstance) {
app.get('/api/resource-manager/tree', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const repoRootPath = resolveRepoRootPath();
await ensureResourceManagerRoot(repoRootPath);
return {
ok: true,
item: await getResourceManagerTree(repoRootPath),
};
});
app.get('/api/resource-manager/directory', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const query = queryPathSchema.parse(request.query ?? {});
return {
ok: true,
item: await listResourceManagerDirectory(resolveRepoRootPath(), query.path),
};
});
app.get('/api/resource-manager/file', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const query = queryPathSchema.extend({
path: z.string().trim().min(1),
}).parse(request.query ?? {});
return {
ok: true,
item: await readResourceManagerFile(resolveRepoRootPath(), query.path),
};
});
app.get('/api/resource-manager/preview/*', async (request, reply) => {
const query = z.object({
token: z.string().trim().optional(),
}).parse(request.query ?? {});
if (!ensureAuthorized(request, reply, query.token ?? null)) {
return;
}
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard));
reply.header('Cache-Control', 'no-store');
reply.type(preview.contentType);
return reply.send(preview.stream);
});
app.post('/api/resource-manager/directories', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = createDirectoryBodySchema.parse(request.body ?? {});
await createResourceManagerDirectory(resolveRepoRootPath(), payload.parentPath, payload.name);
return {
ok: true,
};
});
app.post('/api/resource-manager/files', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = createFileBodySchema.parse(request.body ?? {});
await createResourceManagerFile(resolveRepoRootPath(), payload.parentPath, payload.name, payload.content);
return {
ok: true,
};
});
app.put('/api/resource-manager/files/content', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = saveFileBodySchema.parse(request.body ?? {});
await saveResourceManagerFile(resolveRepoRootPath(), payload.path, payload.content);
return {
ok: true,
};
});
app.post('/api/resource-manager/files/upload', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = uploadFileBodySchema.parse(request.body ?? {});
return {
ok: true,
item: await uploadResourceManagerFile(
resolveRepoRootPath(),
payload.parentPath,
payload.fileName,
payload.contentBase64,
),
};
});
app.post('/api/resource-manager/items/copy', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = copyMoveBodySchema.parse(request.body ?? {});
return {
ok: true,
item: await copyResourceManagerItem(
resolveRepoRootPath(),
payload.path,
payload.targetDirectoryPath,
payload.nextName,
),
};
});
app.post('/api/resource-manager/items/move', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const payload = copyMoveBodySchema.parse(request.body ?? {});
return {
ok: true,
item: await moveResourceManagerItem(
resolveRepoRootPath(),
payload.path,
payload.targetDirectoryPath,
payload.nextName,
),
};
});
app.delete('/api/resource-manager/items', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
const query = queryPathSchema.extend({
path: z.string().trim().min(1),
}).parse(request.query ?? {});
await deleteResourceManagerItem(resolveRepoRootPath(), query.path);
return {
ok: true,
};
});
}

View File

@@ -2,16 +2,45 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
import {
cancelServerRestartReservation,
confirmServerRestartReservation,
getRestartReservationWorkloadSummary,
getServerRestartReservation,
scheduleServerRestartReservation,
} from '../services/server-restart-reservation-service.js';
const serverCommandParamSchema = z.object({
key: z.enum(serverCommandKeys),
});
const restartReservationBodySchema = z.object({
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
});
function getRequestAccessToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-access-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestClientId(request: FastifyRequest) {
const clientIdHeader = request.headers['x-client-id'];
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
}
function getRequestAppOrigin(request: FastifyRequest) {
const appOriginHeader = request.headers['x-app-origin'];
const appOrigin = Array.isArray(appOriginHeader) ? appOriginHeader[0] : appOriginHeader;
if (appOrigin?.trim()) {
return appOrigin.trim();
}
const originHeader = request.headers.origin;
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
return origin?.trim() ?? '';
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
@@ -42,6 +71,25 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
}
const { key } = serverCommandParamSchema.parse(request.params);
if (key === 'test' || key === 'work-server') {
const workloadSummary = await getRestartReservationWorkloadSummary();
const pendingCount =
workloadSummary.codexRunningCount
+ workloadSummary.codexQueuedCount
+ workloadSummary.automationRunningCount
+ workloadSummary.automationQueuedCount;
if (pendingCount > 0) {
reply.status(409);
return {
ok: false,
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
workloadSummary,
};
}
}
const result = await restartServerCommand(key);
return {
@@ -51,4 +99,64 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
restartState: result.restartState,
};
});
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
return {
ok: true,
item: await getServerRestartReservation(),
};
});
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = restartReservationBodySchema.parse(payload ?? {});
return {
ok: true,
item: await scheduleServerRestartReservation({
clientId: getRequestClientId(request),
appOrigin: getRequestAppOrigin(request),
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
}),
};
});
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
return {
ok: true,
item: await confirmServerRestartReservation(app.log),
};
});
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
return;
}
return {
ok: true,
item: await cancelServerRestartReservation(),
};
});
}