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

@@ -19,6 +19,12 @@ docker compose logs -f work-server
`work-server``3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
운영 기본 규칙:
- `work-server` 재기동은 기존 활성 슬롯을 바로 내리는 단일 컨테이너 재시작으로 처리하지 않습니다.
- 항상 `비활성 슬롯 기동 -> /health 확인 -> nginx upstream 전환 -> 이전 슬롯 정리` 순서를 유지합니다.
- 문서, 스크립트, 운영 가이드에 재기동 예시를 추가할 때도 무중단 전환 절차를 기본값으로 적고, 연결이 끊기는 재시작은 장애 대응이나 예외 상황으로만 취급합니다.
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
```bash
@@ -123,8 +129,9 @@ npm run server-command:runner
## 웹푸쉬 호출 메모
- `POST /api/notifications/send``title`, `body`, `data`, `threadId` 외에 `targetClientIds`도 받을 수 있습니다.
- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다.
- `POST /api/notifications/send``title`, `body`, `data`, `threadId` 외에 `targetDeviceIds`도 받을 수 있습니다.
- `targetDeviceIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 기기에만 알림을 보냅니다.
- 기존 `targetClientIds`도 호환 입력으로는 허용되지만, 새 호출은 `targetDeviceIds` 사용을 기준으로 합니다.
- 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다.
- `POST /api/notifications/send``targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다.
- `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다.

File diff suppressed because one or more lines are too long

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({

View File

@@ -1,3 +1,4 @@
import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
@@ -8,6 +9,7 @@ const MAX_CATEGORY_COUNT = 16;
const MAX_RESULT_COUNT = 12;
const TICKET_BAY_FETCH_TIMEOUT_MS = 12_000;
const TICKET_BAY_PRODUCT_PAGE_SIZE = 100;
const BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY = 741_205_261;
const teamKeywordMap: Record<string, string> = {
LG: 'LG',
@@ -690,6 +692,9 @@ export type BaseballTicketBayTimeWindow = {
export type BaseballTicketBayAlertItem = {
id: string;
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
title: string;
eventDate: string;
team: string;
@@ -714,6 +719,9 @@ export type BaseballTicketBayAlertLogStatus = 'info' | 'success' | 'warning' | '
export type BaseballTicketBayAlertLogItem = {
id: string;
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
alertId: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -759,9 +767,21 @@ export type BaseballTicketBayAlertMutation = {
timeWindows: BaseballTicketBayTimeWindow[];
};
type BaseballTicketBayOwnerType = 'client' | 'shared-token';
type BaseballTicketBayOwnerScope =
| { kind: 'all' }
| {
kind: 'owner';
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
};
type BaseballTicketBayAlertRow = {
id: string;
client_id: string;
owner_type: BaseballTicketBayOwnerType;
owner_id: string;
app_origin: string | null;
app_domain: string | null;
title: string;
@@ -786,6 +806,8 @@ type BaseballTicketBayAlertRow = {
type BaseballTicketBayLogRow = {
id: string;
client_id: string;
owner_type: BaseballTicketBayOwnerType;
owner_id: string;
alert_id: string | null;
alert_title: string;
action: BaseballTicketBayAlertLogAction;
@@ -810,6 +832,25 @@ function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function normalizeOwnerType(value: unknown): BaseballTicketBayOwnerType {
return normalizeText(value) === 'shared-token' ? 'shared-token' : 'client';
}
function normalizeOwnerId(row: { owner_id?: unknown; client_id?: unknown }) {
return normalizeText(row.owner_id) || normalizeText(row.client_id);
}
function applyOwnerScope(query: Knex.QueryBuilder, scope: BaseballTicketBayOwnerScope) {
if (scope.kind === 'all') {
return query;
}
return query.where({
owner_type: scope.ownerType,
owner_id: scope.ownerId,
});
}
function normalizeNumericValue(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
@@ -858,6 +899,9 @@ function parseTimeWindows(value: string) {
function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem {
return {
id: normalizeText(row.id),
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
title: normalizeText(row.title),
eventDate: normalizeText(row.event_date),
team: normalizeText(row.team) || '전체',
@@ -891,6 +935,9 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
return {
id: normalizeText(row.id),
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
alertTitle: normalizeText(row.alert_title),
action: row.action,
@@ -978,6 +1025,8 @@ export async function ensureBaseballTicketBayTables() {
await db.schema.createTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
table.string('id', 120).primary();
table.string('client_id', 200).notNullable().index();
table.string('owner_type', 40).notNullable().defaultTo('client').index();
table.string('owner_id', 200).notNullable().defaultTo('').index();
table.text('app_origin').nullable();
table.string('app_domain', 255).nullable();
table.string('title', 255).notNullable();
@@ -1006,6 +1055,8 @@ export async function ensureBaseballTicketBayTables() {
await db.schema.createTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.string('id', 120).primary();
table.string('client_id', 200).notNullable().index();
table.string('owner_type', 40).notNullable().defaultTo('client').index();
table.string('owner_id', 200).notNullable().defaultTo('').index();
table.string('alert_id', 120).nullable().index();
table.string('alert_title', 255).notNullable();
table.string('action', 40).notNullable();
@@ -1025,6 +1076,60 @@ export async function ensureBaseballTicketBayTables() {
});
}
const hasAlertOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_type');
if (!hasAlertOwnerTypeColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
table.string('owner_type', 40).notNullable().defaultTo('client').index();
});
}
const hasAlertOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_id');
if (!hasAlertOwnerIdColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
table.string('owner_id', 200).notNullable().defaultTo('').index();
});
}
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where((builder) => {
builder.whereNull('owner_type').orWhere('owner_type', '');
})
.update({ owner_type: 'client' });
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where((builder) => {
builder.whereNull('owner_id').orWhere('owner_id', '');
})
.update({ owner_id: db.ref('client_id') });
const hasLogOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_type');
if (!hasLogOwnerTypeColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.string('owner_type', 40).notNullable().defaultTo('client').index();
});
}
const hasLogOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_id');
if (!hasLogOwnerIdColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.string('owner_id', 200).notNullable().defaultTo('').index();
});
}
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.where((builder) => {
builder.whereNull('owner_type').orWhere('owner_type', '');
})
.update({ owner_type: 'client' });
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.where((builder) => {
builder.whereNull('owner_id').orWhere('owner_id', '');
})
.update({ owner_id: db.ref('client_id') });
const hasSeenTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE);
if (!hasSeenTable) {
@@ -1046,33 +1151,39 @@ export async function ensureBaseballTicketBayTables() {
return baseballTicketBayTableSetupPromise;
}
export async function listBaseballTicketBayAlerts(clientId: string) {
export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ client_id: clientId })
.orderBy([{ column: 'event_date', order: 'asc' }, { column: 'created_at', order: 'desc' }]);
return rows.map((row) => mapAlertRow(row as BaseballTicketBayAlertRow));
const query = applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*'), scope);
const rows = (await query.orderBy([
{ column: 'event_date', order: 'asc' },
{ column: 'owner_type', order: 'asc' },
{ column: 'owner_id', order: 'asc' },
{ column: 'client_id', order: 'asc' },
{ column: 'created_at', order: 'desc' },
])) as BaseballTicketBayAlertRow[];
return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
}
export async function listBaseballTicketBayLogs(clientId: string, alertId?: string) {
export async function listBaseballTicketBayLogs(
scope: BaseballTicketBayOwnerScope,
alertId?: string,
) {
await ensureBaseballTicketBayTables();
let query = db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({ client_id: clientId })
.orderBy('created_at', 'desc')
.limit(200);
let query = applyOwnerScope(db(BASEBALL_TICKET_BAY_LOG_TABLE).select('*'), scope).orderBy('created_at', 'desc').limit(200);
if (alertId) {
query = query.andWhere({ alert_id: alertId });
}
const rows = await query;
return rows.map((row) => mapLogRow(row as BaseballTicketBayLogRow));
const rows = (await query) as BaseballTicketBayLogRow[];
return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
}
export async function createBaseballTicketBayLog(args: {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
alertId?: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -1085,6 +1196,8 @@ export async function createBaseballTicketBayLog(args: {
const row: BaseballTicketBayLogRow = {
id: createId('log'),
client_id: args.clientId,
owner_type: args.ownerType,
owner_id: args.ownerId,
alert_id: args.alertId ?? null,
alert_title: args.alertTitle,
action: args.action,
@@ -1098,40 +1211,43 @@ export async function createBaseballTicketBayLog(args: {
return mapLogRow(row);
}
export async function deleteBaseballTicketBayLog(logId: string, clientId: string) {
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const existing = await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({
id: logId,
client_id: clientId,
})
.first();
const existing = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({
id: logId,
}),
scope,
).first();
if (!existing) {
return null;
}
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.where({
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_LOG_TABLE).where({
id: logId,
client_id: clientId,
})
.delete();
}),
scope,
).delete();
return mapLogRow(existing as BaseballTicketBayLogRow);
}
export async function createBaseballTicketBayAlert(
payload: BaseballTicketBayAlertMutation,
context: { clientId: string; appOrigin?: string; appDomain?: string },
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const now = new Date().toISOString();
const row: BaseballTicketBayAlertRow = {
id: createId('alert'),
client_id: context.clientId,
owner_type: context.ownerType,
owner_id: context.ownerId,
app_origin: normalizeText(context.appOrigin) || null,
app_domain: normalizeText(context.appDomain) || null,
title: payload.title.trim(),
@@ -1159,13 +1275,15 @@ export async function createBaseballTicketBayAlert(
export async function updateBaseballTicketBayAlert(
alertId: string,
payload: Partial<BaseballTicketBayAlertMutation>,
context: { clientId: string; appOrigin?: string; appDomain?: string },
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const current = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId, client_id: context.clientId })
.first();
const current = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).first();
if (!current) {
throw new Error('수정할 알림을 찾지 못했습니다.');
@@ -1191,37 +1309,53 @@ export async function updateBaseballTicketBayAlert(
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where({ id: alertId, client_id: context.clientId })
.update(patch);
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).update(patch);
const updated = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId, client_id: context.clientId })
.first();
const updated = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).first();
return mapAlertRow(updated as BaseballTicketBayAlertRow);
}
export async function deleteBaseballTicketBayAlert(alertId: string, clientId: string) {
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId, client_id: clientId })
.first();
const row = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
).first();
if (!row) {
return null;
}
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where({ id: alertId, client_id: clientId })
.delete();
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
scope,
).delete();
return mapAlertRow(row as BaseballTicketBayAlertRow);
}
async function getAlertRow(alertId: string) {
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ id: alertId }).first();
const scopedQuery = scope
? applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
)
: db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId });
const row = await scopedQuery.first();
return row ? (row as BaseballTicketBayAlertRow) : null;
}
@@ -1268,8 +1402,11 @@ async function updateAlertRunTimestamp(alertId: string, patch: { lastRunAt: stri
});
}
export async function runBaseballTicketBayAlert(alertId: string, options?: { ignoreTimeWindow?: boolean }) {
const row = await getAlertRow(alertId);
export async function runBaseballTicketBayAlert(
alertId: string,
options?: { ignoreTimeWindow?: boolean; scope?: BaseballTicketBayOwnerScope },
) {
const row = await getAlertRow(alertId, options?.scope);
if (!row) {
throw new Error('실행할 알림을 찾지 못했습니다.');
@@ -1281,6 +1418,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
if (!options?.ignoreTimeWindow && isWithinBlockedTime(alert.timeWindows, now)) {
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
@@ -1328,6 +1467,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
: [];
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
@@ -1372,6 +1513,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
].join('\n');
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
@@ -1414,36 +1557,61 @@ function isAlertDue(alert: BaseballTicketBayAlertItem, now: Date) {
return now.getTime() - lastRunAt >= alert.batchIntervalMinutes * 60 * 1000;
}
function readBooleanLikeValue(value: unknown) {
return value === true || value === 't' || value === 'true' || value === 1 || value === '1';
}
async function tryAcquireBaseballTicketBayBatchLock() {
const result = (await db.raw('select pg_try_advisory_lock(?) as locked', [
BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY,
])) as { rows?: Array<{ locked?: unknown }> };
return readBooleanLikeValue(result.rows?.[0]?.locked);
}
async function releaseBaseballTicketBayBatchLock() {
await db.raw('select pg_advisory_unlock(?)', [BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY]);
}
export async function processDueBaseballTicketBayAlerts(now = new Date()) {
await ensureBaseballTicketBayTables();
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true });
const results: Array<{ alertId: string; ok: boolean; message?: string }> = [];
for (const row of rows as BaseballTicketBayAlertRow[]) {
const alert = mapAlertRow(row);
if (!isAlertDue(alert, now)) {
continue;
}
try {
await runBaseballTicketBayAlert(alert.id);
results.push({ alertId: alert.id, ok: true });
} catch (error) {
const handledError = error instanceof Error ? error : new Error(String(error));
await createBaseballTicketBayLog({
clientId: row.client_id,
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
status: 'error',
message: handledError.message || '배치 실행에 실패했습니다.',
detail: '',
});
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
results.push({ alertId: alert.id, ok: false, message: handledError.message });
}
if (!(await tryAcquireBaseballTicketBayBatchLock())) {
return [];
}
return results;
try {
await ensureBaseballTicketBayTables();
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true });
const results: Array<{ alertId: string; ok: boolean; message?: string }> = [];
for (const row of rows as BaseballTicketBayAlertRow[]) {
const alert = mapAlertRow(row);
if (!isAlertDue(alert, now)) {
continue;
}
try {
await runBaseballTicketBayAlert(alert.id);
results.push({ alertId: alert.id, ok: true });
} catch (error) {
const handledError = error instanceof Error ? error : new Error(String(error));
await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
status: 'error',
message: handledError.message || '배치 실행에 실패했습니다.',
detail: '',
});
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
results.push({ alertId: alert.id, ok: false, message: handledError.message });
}
}
return results;
} finally {
await releaseBaseballTicketBayBatchLock();
}
}

View File

@@ -19,6 +19,8 @@ import {
selectStaleOfflineNotificationClientIds,
resolveNextConversationContextValue,
resolveNextConversationChatTypeId,
hasPendingAttentionVerificationRequest,
isConversationAttentionPending,
shouldClearConversationJobState,
selectChatConversationResponseCandidate,
} from './chat-room-service.js';
@@ -97,6 +99,69 @@ test('buildChatConversationContextUpdateFields ignores undefined payload keys so
);
});
test('buildChatConversationContextUpdateFields updates shared room chat type metadata together', () => {
assert.deepEqual(
buildChatConversationContextUpdateFields({
current: {
title: '공유채팅',
chat_type_id: 'general-request',
last_chat_type_id: 'general-request',
context_label: '일반 요청',
context_description: 'old',
notify_offline: true,
},
payload: {
chatTypeId: 'codex-live-default',
lastChatTypeId: 'codex-live-default',
contextLabel: 'Codex Live 기본',
contextDescription: null,
},
}),
{
chat_type_id: 'codex-live-default',
last_chat_type_id: 'codex-live-default',
context_label: 'Codex Live 기본',
context_description: null,
},
);
});
test('buildChatConversationContextUpdateFields writes global notify flag only when no client is bound', () => {
assert.deepEqual(
buildChatConversationContextUpdateFields({
current: {
title: '공유채팅',
chat_type_id: 'general-request',
last_chat_type_id: 'general-request',
notify_offline: false,
},
payload: {
notifyOffline: true,
},
}),
{
notify_offline: true,
},
);
assert.deepEqual(
buildChatConversationContextUpdateFields({
current: {
title: '공유채팅',
chat_type_id: 'general-request',
last_chat_type_id: 'general-request',
client_id: 'client-1',
notify_offline: false,
},
payload: {
clientId: 'client-1',
notifyOffline: true,
},
}),
{},
);
});
test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
assert.deepEqual(
selectStaleOfflineNotificationClientIds(
@@ -147,6 +212,149 @@ test('collectRegisteredNotificationClientIds keeps both web push client ids and
);
});
test('hasPendingAttentionVerificationRequest keeps 일반 답변 in pending attention until manual completion', () => {
assert.equal(
hasPendingAttentionVerificationRequest(
{
status: 'completed',
responseMessageId: 101,
responseText: '일반 답변입니다.',
requestOrigin: 'composer',
},
[],
),
true,
);
assert.equal(
hasPendingAttentionVerificationRequest(
{
status: 'completed',
responseMessageId: null,
responseText: '',
requestOrigin: 'composer',
},
[
{
author: 'codex',
text: '```diff\n+ hello\n```',
},
],
),
true,
);
assert.equal(
hasPendingAttentionVerificationRequest(
{
status: 'completed',
responseMessageId: null,
responseText: '',
requestOrigin: 'composer',
},
[
{
author: 'codex',
text: '짧은 진행 로그',
},
],
),
false,
);
});
test('isConversationAttentionPending clears verification attention when 답변하기 child request exists', () => {
assert.equal(
isConversationAttentionPending({
request: {
sessionId: 'session-1',
requestId: 'parent-request',
requesterClientId: null,
chatTypeId: null,
chatTypeLabel: '기본처리',
requestOrigin: 'composer',
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'completed',
statusMessage: null,
retryCount: 0,
userMessageId: 1,
userText: '원본 질문',
responseMessageId: 2,
responseText: '원본 답변',
usageSnapshot: null,
totalTokens: null,
hasResponse: true,
canDelete: false,
manualPromptCompletedAt: null,
manualVerificationCompletedAt: null,
createdAt: '2026-05-26T00:00:00.000Z',
updatedAt: '2026-05-26T00:01:00.000Z',
answeredAt: '2026-05-26T00:01:00.000Z',
terminalAt: '2026-05-26T00:01:00.000Z',
},
relatedMessages: [
{
id: 2,
author: 'codex',
text: '원본 답변',
timestamp: '2026-05-26T00:01:00.000Z',
},
],
childRequestCountByParentId: new Map([['parent-request', 1]]),
promptFollowupCountByParentId: new Map(),
}),
false,
);
});
test('isConversationAttentionPending keeps completed 일반 답변 visible when no child request exists', () => {
assert.equal(
isConversationAttentionPending({
request: {
sessionId: 'session-1',
requestId: 'standalone-request',
requesterClientId: null,
chatTypeId: null,
chatTypeLabel: '기본처리',
requestOrigin: 'composer',
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'completed',
statusMessage: null,
retryCount: 0,
userMessageId: 1,
userText: '독립 질문',
responseMessageId: 2,
responseText: '독립 답변',
usageSnapshot: null,
totalTokens: null,
hasResponse: true,
canDelete: false,
manualPromptCompletedAt: null,
manualVerificationCompletedAt: null,
createdAt: '2026-05-26T00:00:00.000Z',
updatedAt: '2026-05-26T00:01:00.000Z',
answeredAt: '2026-05-26T00:01:00.000Z',
terminalAt: '2026-05-26T00:01:00.000Z',
},
relatedMessages: [
{
id: 2,
author: 'codex',
text: '독립 답변',
timestamp: '2026-05-26T00:01:00.000Z',
},
],
childRequestCountByParentId: new Map(),
promptFollowupCountByParentId: new Map(),
}),
true,
);
});
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
assert.equal(
buildChatConversationRequestPatchFromMessage({
@@ -227,6 +435,38 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
});
test('applyChatPromptSelectionPatch keeps followup text for free-text-only prompt submissions', () => {
const promptPart = {
type: 'prompt' as const,
title: '다음 단계 선택',
description: '원하는 작업을 고르세요.',
submitLabel: '선택 전달',
mode: 'queue' as const,
selectedValues: [],
options: [],
steps: [],
};
const patched = applyChatPromptSelectionPatch(
[promptPart],
{
promptIndex: 0,
promptTitle: promptPart.title,
promptSignature: buildChatPromptTargetSignature(promptPart),
selectedValues: [],
freeText: '',
followupText: '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.',
summaryText: '',
},
'2026-05-18T08:25:00.000Z',
);
assert.ok(patched);
assert.equal(patched?.[0]?.type, 'prompt');
assert.equal(patched?.[0]?.resolvedBy, 'user');
assert.equal(patched?.[0]?.resultText, '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.');
});
test('collectPromptSelectionCandidateRequestIds includes descendant requests and prefers recent responses first', () => {
assert.deepEqual(
collectPromptSelectionCandidateRequestIds(
@@ -552,6 +792,28 @@ test('shouldClearConversationJobState keeps placeholder-only started responses w
);
});
test('shouldClearConversationJobState clears in-progress state immediately after process restart when runtime is gone', () => {
assert.equal(
shouldClearConversationJobState({
currentRequestId: 'chat-req-9',
currentJobStatus: 'started',
currentStatusUpdatedAt: '2026-05-27T00:57:53.000Z',
runtimeActive: false,
nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
request: {
requestId: 'chat-req-9',
status: 'started',
responseMessageId: null,
responseText: '',
terminalAt: null,
updatedAt: '2026-05-27T00:58:10.000Z',
},
}),
true,
);
});
test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => {
assert.deepEqual(
normalizeStaleRequestItem(
@@ -562,8 +824,10 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
requestOrigin: null,
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'queued',
statusMessage: '대기열 1건',
retryCount: 0,
userMessageId: 11,
userText: '다음 요청',
responseMessageId: null,
@@ -591,6 +855,7 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
parentRequestId: null,
status: 'queued',
statusMessage: '대기열 1건',
retryCount: 0,
userMessageId: 11,
userText: '다음 요청',
responseMessageId: null,
@@ -606,3 +871,68 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
},
);
});
test('normalizeStaleRequestItem marks detached queued requests as failed after process restart when runtime is gone', () => {
assert.deepEqual(
normalizeStaleRequestItem(
{
sessionId: 'session-1',
requestId: 'chat-req-detached',
requesterClientId: null,
requestOrigin: null,
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'queued',
statusMessage: '대기열 1건',
retryCount: 0,
userMessageId: 12,
userText: '끊긴 요청',
responseMessageId: null,
responseText: '',
usageSnapshot: null,
totalTokens: null,
hasResponse: false,
canDelete: false,
createdAt: '2026-05-27T00:57:53.000Z',
updatedAt: '2026-05-27T00:58:10.000Z',
answeredAt: null,
terminalAt: null,
},
{
current_request_id: 'chat-req-running',
current_job_status: 'started',
current_status_updated_at: '2026-05-27T01:03:05.000Z',
},
{
runtimeActive: false,
nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
},
),
{
sessionId: 'session-1',
requestId: 'chat-req-detached',
requesterClientId: null,
requestOrigin: null,
sharedResourceTokenId: null,
parentRequestId: null,
promptContextRef: null,
status: 'failed',
statusMessage: '대기열 1건',
retryCount: 0,
userMessageId: 12,
userText: '끊긴 요청',
responseMessageId: null,
responseText: '',
usageSnapshot: null,
totalTokens: null,
hasResponse: false,
canDelete: true,
createdAt: '2026-05-27T00:57:53.000Z',
updatedAt: '2026-05-27T00:58:10.000Z',
answeredAt: null,
terminalAt: '2026-05-27T00:58:10.000Z',
},
);
});

View File

@@ -19,8 +19,10 @@ export const CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES = [
'chat_type_label',
'request_origin',
'parent_request_id',
'prompt_context_ref',
'status',
'status_message',
'retry_count',
'user_message_id',
'user_text',
'response_message_id',
@@ -38,6 +40,7 @@ export const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000;
const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
const PROCESS_RESTART_STALE_GRACE_MS = 10_000;
const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const;
const conversationPayloadSchema = z.object({
@@ -125,6 +128,16 @@ export type ChatConversationRequestUsageSnapshot = {
totalTokens: number;
};
export class ChatConversationManualCompletionBlockedError extends Error {
readonly code: 'child-followup-exists';
constructor(message: string) {
super(message);
this.name = 'ChatConversationManualCompletionBlockedError';
this.code = 'child-followup-exists';
}
}
export type ChatConversationRequestItem = {
sessionId: string;
requestId: string;
@@ -134,8 +147,15 @@ export type ChatConversationRequestItem = {
requestOrigin: 'composer' | 'prompt' | null;
sharedResourceTokenId: string | null;
parentRequestId: string | null;
promptContextRef: {
key: 'prompt_parent_question';
promptTitle: string;
promptDescription?: string | null;
parentQuestionText?: string | null;
} | null;
status: ChatConversationRequestStatus;
statusMessage: string | null;
retryCount: number;
userMessageId: number | null;
userText: string;
responseMessageId: number | null;
@@ -174,6 +194,7 @@ type ChatPromptSelectionPatch = {
sourceMessageId?: number;
selectedValues: string[];
freeText?: string | null;
followupText?: string | null;
stepSelections?: ChatPromptStepSelectionPatch[];
summaryText?: string | null;
attachments?: Array<{
@@ -624,6 +645,9 @@ export function applyChatPromptSelectionPatch(
.map((step) => String(step.stepKey ?? '').trim())
.filter(Boolean)
.at(-1);
const normalizedSummaryText = String(selection.summaryText ?? '').trim();
const normalizedFreeText = String(selection.freeText ?? '').trim();
const normalizedFollowupText = String(selection.followupText ?? '').trim();
nextParts[matchedPrompt.index] = {
...currentPart,
@@ -633,7 +657,7 @@ export function applyChatPromptSelectionPatch(
readOnly: true,
resolvedBy: 'user',
resolvedAt,
resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null,
resultText: normalizedSummaryText || normalizedFreeText || normalizedFollowupText || null,
attachments: Array.isArray(selection.attachments) ? selection.attachments : [],
};
@@ -667,6 +691,58 @@ function normalizePromptSelectionSourceMessageId(selection: ChatPromptSelectionP
: null;
}
function normalizePromptContextText(value: string | null | undefined, maxLength = 1000) {
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return normalized.slice(0, maxLength).trimEnd() + '...';
}
function normalizeStoredPromptContextRef(
value: unknown,
): ChatConversationRequestItem['promptContextRef'] {
if (!value) {
return null;
}
const parsedValue =
typeof value === 'string'
? (() => {
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
})()
: value;
if (!parsedValue || typeof parsedValue !== 'object' || (parsedValue as { key?: unknown }).key !== 'prompt_parent_question') {
return null;
}
const promptTitle = normalizePromptContextText((parsedValue as { promptTitle?: string }).promptTitle, 500);
if (!promptTitle) {
return null;
}
return {
key: 'prompt_parent_question',
promptTitle,
promptDescription:
normalizePromptContextText((parsedValue as { promptDescription?: string | null }).promptDescription, 1000) || null,
parentQuestionText:
normalizePromptContextText((parsedValue as { parentQuestionText?: string | null }).parentQuestionText, 1000) || null,
};
}
export function collectPromptSelectionCandidateRequestIds(
requestRows: Array<{
request_id?: unknown;
@@ -1223,38 +1299,77 @@ function hasPendingAttentionVerificationTarget(text: string | null | undefined)
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized);
}
function isConversationAttentionPending(options: {
export function hasPendingAttentionVerificationRequest(
request: Pick<ChatConversationRequestItem, 'status' | 'responseMessageId' | 'responseText' | 'requestOrigin'>,
relatedMessages: Pick<StoredChatMessage, 'author' | 'text'>[],
) {
if (request.responseMessageId != null) {
return true;
}
if (String(request.responseText ?? '').trim().length > 0) {
return true;
}
if (request.status === 'completed' && request.requestOrigin === 'composer') {
return relatedMessages.some(
(message) =>
(message.author === 'codex' || message.author === 'system')
&& hasPendingAttentionVerificationTarget(message.text),
);
}
return false;
}
function hasChildFollowupRequest(
request: Pick<ChatConversationRequestItem, 'requestId'>,
childRequestCountByParentId: Map<string, number>,
) {
return (childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0;
}
export function isConversationAttentionPending(options: {
request: ChatConversationRequestItem;
relatedMessages: StoredChatMessage[];
childRequestCountByParentId: Map<string, number>;
promptFollowupCountByParentId: Map<string, number>;
}) {
const { request, relatedMessages, childRequestCountByParentId } = options;
const { request, relatedMessages, childRequestCountByParentId, promptFollowupCountByParentId } = options;
if (request.requestOrigin === 'prompt') {
return false;
}
if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
return true;
}
if (!request.manualPromptCompletedAt) {
const hasOpenPrompt = relatedMessages.some(
(message) =>
(message.author === 'codex' || message.author === 'system')
&& hasPendingAttentionPromptMessageParts(message.parts),
);
const unresolvedPromptCount = relatedMessages.reduce((count, message) => {
if (message.author !== 'codex' && message.author !== 'system') {
return count;
}
return count + (message.parts ?? []).filter((part) => isPendingAttentionPromptPart(part)).length;
}, 0);
const promptSubmittedCount = promptFollowupCountByParentId.get(request.requestId.trim()) ?? 0;
const hasOpenPrompt = unresolvedPromptCount > Math.max(0, promptSubmittedCount);
if (hasOpenPrompt) {
return true;
}
}
if ((childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0) {
if (request.manualPromptCompletedAt) {
return false;
}
const hasVerificationTarget = relatedMessages.some(
(message) =>
(message.author === 'codex' || message.author === 'system')
&& hasPendingAttentionVerificationTarget(message.text),
);
if (hasChildFollowupRequest(request, childRequestCountByParentId)) {
return false;
}
const hasVerificationTarget = hasPendingAttentionVerificationRequest(request, relatedMessages);
if (!hasVerificationTarget) {
return false;
@@ -1321,6 +1436,19 @@ async function getConversationPendingAttentionMap(sessionIds: string[]) {
return map;
}, new Map());
const promptFollowupCountByParentId = requests.reduce<Map<string, number>>((map, request) => {
if (request.requestOrigin !== 'prompt') {
return map;
}
const parentRequestId = request.parentRequestId?.trim() || '';
if (parentRequestId) {
map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1);
}
return map;
}, new Map());
const requestMessagesById = messages.reduce<Map<string, StoredChatMessage[]>>((map, message) => {
const requestId = message.clientRequestId?.trim() || '';
@@ -1341,6 +1469,7 @@ async function getConversationPendingAttentionMap(sessionIds: string[]) {
request,
relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [],
childRequestCountByParentId,
promptFollowupCountByParentId,
}),
),
);
@@ -1596,8 +1725,15 @@ function mapRequestRow(row: Record<string, unknown>): ChatConversationRequestIte
requestOrigin: requestOrigin === 'prompt' || requestOrigin === 'composer' ? requestOrigin : null,
sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id),
parentRequestId: parentRequestId || null,
promptContextRef: normalizeStoredPromptContextRef(row.prompt_context_ref),
status,
statusMessage: row.status_message == null ? null : String(row.status_message),
retryCount:
row.retry_count == null || row.retry_count === ''
? 0
: Number.isFinite(Number(row.retry_count))
? Math.max(0, Math.round(Number(row.retry_count)))
: 0,
userMessageId: row.user_message_id == null ? null : Number(row.user_message_id),
userText: String(row.user_text ?? ''),
responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id),
@@ -1781,6 +1917,78 @@ function isConversationRequestActive(
return currentJobStatus === 'queued' || currentJobStatus === 'started';
}
function resolveCurrentProcessStartedAtMs(nowMs = Date.now()) {
const uptimeMs = Math.max(0, Math.floor(process.uptime() * 1000));
return Math.max(0, nowMs - uptimeMs);
}
function shouldFailStaleInProgressRequest(params: {
currentRequestId?: string | null;
currentJobStatus?: ChatConversationItem['currentJobStatus'];
currentStatusUpdatedAt?: string | null;
runtimeActive?: boolean;
nowMs?: number;
processStartedAtMs?: number;
request:
| {
requestId?: string | null;
status?: ChatConversationRequestStatus | null;
responseMessageId?: number | null;
responseText?: string | null;
terminalAt?: string | null;
updatedAt?: string | null;
}
| null
| undefined;
}) {
const requestStatus = params.request?.status ?? null;
const hasStoredResponse = hasStoredRequestResponse(params.request ?? {});
const runtimeActive = params.runtimeActive === true;
const currentRequestId = params.currentRequestId?.trim() || null;
const requestId = params.request?.requestId?.trim() || null;
const currentJobStatus = params.currentJobStatus ?? null;
const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now();
const processStartedAtMs = Number.isFinite(params.processStartedAtMs)
? Number(params.processStartedAtMs)
: resolveCurrentProcessStartedAtMs(nowMs);
const isInProgressRequest =
(requestStatus === 'accepted' || requestStatus === 'queued' || requestStatus === 'started')
&& !hasStoredResponse
&& !isTerminalRequestStatus(requestStatus);
const isCurrentTrackedRequest =
Boolean(currentRequestId)
&& Boolean(requestId)
&& currentRequestId === requestId
&& (currentJobStatus === 'queued' || currentJobStatus === 'started');
const isDetachedInProgressRequest =
Boolean(requestId)
&& !isCurrentTrackedRequest
&& (requestStatus === 'queued' || requestStatus === 'started');
const lastUpdatedAt = isCurrentTrackedRequest
? Math.max(
getTimeValue(params.currentStatusUpdatedAt),
getTimeValue(params.request?.updatedAt),
)
: getTimeValue(params.request?.updatedAt);
if (!requestId || !isInProgressRequest || runtimeActive || lastUpdatedAt <= 0) {
return false;
}
if (
processStartedAtMs > 0 &&
lastUpdatedAt <= processStartedAtMs - PROCESS_RESTART_STALE_GRACE_MS &&
(isCurrentTrackedRequest || isDetachedInProgressRequest)
) {
return true;
}
return (
nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS &&
(isCurrentTrackedRequest || isDetachedInProgressRequest)
);
}
function hasConversationMetadata(
conversation: {
title?: unknown;
@@ -1820,20 +2028,13 @@ export function normalizeStaleRequestItem(
current_job_status?: unknown;
current_status_updated_at?: unknown;
} | null | undefined,
options?: {
runtimeActive?: boolean;
nowMs?: number;
processStartedAtMs?: number;
},
) {
const runtimeActive = isRuntimeRequestActive(item.requestId);
const lastUpdatedAt = Math.max(
getTimeValue(conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at)),
getTimeValue(item.updatedAt),
);
const isDetachedStaleInProgressState =
!runtimeActive &&
!isConversationRequestActive(conversation, item.requestId) &&
(item.status === 'queued' || item.status === 'started') &&
!hasStoredRequestResponse(item) &&
!isTerminalRequestStatus(item.status) &&
lastUpdatedAt > 0 &&
Date.now() - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS;
const runtimeActive = options?.runtimeActive ?? isRuntimeRequestActive(item.requestId);
if (
shouldClearConversationJobState({
@@ -1845,13 +2046,27 @@ export function normalizeStaleRequestItem(
currentStatusUpdatedAt:
conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at),
runtimeActive,
nowMs: options?.nowMs,
processStartedAtMs: options?.processStartedAtMs,
request: item,
}) || isDetachedStaleInProgressState
}) || shouldFailStaleInProgressRequest({
currentRequestId: String(conversation?.current_request_id ?? ''),
currentJobStatus:
conversation?.current_job_status == null
? null
: String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'],
currentStatusUpdatedAt:
conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at),
runtimeActive,
nowMs: options?.nowMs,
processStartedAtMs: options?.processStartedAtMs,
request: item,
})
) {
return {
...item,
status: 'failed' as const,
statusMessage: item.statusMessage ?? '중단된 오래된 요청',
statusMessage: '중단된 오래된 요청',
canDelete: true,
terminalAt: item.terminalAt ?? item.updatedAt,
};
@@ -1860,12 +2075,49 @@ export function normalizeStaleRequestItem(
return item;
}
async function reconcileStaleConversationRequests(
sessionId: string,
rows: Record<string, unknown>[],
conversation: {
current_request_id?: unknown;
current_job_status?: unknown;
current_status_updated_at?: unknown;
} | null | undefined,
) {
const normalizedSessionId = sessionId.trim();
const normalizedItems = rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
const staleRequestIds = normalizedItems
.flatMap((item, index) => {
const persistedStatus = String(rows[index]?.status ?? '').trim();
return item.status === 'failed' && (persistedStatus === 'accepted' || persistedStatus === 'queued' || persistedStatus === 'started')
? [item.requestId.trim()]
: [];
})
.filter(Boolean);
if (staleRequestIds.length > 0) {
await db(CHAT_CONVERSATION_REQUEST_TABLE)
.where({ session_id: normalizedSessionId })
.whereIn('request_id', staleRequestIds)
.whereIn('status', ['accepted', 'queued', 'started'])
.update({
status: 'failed',
status_message: '중단된 오래된 요청',
terminal_at: db.fn.now(),
updated_at: db.fn.now(),
});
}
return normalizedItems;
}
export function shouldClearConversationJobState(params: {
currentRequestId?: string | null;
currentJobStatus?: ChatConversationItem['currentJobStatus'];
currentStatusUpdatedAt?: string | null;
runtimeActive?: boolean;
nowMs?: number;
processStartedAtMs?: number;
request:
| {
requestId?: string | null;
@@ -1899,23 +2151,19 @@ export function shouldClearConversationJobState(params: {
}
const runtimeActive = params.runtimeActive === true;
const lastUpdatedAt = Math.max(
getTimeValue(params.currentStatusUpdatedAt),
getTimeValue(params.request?.updatedAt),
);
const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now();
const isStaleInProgressState =
!runtimeActive &&
(currentJobStatus === 'queued' || currentJobStatus === 'started') &&
!hasStoredRequestResponse(params.request ?? {}) &&
!isTerminalRequestStatus(params.request?.status ?? null) &&
lastUpdatedAt > 0 &&
nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS;
return (
(requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) ||
hasStoredResponse ||
isStaleInProgressState
shouldFailStaleInProgressRequest({
currentRequestId,
currentJobStatus,
currentStatusUpdatedAt: params.currentStatusUpdatedAt,
runtimeActive,
nowMs: params.nowMs,
processStartedAtMs: params.processStartedAtMs,
request: params.request,
})
);
}
@@ -1961,6 +2209,9 @@ function getDefaultChatConversationRequestStatusMessage(status: ChatConversation
export function mergeChatConversationRequestStatus(
currentStatus: ChatConversationRequestStatus | null | undefined,
incomingStatus: ChatConversationRequestStatus | null | undefined,
options?: {
allowTerminalStatusReset?: boolean;
},
): ChatConversationRequestStatus {
const normalizedCurrent = currentStatus ?? null;
const normalizedIncoming = incomingStatus ?? null;
@@ -1977,7 +2228,11 @@ export function mergeChatConversationRequestStatus(
return normalizedCurrent;
}
if (isTerminalRequestStatus(normalizedCurrent) && !isTerminalRequestStatus(normalizedIncoming)) {
if (
isTerminalRequestStatus(normalizedCurrent)
&& !isTerminalRequestStatus(normalizedIncoming)
&& options?.allowTerminalStatusReset !== true
) {
return normalizedCurrent;
}
@@ -2455,8 +2710,10 @@ export async function ensureChatConversationTables() {
table.string('request_origin', 40).nullable();
table.string('shared_resource_token_id', 120).nullable().index();
table.string('parent_request_id', 120).nullable();
table.text('prompt_context_ref').nullable();
table.string('status', 40).notNullable().defaultTo('accepted');
table.text('status_message').nullable();
table.integer('retry_count').notNullable().defaultTo(0);
table.bigInteger('user_message_id').nullable();
table.text('user_text').notNullable().defaultTo('');
table.bigInteger('response_message_id').nullable();
@@ -2482,8 +2739,10 @@ export async function ensureChatConversationTables() {
['request_origin', (table) => table.string('request_origin', 40).nullable()],
['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).nullable().index()],
['parent_request_id', (table) => table.string('parent_request_id', 120).nullable()],
['prompt_context_ref', (table) => table.text('prompt_context_ref').nullable()],
['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')],
['status_message', (table) => table.text('status_message').nullable()],
['retry_count', (table) => table.integer('retry_count').notNullable().defaultTo(0)],
['user_message_id', (table) => table.bigInteger('user_message_id').nullable()],
['user_text', (table) => table.text('user_text').notNullable().defaultTo('')],
['response_message_id', (table) => table.bigInteger('response_message_id').nullable()],
@@ -3347,7 +3606,7 @@ export async function listChatConversationDetailPage(
.limit(normalizedLimit);
const orderedRequestRows = [...requestRows].reverse();
const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
const requests = await reconcileStaleConversationRequests(normalizedSessionId, orderedRequestRows, conversation);
const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean);
if (requestIds.length === 0) {
@@ -3420,7 +3679,7 @@ export async function listChatConversationRequests(sessionId: string, limit = 20
.orderBy('created_at', 'asc')
.limit(Math.max(1, Math.min(1000, Math.round(limit))));
return rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
return reconcileStaleConversationRequests(normalizedSessionId, rows, conversation);
}
export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) {
@@ -3682,7 +3941,12 @@ export async function getChatConversationRequest(sessionId: string, requestId: s
})
.first();
return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null;
if (!row) {
return null;
}
const [item] = await reconcileStaleConversationRequests(normalizedSessionId, [row], conversation);
return item ?? null;
}
async function refreshConversationPreview(sessionId: string) {
@@ -3996,6 +4260,19 @@ export async function listRecoverableChatConversationRequests(): Promise<Recover
terminalAt: null,
updatedAt: item.updatedAt,
},
}) && !shouldFailStaleInProgressRequest({
currentRequestId: item.currentRequestId,
currentJobStatus: item.currentJobStatus,
currentStatusUpdatedAt: item.currentStatusUpdatedAt,
runtimeActive: false,
request: {
requestId: item.requestId,
status: item.status,
responseMessageId: null,
responseText: '',
terminalAt: null,
updatedAt: item.updatedAt,
},
});
});
}
@@ -4059,8 +4336,12 @@ export async function upsertChatConversationRequest(
requestOrigin?: 'composer' | 'prompt' | null;
sharedResourceTokenId?: string | null;
parentRequestId?: string | null;
promptContextRef?: ChatConversationRequestItem['promptContextRef'];
status?: ChatConversationRequestStatus;
statusMessage?: string | null;
retryCount?: number | null;
incrementRetryCount?: boolean;
allowTerminalStatusReset?: boolean;
userMessageId?: number | null;
userText?: string | null;
responseMessageId?: number | null;
@@ -4078,6 +4359,7 @@ export async function upsertChatConversationRequest(
payload.requestOrigin === 'prompt' || payload.requestOrigin === 'composer' ? payload.requestOrigin : null;
const normalizedSharedResourceTokenId = payload.sharedResourceTokenId?.trim() || null;
const normalizedParentRequestId = payload.parentRequestId?.trim() || null;
const normalizedPromptContextRef = normalizeStoredPromptContextRef(payload.promptContextRef);
if (!normalizedSessionId || !normalizedRequestId) {
return null;
@@ -4093,8 +4375,10 @@ export async function upsertChatConversationRequest(
request_origin: 'composer' | 'prompt' | null;
shared_resource_token_id: string | null;
parent_request_id: string | null;
prompt_context_ref: string | null;
status: ChatConversationRequestStatus;
status_message: string | null;
retry_count: number;
user_message_id: number | null;
user_text: string;
response_message_id: number | null;
@@ -4122,7 +4406,26 @@ export async function upsertChatConversationRequest(
nextStatus = mergeChatConversationRequestStatus(
(current?.status as ChatConversationRequestStatus | undefined) ?? null,
payload.status ?? null,
{
allowTerminalStatusReset: payload.allowTerminalStatusReset === true,
},
);
const currentRetryCount =
current?.retry_count == null || current.retry_count === ''
? 0
: Number.isFinite(Number(current.retry_count))
? Math.max(0, Math.round(Number(current.retry_count)))
: 0;
const payloadRetryCount =
payload.retryCount == null
? null
: Number.isFinite(Number(payload.retryCount))
? Math.max(0, Math.round(Number(payload.retryCount)))
: 0;
const nextRetryCount =
payload.incrementRetryCount === true
? currentRetryCount + 1
: payloadRetryCount ?? currentRetryCount;
const currentStatus = (current?.status as ChatConversationRequestStatus | undefined) ?? null;
const defaultStatusMessage =
payload.status && payload.status !== currentStatus
@@ -4130,7 +4433,9 @@ export async function upsertChatConversationRequest(
: null;
const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus)
? db.fn.now()
: current?.terminal_at ?? null;
: payload.allowTerminalStatusReset === true
? null
: current?.terminal_at ?? null;
const answeredAt =
payload.responseMessageId != null || (payload.responseText?.trim() ?? '').length > 0
? current?.answered_at ?? db.fn.now()
@@ -4159,8 +4464,13 @@ export async function upsertChatConversationRequest(
request_origin: normalizedRequestOrigin ?? current?.request_origin ?? null,
shared_resource_token_id: normalizedSharedResourceTokenId ?? current?.shared_resource_token_id ?? null,
parent_request_id: normalizedParentRequestId ?? current?.parent_request_id ?? null,
prompt_context_ref:
normalizedPromptContextRef != null
? JSON.stringify(normalizedPromptContextRef)
: current?.prompt_context_ref ?? null,
status: nextStatus,
status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null,
retry_count: nextRetryCount,
user_message_id: payload.userMessageId ?? current?.user_message_id ?? null,
user_text: payload.userText ?? current?.user_text ?? '',
response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null,
@@ -4247,6 +4557,35 @@ export async function markChatConversationRequestManualCompletion(
return null;
}
const existingRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.where({
session_id: normalizedSessionId,
request_id: normalizedRequestId,
})
.first();
if (!existingRow) {
return null;
}
if (completionType === 'verification') {
const childRequestCountRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.where({
session_id: normalizedSessionId,
parent_request_id: normalizedRequestId,
})
.count<{ count: string | number }[]>({ count: '*' })
.first();
const childRequestCount = Number(childRequestCountRow?.count ?? 0) || 0;
if (childRequestCount > 0) {
throw new ChatConversationManualCompletionBlockedError(
'후속 요청이 있는 답변은 응답 확인 완료로 처리할 수 없습니다.',
);
}
}
const targetColumn =
completionType === 'prompt' ? 'manual_prompt_completed_at' : 'manual_verification_completed_at';

View File

@@ -26,7 +26,6 @@ import {
resolveChatContextAppDomain,
rewriteCodexOutputWithChatResources,
summarizeActivityProgressLine,
shouldAutoCompleteReplyParentVerification,
shouldSendOfflineChatNotification,
shouldUseAgenticCodexReply,
shouldUseTemplateMacroReply,
@@ -154,58 +153,6 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl
);
});
test('shouldAutoCompleteReplyParentVerification only completes answered composer followups that are not already verified', () => {
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: 101,
responseText: '',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: null,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
true,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'prompt',
responseMessageId: 101,
responseText: '답변 본문',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: null,
responseText: '',
manualVerificationCompletedAt: null,
}),
false,
);
assert.equal(
shouldAutoCompleteReplyParentVerification({
requestOrigin: 'composer',
responseMessageId: 102,
responseText: '답변 본문',
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
}),
false,
);
});
test('resolveChatContextAppOrigin returns normalized origin from session page url', () => {
assert.equal(
resolveChatContextAppOrigin({

View File

@@ -18,7 +18,6 @@ import {
appendChatConversationMessage,
appendChatConversationActivityLine,
getChatConversationRequest,
markChatConversationRequestManualCompletion,
type ChatConversationRequestItem,
type ChatConversationRequestUsageSnapshot,
getChatConversation,
@@ -46,6 +45,7 @@ import {
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js';
import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js';
import {
findLatestPlanItem,
findPlanItemByPreviewUrl,
@@ -117,23 +117,6 @@ type ChatPromptContextRef = {
parentQuestionText?: string | null;
};
export function shouldAutoCompleteReplyParentVerification(options: {
requestOrigin?: 'composer' | 'prompt' | null;
responseMessageId?: number | null;
responseText?: string | null;
manualVerificationCompletedAt?: string | null;
} | null | undefined) {
if (!options || options.requestOrigin !== 'composer' || options.manualVerificationCompletedAt) {
return false;
}
if (options.responseMessageId != null) {
return true;
}
return String(options.responseText ?? '').trim().length > 0;
}
type ChatInboundMessage =
| {
type: 'context:update';
@@ -4428,6 +4411,21 @@ export class ChatService {
}
async recoverInterruptedSessions() {
if (!(await isCurrentWorkServerSlotActive())) {
this.logger.info(
{
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
},
'skip interrupted chat recovery on inactive work-server slot',
);
return {
sessionCount: 0,
restartedCount: 0,
requeuedCount: 0,
};
}
const recoverableRequests = await listRecoverableChatConversationRequests();
if (recoverableRequests.length === 0) {
@@ -5748,31 +5746,6 @@ export class ChatService {
omitPromptHistory: requestOptions?.omitPromptHistory === true,
context: cloneChatContext(state.context),
};
const parentRequest = request.parentRequestId
? await getChatConversationRequest(state.sessionId, request.parentRequestId)
: null;
const shouldAutoCompleteParentVerification = shouldAutoCompleteReplyParentVerification({
requestOrigin,
responseMessageId: parentRequest?.responseMessageId ?? null,
responseText: parentRequest?.responseText ?? '',
manualVerificationCompletedAt: parentRequest?.manualVerificationCompletedAt ?? null,
});
const completeParentVerificationIfNeeded = async () => {
if (!request.parentRequestId || !shouldAutoCompleteParentVerification) {
return;
}
const updatedParentRequest = await markChatConversationRequestManualCompletion(
state.sessionId,
request.parentRequestId,
'verification',
);
if (updatedParentRequest) {
this.broadcastRequestUpdate(state.sessionId, updatedParentRequest);
}
};
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
const queuedUserMessage = {
...createMessage('user', trimmed, nextRequestId),
@@ -5807,6 +5780,7 @@ export class ChatService {
requestOrigin: request.requestOrigin,
sharedResourceTokenId: request.sharedResourceTokenId,
parentRequestId: request.parentRequestId,
promptContextRef: request.promptContextRef,
status: 'queued',
statusMessage: `대기열 ${state.queue.length}`,
userMessageId: queuedUserMessage.id,
@@ -5814,11 +5788,9 @@ export class ChatService {
}).catch((error: unknown) => {
this.logger.error(error, 'failed to persist queued chat request');
});
await completeParentVerificationIfNeeded();
return nextRequestId;
}
await completeParentVerificationIfNeeded();
void this.executeRequest(state, request).catch((error: unknown) => {
this.logger.error(error, 'direct chat reply build failed');
this.sendToSession(state, {
@@ -5851,6 +5823,10 @@ export class ChatService {
const compactActivityLineMap = new Map<number, string>();
session.activeRequestCount += 1;
const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId);
const isRetryAttempt =
existingRequest != null
&& !existingRequest.hasResponse
&& (existingRequest.status === 'failed' || existingRequest.status === 'cancelled');
const hasStoredUserMessage = existingRequest?.userMessageId != null;
let userMessageId = existingRequest?.userMessageId ?? null;
@@ -5873,7 +5849,11 @@ export class ChatService {
requestOrigin: request.requestOrigin,
sharedResourceTokenId: request.sharedResourceTokenId,
parentRequestId: request.parentRequestId,
promptContextRef: request.promptContextRef,
status: request.mode === 'direct' ? 'accepted' : existingRequest?.status ?? 'queued',
statusMessage: isRetryAttempt ? '재처리 요청 접수' : undefined,
incrementRetryCount: isRetryAttempt,
allowTerminalStatusReset: isRetryAttempt,
userMessageId,
userText: request.text,
});

View File

@@ -67,6 +67,7 @@ export const sendIosNotificationSchema = z.object({
body: z.string().trim().min(1),
data: z.record(z.string(), z.string()).default({}),
threadId: z.string().trim().min(1).optional(),
targetDeviceIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(),
targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(),
@@ -81,8 +82,12 @@ type NotificationPreferenceTarget = {
id: string;
};
function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
function normalizeTargetDeviceIds(payload: {
targetDeviceIds?: string[];
targetClientIds?: string[];
}) {
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
@@ -121,21 +126,18 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetClientId(
function isAllowedTargetDeviceId(
target: {
deviceId?: string;
clientId?: string;
},
targetClientIds: string[],
targetDeviceIds: string[],
) {
if (targetClientIds.length === 0) {
if (targetDeviceIds.length === 0) {
return true;
}
return [target.deviceId, target.clientId]
.map((value) => String(value ?? '').trim())
.filter(Boolean)
.some((value) => targetClientIds.includes(value));
const deviceId = String(target.deviceId ?? '').trim();
return Boolean(deviceId) && targetDeviceIds.includes(deviceId);
}
function normalizeAppOrigin(value: unknown) {
@@ -914,7 +916,7 @@ async function isNotificationRecipientAllowed(
export async function sendIosNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const provider = await getProvider();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
@@ -950,7 +952,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
.filter(
(row) =>
row.allowed &&
isAllowedTargetClientId({ deviceId: row.deviceId }, targetClientIds) &&
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
@@ -999,7 +1001,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetDeviceIds = normalizeTargetDeviceIds(payload);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) {
@@ -1029,7 +1031,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
).filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row, targetClientIds) &&
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
const matchedSubscriptions = subscriptions.map((row) => ({

View File

@@ -103,12 +103,17 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$TARGET_SERVICE"/,
);
assert.match(workServerScript, /RUNTIME_ENDPOINT="\$\{WORK_SERVER_RUNTIME_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\}"/);
assert.match(
workServerScript,
/RECOVERY_ENDPOINT="\$\{WORK_SERVER_RECOVERY_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\/recover-interrupted-chat\}"/,
);
assert.match(workServerScript, /set_container_draining "\$PREVIOUS_CONTAINER" true/);
assert.match(workServerScript, /wait_for_previous_slot_drain "\$PREVIOUS_CONTAINER"/);
assert.match(
workServerScript,
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$PREVIOUS_SERVICE"/,
);
assert.match(workServerScript, /recover_interrupted_chat_requests "\$TARGET_CONTAINER"/);
assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/);
assert.match(workServerScript, /work-server zero-downtime switch completed/);
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
@@ -132,6 +137,22 @@ test('test restart script pulls the configured remote main branch before restart
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
});
test('test deploy script commits the main worktree before pushing and restarting the preview server', () => {
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
const deployScript = fs.readFileSync(new URL('deploy-test.sh', commandsRoot), 'utf8');
assert.match(deployScript, /TEST_BUILD_COMMAND="\$\{TEST_BUILD_COMMAND:-npm run build:test-app\}"/);
assert.match(deployScript, /TEST_DEPLOY_COMMIT_MESSAGE="\$\{TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot\}"/);
assert.match(deployScript, /echo "::step::commit-main-worktree"/);
assert.match(deployScript, /git add -A -- \./);
assert.match(deployScript, /git commit -m "\$TEST_DEPLOY_COMMIT_MESSAGE"/);
assert.match(deployScript, /echo "::step::push-origin-main"/);
assert.match(deployScript, /git push "\$TEST_DEPLOY_GIT_REMOTE" "\$TEST_DEPLOY_GIT_BRANCH"/);
assert.match(deployScript, /TEST_SERVER_RESTART_SCRIPT="\$\{TEST_SERVER_RESTART_SCRIPT:-\$SCRIPT_DIR\/restart-test\.sh\}"/);
assert.doesNotMatch(deployScript, /restart-work-server\.sh/);
assert.match(deployScript, /REPO_ROOT="\$REPO_ROOT" sh "\$TEST_SERVER_RESTART_SCRIPT"/);
});
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
const packageJsonPath = new URL('../../package.json', import.meta.url);
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
@@ -416,3 +437,45 @@ test('listServerCommands ignores work-server test-only source changes when compu
await rm(tempRoot, { recursive: true, force: true });
}
});
test('listServerCommands keeps work-server updateAvailable false when only a standby rebuild is newer', async () => {
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-standby-build-'));
try {
const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
await writeFile(
path.join(workServerRoot, 'dist', 'build-info.json'),
JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-26T16:10:05.960Z', builtAt: '2026-05-26T16:10:05.960Z' }),
'utf8',
);
const sourceDate = new Date('2026-05-26T16:06:46.162Z');
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), sourceDate, sourceDate);
await fs.promises.utimes(path.join(workServerRoot, 'package.json'), sourceDate, sourceDate);
await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), sourceDate, sourceDate);
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
const commands = await listServerCommands();
const workServerCommand = commands.find((item) => item.key === 'work-server');
assert.ok(workServerCommand);
assert.equal(workServerCommand.buildRequired, false);
assert.equal(workServerCommand.updateAvailable, false);
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -1,11 +1,16 @@
import { execFile, spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import { readFile, rm, stat } from 'node:fs/promises';
import { mkdir, open, readFile, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import { env } from '../config/env.js';
import { resolveMainProjectRoot } from './main-project-root-service.js';
import {
readTestServerDeploymentState,
startTestServerDeployment,
type TestServerDeploymentSnapshot,
} from './test-server-deployment-service.js';
import {
getRuntimeWorkServerBuildInfo,
readLatestWorkServerBuildInfo,
@@ -66,12 +71,21 @@ export type ServerCommandSnapshot = {
commandScript: string;
commandWorkingDirectory: string;
errorMessage: string | null;
deployment: WorkServerDeploymentSnapshot | null;
};
export type ServerCommandRestartResult = {
server: ServerCommandSnapshot;
commandOutput: string | null;
restartState: 'completed' | 'accepted';
deployment?: WorkServerDeploymentSnapshot | null;
testDeployment?: TestServerDeploymentSnapshot | null;
};
type ServerCommandScriptExecutionOptions = {
commandScript?: string;
environment?: Record<string, string>;
timeoutMs?: number;
};
type ExecFileFailure = Error & {
@@ -119,6 +133,56 @@ type BuildInspectionResult = {
type WorkServerSlot = 'blue' | 'green';
export type WorkServerDeploymentStepKey =
| 'build-target-slot'
| 'verify-target-health'
| 'switch-proxy'
| 'drain-previous-slot'
| 'rebuild-previous-slot'
| 'recover-interrupted-chat';
export type WorkServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
export type WorkServerDeploymentStepSnapshot = {
key: WorkServerDeploymentStepKey;
status: WorkServerDeploymentStepStatus;
detail: string | null;
updatedAt: string | null;
};
export type WorkServerDeploymentPhase =
| 'idle'
| 'build-target-slot'
| 'verify-target-health'
| 'switch-proxy'
| 'drain-previous-slot'
| 'rebuild-previous-slot'
| 'recover-interrupted-chat'
| 'completed'
| 'failed';
export type WorkServerDeploymentSnapshot = {
status: 'idle' | 'running' | 'completed' | 'failed';
phase: WorkServerDeploymentPhase;
summary: string | null;
startedAt: string | null;
updatedAt: string | null;
completedAt: string | null;
activeSlot: WorkServerSlot | null;
targetSlot: WorkServerSlot | null;
previousSlot: WorkServerSlot | null;
targetContainer: string | null;
previousContainer: string | null;
previousSlotActiveChatRequestCount: number | null;
previousSlotQueuedChatRequestCount: number | null;
recoveredSessionCount: number | null;
recoveredRestartedCount: number | null;
recoveredRequeuedCount: number | null;
lastError: string | null;
logExcerpt: string | null;
steps: WorkServerDeploymentStepSnapshot[];
};
const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
const DEFERRED_RESTART_DELAY_MS = 2_000;
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
@@ -141,6 +205,35 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
const APP_BUILD_STAMP_RELATIVE_PATH = '.server-command-test-app-built-at';
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
const APP_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const;
const WORK_SERVER_RESTART_LOCK_STALE_MS = 20 * 60 * 1000;
type WorkServerRestartLockPayload = {
startedAt: string;
key: ServerCommandKey;
pid: number;
};
type WorkServerDeploymentStateFilePayload = {
status?: unknown;
phase?: unknown;
summary?: unknown;
startedAt?: unknown;
updatedAt?: unknown;
completedAt?: unknown;
activeSlot?: unknown;
targetSlot?: unknown;
previousSlot?: unknown;
targetContainer?: unknown;
previousContainer?: unknown;
previousSlotActiveChatRequestCount?: unknown;
previousSlotQueuedChatRequestCount?: unknown;
recoveredSessionCount?: unknown;
recoveredRestartedCount?: unknown;
recoveredRequeuedCount?: unknown;
lastError?: unknown;
logExcerpt?: unknown;
steps?: unknown;
};
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
const allowLocal = options?.allowLocal ?? false;
@@ -642,8 +735,8 @@ function getServerDefinitions(): ServerDefinition[] {
return [
{
key: 'test',
label: 'TEST',
summary: '메인 프로젝트의 테스트 앱 컨테이너',
label: 'PREVIEW',
summary: 'preview.sm-home.cloud 테스트 앱 컨테이너',
environment: 'test',
publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL),
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL),
@@ -751,6 +844,25 @@ function getServerDefinition(key: ServerCommandKey) {
return definition;
}
async function executeServerCommandScript(
definition: ServerDefinition,
options: ServerCommandScriptExecutionOptions = {},
) {
const commandScript = options.commandScript ?? definition.commandScript;
const timeoutMs = options.timeoutMs ?? 30000;
return execFileAsync('sh', [commandScript], {
cwd: definition.commandWorkingDirectory,
timeout: timeoutMs,
maxBuffer: 1024 * 1024,
env: {
...process.env,
...definition.commandEnvironment,
...options.environment,
},
});
}
function trimPreview(value: string | null | undefined, maxLength = 220) {
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
@@ -772,6 +884,209 @@ function normalizeDateTimeValue(value: string | null | undefined) {
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
function getWorkServerRestartLockPath() {
return path.join(resolveMainProjectRoot(), "etc", "servers", "work-server", ".docker", "runtime", "restart-in-progress.json");
}
function getWorkServerDeploymentStatePath() {
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'deployment-state.json');
}
const WORK_SERVER_DEPLOYMENT_STEP_KEYS: WorkServerDeploymentStepKey[] = [
'build-target-slot',
'verify-target-health',
'switch-proxy',
'drain-previous-slot',
'rebuild-previous-slot',
'recover-interrupted-chat',
];
function normalizeWorkServerDeploymentStepKey(value: unknown): WorkServerDeploymentStepKey | null {
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as WorkServerDeploymentStepKey)
? (value as WorkServerDeploymentStepKey)
: null;
}
function normalizeWorkServerSlotValue(value: unknown): WorkServerSlot | null {
return value === 'blue' || value === 'green' ? value : null;
}
function normalizeWorkServerDeploymentPhase(value: unknown): WorkServerDeploymentPhase {
return value === 'build-target-slot'
|| value === 'verify-target-health'
|| value === 'switch-proxy'
|| value === 'drain-previous-slot'
|| value === 'rebuild-previous-slot'
|| value === 'recover-interrupted-chat'
|| value === 'completed'
|| value === 'failed'
? value
: 'idle';
}
function normalizeWorkServerDeploymentStatus(value: unknown): WorkServerDeploymentSnapshot['status'] {
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
}
function normalizeNumberOrNull(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function buildEmptyWorkServerDeploymentSnapshot(): WorkServerDeploymentSnapshot {
return {
status: 'idle',
phase: 'idle',
summary: null,
startedAt: null,
updatedAt: null,
completedAt: null,
activeSlot: null,
targetSlot: null,
previousSlot: null,
targetContainer: null,
previousContainer: null,
previousSlotActiveChatRequestCount: null,
previousSlotQueuedChatRequestCount: null,
recoveredSessionCount: null,
recoveredRestartedCount: null,
recoveredRequeuedCount: null,
lastError: null,
logExcerpt: null,
steps: WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
key,
status: 'pending',
detail: null,
updatedAt: null,
})),
};
}
function normalizeWorkServerDeploymentSteps(value: unknown) {
const fallback = buildEmptyWorkServerDeploymentSnapshot().steps;
if (!Array.isArray(value)) {
return fallback;
}
const normalizedByKey = new Map<WorkServerDeploymentStepKey, WorkServerDeploymentStepSnapshot>();
value.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const candidate = item as Record<string, unknown>;
const key = normalizeWorkServerDeploymentStepKey(candidate.key);
if (!key) {
return;
}
const status =
candidate.status === 'running'
|| candidate.status === 'completed'
|| candidate.status === 'failed'
|| candidate.status === 'pending'
? candidate.status
: 'pending';
normalizedByKey.set(key, {
key,
status,
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
});
});
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
}
function normalizeWorkServerDeploymentSnapshot(value: unknown): WorkServerDeploymentSnapshot {
if (!value || typeof value !== 'object') {
return buildEmptyWorkServerDeploymentSnapshot();
}
const candidate = value as WorkServerDeploymentStateFilePayload;
return {
status: normalizeWorkServerDeploymentStatus(candidate.status),
phase: normalizeWorkServerDeploymentPhase(candidate.phase),
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
activeSlot: normalizeWorkServerSlotValue(candidate.activeSlot),
targetSlot: normalizeWorkServerSlotValue(candidate.targetSlot),
previousSlot: normalizeWorkServerSlotValue(candidate.previousSlot),
targetContainer: typeof candidate.targetContainer === 'string' ? candidate.targetContainer : null,
previousContainer: typeof candidate.previousContainer === 'string' ? candidate.previousContainer : null,
previousSlotActiveChatRequestCount: normalizeNumberOrNull(candidate.previousSlotActiveChatRequestCount),
previousSlotQueuedChatRequestCount: normalizeNumberOrNull(candidate.previousSlotQueuedChatRequestCount),
recoveredSessionCount: normalizeNumberOrNull(candidate.recoveredSessionCount),
recoveredRestartedCount: normalizeNumberOrNull(candidate.recoveredRestartedCount),
recoveredRequeuedCount: normalizeNumberOrNull(candidate.recoveredRequeuedCount),
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
steps: normalizeWorkServerDeploymentSteps(candidate.steps),
};
}
export async function readWorkServerDeploymentState(): Promise<WorkServerDeploymentSnapshot | null> {
try {
const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8');
return normalizeWorkServerDeploymentSnapshot(JSON.parse(raw));
} catch {
return null;
}
}
async function acquireWorkServerRestartLock() {
const lockPath = getWorkServerRestartLockPath();
await mkdir(path.dirname(lockPath), { recursive: true });
const startedAt = new Date().toISOString();
try {
const handle = await open(lockPath, "wx");
try {
await handle.writeFile(JSON.stringify({ startedAt, key: "work-server", pid: process.pid }) + "\n", "utf8");
} finally {
await handle.close();
}
return lockPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
let existingStartedAt: string | null = null;
try {
const raw = await readFile(lockPath, "utf8");
const parsed = JSON.parse(raw) as Partial<WorkServerRestartLockPayload>;
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === "string" ? parsed.startedAt : null);
const lockStat = await stat(lockPath).catch(() => null);
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) {
await rm(lockPath, { force: true }).catch(() => undefined);
return acquireWorkServerRestartLock();
}
} catch {
// ignore read failures and keep conflict response below
}
const conflictError = new Error(
existingStartedAt
? "WORK-SERVER 무중단 재기동이 이미 진행 중입니다. 시작 시각 " + existingStartedAt
: "WORK-SERVER 무중단 재기동이 이미 진행 중입니다.",
);
(conflictError as Error & { statusCode?: number }).statusCode = 409;
throw conflictError;
}
}
function buildRestartCommandPreview(definition: ServerDefinition) {
return `sh ${definition.commandScript}`;
}
@@ -817,6 +1132,7 @@ function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerComma
commandScript: definition.commandScript,
commandWorkingDirectory: definition.commandWorkingDirectory,
errorMessage: null,
deployment: definition.key === 'work-server' ? buildEmptyWorkServerDeploymentSnapshot() : null,
};
}
@@ -969,6 +1285,7 @@ async function waitForDeferredRestartResult(
async function restartServerCommandDeferred(definition: ServerDefinition): Promise<ServerCommandRestartResult> {
const { logPath, statusPath } = buildDeferredRestartProbePaths(definition);
const workServerLockPath = definition.key === "work-server" ? await acquireWorkServerRestartLock() : null;
const shellCommand = [
`sleep ${Math.ceil(DEFERRED_RESTART_DELAY_MS / 1000)}`,
`sh ${JSON.stringify(definition.commandScript)} >${JSON.stringify(logPath)} 2>&1`,
@@ -976,7 +1293,8 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
`printf '%s' \"$status\" >${JSON.stringify(statusPath)}`,
].join('; ');
await new Promise<void>((resolve, reject) => {
try {
await new Promise<void>((resolve, reject) => {
const child = spawn('sh', ['-c', shellCommand], {
cwd: definition.commandWorkingDirectory,
detached: true,
@@ -984,21 +1302,30 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
env: {
...process.env,
...definition.commandEnvironment,
...(workServerLockPath ? { WORK_SERVER_RESTART_LOCK_FILE: workServerLockPath } : {}),
},
});
child.once('error', reject);
child.once('spawn', () => {
child.unref();
resolve();
child.once('spawn', () => {
child.unref();
resolve();
});
});
});
} catch (error) {
if (workServerLockPath) {
await rm(workServerLockPath, { force: true }).catch(() => undefined);
}
throw error;
}
if (definition.deferredResponseMode === 'accept-immediately') {
return {
server: buildAcceptedRestartSnapshot(definition),
commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
restartState: 'accepted',
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
};
}
@@ -1008,6 +1335,7 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
server: buildAcceptedRestartSnapshot(definition),
commandOutput: commandOutput ?? `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
restartState: 'accepted',
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
};
}
@@ -1463,7 +1791,12 @@ async function inspectBuild(definition: ServerDefinition): Promise<BuildInspecti
? !latestBuild?.builtAt || latestSourceChangedAt > latestBuild.builtAt
: false;
const updateAvailable =
Boolean(runningBuild?.buildId) && Boolean(latestBuild?.buildId) && runningBuild?.buildId !== latestBuild?.buildId;
!buildRequired &&
Boolean(runningBuild?.builtAt) &&
Boolean(latestBuild?.builtAt) &&
Boolean(latestSourceChangedAt) &&
runningBuild!.builtAt < latestBuild!.builtAt &&
runningBuild!.builtAt < latestSourceChangedAt!;
return {
runningVersion: runningBuild?.buildId ?? null,
@@ -1519,6 +1852,7 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
const runtimeInfo = await inspectRuntime(definition);
const buildInfo = await inspectBuild(definition);
const deployment = definition.key === 'work-server' ? await readWorkServerDeploymentState() : null;
const fallbackAttempt = selectedAttempt.url !== definition.checkUrl ? `fallback health check succeeded via ${selectedAttempt.url}` : null;
const collectedErrors = attempts
.filter((attempt) => attempt.errorMessage)
@@ -1557,12 +1891,26 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
updateAvailable: buildInfo.updateAvailable,
updateSummary: buildInfo.updateSummary,
responseTimeMs: Date.now() - startedAt,
composeStatus: runtimeInfo.composeStatus,
composeDetails: runtimeInfo.composeDetails,
composeStatus:
definition.key === 'work-server' && deployment?.status === 'running'
? 'deploying'
: runtimeInfo.composeStatus,
composeDetails:
definition.key === 'work-server' && deployment
? appendComposeDetails([
runtimeInfo.composeDetails,
deployment.status !== 'idle'
? `deploy:${deployment.status}${deployment.targetSlot ? `:${deployment.targetSlot}` : ''}`
: null,
])
: runtimeInfo.composeDetails,
lastCommand: buildRestartCommandPreview(definition),
commandScript: definition.commandScript,
commandWorkingDirectory: definition.commandWorkingDirectory,
errorMessage,
errorMessage: deployment?.status === 'failed' && deployment.lastError
? trimPreview([deployment.lastError, errorMessage].filter(Boolean).join(' | '), 400)
: errorMessage,
deployment,
};
}
@@ -1591,15 +1939,7 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
}
try {
const commandResult = await execFileAsync('sh', [definition.commandScript], {
cwd: definition.commandWorkingDirectory,
timeout: 30000,
maxBuffer: 1024 * 1024,
env: {
...process.env,
...definition.commandEnvironment,
},
});
const commandResult = await executeServerCommandScript(definition);
stdout = commandResult.stdout;
stderr = commandResult.stderr;
} catch (error) {
@@ -1626,5 +1966,23 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
server,
commandOutput: trimPreview([stdout, stderr].filter(Boolean).join('\n'), 400),
restartState: 'completed',
deployment: server.deployment,
};
}
export async function deployWorkServerCommand(): Promise<ServerCommandRestartResult> {
return restartServerCommand('work-server');
}
export async function deployTestServerCommand(): Promise<ServerCommandRestartResult> {
const testDefinition = getServerDefinition('test');
const testDeployment = await startTestServerDeployment();
const server = await checkServer(testDefinition);
return {
server,
commandOutput: 'TEST 배포를 시작했습니다. origin/main 푸시, 테스트 빌드, 테스트 배포 과정을 확인합니다.',
restartState: 'accepted',
testDeployment: testDeployment ?? (await readTestServerDeploymentState()),
};
}

View File

@@ -577,7 +577,11 @@ async function requestCommandRunner(requestPath: string, init?: RequestInit) {
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
}
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
function buildWaitingReason(target: RestartReservationTarget, summary: RestartReservationWorkloadSummary) {
if (target === 'work-server') {
return null;
}
const reasons: string[] = [];
const codexPending = summary.codexRunningCount + summary.codexQueuedCount;
@@ -1232,7 +1236,9 @@ export async function scheduleServerRestartReservation(options?: {
requested_at: db.fn.now(),
requested_by_client_id: options?.clientId?.trim() || null,
last_checked_at: null,
waiting_reason: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.',
waiting_reason: target === 'work-server'
? 'WORK 서버 무중단 재기동 가능 여부를 확인하는 중입니다.'
: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.',
workload_summary_json: getDefaultWorkloadSummary(),
started_at: null,
completed_at: null,
@@ -1367,7 +1373,7 @@ export class ServerRestartReservationWorker {
}
const workloadSummary = await getRestartReservationWorkloadSummary();
const waitingReason = buildWaitingReason(workloadSummary);
const waitingReason = buildWaitingReason(normalizeReservationTarget(row.target), workloadSummary);
if (!waitingReason && row.status === 'ready' && isReservationAutoExecuteDue(row)) {
await confirmServerRestartReservation(this.logger);

View File

@@ -8,6 +8,7 @@ import {
listChatConversationRequests,
} from './chat-room-service.js';
import { getTokenSettingById, getTokenSettingsConfig, type TokenSettingRecord } from './token-setting-config-service.js';
import type { RequestAuditContext } from '../utils/request-audit.js';
const SHARED_RESOURCE_TOKENS_TABLE = 'shared_resource_tokens';
const SHARED_RESOURCE_TOKEN_ACTIVITIES_TABLE = 'shared_resource_token_activities';
@@ -130,6 +131,15 @@ export type SharedResourceTokenActivityRecord = {
summary: string;
detail: string | null;
usageDelta: number;
clientIp: string | null;
externalIp: string | null;
forwardedFor: string | null;
realIp: string | null;
host: string | null;
origin: string | null;
referer: string | null;
userAgent: string | null;
clientId: string | null;
createdAt: string;
};
@@ -831,29 +841,36 @@ async function attachLinkedTokenSettings(tokens: SharedResourceTokenRecord[]) {
});
}
async function attachRequestUsageSummaries(tokens: SharedResourceTokenRecord[]) {
async function attachRequestUsageSummaries(
tokens: SharedResourceTokenRecord[],
options?: {
includeFallback?: boolean;
},
) {
if (tokens.length === 0) {
return tokens;
}
const summaries = await listChatConversationRequestUsageBySharedResourceTokenIds(tokens.map((token) => token.id));
const summaryByTokenId = new Map(summaries.map((summary) => [summary.sharedResourceTokenId, summary] as const));
const unresolvedTokens = tokens.filter((token) => !summaryByTokenId.has(token.id));
const fallbackSummaries = await Promise.all(unresolvedTokens.map((token) => resolveChatShareFallbackUsageSummary(token)));
if (options?.includeFallback !== false) {
const unresolvedTokens = tokens.filter((token) => !summaryByTokenId.has(token.id));
const fallbackSummaries = await Promise.all(unresolvedTokens.map((token) => resolveChatShareFallbackUsageSummary(token)));
fallbackSummaries.forEach((summary) => {
if (!summary) {
return;
}
fallbackSummaries.forEach((summary) => {
if (!summary) {
return;
}
summaryByTokenId.set(summary.tokenId, {
sharedResourceTokenId: summary.tokenId,
requestCount: summary.usageRequestCount,
completedRequestCount: summary.usageCompletedRequestCount,
totalTokens: summary.usageTokenTotal,
lastUsedAt: summary.lastTokenUsedAt,
summaryByTokenId.set(summary.tokenId, {
sharedResourceTokenId: summary.tokenId,
requestCount: summary.usageRequestCount,
completedRequestCount: summary.usageCompletedRequestCount,
totalTokens: summary.usageTokenTotal,
lastUsedAt: summary.lastTokenUsedAt,
});
});
});
}
return tokens.map((token) => {
const summary = summaryByTokenId.get(token.id);
@@ -954,6 +971,15 @@ function mapActivityRow(row: Record<string, unknown>): SharedResourceTokenActivi
summary: normalizeText(row.summary),
detail: normalizeOptionalText(row.detail),
usageDelta: normalizePositiveInteger(row.usage_delta, 0, 0, 1_000_000),
clientIp: normalizeOptionalText(row.client_ip),
externalIp: normalizeOptionalText(row.external_ip),
forwardedFor: normalizeOptionalText(row.forwarded_for),
realIp: normalizeOptionalText(row.real_ip),
host: normalizeOptionalText(row.host),
origin: normalizeOptionalText(row.origin),
referer: normalizeOptionalText(row.referer),
userAgent: normalizeOptionalText(row.user_agent),
clientId: normalizeOptionalText(row.client_id),
createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(),
};
}
@@ -1110,6 +1136,15 @@ async function ensureSharedResourceTokenTables() {
table.string('summary', 400).notNullable();
table.text('detail').nullable();
table.integer('usage_delta').notNullable().defaultTo(0);
table.string('client_ip', 120).nullable();
table.string('external_ip', 120).nullable();
table.text('forwarded_for').nullable();
table.string('real_ip', 120).nullable();
table.string('host', 255).nullable();
table.text('origin').nullable();
table.text('referer').nullable();
table.text('user_agent').nullable();
table.string('client_id', 255).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
@@ -1122,6 +1157,15 @@ async function ensureSharedResourceTokenTables() {
['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')],
['detail', (table) => table.text('detail').nullable()],
['usage_delta', (table) => table.integer('usage_delta').notNullable().defaultTo(0)],
['client_ip', (table) => table.string('client_ip', 120).nullable()],
['external_ip', (table) => table.string('external_ip', 120).nullable()],
['forwarded_for', (table) => table.text('forwarded_for').nullable()],
['real_ip', (table) => table.string('real_ip', 120).nullable()],
['host', (table) => table.string('host', 255).nullable()],
['origin', (table) => table.text('origin').nullable()],
['referer', (table) => table.text('referer').nullable()],
['user_agent', (table) => table.text('user_agent').nullable()],
['client_id', (table) => table.string('client_id', 255).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
@@ -1243,7 +1287,11 @@ async function persistSharedResourceAccessPinSession(
});
}
async function appendActivity(trx: any, tokenId: string, input: SharedResourceActivityInput) {
async function appendActivity(
trx: any,
tokenId: string,
input: SharedResourceActivityInput & { audit?: RequestAuditContext | null },
) {
const payload = sharedResourceActivityInputSchema.parse(input);
await trx(SHARED_RESOURCE_TOKEN_ACTIVITIES_TABLE).insert({
@@ -1253,6 +1301,15 @@ async function appendActivity(trx: any, tokenId: string, input: SharedResourceAc
summary: payload.summary,
detail: payload.detail ?? null,
usage_delta: payload.usageDelta ?? 0,
client_ip: input.audit?.clientIp ?? null,
external_ip: input.audit?.externalIp ?? null,
forwarded_for: input.audit?.forwardedFor ?? null,
real_ip: input.audit?.realIp ?? null,
host: input.audit?.host ?? null,
origin: input.audit?.origin ?? null,
referer: input.audit?.referer ?? null,
user_agent: input.audit?.userAgent ?? null,
client_id: input.audit?.clientId ?? null,
created_at: db.fn.now(),
});
@@ -1302,7 +1359,7 @@ export async function listSharedResourceTokens() {
.map((row) => mapTokenRow(row as Record<string, unknown>))
.filter((item): item is SharedResourceTokenRecord => Boolean(item));
return attachRequestUsageSummaries(await attachLinkedTokenSettings(tokens));
return attachRequestUsageSummaries(await attachLinkedTokenSettings(tokens), { includeFallback: false });
}
async function getSharedResourceTokenDetailInternal(tokenId: string, options?: { includeDeleted?: boolean }) {
@@ -1370,6 +1427,38 @@ export async function getSharedResourceTokenDetailBySharePath(sharePath: string)
};
}
export async function getSharedResourceTokenDetailByShareToken(shareToken: string) {
await ensureSharedResourceTokenTables();
const normalizedShareToken = normalizeText(shareToken);
if (!normalizedShareToken) {
return null;
}
const row = await db(SHARED_RESOURCE_TOKENS_TABLE)
.where({ share_token: normalizedShareToken })
.whereNull('deleted_at')
.first();
if (!row) {
return null;
}
const token = mapTokenRow(row as Record<string, unknown>);
if (!token) {
return null;
}
const [linkedToken] = await attachRequestUsageSummaries(await attachLinkedTokenSettings([token]));
return {
token: linkedToken,
activities: await listActivitiesByTokenId(token.id),
};
}
export async function validateSharedResourceAccessPinBySharePath(
sharePath: string,
providedPin?: string | null,
@@ -1468,7 +1557,10 @@ export async function validateSharedResourceAccessPinBySharePath(
} as const;
}
export async function upsertSharedResourceToken(input: SharedResourceTokenInput) {
export async function upsertSharedResourceToken(
input: SharedResourceTokenInput,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
await ensureSharedResourceTokenTables();
const parsed = sharedResourceTokenSchema.parse(input);
@@ -1640,9 +1732,10 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
await appendActivity(trx, nextRecord.id, {
type: existing ? 'updated' : 'created',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: existing ? '공유 리소스 토큰을 수정했습니다.' : '공유 리소스 토큰을 생성했습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
const previousPermissions = new Set(existing?.token.permissions ?? []);
@@ -1651,18 +1744,20 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
for (const permission of nextRecord.permissions.filter((item) => !previousPermissions.has(item))) {
await appendActivity(trx, nextRecord.id, {
type: 'permission-granted',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: `${permission} 권한을 추가했습니다.`,
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
for (const permission of (existing?.token.permissions ?? []).filter((item) => !nextPermissions.has(item))) {
await appendActivity(trx, nextRecord.id, {
type: 'permission-revoked',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: `${permission} 권한을 회수했습니다.`,
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
@@ -1672,18 +1767,20 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
for (const appId of nextRecord.allowedAppIds.filter((item) => !previousAllowedApps.has(item))) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: `${appId} 앱 권한을 추가했습니다.`,
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
for (const appId of (existing?.token.allowedAppIds ?? []).filter((item) => !nextAllowedApps.has(item))) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: `${appId} 앱 권한을 제거했습니다.`,
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
@@ -1695,56 +1792,62 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
if (!previousHasAccessPin && nextHasAccessPin) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 접근 비밀번호를 설정했습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
if (previousHasAccessPin && !nextHasAccessPin) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 접근 비밀번호를 해제했습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
if (previousHasAccessPin && nextHasAccessPin && typeof parsed.accessPin === 'string') {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 접근 비밀번호를 변경했습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
if (previousAccessPinPromptTtlMinutes !== nextRecord.accessPinPromptTtlMinutes) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: nextRecord.accessPinPromptTtlMinutes
? `공유 비밀번호 재입력 유지시간을 ${nextRecord.accessPinPromptTtlMinutes}분으로 변경했습니다.`
: '공유 비밀번호 재입력 방식을 매번 묻기로 변경했습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
if (!previousAllowAccessPinChangeWithoutManage && nextRecord.allowAccessPinChangeWithoutManage) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '관리 권한 없이 비밀번호 변경 가능한 모드를 켰습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
if (previousAllowAccessPinChangeWithoutManage && !nextRecord.allowAccessPinChangeWithoutManage) {
await appendActivity(trx, nextRecord.id, {
type: 'updated',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '관리 권한 없이 비밀번호 변경 가능한 모드를 껐습니다.',
detail: nextRecord.resourceLabel,
audit: options?.audit,
});
}
});
@@ -1772,7 +1875,11 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
return getSharedResourceTokenDetail(savedRecord.id);
}
export async function revokeSharedResourceToken(tokenId: string, reason?: string | null) {
export async function revokeSharedResourceToken(
tokenId: string,
reason?: string | null,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
await ensureSharedResourceTokenTables();
const normalizedTokenId = normalizeText(tokenId);
@@ -1793,16 +1900,21 @@ export async function revokeSharedResourceToken(tokenId: string, reason?: string
await appendActivity(trx, normalizedTokenId, {
type: 'revoked',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 토큰을 회수했습니다.',
detail: normalizeText(reason) || existing.token.resourceLabel,
audit: options?.audit,
});
});
return getSharedResourceTokenDetail(normalizedTokenId);
}
export async function revokeSharedResourceTokens(tokenIds: string[], reason?: string | null): Promise<SharedResourceTokenBulkActionResult> {
export async function revokeSharedResourceTokens(
tokenIds: string[],
reason?: string | null,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
): Promise<SharedResourceTokenBulkActionResult> {
await ensureSharedResourceTokenTables();
const requestedTokenIds = Array.from(new Set(tokenIds.map((tokenId) => normalizeText(tokenId)).filter(Boolean)));
@@ -1853,9 +1965,10 @@ export async function revokeSharedResourceTokens(tokenIds: string[], reason?: st
await appendActivity(trx, tokenId, {
type: 'revoked',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 토큰을 일괄 회수했습니다.',
detail: normalizeText(reason) || existing.resourceLabel,
audit: options?.audit,
});
processedTokenIds.push(tokenId);
@@ -1870,7 +1983,10 @@ export async function revokeSharedResourceTokens(tokenIds: string[], reason?: st
};
}
export async function restoreSharedResourceToken(tokenId: string) {
export async function restoreSharedResourceToken(
tokenId: string,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
await ensureSharedResourceTokenTables();
const normalizedTokenId = normalizeText(tokenId);
@@ -1891,16 +2007,20 @@ export async function restoreSharedResourceToken(tokenId: string) {
await appendActivity(trx, normalizedTokenId, {
type: 'restored',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 토큰을 다시 활성화했습니다.',
detail: existing.token.resourceLabel,
audit: options?.audit,
});
});
return getSharedResourceTokenDetail(normalizedTokenId);
}
export async function deleteSharedResourceToken(tokenId: string) {
export async function deleteSharedResourceToken(
tokenId: string,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
await ensureSharedResourceTokenTables();
const normalizedTokenId = normalizeText(tokenId);
@@ -1927,16 +2047,20 @@ export async function deleteSharedResourceToken(tokenId: string) {
await appendActivity(trx, normalizedTokenId, {
type: 'deleted',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 토큰을 삭제 목록에서 숨기고 사용 이력을 보존했습니다.',
detail: existing.token.resourceLabel,
audit: options?.audit,
});
});
return true;
}
export async function deleteSharedResourceTokens(tokenIds: string[]): Promise<SharedResourceTokenBulkActionResult> {
export async function deleteSharedResourceTokens(
tokenIds: string[],
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
): Promise<SharedResourceTokenBulkActionResult> {
await ensureSharedResourceTokenTables();
const requestedTokenIds = Array.from(new Set(tokenIds.map((tokenId) => normalizeText(tokenId)).filter(Boolean)));
@@ -1981,9 +2105,10 @@ export async function deleteSharedResourceTokens(tokenIds: string[]): Promise<Sh
await appendActivity(trx, tokenId, {
type: 'deleted',
actorLabel: 'manager',
actorLabel: options?.actorLabel ?? 'manager',
summary: '공유 토큰을 삭제 목록에서 숨기고 사용 이력을 보존했습니다.',
detail: existing?.token.resourceLabel ?? null,
audit: options?.audit,
});
processedTokenIds.push(tokenId);
}
@@ -1999,7 +2124,13 @@ export async function deleteSharedResourceTokens(tokenIds: string[]): Promise<Sh
export async function recordSharedResourceTokenUsage(
tokenId: string,
payload: { actorLabel?: string | null; summary?: string | null; detail?: string | null; usageDelta?: number | null } = {},
payload: {
actorLabel?: string | null;
summary?: string | null;
detail?: string | null;
usageDelta?: number | null;
audit?: RequestAuditContext | null;
} = {},
) {
await ensureSharedResourceTokenTables();
@@ -2027,6 +2158,7 @@ export async function recordSharedResourceTokenUsage(
summary: normalizeText(payload.summary) || '공유 URL 사용 이력을 기록했습니다.',
detail: normalizeOptionalText(payload.detail),
usageDelta,
audit: payload.audit,
});
});

View File

@@ -0,0 +1,516 @@
import { spawn } from 'node:child_process';
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { resolveMainProjectRoot } from './main-project-root-service.js';
const TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS = 20 * 60 * 1000;
const TEST_SERVER_DEPLOYMENT_LOG_LIMIT = 4000;
const TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS = 15_000;
export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server';
export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
export type TestServerDeploymentStepSnapshot = {
key: TestServerDeploymentStepKey;
status: TestServerDeploymentStepStatus;
detail: string | null;
updatedAt: string | null;
};
export type TestServerDeploymentPhase =
| 'idle'
| 'commit-main-worktree'
| 'push-origin-main'
| 'build-test-app'
| 'deploy-test-server'
| 'completed'
| 'failed';
export type TestServerDeploymentSnapshot = {
status: 'idle' | 'running' | 'completed' | 'failed';
phase: TestServerDeploymentPhase;
summary: string | null;
startedAt: string | null;
updatedAt: string | null;
completedAt: string | null;
lastError: string | null;
logExcerpt: string | null;
steps: TestServerDeploymentStepSnapshot[];
};
type TestServerDeploymentStateFilePayload = {
status?: unknown;
phase?: unknown;
summary?: unknown;
startedAt?: unknown;
updatedAt?: unknown;
completedAt?: unknown;
lastError?: unknown;
logExcerpt?: unknown;
steps?: unknown;
};
type RestartLockPayload = {
startedAt: string;
key: 'test';
pid: number;
};
const TEST_SERVER_DEPLOYMENT_STEP_KEYS: TestServerDeploymentStepKey[] = [
'commit-main-worktree',
'push-origin-main',
'build-test-app',
'deploy-test-server',
];
function normalizeDateTimeValue(value: string | null | undefined) {
const normalized = value?.trim();
if (!normalized) {
return null;
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
function trimPreview(value: string | null | undefined, maxLength = 220) {
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
if (!normalized) {
return null;
}
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
}
function getTestServerDeploymentStatePath() {
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-state.json');
}
function getTestServerDeploymentLockPath() {
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-in-progress.json');
}
function buildEmptyTestServerDeploymentSnapshot(): TestServerDeploymentSnapshot {
return {
status: 'idle',
phase: 'idle',
summary: null,
startedAt: null,
updatedAt: null,
completedAt: null,
lastError: null,
logExcerpt: null,
steps: TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
key,
status: 'pending',
detail: null,
updatedAt: null,
})),
};
}
function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null {
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey)
? (value as TestServerDeploymentStepKey)
: null;
}
function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase {
return value === 'commit-main-worktree'
|| value === 'push-origin-main'
|| value === 'build-test-app'
|| value === 'deploy-test-server'
|| value === 'completed'
|| value === 'failed'
? value
: 'idle';
}
function normalizeTestServerDeploymentStatus(value: unknown): TestServerDeploymentSnapshot['status'] {
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
}
function normalizeTestServerDeploymentSteps(value: unknown) {
const fallback = buildEmptyTestServerDeploymentSnapshot().steps;
if (!Array.isArray(value)) {
return fallback;
}
const normalizedByKey = new Map<TestServerDeploymentStepKey, TestServerDeploymentStepSnapshot>();
value.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const candidate = item as Record<string, unknown>;
const key = normalizeTestServerDeploymentStepKey(candidate.key);
if (!key) {
return;
}
normalizedByKey.set(key, {
key,
status:
candidate.status === 'running'
|| candidate.status === 'completed'
|| candidate.status === 'failed'
|| candidate.status === 'pending'
? candidate.status
: 'pending',
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
});
});
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
}
function normalizeTestServerDeploymentSnapshot(value: unknown): TestServerDeploymentSnapshot {
if (!value || typeof value !== 'object') {
return buildEmptyTestServerDeploymentSnapshot();
}
const candidate = value as TestServerDeploymentStateFilePayload;
return {
status: normalizeTestServerDeploymentStatus(candidate.status),
phase: normalizeTestServerDeploymentPhase(candidate.phase),
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
steps: normalizeTestServerDeploymentSteps(candidate.steps),
};
}
export async function readTestServerDeploymentState(): Promise<TestServerDeploymentSnapshot | null> {
try {
const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8');
return normalizeTestServerDeploymentSnapshot(JSON.parse(raw));
} catch {
return null;
}
}
async function writeTestServerDeploymentState(snapshot: TestServerDeploymentSnapshot) {
const statePath = getTestServerDeploymentStatePath();
await mkdir(path.dirname(statePath), { recursive: true });
await writeFile(statePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
}
async function clearTestServerDeploymentState() {
await rm(getTestServerDeploymentStatePath(), { force: true }).catch(() => undefined);
}
async function acquireTestServerDeploymentLock() {
const lockPath = getTestServerDeploymentLockPath();
await mkdir(path.dirname(lockPath), { recursive: true });
const startedAt = new Date().toISOString();
try {
const handle = await open(lockPath, 'wx');
try {
await handle.writeFile(JSON.stringify({ startedAt, key: 'test', pid: process.pid } satisfies RestartLockPayload) + '\n', 'utf8');
} finally {
await handle.close();
}
return lockPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
let existingStartedAt: string | null = null;
try {
const raw = await readFile(lockPath, 'utf8');
const parsed = JSON.parse(raw) as Partial<RestartLockPayload>;
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === 'string' ? parsed.startedAt : null);
const lockStat = await stat(lockPath).catch(() => null);
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
await rm(lockPath, { force: true }).catch(() => undefined);
return acquireTestServerDeploymentLock();
}
} catch {
// ignore read failures and keep conflict response below
}
const conflictError = new Error(
existingStartedAt
? `TEST 배포가 이미 진행 중입니다. 시작 시각 ${existingStartedAt}`
: 'TEST 배포가 이미 진행 중입니다.',
);
(conflictError as Error & { statusCode?: number }).statusCode = 409;
throw conflictError;
}
}
function buildTestServerDeploymentSummary(phase: TestServerDeploymentPhase) {
switch (phase) {
case 'commit-main-worktree':
return 'main 작업트리 커밋 진행 중';
case 'push-origin-main':
return 'origin/main 푸시 진행 중';
case 'build-test-app':
return '테스트 앱 빌드 진행 중';
case 'deploy-test-server':
return '테스트 서버 배포 진행 중';
case 'completed':
return 'origin/main 푸시, 테스트 빌드, 테스트 배포가 완료되었습니다.';
case 'failed':
return 'TEST 배포에 실패했습니다.';
default:
return '테스트 배포 준비 중';
}
}
function buildTestDeploymentFailureMessage(
snapshot: Pick<TestServerDeploymentSnapshot, 'logExcerpt'>,
error: unknown,
) {
const failure = error instanceof Error ? (error as Error & { code?: number | string; signal?: string | null }) : null;
const exitInfo = [
failure?.code != null ? `exit:${String(failure.code)}` : null,
failure?.signal ? `signal:${String(failure.signal)}` : null,
].filter(Boolean).join(' ');
const logLines = (snapshot.logExcerpt ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const lastMeaningfulLog = logLines.length > 0 ? logLines[logLines.length - 1] : null;
return trimPreview([
lastMeaningfulLog && lastMeaningfulLog !== failure?.message ? lastMeaningfulLog : null,
failure?.message || null,
exitInfo || null,
].filter(Boolean).join(' | '), 500) ?? 'TEST 배포에 실패했습니다.';
}
function appendTestServerDeploymentLog(previous: string | null, chunk: string) {
const normalizedChunk = chunk.trim();
if (!normalizedChunk) {
return previous;
}
const combined = [previous, normalizedChunk].filter(Boolean).join('\n');
return combined.length > TEST_SERVER_DEPLOYMENT_LOG_LIMIT
? combined.slice(combined.length - TEST_SERVER_DEPLOYMENT_LOG_LIMIT)
: combined;
}
function updateTestServerDeploymentStep(
snapshot: TestServerDeploymentSnapshot,
key: TestServerDeploymentStepKey,
status: TestServerDeploymentStepStatus,
detail?: string | null,
) {
const now = new Date().toISOString();
snapshot.steps = snapshot.steps.map((step) => {
if (step.key !== key) {
return step;
}
return {
...step,
status,
detail: detail === undefined ? step.detail : detail,
updatedAt: now,
};
});
snapshot.updatedAt = now;
}
function markPreviousRunningStepCompleted(snapshot: TestServerDeploymentSnapshot, nextKey: TestServerDeploymentStepKey) {
const previousRunning = snapshot.steps.find((step) => step.status === 'running' && step.key !== nextKey);
if (previousRunning) {
updateTestServerDeploymentStep(snapshot, previousRunning.key, 'completed');
}
}
function scheduleTestServerDeploymentCleanup(completedAt: string) {
const timer = setTimeout(() => {
void (async () => {
const snapshot = await readTestServerDeploymentState();
if (snapshot?.status === 'completed' && snapshot.completedAt === completedAt) {
await clearTestServerDeploymentState();
}
})();
}, TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS);
if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
timer.unref();
}
}
async function runTestServerDeployment(
lockPath: string,
snapshot: TestServerDeploymentSnapshot,
persist: () => Promise<void>,
) {
const mainProjectRoot = resolveMainProjectRoot();
const deployScript = path.join(mainProjectRoot, 'etc', 'commands', 'server-command', 'deploy-test.sh');
const moveToStep = (key: TestServerDeploymentStepKey) => {
markPreviousRunningStepCompleted(snapshot, key);
snapshot.phase = key;
snapshot.summary = buildTestServerDeploymentSummary(key);
updateTestServerDeploymentStep(snapshot, key, 'running');
void persist();
};
const appendOutput = (line: string) => {
snapshot.logExcerpt = appendTestServerDeploymentLog(snapshot.logExcerpt, line);
snapshot.updatedAt = new Date().toISOString();
void persist();
};
const fail = async (message: string) => {
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key ?? 'commit-main-worktree';
snapshot.status = 'failed';
snapshot.phase = 'failed';
snapshot.summary = buildTestServerDeploymentSummary('failed');
snapshot.lastError = message;
snapshot.updatedAt = new Date().toISOString();
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', message);
await persist();
};
try {
await new Promise<void>((resolve, reject) => {
const child = spawn('sh', [deployScript], {
cwd: mainProjectRoot,
env: {
...process.env,
MAIN_PROJECT_ROOT: mainProjectRoot,
REPO_ROOT: mainProjectRoot,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdoutBuffer = '';
let stderrBuffer = '';
const processLine = (line: string) => {
const trimmed = line.trim();
const marker = trimmed.match(/^::step::([a-z-]+)$/);
if (marker) {
const nextStep = normalizeTestServerDeploymentStepKey(marker[1]);
if (nextStep) {
moveToStep(nextStep);
}
return;
}
appendOutput(line);
};
const flushBufferedLines = (buffer: string) => {
const normalized = buffer.replace(/\r$/, '').trim();
if (normalized) {
processLine(normalized);
}
};
const attachReader = (stream: NodeJS.ReadableStream | null, target: 'stdout' | 'stderr') => {
if (!stream) {
return;
}
stream.setEncoding('utf8');
stream.on('data', (chunk: string) => {
if (target === 'stdout') {
stdoutBuffer += chunk;
while (stdoutBuffer.includes('\n')) {
const index = stdoutBuffer.indexOf('\n');
const line = stdoutBuffer.slice(0, index).replace(/\r$/, '');
stdoutBuffer = stdoutBuffer.slice(index + 1);
processLine(line);
}
return;
}
stderrBuffer += chunk;
while (stderrBuffer.includes('\n')) {
const index = stderrBuffer.indexOf('\n');
const line = stderrBuffer.slice(0, index).replace(/\r$/, '');
stderrBuffer = stderrBuffer.slice(index + 1);
processLine(line);
}
});
};
attachReader(child.stdout, 'stdout');
attachReader(child.stderr, 'stderr');
child.once('error', reject);
child.once('close', (code, signal) => {
flushBufferedLines(stdoutBuffer);
flushBufferedLines(stderrBuffer);
if (code === 0) {
resolve();
return;
}
reject(Object.assign(new Error(`deploy-test exited with ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`), {
code,
signal,
}));
});
});
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
if (activeStep) {
updateTestServerDeploymentStep(snapshot, activeStep, 'completed');
}
const completedAt = new Date().toISOString();
snapshot.status = 'completed';
snapshot.phase = 'completed';
snapshot.summary = buildTestServerDeploymentSummary('completed');
snapshot.completedAt = completedAt;
snapshot.updatedAt = completedAt;
snapshot.lastError = null;
await persist();
scheduleTestServerDeploymentCleanup(completedAt);
} catch (error) {
const message = buildTestDeploymentFailureMessage(snapshot, error);
await fail(message);
} finally {
await rm(lockPath, { force: true }).catch(() => undefined);
}
}
export async function startTestServerDeployment() {
const lockPath = await acquireTestServerDeploymentLock();
const startedAt = new Date().toISOString();
const snapshot = buildEmptyTestServerDeploymentSnapshot();
snapshot.status = 'running';
snapshot.phase = 'commit-main-worktree';
snapshot.summary = buildTestServerDeploymentSummary('commit-main-worktree');
snapshot.startedAt = startedAt;
snapshot.updatedAt = startedAt;
updateTestServerDeploymentStep(snapshot, 'commit-main-worktree', 'running', 'main 작업트리 변경을 커밋합니다.');
await writeTestServerDeploymentState(snapshot);
let persistQueue = Promise.resolve();
const persist = async () => {
persistQueue = persistQueue.then(() => writeTestServerDeploymentState(snapshot)).catch(() => undefined);
await persistQueue;
};
void runTestServerDeployment(lockPath, snapshot, persist);
return snapshot;
}

View File

@@ -0,0 +1,154 @@
import { db } from '../db/client.js';
import type { RequestAuditContext } from '../utils/request-audit.js';
const TOKEN_SETTING_ACTIVITIES_TABLE = 'token_setting_activities';
export type TokenSettingActivityRecord = {
id: number;
settingId: string;
activityType: 'created' | 'updated' | 'deleted';
actorLabel: string | null;
summary: string;
detail: string | null;
clientIp: string | null;
externalIp: string | null;
forwardedFor: string | null;
realIp: string | null;
host: string | null;
origin: string | null;
referer: string | null;
userAgent: string | null;
clientId: string | null;
createdAt: string;
};
export type TokenSettingActivityInput = {
settingId: string;
activityType: TokenSettingActivityRecord['activityType'];
actorLabel?: string | null;
summary: string;
detail?: string | null;
audit?: RequestAuditContext | null;
};
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeOptionalText(value: unknown) {
const normalized = normalizeText(value);
return normalized || null;
}
function normalizeDateTime(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
const timestamp = Date.parse(normalized);
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
}
export async function ensureTokenSettingActivityTable() {
const hasTable = await db.schema.hasTable(TOKEN_SETTING_ACTIVITIES_TABLE);
if (!hasTable) {
await db.schema.createTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => {
table.increments('id').primary();
table.string('setting_id', 120).notNullable().index();
table.string('activity_type', 40).notNullable();
table.string('actor_label', 120).nullable();
table.string('summary', 400).notNullable();
table.text('detail').nullable();
table.string('client_ip', 120).nullable();
table.string('external_ip', 120).nullable();
table.text('forwarded_for').nullable();
table.string('real_ip', 120).nullable();
table.string('host', 255).nullable();
table.text('origin').nullable();
table.text('referer').nullable();
table.text('user_agent').nullable();
table.string('client_id', 255).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['setting_id', (table) => table.string('setting_id', 120).notNullable().defaultTo('').index()],
['activity_type', (table) => table.string('activity_type', 40).notNullable().defaultTo('updated')],
['actor_label', (table) => table.string('actor_label', 120).nullable()],
['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')],
['detail', (table) => table.text('detail').nullable()],
['client_ip', (table) => table.string('client_ip', 120).nullable()],
['external_ip', (table) => table.string('external_ip', 120).nullable()],
['forwarded_for', (table) => table.text('forwarded_for').nullable()],
['real_ip', (table) => table.string('real_ip', 120).nullable()],
['host', (table) => table.string('host', 255).nullable()],
['origin', (table) => table.text('origin').nullable()],
['referer', (table) => table.text('referer').nullable()],
['user_agent', (table) => table.text('user_agent').nullable()],
['client_id', (table) => table.string('client_id', 255).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(TOKEN_SETTING_ACTIVITIES_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => {
createColumn(table);
});
}
}
}
export async function appendTokenSettingActivity(input: TokenSettingActivityInput) {
await ensureTokenSettingActivityTable();
await db(TOKEN_SETTING_ACTIVITIES_TABLE).insert({
setting_id: normalizeText(input.settingId),
activity_type: input.activityType,
actor_label: normalizeOptionalText(input.actorLabel),
summary: normalizeText(input.summary),
detail: normalizeOptionalText(input.detail),
client_ip: normalizeOptionalText(input.audit?.clientIp),
external_ip: normalizeOptionalText(input.audit?.externalIp),
forwarded_for: normalizeOptionalText(input.audit?.forwardedFor),
real_ip: normalizeOptionalText(input.audit?.realIp),
host: normalizeOptionalText(input.audit?.host),
origin: normalizeOptionalText(input.audit?.origin),
referer: normalizeOptionalText(input.audit?.referer),
user_agent: normalizeOptionalText(input.audit?.userAgent),
client_id: normalizeOptionalText(input.audit?.clientId),
created_at: db.fn.now(),
});
}
export async function listTokenSettingActivities(settingId: string) {
await ensureTokenSettingActivityTable();
const rows = await db(TOKEN_SETTING_ACTIVITIES_TABLE)
.select('*')
.where({ setting_id: normalizeText(settingId) })
.orderBy('created_at', 'desc')
.limit(200);
return rows.map((row) => ({
id: Number(row.id),
settingId: normalizeText(row.setting_id),
activityType: (normalizeText(row.activity_type) as TokenSettingActivityRecord['activityType']) || 'updated',
actorLabel: normalizeOptionalText(row.actor_label),
summary: normalizeText(row.summary),
detail: normalizeOptionalText(row.detail),
clientIp: normalizeOptionalText(row.client_ip),
externalIp: normalizeOptionalText(row.external_ip),
forwardedFor: normalizeOptionalText(row.forwarded_for),
realIp: normalizeOptionalText(row.real_ip),
host: normalizeOptionalText(row.host),
origin: normalizeOptionalText(row.origin),
referer: normalizeOptionalText(row.referer),
userAgent: normalizeOptionalText(row.user_agent),
clientId: normalizeOptionalText(row.client_id),
createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(),
}));
}

View File

@@ -1,4 +1,6 @@
import { db } from '../db/client.js';
import { appendTokenSettingActivity, ensureTokenSettingActivityTable } from './token-setting-activity-service.js';
import type { RequestAuditContext } from '../utils/request-audit.js';
const TOKEN_SETTINGS_TABLE = 'token_settings';
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
@@ -173,6 +175,7 @@ async function ensureTokenSettingsTable() {
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
await ensureTokenSettingActivityTable();
return;
}
@@ -200,6 +203,8 @@ async function ensureTokenSettingsTable() {
});
}
}
await ensureTokenSettingActivityTable();
}
function parseAllowedAppIds(row: Record<string, unknown>) {
@@ -289,10 +294,45 @@ async function readTokenSettingsFromTable() {
);
}
async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) {
function buildActivityDetail(previous: TokenSettingRecord | null, next: TokenSettingRecord | null) {
if (!previous && next) {
return `${next.allowedAppIds.join(', ') || '-'} / 기본 ${next.defaultExpiresInMinutes}`;
}
if (previous && !next) {
return `삭제 전 앱 ${previous.allowedAppIds.join(', ') || '-'} / 기본 ${previous.defaultExpiresInMinutes}`;
}
if (!previous || !next) {
return null;
}
const changedFields: string[] = [];
if (previous.name !== next.name) changedFields.push(`이름 ${previous.name} -> ${next.name}`);
if (previous.description !== next.description) changedFields.push('설명');
if (previous.defaultExpiresInMinutes !== next.defaultExpiresInMinutes) changedFields.push(`기본만료 ${previous.defaultExpiresInMinutes} -> ${next.defaultExpiresInMinutes}`);
if (previous.maxTokensPer30Days !== next.maxTokensPer30Days) changedFields.push(`30일 ${previous.maxTokensPer30Days} -> ${next.maxTokensPer30Days}`);
if (previous.maxTokensPer7Days !== next.maxTokensPer7Days) changedFields.push(`7일 ${previous.maxTokensPer7Days} -> ${next.maxTokensPer7Days}`);
if (previous.maxTokensPer5Hours !== next.maxTokensPer5Hours) changedFields.push(`5시간 ${previous.maxTokensPer5Hours} -> ${next.maxTokensPer5Hours}`);
if (previous.oneTimeTokenLimit !== next.oneTimeTokenLimit) changedFields.push(`1회 ${previous.oneTimeTokenLimit} -> ${next.oneTimeTokenLimit}`);
if (previous.enabled !== next.enabled) changedFields.push(`사용 ${previous.enabled} -> ${next.enabled}`);
if (JSON.stringify(previous.allowedAppIds) !== JSON.stringify(next.allowedAppIds)) {
changedFields.push(`${previous.allowedAppIds.join(', ') || '-'} -> ${next.allowedAppIds.join(', ') || '-'}`);
}
return changedFields.join(' / ') || null;
}
async function replaceTokenSettingsInTable(
items: TokenSettingRecord[],
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
await ensureTokenSettingsTable();
const previousItems = await readTokenSettingsFromTable();
const nextItems = sanitizeTokenSettings(items);
const previousById = new Map(previousItems.map((item) => [item.id, item] as const));
const nextById = new Map(nextItems.map((item) => [item.id, item] as const));
await db.transaction(async (trx) => {
await trx(TOKEN_SETTINGS_TABLE).del();
@@ -318,6 +358,48 @@ async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) {
}
});
const affectedIds = Array.from(new Set([...previousById.keys(), ...nextById.keys()]));
for (const settingId of affectedIds) {
const previous = previousById.get(settingId) ?? null;
const next = nextById.get(settingId) ?? null;
if (!previous && next) {
await appendTokenSettingActivity({
settingId,
activityType: 'created',
actorLabel: options?.actorLabel ?? 'manager',
summary: '토큰 설정을 생성했습니다.',
detail: buildActivityDetail(previous, next),
audit: options?.audit,
});
continue;
}
if (previous && !next) {
await appendTokenSettingActivity({
settingId,
activityType: 'deleted',
actorLabel: options?.actorLabel ?? 'manager',
summary: '토큰 설정을 삭제했습니다.',
detail: buildActivityDetail(previous, next),
audit: options?.audit,
});
continue;
}
if (previous && next && JSON.stringify(previous) !== JSON.stringify(next)) {
await appendTokenSettingActivity({
settingId,
activityType: 'updated',
actorLabel: options?.actorLabel ?? 'manager',
summary: '토큰 설정을 수정했습니다.',
detail: buildActivityDetail(previous, next),
audit: options?.audit,
});
}
}
return nextItems;
}
@@ -325,8 +407,11 @@ export async function getTokenSettingsConfig() {
return readTokenSettingsFromTable();
}
export async function upsertTokenSettingsConfig(items: Partial<TokenSettingRecord>[] | null | undefined) {
return replaceTokenSettingsInTable(sanitizeTokenSettings(items));
export async function upsertTokenSettingsConfig(
items: Partial<TokenSettingRecord>[] | null | undefined,
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
) {
return replaceTokenSettingsInTable(sanitizeTokenSettings(items), options);
}
export async function getTokenSettingById(id: string) {

View File

@@ -0,0 +1,51 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { env } from '../config/env.js';
type WorkServerSlot = 'blue' | 'green';
function normalizeSlot(value: string | null | undefined): WorkServerSlot | null {
const normalized = String(value ?? '').trim().toLowerCase();
return normalized === 'blue' || normalized === 'green' ? normalized : null;
}
function buildActiveSlotFileCandidates() {
const candidates = [
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim(),
path.join(env.SERVER_COMMAND_MAIN_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
path.join(env.SERVER_COMMAND_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
path.join(env.SERVER_COMMAND_PROJECT_ROOT, '.docker', 'runtime', 'active-slot'),
]
.map((value) => String(value ?? '').trim())
.filter(Boolean);
return [...new Set(candidates)];
}
async function readActiveSlotFromFile() {
for (const candidate of buildActiveSlotFileCandidates()) {
try {
const value = await readFile(candidate, 'utf8');
const slot = normalizeSlot(value);
if (slot) {
return slot;
}
} catch {
// Ignore missing or unreadable candidates and continue.
}
}
return null;
}
export async function isCurrentWorkServerSlotActive() {
const currentSlot = normalizeSlot(process.env.WORK_SERVER_SLOT);
if (!currentSlot) {
return true;
}
const activeSlot = (await readActiveSlotFromFile()) ?? 'blue';
return currentSlot === activeSlot;
}

View File

@@ -0,0 +1,105 @@
import type { FastifyRequest } from 'fastify';
export type RequestAuditContext = {
clientIp: string | null;
externalIp: string | null;
forwardedFor: string | null;
realIp: string | null;
host: string | null;
origin: string | null;
referer: string | null;
userAgent: string | null;
clientId: string | null;
};
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHeaderValue(value: unknown) {
if (Array.isArray(value)) {
return normalizeText(value[0]);
}
return normalizeText(value);
}
function splitForwardedFor(value: string) {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function stripIpDecorations(value: string) {
const normalized = value.replace(/^for=/iu, '').replace(/^"|"$/g, '').trim();
if (normalized.startsWith('[') && normalized.includes(']')) {
return normalized.slice(1, normalized.indexOf(']')).trim();
}
const ipv4PortMatch = normalized.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/u);
if (ipv4PortMatch) {
return ipv4PortMatch[1] ?? normalized;
}
return normalized;
}
function isPrivateOrLocalIp(value: string) {
const normalized = stripIpDecorations(value).toLowerCase();
if (!normalized) {
return true;
}
if (normalized === '::1' || normalized === 'localhost') {
return true;
}
if (normalized.startsWith('127.') || normalized.startsWith('10.') || normalized.startsWith('192.168.')) {
return true;
}
if (/^172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) {
return true;
}
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:')) {
return true;
}
if (normalized.startsWith('::ffff:127.') || normalized.startsWith('::ffff:10.') || normalized.startsWith('::ffff:192.168.')) {
return true;
}
if (/^::ffff:172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) {
return true;
}
return false;
}
function resolveExternalIp(candidates: string[]) {
const cleaned = candidates.map((item) => stripIpDecorations(item)).filter(Boolean);
return cleaned.find((item) => !isPrivateOrLocalIp(item)) ?? cleaned[0] ?? null;
}
export function extractRequestAuditContext(request: FastifyRequest): RequestAuditContext {
const forwardedFor = normalizeHeaderValue(request.headers['x-forwarded-for']);
const realIp = normalizeHeaderValue(request.headers['x-real-ip']) || normalizeHeaderValue(request.headers['cf-connecting-ip']);
const clientIp = normalizeText(request.ip) || normalizeText(request.raw.socket.remoteAddress) || null;
return {
clientIp: clientIp ? stripIpDecorations(clientIp) : null,
externalIp: resolveExternalIp([
...splitForwardedFor(forwardedFor),
realIp,
normalizeText(request.headers['x-client-ip']),
clientIp ?? '',
]),
forwardedFor: forwardedFor || null,
realIp: realIp ? stripIpDecorations(realIp) : null,
host: normalizeHeaderValue(request.headers.host) || null,
origin: normalizeHeaderValue(request.headers.origin) || null,
referer: normalizeHeaderValue(request.headers.referer) || null,
userAgent: normalizeHeaderValue(request.headers['user-agent']) || null,
clientId: normalizeHeaderValue(request.headers['x-client-id']) || null,
};
}

View File

@@ -1,5 +1,6 @@
import type { FastifyBaseLogger } from 'fastify';
import { processDueBaseballTicketBayAlerts } from '../services/baseball-ticket-bay-service.js';
import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js';
const DEFAULT_INTERVAL_MS = 60_000;
@@ -38,6 +39,10 @@ export class BaseballTicketBayWorker {
return;
}
if (!(await isCurrentWorkServerSlotActive())) {
return;
}
this.running = true;
try {