feat: refresh shared chat and server workflows
This commit is contained in:
205
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
205
etc/servers/work-server/src/routes/baseball-ticket-bay.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createBaseballTicketBayAlert,
|
||||
createBaseballTicketBayLog,
|
||||
deleteBaseballTicketBayLog,
|
||||
deleteBaseballTicketBayAlert,
|
||||
listBaseballTicketBayAlerts,
|
||||
listBaseballTicketBayLogs,
|
||||
runBaseballTicketBayAlert,
|
||||
searchBaseballTicketBayListings,
|
||||
updateBaseballTicketBayAlert,
|
||||
} from '../services/baseball-ticket-bay-service.js';
|
||||
|
||||
const timeWindowSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
start: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||
end: z.string().trim().regex(/^\d{2}:\d{2}$/),
|
||||
});
|
||||
|
||||
const alertPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(255),
|
||||
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
team: z.string().trim().min(1).max(50),
|
||||
zone: z.string().trim().min(1).max(100),
|
||||
aisleSide: z.string().trim().min(1).max(100),
|
||||
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10),
|
||||
maxPrice: z.number().finite().positive().nullable(),
|
||||
seatCount: z.number().int().positive().max(10),
|
||||
batchIntervalMinutes: z.number().int().min(1).max(120),
|
||||
sameProductAlertEnabled: z.boolean(),
|
||||
sameProductNotifyOnce: z.boolean(),
|
||||
active: z.boolean().default(true),
|
||||
timeWindows: z.array(timeWindowSchema).min(1).max(24),
|
||||
});
|
||||
|
||||
function readHeader(request: { headers: Record<string, string | string[] | undefined> }, key: string) {
|
||||
const raw = request.headers[key];
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
}
|
||||
|
||||
export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {}));
|
||||
|
||||
app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림 목록을 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: await listBaseballTicketBayAlerts(clientId),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: await listBaseballTicketBayLogs(clientId, query.alertId),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayLog(params.id, clientId);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 저장할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const payload = alertPayloadSchema.parse(request.body ?? {});
|
||||
const item = await createBaseballTicketBayAlert(payload, {
|
||||
clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
await createBaseballTicketBayLog({
|
||||
clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'create',
|
||||
status: 'info',
|
||||
message: '알림 조건을 저장했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 수정할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
|
||||
const item = await updateBaseballTicketBayAlert(params.id, payload, {
|
||||
clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
await createBaseballTicketBayLog({
|
||||
clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
|
||||
status: 'info',
|
||||
message:
|
||||
payload.active === false
|
||||
? '알림을 중지했습니다.'
|
||||
: payload.active === true
|
||||
? '알림을 다시 실행 상태로 전환했습니다.'
|
||||
: '알림 조건을 수정 저장했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayAlert(params.id, clientId);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
await createBaseballTicketBayLog({
|
||||
clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'delete',
|
||||
status: 'info',
|
||||
message: '알림 항목을 삭제했습니다.',
|
||||
detail: `${item.team} · ${item.eventDate}`,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 즉시 실행할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true });
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
alert: result.alert,
|
||||
matches: result.matches,
|
||||
notifiedMatches: result.notifiedMatches,
|
||||
log: result.log,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||
import {
|
||||
assignSharedResourceTokenToRequests,
|
||||
appendChatConversationActivityLine,
|
||||
appendChatConversationMessage,
|
||||
buildChatPromptTargetSignature,
|
||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||
cancelUnansweredChatConversationRequest,
|
||||
clearChatConversationData,
|
||||
createChatConversation,
|
||||
deleteUnansweredChatConversationRequest,
|
||||
@@ -90,6 +92,15 @@ const chatPromptContextRefSchema = z
|
||||
.optional()
|
||||
.nullable();
|
||||
|
||||
const chatComposerAttachmentSchema = z.object({
|
||||
id: z.string().trim().min(1).max(240),
|
||||
name: z.string().trim().min(1).max(500),
|
||||
path: z.string().trim().min(1).max(4000),
|
||||
publicUrl: z.string().trim().min(1).max(4000),
|
||||
size: z.number().finite().min(0).max(CHAT_ATTACHMENT_FILE_SIZE_LIMIT),
|
||||
mimeType: z.string().trim().min(1).max(255),
|
||||
});
|
||||
|
||||
async function findExistingActivePromptFollowupRequest(
|
||||
sessionId: string,
|
||||
parentRequestId: string,
|
||||
@@ -244,6 +255,127 @@ function createManagedChatShareMessageIds() {
|
||||
};
|
||||
}
|
||||
|
||||
function sortShareMessages(messages: ListedChatConversationMessage[]) {
|
||||
return [...messages].sort((left, right) => {
|
||||
if (left.id !== right.id) {
|
||||
return left.id - right.id;
|
||||
}
|
||||
|
||||
return left.timestamp.localeCompare(right.timestamp);
|
||||
});
|
||||
}
|
||||
|
||||
function sortShareRequests(requests: ListedChatConversationRequest[]) {
|
||||
return [...requests].sort((left, right) => {
|
||||
const createdAtDiff = left.createdAt.localeCompare(right.createdAt);
|
||||
|
||||
if (createdAtDiff !== 0) {
|
||||
return createdAtDiff;
|
||||
}
|
||||
|
||||
return left.requestId.localeCompare(right.requestId);
|
||||
});
|
||||
}
|
||||
|
||||
async function materializeManagedShareConversation(args: {
|
||||
shareSnapshot: NonNullable<Awaited<ReturnType<typeof buildChatShareSnapshot>>>;
|
||||
managedResourceTokenId: string;
|
||||
ownerClientId: string | null;
|
||||
shareTitle: string;
|
||||
}) {
|
||||
const { shareSnapshot, managedResourceTokenId, ownerClientId, shareTitle } = args;
|
||||
const sessionId = createManagedChatShareSessionId();
|
||||
const requestIdSet = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean));
|
||||
const sortedRequests = sortShareRequests(shareSnapshot.requests);
|
||||
const sortedMessages = sortShareMessages(shareSnapshot.messages);
|
||||
const sourceConversation = shareSnapshot.conversation;
|
||||
|
||||
await createChatConversation({
|
||||
sessionId,
|
||||
clientId: ownerClientId,
|
||||
title: shareTitle,
|
||||
draftText: '',
|
||||
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
|
||||
codexModel: sourceConversation?.codexModel ?? null,
|
||||
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
|
||||
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
|
||||
generalSectionName: sourceConversation?.generalSectionName ?? null,
|
||||
contextLabel: sourceConversation?.contextLabel ?? null,
|
||||
contextDescription: sourceConversation?.contextDescription ?? null,
|
||||
notifyOffline: true,
|
||||
});
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
await appendChatConversationMessage(
|
||||
{
|
||||
sessionId,
|
||||
clientId: ownerClientId,
|
||||
title: shareTitle,
|
||||
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
|
||||
codexModel: sourceConversation?.codexModel ?? null,
|
||||
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
|
||||
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
|
||||
generalSectionName: sourceConversation?.generalSectionName ?? null,
|
||||
contextLabel: sourceConversation?.contextLabel ?? null,
|
||||
contextDescription: sourceConversation?.contextDescription ?? null,
|
||||
notifyOffline: true,
|
||||
},
|
||||
{
|
||||
sessionId,
|
||||
messageId: message.id,
|
||||
author: message.author,
|
||||
text: message.text,
|
||||
timestamp: message.timestamp,
|
||||
clientRequestId: message.clientRequestId?.trim() || null,
|
||||
parts: message.parts ?? [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const request of sortedRequests) {
|
||||
const normalizedParentRequestId = request.parentRequestId?.trim() || '';
|
||||
|
||||
await upsertChatConversationRequest(sessionId, {
|
||||
requestId: request.requestId,
|
||||
requesterClientId: request.requesterClientId ?? ownerClientId,
|
||||
chatTypeId: request.chatTypeId ?? sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
|
||||
chatTypeLabel: request.chatTypeLabel ?? sourceConversation?.contextLabel ?? null,
|
||||
requestOrigin: request.requestOrigin,
|
||||
sharedResourceTokenId: managedResourceTokenId,
|
||||
parentRequestId: normalizedParentRequestId && requestIdSet.has(normalizedParentRequestId) ? normalizedParentRequestId : null,
|
||||
status: request.status,
|
||||
statusMessage: request.statusMessage,
|
||||
userMessageId: request.userMessageId,
|
||||
userText: request.userText,
|
||||
responseMessageId: request.responseMessageId,
|
||||
responseText: request.responseText,
|
||||
usageSnapshot: request.usageSnapshot,
|
||||
totalTokens: request.totalTokens,
|
||||
});
|
||||
}
|
||||
|
||||
for (const activityLog of shareSnapshot.activityLogs) {
|
||||
let lineNo = 1;
|
||||
for (const line of activityLog.lines) {
|
||||
await appendChatConversationActivityLine(sessionId, activityLog.requestId, line, lineNo);
|
||||
lineNo += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await assignSharedResourceTokenToRequests(
|
||||
sessionId,
|
||||
sortedRequests.map((item) => item.requestId),
|
||||
managedResourceTokenId,
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
requestId:
|
||||
sortedRequests.find((item) => item.requestId.trim() === shareSnapshot.targetRequest.requestId.trim())?.requestId
|
||||
?? shareSnapshot.targetRequest.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStaticContentType(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
@@ -1546,6 +1678,30 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const shareSnapshot = await buildChatShareSnapshot({
|
||||
version: CHAT_SHARE_TOKEN_VERSION,
|
||||
kind: payload.kind,
|
||||
sessionId: payload.sessionId,
|
||||
requestId: payload.requestId,
|
||||
tokenSettingId: tokenSetting.id,
|
||||
tokenSettingName: tokenSetting.name,
|
||||
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
|
||||
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
|
||||
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
|
||||
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
|
||||
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
|
||||
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
|
||||
sourceMessageId: payload.sourceMessageId,
|
||||
promptIndex: payload.promptIndex,
|
||||
promptSignature: payload.promptSignature,
|
||||
});
|
||||
|
||||
if (!shareSnapshot) {
|
||||
return reply.code(404).send({
|
||||
message: '공유할 채팅 범위를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.kind === 'prompt') {
|
||||
if (payload.promptIndex == null || !payload.promptSignature) {
|
||||
return reply.code(400).send({
|
||||
@@ -1553,25 +1709,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const shareSnapshot = await buildChatShareSnapshot({
|
||||
version: CHAT_SHARE_TOKEN_VERSION,
|
||||
kind: payload.kind,
|
||||
sessionId: payload.sessionId,
|
||||
requestId: payload.requestId,
|
||||
tokenSettingId: tokenSetting.id,
|
||||
tokenSettingName: tokenSetting.name,
|
||||
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
|
||||
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
|
||||
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
|
||||
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
|
||||
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
|
||||
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
|
||||
sourceMessageId: payload.sourceMessageId,
|
||||
promptIndex: payload.promptIndex,
|
||||
promptSignature: payload.promptSignature,
|
||||
});
|
||||
|
||||
if (!shareSnapshot?.promptTarget) {
|
||||
if (!shareSnapshot.promptTarget) {
|
||||
return reply.code(404).send({
|
||||
message: '공유할 prompt 대상을 찾을 수 없습니다.',
|
||||
});
|
||||
@@ -1581,6 +1719,12 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
const managedResourceTokenId = createManagedChatShareTokenId();
|
||||
const token = randomUUID().replace(/-/g, '').slice(0, 24);
|
||||
const sharePath = resolveChatSharePath(token);
|
||||
const managedShareConversation = await materializeManagedShareConversation({
|
||||
shareSnapshot,
|
||||
managedResourceTokenId,
|
||||
ownerClientId: clientId || null,
|
||||
shareTitle: payload.name,
|
||||
});
|
||||
|
||||
await upsertSharedResourceToken({
|
||||
id: managedResourceTokenId,
|
||||
@@ -1599,8 +1743,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
},
|
||||
resourceContext: {
|
||||
kind: payload.kind,
|
||||
sessionId: payload.sessionId,
|
||||
requestId: payload.requestId,
|
||||
sessionId: managedShareConversation.sessionId,
|
||||
requestId: managedShareConversation.requestId,
|
||||
sourceMessageId: payload.sourceMessageId ?? null,
|
||||
promptIndex: payload.promptIndex ?? null,
|
||||
promptSignature: payload.promptSignature ?? null,
|
||||
@@ -1620,33 +1764,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
usageLimit: 0,
|
||||
});
|
||||
|
||||
const createdShareSnapshot = await buildChatShareSnapshot({
|
||||
version: CHAT_SHARE_TOKEN_VERSION,
|
||||
kind: payload.kind,
|
||||
sessionId: payload.sessionId,
|
||||
requestId: payload.requestId,
|
||||
tokenSettingId: tokenSetting.id,
|
||||
tokenSettingName: tokenSetting.name,
|
||||
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
|
||||
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
|
||||
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
|
||||
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
|
||||
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
|
||||
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
|
||||
managedResourceTokenId,
|
||||
sourceMessageId: payload.sourceMessageId,
|
||||
promptIndex: payload.promptIndex,
|
||||
promptSignature: payload.promptSignature,
|
||||
});
|
||||
|
||||
if (createdShareSnapshot?.requests.length) {
|
||||
await assignSharedResourceTokenToRequests(
|
||||
payload.sessionId,
|
||||
createdShareSnapshot.requests.map((item) => item.requestId),
|
||||
managedResourceTokenId,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
token,
|
||||
@@ -2060,6 +2177,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}),
|
||||
).max(20).optional(),
|
||||
summaryText: z.string().max(10000).optional().nullable(),
|
||||
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||
followupText: z.string().trim().min(1).max(20000),
|
||||
contextRef: chatPromptContextRefSchema,
|
||||
}).parse(request.body ?? {});
|
||||
@@ -2265,6 +2383,237 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-cancel`, async (request, reply) => {
|
||||
const params = z.object({
|
||||
token: z.string().trim().min(1).max(16000),
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
parentRequestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
|
||||
if (!tokenPayload) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 링크가 유효하지 않습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
|
||||
|
||||
if (!shareSnapshot) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 대상을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
|
||||
|
||||
if (unavailableMessage) {
|
||||
return reply.code(403).send({
|
||||
message: unavailableMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크에는 요청 취소 처리 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
|
||||
shareSnapshot,
|
||||
payload.parentRequestId,
|
||||
[shareSnapshot.targetRequest.requestId],
|
||||
);
|
||||
|
||||
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크 범위를 벗어난 요청입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
|
||||
|
||||
if (
|
||||
!targetRequest
|
||||
|| targetRequest.hasResponse
|
||||
|| targetRequest.status !== 'failed'
|
||||
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
|
||||
) {
|
||||
return reply.code(409).send({
|
||||
message: '지금은 이 요청을 취소 처리할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await cancelUnansweredChatConversationRequest(
|
||||
tokenPayload.sessionId,
|
||||
normalizedParentRequestId,
|
||||
'사용자 요청으로 중단된 요청을 취소 처리했습니다.',
|
||||
);
|
||||
|
||||
if (!result.cancelled || !result.item) {
|
||||
if (result.reason === 'answered') {
|
||||
return reply.code(409).send({
|
||||
message: '이미 답변이 연결된 요청은 취소 처리할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (result.reason === 'active') {
|
||||
return reply.code(409).send({
|
||||
message: '현재 처리 중인 요청은 여기서 취소 처리할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (result.reason === 'already_terminal') {
|
||||
return reply.code(409).send({
|
||||
message: '이미 취소 처리된 요청입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(404).send({
|
||||
message: '취소 처리할 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, result.item);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-retry`, async (request, reply) => {
|
||||
const params = z.object({
|
||||
token: z.string().trim().min(1).max(16000),
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
parentRequestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
|
||||
if (!tokenPayload) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 링크가 유효하지 않습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
|
||||
|
||||
if (!shareSnapshot) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 대상을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
|
||||
|
||||
if (unavailableMessage) {
|
||||
return reply.code(403).send({
|
||||
message: unavailableMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크에는 요청 재처리 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const blockedReason = resolveShareBlockedReason(
|
||||
shareSnapshot.requests,
|
||||
resolveChatShareTokenSettingSnapshot(tokenPayload),
|
||||
);
|
||||
|
||||
if (blockedReason) {
|
||||
return reply.code(409).send({
|
||||
message: blockedReason,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
|
||||
shareSnapshot,
|
||||
payload.parentRequestId,
|
||||
[shareSnapshot.targetRequest.requestId],
|
||||
);
|
||||
|
||||
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크 범위를 벗어난 요청입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
|
||||
return reply.code(403).send({
|
||||
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
|
||||
|
||||
if (
|
||||
!targetRequest
|
||||
|| targetRequest.hasResponse
|
||||
|| targetRequest.status !== 'failed'
|
||||
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
|
||||
) {
|
||||
return reply.code(409).send({
|
||||
message: '지금은 이 요청을 재처리할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedUserText = targetRequest.userText.trim();
|
||||
|
||||
if (!normalizedUserText) {
|
||||
return reply.code(409).send({
|
||||
message: '재처리할 요청 본문이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, {
|
||||
mode: 'direct',
|
||||
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
|
||||
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
|
||||
parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null,
|
||||
clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
|
||||
});
|
||||
|
||||
if (!queuedRequestId) {
|
||||
return reply.code(503).send({
|
||||
message: '중단된 요청 재처리를 시작하지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (managedContext.managedResource) {
|
||||
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
|
||||
actorLabel: 'share-viewer',
|
||||
summary: '공유 채팅에서 중단 요청 재처리를 시작했습니다.',
|
||||
detail: normalizedParentRequestId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
queuedRequestId,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/conversations', async (request) => {
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
@@ -2622,6 +2971,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}),
|
||||
).max(20).optional(),
|
||||
summaryText: z.string().max(10000).optional().nullable(),
|
||||
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
if (params.requestId !== payload.parentRequestId) {
|
||||
@@ -2675,6 +3025,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}),
|
||||
).max(20).optional(),
|
||||
summaryText: z.string().max(10000).optional().nullable(),
|
||||
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||
followupText: z.string().trim().min(1).max(20000),
|
||||
mode: z.enum(['queue', 'direct']).optional(),
|
||||
contextRef: chatPromptContextRefSchema,
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getActiveChatService } from '../services/chat-service.js';
|
||||
import { getRuntimeDrainSnapshot } from '../services/runtime-drain-service.js';
|
||||
import { getRuntimeWorkServerBuildInfo } from '../services/work-server-build-service.js';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance) {
|
||||
const respondHealth = async () => ({
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
const respondHealth = async () => {
|
||||
const buildInfo = getRuntimeWorkServerBuildInfo();
|
||||
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
buildId: buildInfo?.buildId ?? null,
|
||||
builtAt: buildInfo?.builtAt ?? null,
|
||||
...getRuntimeDrainSnapshot(),
|
||||
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/', respondHealth);
|
||||
app.get('/api', respondHealth);
|
||||
|
||||
42
etc/servers/work-server/src/routes/runtime.ts
Normal file
42
etc/servers/work-server/src/routes/runtime.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getActiveChatService } from '../services/chat-service.js';
|
||||
import {
|
||||
beginRuntimeDrain,
|
||||
endRuntimeDrain,
|
||||
getRuntimeDrainSnapshot,
|
||||
} from '../services/runtime-drain-service.js';
|
||||
|
||||
const runtimeDrainBodySchema = z.object({
|
||||
draining: z.boolean(),
|
||||
});
|
||||
|
||||
function buildRuntimeResponse() {
|
||||
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...getRuntimeDrainSnapshot(),
|
||||
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
|
||||
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
|
||||
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
|
||||
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
|
||||
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerRuntimeRoutes(app: FastifyInstance) {
|
||||
app.get('/api/runtime', async () => buildRuntimeResponse());
|
||||
|
||||
app.post('/api/runtime/drain', async (request) => {
|
||||
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
|
||||
|
||||
if (draining) {
|
||||
beginRuntimeDrain();
|
||||
} else {
|
||||
endRuntimeDrain();
|
||||
}
|
||||
|
||||
return buildRuntimeResponse();
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
||||
import {
|
||||
cancelServerRestartReservation,
|
||||
@@ -16,8 +17,10 @@ const serverCommandParamSchema = z.object({
|
||||
});
|
||||
|
||||
const restartReservationBodySchema = z.object({
|
||||
target: z.enum(['all', 'test', 'work-server']).optional(),
|
||||
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
|
||||
});
|
||||
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||
|
||||
function getImmediateRestartBlockInfo(
|
||||
key: z.infer<typeof serverCommandParamSchema>['key'],
|
||||
@@ -60,6 +63,39 @@ function getRequestAccessToken(request: FastifyRequest) {
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
async function resolveSharedServerCommandAccessContext(request: FastifyRequest) {
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared' as const,
|
||||
allowedKeys: new Set<string>(['work-server']),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestClientId(request: FastifyRequest) {
|
||||
const clientIdHeader = request.headers['x-client-id'];
|
||||
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
|
||||
@@ -78,36 +114,48 @@ function getRequestAppOrigin(request: FastifyRequest) {
|
||||
return origin?.trim() ?? '';
|
||||
}
|
||||
|
||||
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
async function resolveServerCommandAccessContext(request: FastifyRequest) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
return { scope: 'full' as const };
|
||||
}
|
||||
|
||||
return resolveSharedServerCommandAccessContext(request);
|
||||
}
|
||||
|
||||
function sendAccessDenied(reply: FastifyReply) {
|
||||
reply.status(403);
|
||||
void reply.send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
app.get('/api/server-commands', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await listServerCommands();
|
||||
return {
|
||||
ok: true,
|
||||
items: await listServerCommands(),
|
||||
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = serverCommandParamSchema.parse(request.params);
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' };
|
||||
}
|
||||
|
||||
if (key === 'test' || key === 'work-server') {
|
||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||
@@ -160,7 +208,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +221,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -187,9 +239,14 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
|
||||
const parsed = restartReservationBodySchema.parse(payload ?? {});
|
||||
|
||||
if (accessContext.scope !== 'full' && parsed.target !== 'work-server') {
|
||||
return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await scheduleServerRestartReservation({
|
||||
target: parsed.target,
|
||||
clientId: getRequestClientId(request),
|
||||
appOrigin: getRequestAppOrigin(request),
|
||||
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
|
||||
@@ -198,7 +255,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,7 +268,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user