chore: test deploy snapshot

This commit is contained in:
2026-05-27 10:43:01 +09:00
parent c1d0f4c1db
commit 4c4b3c8d2c
78 changed files with 10392 additions and 2301 deletions

View File

@@ -22,6 +22,8 @@ import {
upsertTokenSettingsConfig,
type TokenSettingRecord,
} from '../services/token-setting-config-service.js';
import { listTokenSettingActivities } from '../services/token-setting-activity-service.js';
import { extractRequestAuditContext } from '../utils/request-audit.js';
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
@@ -388,7 +390,10 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
const savedTokenSettings =
accessContext.scope === 'full'
? await upsertTokenSettingsConfig(nextTokenSettingsInput)
? await upsertTokenSettingsConfig(nextTokenSettingsInput, {
actorLabel: 'manager',
audit: extractRequestAuditContext(request),
})
: await (async () => {
const authorizedSettingId = accessContext.tokenSetting.id;
const requestedSetting = nextTokenSettingsInput.find(
@@ -403,7 +408,10 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
const nextTokenSettings = currentTokenSettings.map((item) =>
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
);
return upsertTokenSettingsConfig(nextTokenSettings);
return upsertTokenSettingsConfig(nextTokenSettings, {
actorLabel: 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
})();
return {
@@ -417,6 +425,26 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
});
app.get('/api/token-settings/:settingId/activities', async (request, reply) => {
const accessContext = await resolveTokenSettingsAccessContext(request);
if (!accessContext) {
sendTokenSettingsAccessDenied(reply);
return;
}
const settingId = z.string().trim().min(1).parse((request.params as { settingId: string }).settingId);
if (accessContext.scope === 'shared' && accessContext.tokenSetting.id !== settingId) {
return reply.code(403).send({
message: '현재 공유 링크에서는 연결된 토큰 설정 이력만 볼 수 있습니다.',
});
}
return {
ok: true,
activities: await listTokenSettingActivities(settingId),
};
});
app.put('/api/app-config', async (request, reply) => {
const accessContext = await resolveAppConfigAccessContext(request);
if (!accessContext) {

View File

@@ -1,5 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
import { getSharedResourceTokenDetailByShareToken } from '../services/shared-resource-token-service.js';
import {
createBaseballTicketBayAlert,
createBaseballTicketBayLog,
@@ -39,46 +41,115 @@ function readHeader(request: { headers: Record<string, string | string[] | undef
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
}
function hasBaseballTicketBayGlobalAccess(request: { headers: Record<string, string | string[] | undefined> }) {
return hasErrorLogViewAccessToken(request.headers['x-access-token']);
}
type BaseballTicketBayRouteAccessContext =
| { scope: 'all' }
| { scope: 'client'; clientId: string }
| { scope: 'shared-token'; clientId: string; tokenId: string };
function toOwnerScope(accessContext: Exclude<BaseballTicketBayRouteAccessContext, { scope: 'all' }> | { scope: 'all' }) {
if (accessContext.scope === 'all') {
return { kind: 'all' } as const;
}
if (accessContext.scope === 'shared-token') {
return { kind: 'owner', ownerType: 'shared-token', ownerId: accessContext.tokenId } as const;
}
return { kind: 'owner', ownerType: 'client', ownerId: accessContext.clientId } as const;
}
async function resolveBaseballTicketBayAccessContext(
request: { headers: Record<string, string | string[] | undefined> },
) : Promise<BaseballTicketBayRouteAccessContext | null> {
const clientId = readHeader(request, 'x-client-id');
if (hasBaseballTicketBayGlobalAccess(request)) {
return { scope: 'all' };
}
const accessToken = readHeader(request, 'x-access-token');
if (accessToken) {
const sharedTokenDetail = await getSharedResourceTokenDetailByShareToken(accessToken);
if (
sharedTokenDetail
&& sharedTokenDetail.token.enabled !== false
&& !sharedTokenDetail.token.revokedAt
&& sharedTokenDetail.token.allowedAppIds.some((item) => item.trim().toLowerCase() === 'baseball-ticket-bay')
) {
if (!clientId) {
return null;
}
return {
scope: 'shared-token',
clientId,
tokenId: sharedTokenDetail.token.id,
};
}
}
if (!clientId) {
return null;
}
return {
scope: 'client',
clientId,
};
}
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');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림 목록을 불러올 수 없습니다.' });
if (!accessContext) {
return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' });
}
return {
ok: true,
items: await listBaseballTicketBayAlerts(clientId),
includeAllClients: accessContext.scope === 'all',
accessScope: accessContext.scope,
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
items: await listBaseballTicketBayAlerts(toOwnerScope(accessContext)),
};
});
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 불러올 수 없습니다.' });
if (!accessContext) {
return reply.code(400).send({ message: '접근 식별값이 없어 로그를 불러올 수 없습니다.' });
}
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
return {
ok: true,
items: await listBaseballTicketBayLogs(clientId, query.alertId),
includeAllClients: accessContext.scope === 'all',
accessScope: accessContext.scope,
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
items: await listBaseballTicketBayLogs(toOwnerScope(accessContext), query.alertId),
};
});
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 삭제할 수 없습니다.' });
if (!accessContext || accessContext.scope === 'all') {
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const item = await deleteBaseballTicketBayLog(params.id, clientId);
const item = await deleteBaseballTicketBayLog(params.id, toOwnerScope(accessContext));
if (!item) {
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
@@ -91,20 +162,24 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
});
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 저장할 수 없습니다.' });
if (!accessContext || accessContext.scope === 'all') {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' });
}
const payload = alertPayloadSchema.parse(request.body ?? {});
const item = await createBaseballTicketBayAlert(payload, {
clientId,
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
});
await createBaseballTicketBayLog({
clientId,
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
alertId: item.id,
alertTitle: item.title,
action: 'create',
@@ -120,21 +195,25 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
});
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 수정할 수 없습니다.' });
if (!accessContext || accessContext.scope === 'all') {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
}
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,
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
});
await createBaseballTicketBayLog({
clientId,
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
alertId: item.id,
alertTitle: item.title,
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
@@ -155,21 +234,23 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
});
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 삭제할 수 없습니다.' });
if (!accessContext || accessContext.scope === 'all') {
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const item = await deleteBaseballTicketBayAlert(params.id, clientId);
const item = await deleteBaseballTicketBayAlert(params.id, toOwnerScope(accessContext));
if (!item) {
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
}
await createBaseballTicketBayLog({
clientId,
clientId: accessContext.clientId,
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
alertId: item.id,
alertTitle: item.title,
action: 'delete',
@@ -185,14 +266,22 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
});
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
const accessContext = await resolveBaseballTicketBayAccessContext(request);
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 즉시 실행할 수 없습니다.' });
if (!accessContext) {
return reply.code(400).send({ message: '접근 식별값이 없어 즉시 실행할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true });
if (accessContext.scope === 'all') {
return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' });
}
const result = await runBaseballTicketBayAlert(params.id, {
ignoreTimeWindow: true,
scope: toOwnerScope(accessContext),
});
return {
ok: true,

View File

@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js';
import { resolvePromptFollowupMode, resolveStaticContentType } from './chat.js';
test('resolveStaticContentType returns html content type for chat resource html files', () => {
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
@@ -12,40 +12,9 @@ test('resolveStaticContentType keeps plain text content type for code resources'
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
});
test('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => {
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: 101,
responseText: '',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: null,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: null,
responseText: '',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteShareReplyParentVerification({
responseMessageId: 102,
responseText: '답변 본문',
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
}),
false,
);
test('resolvePromptFollowupMode defaults to queue and preserves direct mode', () => {
assert.equal(resolvePromptFollowupMode(undefined), 'queue');
assert.equal(resolvePromptFollowupMode(null), 'queue');
assert.equal(resolvePromptFollowupMode('queue'), 'queue');
assert.equal(resolvePromptFollowupMode('direct'), 'direct');
});

View File

@@ -18,7 +18,6 @@ import {
ensureChatSessionResourceDirectories,
getActiveChatService,
getChatRuntimeController,
shouldAutoCompleteReplyParentVerification,
} from '../services/chat-service.js';
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
@@ -43,10 +42,12 @@ import {
listChatConversationDetailPage,
listChatConversations,
markChatConversationRequestManualCompletion,
ChatConversationManualCompletionBlockedError,
markChatConversationResponsesRead,
persistChatConversationPromptSelection,
upsertChatConversationRequest,
updateChatConversationContext,
hasPendingAttentionVerificationRequest,
} from '../services/chat-room-service.js';
import { chatRuntimeService } from '../services/chat-runtime-service.js';
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
@@ -123,6 +124,10 @@ async function findExistingActivePromptFollowupRequest(
) ?? null;
}
export function resolvePromptFollowupMode(mode?: 'queue' | 'direct' | null) {
return mode === 'direct' ? 'direct' : 'queue';
}
function encodeBase64Url(value: string) {
return Buffer.from(value, 'utf-8').toString('base64url');
}
@@ -223,19 +228,6 @@ function resolveChatShareTokenSettingSnapshot(tokenPayload: ChatShareTokenPayloa
};
}
export function shouldAutoCompleteShareReplyParentVerification(request: {
responseMessageId?: number | null;
responseText?: string | null;
manualVerificationCompletedAt?: string | null;
} | null | undefined) {
return shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: request?.responseMessageId ?? null,
responseText: request?.responseText ?? '',
manualVerificationCompletedAt: request?.manualVerificationCompletedAt ?? null,
});
}
function createManagedChatShareTokenId() {
return `chat_share_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
}
@@ -802,36 +794,40 @@ function hasUnresolvedPromptPart(message: ListedChatConversationMessage) {
function hasPendingPromptRequest(
request: ListedChatConversationRequest,
relatedMessages: ListedChatConversationMessage[],
promptSubmittedCount = 0,
) {
if (request.manualPromptCompletedAt) {
return false;
}
return relatedMessages.some(
(message) => (message.author === 'codex' || message.author === 'system') && hasUnresolvedPromptPart(message),
);
}
const unresolvedPromptCount = relatedMessages.reduce((count, message) => {
if (message.author !== 'codex' && message.author !== 'system') {
return count;
}
function hasVerificationTargetMessage(message: ListedChatConversationMessage) {
if (message.author !== 'codex' && message.author !== 'system') {
const promptParts = (message.parts ?? []).filter(
(
part: NonNullable<ListedChatConversationMessage['parts']>[number],
): part is Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }> => part.type === 'prompt',
);
return count + promptParts.filter(
(part: Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>) => !isPromptPartResolved(part),
).length;
}, 0);
if (unresolvedPromptCount === 0) {
return false;
}
const text = String(message.text ?? '').trim();
if (!text) {
return false;
}
if (text.length > 720) {
return true;
}
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(text);
return unresolvedPromptCount > Math.max(0, promptSubmittedCount);
}
function hasVerificationTargetRequest(relatedMessages: ListedChatConversationMessage[]) {
return relatedMessages.some((message) => hasVerificationTargetMessage(message));
function hasVerificationTargetRequest(
request: ListedChatConversationRequest,
relatedMessages: ListedChatConversationMessage[],
) {
return hasPendingAttentionVerificationRequest(request, relatedMessages);
}
function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) {
@@ -850,6 +846,30 @@ function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) {
return nextMap;
}
function buildPromptFollowupCountMap(requests: ListedChatConversationRequest[]) {
const nextMap = new Map<string, number>();
requests.forEach((request) => {
if (request.requestOrigin !== 'prompt') {
return;
}
const parentRequestId = request.parentRequestId?.trim() || '';
if (!parentRequestId) {
return;
}
nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1);
});
return nextMap;
}
function isPromptFollowupRoomRequest(request: ListedChatConversationRequest) {
return request.requestOrigin === 'prompt';
}
function isRequestInFlight(status: ListedChatConversationRequest['status']) {
return status === 'accepted' || status === 'queued' || status === 'started';
}
@@ -858,20 +878,29 @@ function isPendingCompletionRoomRequest(
request: ListedChatConversationRequest,
relatedMessages: ListedChatConversationMessage[],
childRequestCountByParentId?: Map<string, number>,
promptFollowupCountByParentId?: Map<string, number>,
) {
if (isPromptFollowupRoomRequest(request)) {
return false;
}
if (isRequestInFlight(request.status)) {
return true;
}
if (hasPendingPromptRequest(request, relatedMessages)) {
if (hasPendingPromptRequest(request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0)) {
return true;
}
if (request.manualPromptCompletedAt) {
return false;
}
if ((childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0) {
return false;
}
if (!hasVerificationTargetRequest(relatedMessages)) {
if (!hasVerificationTargetRequest(request, relatedMessages)) {
return false;
}
@@ -884,6 +913,7 @@ function buildRoomRequestCounts(
) {
const requestMessagesById = new Map<string, ListedChatConversationMessage[]>();
const childRequestCountByParentId = buildChildRequestCountMap(requests);
const promptFollowupCountByParentId = buildPromptFollowupCountMap(requests);
messages.forEach((message) => {
const requestId = message.clientRequestId?.trim() || '';
@@ -897,12 +927,15 @@ function buildRoomRequestCounts(
requestMessagesById.set(requestId, current);
});
const processingCount = requests.filter((request) => isRequestInFlight(request.status)).length;
const processingCount = requests.filter(
(request) => !isPromptFollowupRoomRequest(request) && isRequestInFlight(request.status),
).length;
const unansweredCount = requests.filter((request) =>
isPendingCompletionRoomRequest(
request,
requestMessagesById.get(request.requestId.trim()) ?? [],
childRequestCountByParentId,
promptFollowupCountByParentId,
),
).length;
@@ -937,8 +970,10 @@ function buildManagedSharePlaceholderRequest(tokenPayload: ChatShareTokenPayload
requestOrigin: 'composer',
sharedResourceTokenId: tokenPayload.managedResourceTokenId?.trim() || null,
parentRequestId: null,
promptContextRef: null,
status: 'completed',
statusMessage: '공유 채팅방 시작 요청을 복원했습니다.',
retryCount: 0,
userMessageId: null,
userText: '',
responseMessageId: null,
@@ -1554,6 +1589,9 @@ export async function registerChatRoutes(app: FastifyInstance) {
const payload = z.object({
accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(),
accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(),
chatTypeId: z.string().trim().min(1).max(120).optional().nullable(),
chatTypeLabel: z.string().trim().min(1).max(200).optional().nullable(),
notifyOffline: z.boolean().optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
@@ -1615,10 +1653,35 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
let updatedConversation = await getChatConversation(tokenPayload.sessionId, getRequestClientId(request));
if (payload.chatTypeId || payload.notifyOffline != null) {
updatedConversation = await updateChatConversationContext(tokenPayload.sessionId, {
clientId: getRequestClientId(request),
chatTypeId: payload.chatTypeId?.trim() || undefined,
lastChatTypeId: payload.chatTypeId?.trim() || undefined,
contextLabel: payload.chatTypeLabel?.trim() || undefined,
contextDescription: payload.chatTypeId ? null : undefined,
notifyOffline: payload.notifyOffline ?? undefined,
});
}
return {
ok: true,
hasAccessPin: saved.token.hasAccessPin,
accessPinPromptTtlMinutes: saved.token.accessPinPromptTtlMinutes,
conversation: updatedConversation
? {
sessionId: updatedConversation.sessionId,
title: updatedConversation.title,
requestBadgeLabel: updatedConversation.requestBadgeLabel ?? null,
chatTypeId: updatedConversation.chatTypeId ?? null,
lastChatTypeId: updatedConversation.lastChatTypeId ?? null,
contextLabel: updatedConversation.contextLabel ?? null,
contextDescription: updatedConversation.contextDescription ?? null,
notifyOffline: updatedConversation.notifyOffline,
}
: null,
};
});
@@ -1866,6 +1929,11 @@ export async function registerChatRoutes(app: FastifyInstance) {
sessionId: shareSnapshot.conversation?.sessionId ?? tokenPayload.sessionId,
title: shareSnapshot.conversation?.title ?? '공유 채팅',
requestBadgeLabel: shareSnapshot.conversation?.requestBadgeLabel ?? null,
chatTypeId: shareSnapshot.conversation?.chatTypeId ?? null,
lastChatTypeId: shareSnapshot.conversation?.lastChatTypeId ?? null,
contextLabel: shareSnapshot.conversation?.contextLabel ?? null,
contextDescription: shareSnapshot.conversation?.contextDescription ?? null,
notifyOffline: shareSnapshot.conversation?.notifyOffline === true,
},
rootRequestId: shareSnapshot.rootRequestId,
targetRequest: shareSnapshot.targetRequest,
@@ -1955,6 +2023,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}).parse(request.params ?? {});
const payload = z.object({
text: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
parentRequestId: z.string().trim().min(1).max(120).optional().nullable(),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
@@ -2012,11 +2081,13 @@ export async function registerChatRoutes(app: FastifyInstance) {
}
const requestedParentRequestId = payload.parentRequestId?.trim() || '';
const resolvedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
requestedParentRequestId,
[shareSnapshot.targetRequest.requestId],
);
const resolvedParentRequestId = requestedParentRequestId
? resolveRecoveredShareParentRequestId(
shareSnapshot,
requestedParentRequestId,
[shareSnapshot.targetRequest.requestId],
)
: null;
if (
resolvedParentRequestId
@@ -2028,7 +2099,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.text, {
mode: 'direct',
mode: payload.mode === 'direct' ? 'direct' : 'queue',
requestOrigin: 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: resolvedParentRequestId,
@@ -2041,22 +2112,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const parentRequest = resolvedParentRequestId
? shareSnapshot.requests.find((request) => request.requestId.trim() === resolvedParentRequestId) ?? null
: null;
if (resolvedParentRequestId && shouldAutoCompleteShareReplyParentVerification(parentRequest)) {
const updatedParentRequest = await markChatConversationRequestManualCompletion(
tokenPayload.sessionId,
resolvedParentRequestId,
'verification',
);
if (updatedParentRequest) {
getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, updatedParentRequest);
}
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
@@ -2179,6 +2234,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
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,
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
@@ -2261,7 +2317,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
);
const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.followupText, {
mode: 'direct',
mode: resolvePromptFollowupMode(payload.mode),
requestOrigin: 'prompt',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: normalizedParentRequestId,
@@ -2363,11 +2419,23 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const item = await markChatConversationRequestManualCompletion(
tokenPayload.sessionId,
normalizedParentRequestId,
payload.type,
);
let item = null;
try {
item = await markChatConversationRequestManualCompletion(
tokenPayload.sessionId,
normalizedParentRequestId,
payload.type,
);
} catch (error) {
if (error instanceof ChatConversationManualCompletionBlockedError) {
return reply.code(409).send({
message: error.message,
});
}
throw error;
}
if (!item) {
return reply.code(404).send({
@@ -2587,6 +2655,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, {
requestId: normalizedParentRequestId,
mode: 'direct',
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
@@ -2928,11 +2997,23 @@ export async function registerChatRoutes(app: FastifyInstance) {
type: z.enum(['prompt', 'verification']),
}).parse(request.body ?? {});
const item = await markChatConversationRequestManualCompletion(
params.sessionId,
params.requestId,
payload.type,
);
let item = null;
try {
item = await markChatConversationRequestManualCompletion(
params.sessionId,
params.requestId,
payload.type,
);
} catch (error) {
if (error instanceof ChatConversationManualCompletionBlockedError) {
return reply.code(409).send({
message: error.message,
});
}
throw error;
}
if (!item) {
return reply.code(404).send({
@@ -3044,7 +3125,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
);
const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(params.sessionId, payload.followupText, {
mode: payload.mode === 'direct' ? 'direct' : 'queue',
mode: resolvePromptFollowupMode(payload.mode),
requestOrigin: 'prompt',
parentRequestId: params.requestId,
promptContextRef: payload.contextRef ?? null,

View File

@@ -28,6 +28,19 @@ function buildRuntimeResponse() {
export async function registerRuntimeRoutes(app: FastifyInstance) {
app.get('/api/runtime', async () => buildRuntimeResponse());
app.post('/api/runtime/recover-interrupted-chat', async () => {
const recovered = await getActiveChatService()?.recoverInterruptedSessions();
return {
ok: true,
recovered: recovered ?? {
sessionCount: 0,
restartedCount: 0,
requeuedCount: 0,
},
};
});
app.post('/api/runtime/drain', async (request) => {
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});

View File

@@ -2,7 +2,15 @@ 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 {
deployTestServerCommand,
deployWorkServerCommand,
listServerCommands,
readWorkServerDeploymentState,
restartServerCommand,
serverCommandKeys,
} from '../services/server-command-service.js';
import { readTestServerDeploymentState } from '../services/test-server-deployment-service.js';
import {
cancelServerRestartReservation,
confirmServerRestartReservation,
@@ -43,16 +51,7 @@ function getImmediateRestartBlockInfo(
}
if (key === 'work-server') {
const pendingCount = codexPendingCount + automationPendingCount;
if (pendingCount === 0) {
return null;
}
return {
pendingCount,
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
};
return null;
}
return null;
@@ -92,7 +91,7 @@ async function resolveSharedServerCommandAccessContext(request: FastifyRequest)
return {
scope: 'shared' as const,
allowedKeys: new Set<string>(['work-server']),
allowedKeys: new Set<string>(['work-server', 'test']),
};
}
@@ -182,6 +181,12 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
};
} catch (error) {
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
if (statusCode === 409) {
reply.status(409);
return { ok: false, message };
}
if (key !== 'test' && key !== 'work-server') {
throw error;
@@ -207,6 +212,99 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
}
});
app.get('/api/server-commands/work-server/deployment', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버 배포 상태를 확인할 수 없습니다.' };
}
return {
ok: true,
item: (await readWorkServerDeploymentState()) ?? null,
};
});
app.post('/api/server-commands/work-server/actions/deploy', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버를 배포할 수 없습니다.' };
}
const result = await deployWorkServerCommand();
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
deployment: result.deployment ?? result.server.deployment ?? null,
};
});
app.get('/api/server-commands/test/deployment', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버 배포 상태를 확인할 수 없습니다.' };
}
return {
ok: true,
item: (await readTestServerDeploymentState()) ?? null,
};
});
app.post('/api/server-commands/test/actions/deploy', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버를 배포할 수 없습니다.' };
}
try {
const result = await deployTestServerCommand();
return {
ok: true,
item: result.server,
commandOutput: result.commandOutput,
restartState: result.restartState,
testDeployment: result.testDeployment ?? null,
};
} catch (error) {
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
if (statusCode === 409) {
reply.status(409);
return { ok: false, message: error instanceof Error ? error.message : 'TEST 서버 배포가 이미 진행 중입니다.' };
}
throw error;
}
});
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {

View File

@@ -14,6 +14,7 @@ import {
sharedResourceTokenSchema,
upsertSharedResourceToken,
} from '../services/shared-resource-token-service.js';
import { extractRequestAuditContext } from '../utils/request-audit.js';
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
const tokenHeader = request.headers['x-access-token'];
@@ -142,7 +143,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
});
}
const saved = await upsertSharedResourceToken(payload);
const saved = await upsertSharedResourceToken(payload, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
return {
ok: true,
...saved,
@@ -174,7 +178,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
});
}
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason);
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
return {
ok: true,
@@ -201,7 +208,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
reason: z.string().trim().max(500).optional().nullable(),
})
.parse(request.body ?? {});
const saved = await revokeSharedResourceToken(tokenId, payload.reason);
const saved = await revokeSharedResourceToken(tokenId, payload.reason, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
if (!saved) {
return reply.code(404).send({
@@ -229,7 +239,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
});
}
const saved = await restoreSharedResourceToken(tokenId);
const saved = await restoreSharedResourceToken(tokenId, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
if (!saved) {
return reply.code(404).send({
@@ -265,7 +278,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
})
.parse(request.body ?? {});
const saved = await recordSharedResourceTokenUsage(tokenId, payload);
const saved = await recordSharedResourceTokenUsage(tokenId, {
...payload,
audit: extractRequestAuditContext(request),
});
if (!saved) {
return reply.code(404).send({
@@ -298,7 +314,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
});
}
const result = await deleteSharedResourceTokens(payload.tokenIds);
const result = await deleteSharedResourceTokens(payload.tokenIds, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
return {
ok: true,
@@ -320,7 +339,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
});
}
const deleted = await deleteSharedResourceToken(tokenId);
const deleted = await deleteSharedResourceToken(tokenId, {
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
audit: extractRequestAuditContext(request),
});
if (!deleted) {
return reply.code(404).send({