feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

172
etc/commands/server-command/restart-work-server.sh Normal file → Executable file
View File

@@ -5,7 +5,177 @@ set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
PROXY_SERVICE="${WORK_SERVER_PROXY_SERVICE:-work-server}"
PROXY_CONTAINER="${WORK_SERVER_PROXY_CONTAINER:-work-server}"
BLUE_SERVICE="${WORK_SERVER_BLUE_SERVICE:-work-server-blue}"
GREEN_SERVICE="${WORK_SERVER_GREEN_SERVICE:-work-server-green}"
BLUE_CONTAINER="${WORK_SERVER_BLUE_CONTAINER:-work-server-blue}"
GREEN_CONTAINER="${WORK_SERVER_GREEN_CONTAINER:-work-server-green}"
ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/active-slot}"
PROXY_CONFIG_FILE="${WORK_SERVER_PROXY_CONFIG_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/proxy/default.conf}"
HEALTH_ENDPOINT="${WORK_SERVER_HEALTH_ENDPOINT:-http://127.0.0.1:3100/health}"
RUNTIME_ENDPOINT="${WORK_SERVER_RUNTIME_ENDPOINT:-http://127.0.0.1:3100/api/runtime}"
PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}"
cd "$REPO_ROOT"
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server
mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")"
read_active_slot() {
if [ -f "$ACTIVE_SLOT_FILE" ]; then
SLOT=$(tr -d '[:space:]' <"$ACTIVE_SLOT_FILE")
if [ "$SLOT" = "blue" ] || [ "$SLOT" = "green" ]; then
printf '%s' "$SLOT"
return 0
fi
fi
printf 'blue'
}
container_is_running() {
CONTAINER_NAME="$1"
STATUS=$(docker inspect -f '{{.State.Status}}' "$CONTAINER_NAME" 2>/dev/null || true)
[ "$STATUS" = "running" ]
}
resolve_active_slot() {
SLOT=$(read_active_slot)
if [ "$SLOT" = "blue" ] && ! container_is_running "$BLUE_CONTAINER" && container_is_running "$GREEN_CONTAINER"; then
printf 'green'
return 0
fi
if [ "$SLOT" = "green" ] && ! container_is_running "$GREEN_CONTAINER" && container_is_running "$BLUE_CONTAINER"; then
printf 'blue'
return 0
fi
printf '%s' "$SLOT"
}
write_proxy_config() {
SLOT="$1"
TARGET_CONTAINER="$BLUE_CONTAINER"
if [ "$SLOT" = "green" ]; then
TARGET_CONTAINER="$GREEN_CONTAINER"
fi
cat >"$PROXY_CONFIG_FILE" <<EOF2
server {
listen 3100;
server_name _;
location /ws/chat {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://$TARGET_CONTAINER:3100;
}
location / {
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
proxy_set_header Connection "";
proxy_pass http://$TARGET_CONTAINER:3100;
}
}
EOF2
}
wait_for_container_health() {
TARGET_CONTAINER="$1"
ATTEMPT=0
while [ "$ATTEMPT" -lt 60 ]; do
if docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then(async (response) => { if (!response.ok) process.exit(1); process.stdout.write(await response.text()); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" >/dev/null 2>&1; then
return 0
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 2
done
echo "health check failed for $TARGET_CONTAINER" >&2
return 1
}
read_runtime_value() {
TARGET_CONTAINER="$1"
FIELD_NAME="$2"
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then((response) => response.json()).then((payload) => { const value = payload?.[process.argv[2]]; if (typeof value === 'boolean') { process.stdout.write(value ? 'true' : 'false'); return; } if (value == null) { process.stdout.write(''); return; } process.stdout.write(String(value)); }).catch(() => process.exit(1));" "$RUNTIME_ENDPOINT" "$FIELD_NAME"
}
set_container_draining() {
TARGET_CONTAINER="$1"
DRAINING_VALUE="$2"
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ draining: process.argv[2] === 'true' }) }).then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));" "${RUNTIME_ENDPOINT}/drain" "$DRAINING_VALUE"
}
wait_for_previous_slot_drain() {
TARGET_CONTAINER="$1"
ELAPSED=0
while [ "$ELAPSED" -lt "$PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS" ]; do
ACTIVE_COUNT=$(read_runtime_value "$TARGET_CONTAINER" activeChatRequestCount 2>/dev/null || printf '0')
QUEUED_COUNT=$(read_runtime_value "$TARGET_CONTAINER" queuedChatRequestCount 2>/dev/null || printf '0')
if [ "${ACTIVE_COUNT:-0}" = "0" ] && [ "${QUEUED_COUNT:-0}" = "0" ]; then
return 0
fi
sleep 2
ELAPSED=$((ELAPSED + 2))
done
echo "drain timeout reached for $TARGET_CONTAINER" >&2
return 1
}
ensure_proxy_running() {
docker compose -f "$COMPOSE_FILE" up -d --no-deps "$PROXY_SERVICE" >/dev/null
docker exec "$PROXY_CONTAINER" nginx -s reload >/dev/null
}
ACTIVE_SLOT=$(resolve_active_slot)
TARGET_SLOT="green"
TARGET_SERVICE="$GREEN_SERVICE"
TARGET_CONTAINER="$GREEN_CONTAINER"
PREVIOUS_SERVICE="$BLUE_SERVICE"
PREVIOUS_CONTAINER="$BLUE_CONTAINER"
if [ "$ACTIVE_SLOT" = "green" ]; then
TARGET_SLOT="blue"
TARGET_SERVICE="$BLUE_SERVICE"
TARGET_CONTAINER="$BLUE_CONTAINER"
PREVIOUS_SERVICE="$GREEN_SERVICE"
PREVIOUS_CONTAINER="$GREEN_CONTAINER"
fi
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE"
wait_for_container_health "$TARGET_CONTAINER"
write_proxy_config "$TARGET_SLOT"
ensure_proxy_running
printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE"
if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then
set_container_draining "$PREVIOUS_CONTAINER" true
wait_for_previous_slot_drain "$PREVIOUS_CONTAINER"
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE"
wait_for_container_health "$PREVIOUS_CONTAINER"
fi
printf 'work-server zero-downtime switch completed: %s -> %s\n' "$ACTIVE_SLOT" "$TARGET_SLOT"

View File

@@ -1,3 +1,4 @@
node_modules
dist
.dist-verify-actual
.env

View File

@@ -17,7 +17,13 @@ docker compose up -d
docker compose logs -f work-server
```
`work-server`HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
`work-server``3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
```bash
docker compose logs -f work-server work-server-blue work-server-green
```
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner를 사용합니다. 이 runner는 별도 명시적 요청이 있을 때만 수동으로 켜거나 재기동합니다.

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,25 @@
services:
work-server:
image: nginx:1.27-alpine
container_name: work-server
logging:
driver: json-file
options:
max-size: "200m"
max-file: "2"
mem_limit: 256m
ports:
- '127.0.0.1:3100:3100'
volumes:
- ./.docker/proxy/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- work-backend
work-server-blue:
build:
context: .
dockerfile: Dockerfile
container_name: work-server
container_name: work-server-blue
logging:
driver: json-file
options:
@@ -19,8 +35,6 @@ services:
required: false
- path: ./.env
required: false
ports:
- '127.0.0.1:3100:3100'
volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
@@ -43,6 +57,57 @@ services:
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
NPM_CONFIG_CACHE: /home/how2ice/.npm
WORK_SERVER_DIST_DIR: /app/dist
WORK_SERVER_SLOT: blue
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
DOCKER_HOST: ${DOCKER_HOST:-}
networks:
- work-backend
extra_hosts:
- 'host.docker.internal:host-gateway'
work-server-green:
build:
context: .
dockerfile: Dockerfile
container_name: work-server-green
logging:
driver: json-file
options:
max-size: "200m"
max-file: "2"
user: "0:0"
group_add:
- "${SERVER_COMMAND_DOCKER_GID:-984}"
mem_limit: 2048m
working_dir: /app
env_file:
- path: ./.env.example
required: false
- path: ./.env
required: false
volumes:
- ./:/app
- work-server-node-modules:/app/node_modules
- ../../../:/workspace/main-project
- ../../../.auto_codex:/workspace/auto_codex
- ../../../scripts:/workspace/repo-scripts:ro
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
- ./.docker/home:/home/how2ice
- ./.docker/codex-home:/codex-home
- ./.docker/codex-home-template:/codex-home-template
environment:
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
HOME: /home/how2ice
CODEX_HOME: /codex-home
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
NPM_CONFIG_CACHE: /home/how2ice/.npm
WORK_SERVER_DIST_DIR: /app/dist
WORK_SERVER_SLOT: green
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
DOCKER_HOST: ${DOCKER_HOST:-}
networks:

View File

@@ -7,12 +7,14 @@ import { registerDdlRoutes } from './routes/ddl.js';
import { registerErrorLogRoutes } from './routes/error-log.js';
import { registerHealthRoutes } from './routes/health.js';
import { registerAppConfigRoutes } from './routes/app-config.js';
import { registerBaseballTicketBayRoutes } from './routes/baseball-ticket-bay.js';
import { registerChatRoutes } from './routes/chat.js';
import { registerNotificationRoutes } from './routes/notification.js';
import { registerPlanRoutes } from './routes/plan.js';
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
import { registerReaderRoutes } from './routes/reader.js';
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
import { registerRuntimeRoutes } from './routes/runtime.js';
import { registerServerCommandRoutes } from './routes/server-command.js';
import { registerSchemaRoutes } from './routes/schema.js';
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
@@ -22,6 +24,20 @@ import { registerTextMemoRoutes } from './routes/text-memo.js';
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
import { shouldPersistNotFoundErrorLog } from './not-found.js';
import { createErrorLog } from './services/error-log-service.js';
import {
isRuntimeDraining,
trackHttpRequestFinished,
trackHttpRequestStarted,
} from './services/runtime-drain-service.js';
function isDrainAllowedPath(method: string, url: string) {
return method === 'OPTIONS'
|| url === '/'
|| url === '/api'
|| url === '/health'
|| url.startsWith('/api/runtime')
|| url.startsWith('/api/server-commands');
}
export function createApp() {
const app = Fastify({
@@ -35,10 +51,37 @@ export function createApp() {
origin: true,
});
app.addHook('onRequest', async (request, reply) => {
trackHttpRequestStarted();
let finished = false;
const finalize = () => {
if (finished) {
return;
}
finished = true;
trackHttpRequestFinished();
reply.raw.off('finish', finalize);
reply.raw.off('close', finalize);
};
reply.raw.on('finish', finalize);
reply.raw.on('close', finalize);
if (isRuntimeDraining() && !isDrainAllowedPath(request.method, request.url)) {
reply.code(503).send({
ok: false,
message: '이 서버는 배포 전환 중이라 새 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
});
return reply;
}
});
registerJsonBodyParser(app);
app.register(registerBoardRoutes);
app.register(registerHealthRoutes);
app.register(registerAppConfigRoutes);
app.register(registerBaseballTicketBayRoutes);
app.register(registerChatRoutes);
app.register(registerSchemaRoutes);
app.register(registerDdlRoutes);
@@ -51,6 +94,7 @@ export function createApp() {
app.register(registerPhotoPrismRoutes);
app.register(registerReaderRoutes);
app.register(registerResourceManagerRoutes);
app.register(registerRuntimeRoutes);
app.register(registerSharedResourceTokenRoutes);
app.register(registerServerCommandRoutes);
app.register(registerTextMemoRoutes);

View File

@@ -79,6 +79,7 @@ const envSchema = z.object({
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE: z.string().optional(),
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'),

View File

@@ -0,0 +1,205 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
createBaseballTicketBayAlert,
createBaseballTicketBayLog,
deleteBaseballTicketBayLog,
deleteBaseballTicketBayAlert,
listBaseballTicketBayAlerts,
listBaseballTicketBayLogs,
runBaseballTicketBayAlert,
searchBaseballTicketBayListings,
updateBaseballTicketBayAlert,
} from '../services/baseball-ticket-bay-service.js';
const timeWindowSchema = z.object({
id: z.string().trim().min(1),
start: z.string().trim().regex(/^\d{2}:\d{2}$/),
end: z.string().trim().regex(/^\d{2}:\d{2}$/),
});
const alertPayloadSchema = z.object({
title: z.string().trim().min(1).max(255),
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
team: z.string().trim().min(1).max(50),
zone: z.string().trim().min(1).max(100),
aisleSide: z.string().trim().min(1).max(100),
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10),
maxPrice: z.number().finite().positive().nullable(),
seatCount: z.number().int().positive().max(10),
batchIntervalMinutes: z.number().int().min(1).max(120),
sameProductAlertEnabled: z.boolean(),
sameProductNotifyOnce: z.boolean(),
active: z.boolean().default(true),
timeWindows: z.array(timeWindowSchema).min(1).max(24),
});
function readHeader(request: { headers: Record<string, string | string[] | undefined> }, key: string) {
const raw = request.headers[key];
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
}
export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {}));
app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림 목록을 불러올 수 없습니다.' });
}
return {
ok: true,
items: await listBaseballTicketBayAlerts(clientId),
};
});
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 불러올 수 없습니다.' });
}
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
return {
ok: true,
items: await listBaseballTicketBayLogs(clientId, query.alertId),
};
});
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 삭제할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const item = await deleteBaseballTicketBayLog(params.id, clientId);
if (!item) {
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
}
return {
ok: true,
item,
};
});
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 저장할 수 없습니다.' });
}
const payload = alertPayloadSchema.parse(request.body ?? {});
const item = await createBaseballTicketBayAlert(payload, {
clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
});
await createBaseballTicketBayLog({
clientId,
alertId: item.id,
alertTitle: item.title,
action: 'create',
status: 'info',
message: '알림 조건을 저장했습니다.',
detail: `${item.team} · ${item.eventDate}`,
});
return {
ok: true,
item,
};
});
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 수정할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
const item = await updateBaseballTicketBayAlert(params.id, payload, {
clientId,
appOrigin: readHeader(request, 'x-app-origin'),
appDomain: readHeader(request, 'x-app-domain'),
});
await createBaseballTicketBayLog({
clientId,
alertId: item.id,
alertTitle: item.title,
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
status: 'info',
message:
payload.active === false
? '알림을 중지했습니다.'
: payload.active === true
? '알림을 다시 실행 상태로 전환했습니다.'
: '알림 조건을 수정 저장했습니다.',
detail: `${item.team} · ${item.eventDate}`,
});
return {
ok: true,
item,
};
});
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 삭제할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const item = await deleteBaseballTicketBayAlert(params.id, clientId);
if (!item) {
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
}
await createBaseballTicketBayLog({
clientId,
alertId: item.id,
alertTitle: item.title,
action: 'delete',
status: 'info',
message: '알림 항목을 삭제했습니다.',
detail: `${item.team} · ${item.eventDate}`,
});
return {
ok: true,
item,
};
});
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
const clientId = readHeader(request, 'x-client-id');
if (!clientId) {
return reply.code(400).send({ message: '클라이언트 ID가 없어 즉시 실행할 수 없습니다.' });
}
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true });
return {
ok: true,
alert: result.alert,
matches: result.matches,
notifiedMatches: result.notifiedMatches,
log: result.log,
};
});
}

View File

@@ -23,9 +23,11 @@ import {
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
import {
assignSharedResourceTokenToRequests,
appendChatConversationActivityLine,
appendChatConversationMessage,
buildChatPromptTargetSignature,
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
cancelUnansweredChatConversationRequest,
clearChatConversationData,
createChatConversation,
deleteUnansweredChatConversationRequest,
@@ -90,6 +92,15 @@ const chatPromptContextRefSchema = z
.optional()
.nullable();
const chatComposerAttachmentSchema = z.object({
id: z.string().trim().min(1).max(240),
name: z.string().trim().min(1).max(500),
path: z.string().trim().min(1).max(4000),
publicUrl: z.string().trim().min(1).max(4000),
size: z.number().finite().min(0).max(CHAT_ATTACHMENT_FILE_SIZE_LIMIT),
mimeType: z.string().trim().min(1).max(255),
});
async function findExistingActivePromptFollowupRequest(
sessionId: string,
parentRequestId: string,
@@ -244,6 +255,127 @@ function createManagedChatShareMessageIds() {
};
}
function sortShareMessages(messages: ListedChatConversationMessage[]) {
return [...messages].sort((left, right) => {
if (left.id !== right.id) {
return left.id - right.id;
}
return left.timestamp.localeCompare(right.timestamp);
});
}
function sortShareRequests(requests: ListedChatConversationRequest[]) {
return [...requests].sort((left, right) => {
const createdAtDiff = left.createdAt.localeCompare(right.createdAt);
if (createdAtDiff !== 0) {
return createdAtDiff;
}
return left.requestId.localeCompare(right.requestId);
});
}
async function materializeManagedShareConversation(args: {
shareSnapshot: NonNullable<Awaited<ReturnType<typeof buildChatShareSnapshot>>>;
managedResourceTokenId: string;
ownerClientId: string | null;
shareTitle: string;
}) {
const { shareSnapshot, managedResourceTokenId, ownerClientId, shareTitle } = args;
const sessionId = createManagedChatShareSessionId();
const requestIdSet = new Set(shareSnapshot.requests.map((item) => item.requestId.trim()).filter(Boolean));
const sortedRequests = sortShareRequests(shareSnapshot.requests);
const sortedMessages = sortShareMessages(shareSnapshot.messages);
const sourceConversation = shareSnapshot.conversation;
await createChatConversation({
sessionId,
clientId: ownerClientId,
title: shareTitle,
draftText: '',
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
codexModel: sourceConversation?.codexModel ?? null,
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
generalSectionName: sourceConversation?.generalSectionName ?? null,
contextLabel: sourceConversation?.contextLabel ?? null,
contextDescription: sourceConversation?.contextDescription ?? null,
notifyOffline: true,
});
for (const message of sortedMessages) {
await appendChatConversationMessage(
{
sessionId,
clientId: ownerClientId,
title: shareTitle,
requestBadgeLabel: sourceConversation?.requestBadgeLabel ?? null,
codexModel: sourceConversation?.codexModel ?? null,
chatTypeId: sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
lastChatTypeId: sourceConversation?.lastChatTypeId ?? sourceConversation?.chatTypeId ?? null,
generalSectionName: sourceConversation?.generalSectionName ?? null,
contextLabel: sourceConversation?.contextLabel ?? null,
contextDescription: sourceConversation?.contextDescription ?? null,
notifyOffline: true,
},
{
sessionId,
messageId: message.id,
author: message.author,
text: message.text,
timestamp: message.timestamp,
clientRequestId: message.clientRequestId?.trim() || null,
parts: message.parts ?? [],
},
);
}
for (const request of sortedRequests) {
const normalizedParentRequestId = request.parentRequestId?.trim() || '';
await upsertChatConversationRequest(sessionId, {
requestId: request.requestId,
requesterClientId: request.requesterClientId ?? ownerClientId,
chatTypeId: request.chatTypeId ?? sourceConversation?.chatTypeId ?? sourceConversation?.lastChatTypeId ?? null,
chatTypeLabel: request.chatTypeLabel ?? sourceConversation?.contextLabel ?? null,
requestOrigin: request.requestOrigin,
sharedResourceTokenId: managedResourceTokenId,
parentRequestId: normalizedParentRequestId && requestIdSet.has(normalizedParentRequestId) ? normalizedParentRequestId : null,
status: request.status,
statusMessage: request.statusMessage,
userMessageId: request.userMessageId,
userText: request.userText,
responseMessageId: request.responseMessageId,
responseText: request.responseText,
usageSnapshot: request.usageSnapshot,
totalTokens: request.totalTokens,
});
}
for (const activityLog of shareSnapshot.activityLogs) {
let lineNo = 1;
for (const line of activityLog.lines) {
await appendChatConversationActivityLine(sessionId, activityLog.requestId, line, lineNo);
lineNo += 1;
}
}
await assignSharedResourceTokenToRequests(
sessionId,
sortedRequests.map((item) => item.requestId),
managedResourceTokenId,
);
return {
sessionId,
requestId:
sortedRequests.find((item) => item.requestId.trim() === shareSnapshot.targetRequest.requestId.trim())?.requestId
?? shareSnapshot.targetRequest.requestId,
};
}
export function resolveStaticContentType(filePath: string) {
const extension = path.extname(filePath).toLowerCase();
@@ -1546,6 +1678,30 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const shareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유할 채팅 범위를 찾을 수 없습니다.',
});
}
if (payload.kind === 'prompt') {
if (payload.promptIndex == null || !payload.promptSignature) {
return reply.code(400).send({
@@ -1553,25 +1709,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
});
}
const shareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (!shareSnapshot?.promptTarget) {
if (!shareSnapshot.promptTarget) {
return reply.code(404).send({
message: '공유할 prompt 대상을 찾을 수 없습니다.',
});
@@ -1581,6 +1719,12 @@ export async function registerChatRoutes(app: FastifyInstance) {
const managedResourceTokenId = createManagedChatShareTokenId();
const token = randomUUID().replace(/-/g, '').slice(0, 24);
const sharePath = resolveChatSharePath(token);
const managedShareConversation = await materializeManagedShareConversation({
shareSnapshot,
managedResourceTokenId,
ownerClientId: clientId || null,
shareTitle: payload.name,
});
await upsertSharedResourceToken({
id: managedResourceTokenId,
@@ -1599,8 +1743,8 @@ export async function registerChatRoutes(app: FastifyInstance) {
},
resourceContext: {
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
sessionId: managedShareConversation.sessionId,
requestId: managedShareConversation.requestId,
sourceMessageId: payload.sourceMessageId ?? null,
promptIndex: payload.promptIndex ?? null,
promptSignature: payload.promptSignature ?? null,
@@ -1620,33 +1764,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
usageLimit: 0,
});
const createdShareSnapshot = await buildChatShareSnapshot({
version: CHAT_SHARE_TOKEN_VERSION,
kind: payload.kind,
sessionId: payload.sessionId,
requestId: payload.requestId,
tokenSettingId: tokenSetting.id,
tokenSettingName: tokenSetting.name,
tokenSettingDefaultExpiresInMinutes: tokenSetting.defaultExpiresInMinutes,
tokenSettingAllowedAppIds: tokenSetting.allowedAppIds,
tokenSettingMaxTokensPer30Days: tokenSetting.maxTokensPer30Days,
tokenSettingMaxTokensPer7Days: tokenSetting.maxTokensPer7Days,
tokenSettingMaxTokensPer5Hours: tokenSetting.maxTokensPer5Hours,
tokenSettingOneTimeTokenLimit: tokenSetting.oneTimeTokenLimit,
managedResourceTokenId,
sourceMessageId: payload.sourceMessageId,
promptIndex: payload.promptIndex,
promptSignature: payload.promptSignature,
});
if (createdShareSnapshot?.requests.length) {
await assignSharedResourceTokenToRequests(
payload.sessionId,
createdShareSnapshot.requests.map((item) => item.requestId),
managedResourceTokenId,
);
}
return {
ok: true,
token,
@@ -2060,6 +2177,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
followupText: z.string().trim().min(1).max(20000),
contextRef: chatPromptContextRefSchema,
}).parse(request.body ?? {});
@@ -2265,6 +2383,237 @@ export async function registerChatRoutes(app: FastifyInstance) {
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-cancel`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
parentRequestId: z.string().trim().min(1).max(120),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 요청 취소 처리 권한이 없습니다.',
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
if (
!targetRequest
|| targetRequest.hasResponse
|| targetRequest.status !== 'failed'
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
) {
return reply.code(409).send({
message: '지금은 이 요청을 취소 처리할 수 없습니다.',
});
}
const result = await cancelUnansweredChatConversationRequest(
tokenPayload.sessionId,
normalizedParentRequestId,
'사용자 요청으로 중단된 요청을 취소 처리했습니다.',
);
if (!result.cancelled || !result.item) {
if (result.reason === 'answered') {
return reply.code(409).send({
message: '이미 답변이 연결된 요청은 취소 처리할 수 없습니다.',
});
}
if (result.reason === 'active') {
return reply.code(409).send({
message: '현재 처리 중인 요청은 여기서 취소 처리할 수 없습니다.',
});
}
if (result.reason === 'already_terminal') {
return reply.code(409).send({
message: '이미 취소 처리된 요청입니다.',
});
}
return reply.code(404).send({
message: '취소 처리할 요청을 찾지 못했습니다.',
});
}
getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, result.item);
return {
ok: true,
item: result.item,
};
});
app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/request-retry`, async (request, reply) => {
const params = z.object({
token: z.string().trim().min(1).max(16000),
}).parse(request.params ?? {});
const payload = z.object({
parentRequestId: z.string().trim().min(1).max(120),
}).parse(request.body ?? {});
const managedContext = await resolveManagedChatShareContext(params.token);
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
if (!tokenPayload) {
return reply.code(404).send({
message: '공유 링크가 유효하지 않습니다.',
});
}
const shareSnapshot = await buildChatShareSnapshot(tokenPayload);
if (!shareSnapshot) {
return reply.code(404).send({
message: '공유 대상을 찾을 수 없습니다.',
});
}
const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource);
if (unavailableMessage) {
return reply.code(403).send({
message: unavailableMessage,
});
}
if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) {
return;
}
if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) {
return reply.code(403).send({
message: '이 공유 링크에는 요청 재처리 권한이 없습니다.',
});
}
const blockedReason = resolveShareBlockedReason(
shareSnapshot.requests,
resolveChatShareTokenSettingSnapshot(tokenPayload),
);
if (blockedReason) {
return reply.code(409).send({
message: blockedReason,
});
}
const normalizedParentRequestId = resolveRecoveredShareParentRequestId(
shareSnapshot,
payload.parentRequestId,
[shareSnapshot.targetRequest.requestId],
);
if (!normalizedParentRequestId || !shareSnapshot.requests.some((request) => request.requestId.trim() === normalizedParentRequestId)) {
return reply.code(403).send({
message: '이 공유 링크 범위를 벗어난 요청입니다.',
});
}
if (tokenPayload.kind === 'prompt' && normalizedParentRequestId !== tokenPayload.requestId) {
return reply.code(403).send({
message: '이 공유 링크에서 허용되지 않은 요청입니다.',
});
}
const targetRequest = shareSnapshot.requests.find((request) => request.requestId.trim() === normalizedParentRequestId) ?? null;
if (
!targetRequest
|| targetRequest.hasResponse
|| targetRequest.status !== 'failed'
|| (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청'
) {
return reply.code(409).send({
message: '지금은 이 요청을 재처리할 수 없습니다.',
});
}
const normalizedUserText = targetRequest.userText.trim();
if (!normalizedUserText) {
return reply.code(409).send({
message: '재처리할 요청 본문이 없습니다.',
});
}
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, {
mode: 'direct',
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
parentRequestId: targetRequest.requestOrigin === 'prompt' ? targetRequest.parentRequestId?.trim() || null : null,
clientId: targetRequest.requesterClientId ?? shareSnapshot.conversation?.clientId ?? null,
});
if (!queuedRequestId) {
return reply.code(503).send({
message: '중단된 요청 재처리를 시작하지 못했습니다.',
});
}
if (managedContext.managedResource) {
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
actorLabel: 'share-viewer',
summary: '공유 채팅에서 중단 요청 재처리를 시작했습니다.',
detail: normalizedParentRequestId,
});
}
return {
ok: true,
queuedRequestId,
};
});
app.get('/api/chat/conversations', async (request) => {
const query = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
@@ -2622,6 +2971,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
}).parse(request.body ?? {});
if (params.requestId !== payload.parentRequestId) {
@@ -2675,6 +3025,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
}),
).max(20).optional(),
summaryText: z.string().max(10000).optional().nullable(),
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
followupText: z.string().trim().min(1).max(20000),
mode: z.enum(['queue', 'direct']).optional(),
contextRef: chatPromptContextRefSchema,

View File

@@ -1,11 +1,28 @@
import type { FastifyInstance } from 'fastify';
import { getActiveChatService } from '../services/chat-service.js';
import { getRuntimeDrainSnapshot } from '../services/runtime-drain-service.js';
import { getRuntimeWorkServerBuildInfo } from '../services/work-server-build-service.js';
export async function registerHealthRoutes(app: FastifyInstance) {
const respondHealth = async () => ({
ok: true,
service: 'work-server',
timestamp: new Date().toISOString(),
});
const respondHealth = async () => {
const buildInfo = getRuntimeWorkServerBuildInfo();
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
return {
ok: true,
service: 'work-server',
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
timestamp: new Date().toISOString(),
buildId: buildInfo?.buildId ?? null,
builtAt: buildInfo?.builtAt ?? null,
...getRuntimeDrainSnapshot(),
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
};
};
app.get('/', respondHealth);
app.get('/api', respondHealth);

View File

@@ -0,0 +1,42 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getActiveChatService } from '../services/chat-service.js';
import {
beginRuntimeDrain,
endRuntimeDrain,
getRuntimeDrainSnapshot,
} from '../services/runtime-drain-service.js';
const runtimeDrainBodySchema = z.object({
draining: z.boolean(),
});
function buildRuntimeResponse() {
const chatRuntimeSnapshot = getActiveChatService()?.getRuntimeSnapshot() ?? null;
return {
ok: true,
...getRuntimeDrainSnapshot(),
activeChatRequestCount: chatRuntimeSnapshot?.activeRequestCount ?? 0,
queuedChatRequestCount: chatRuntimeSnapshot?.queuedRequestCount ?? 0,
connectedChatSessionCount: chatRuntimeSnapshot?.connectedSessionCount ?? 0,
activeChatSocketCount: chatRuntimeSnapshot?.activeSocketCount ?? 0,
canAcceptNewChatRequests: chatRuntimeSnapshot?.canAcceptNewRequests ?? true,
};
}
export async function registerRuntimeRoutes(app: FastifyInstance) {
app.get('/api/runtime', async () => buildRuntimeResponse());
app.post('/api/runtime/drain', async (request) => {
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
if (draining) {
beginRuntimeDrain();
} else {
endRuntimeDrain();
}
return buildRuntimeResponse();
});
}

View File

@@ -1,6 +1,7 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
import {
cancelServerRestartReservation,
@@ -16,8 +17,10 @@ const serverCommandParamSchema = z.object({
});
const restartReservationBodySchema = z.object({
target: z.enum(['all', 'test', 'work-server']).optional(),
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
});
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
function getImmediateRestartBlockInfo(
key: z.infer<typeof serverCommandParamSchema>['key'],
@@ -60,6 +63,39 @@ function getRequestAccessToken(request: FastifyRequest) {
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function getRequestChatShareToken(request: FastifyRequest) {
const tokenHeader = request.headers['x-chat-share-token'];
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
}
function resolveChatSharePath(token: string) {
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
}
async function resolveSharedServerCommandAccessContext(request: FastifyRequest) {
const shareToken = getRequestChatShareToken(request);
if (!shareToken) {
return null;
}
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
return null;
}
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('server-command')) {
return null;
}
return {
scope: 'shared' as const,
allowedKeys: new Set<string>(['work-server']),
};
}
function getRequestClientId(request: FastifyRequest) {
const clientIdHeader = request.headers['x-client-id'];
return Array.isArray(clientIdHeader) ? clientIdHeader[0]?.trim() ?? '' : String(clientIdHeader ?? '').trim();
@@ -78,36 +114,48 @@ function getRequestAppOrigin(request: FastifyRequest) {
return origin?.trim() ?? '';
}
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
async function resolveServerCommandAccessContext(request: FastifyRequest) {
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
return true;
return { scope: 'full' as const };
}
return resolveSharedServerCommandAccessContext(request);
}
function sendAccessDenied(reply: FastifyReply) {
reply.status(403);
void reply.send({
message: '권한 토큰 필요합니다.',
message: '권한 토큰 또는 워크서버 재기동 권한이 있는 공유채팅 링크가 필요합니다.',
});
return false;
}
export async function registerServerCommandRoutes(app: FastifyInstance) {
app.get('/api/server-commands', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const items = await listServerCommands();
return {
ok: true,
items: await listServerCommands(),
items: accessContext.scope === 'full' ? items : items.filter((item) => accessContext.allowedKeys.has(item.key)),
};
});
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
const { key } = serverCommandParamSchema.parse(request.params);
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has(key)) {
reply.status(403);
return { ok: false, message: '현재 공유채팅 링크로는 이 서버를 재기동할 수 없습니다.' };
}
if (key === 'test' || key === 'work-server') {
const workloadSummary = await getRestartReservationWorkloadSummary();
@@ -160,7 +208,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -171,7 +221,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.put('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -187,9 +239,14 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
const parsed = restartReservationBodySchema.parse(payload ?? {});
if (accessContext.scope !== 'full' && parsed.target !== 'work-server') {
return reply.status(403).send({ message: '현재 공유채팅 링크로는 WORK 서버 재기동 예약만 사용할 수 있습니다.' });
}
return {
ok: true,
item: await scheduleServerRestartReservation({
target: parsed.target,
clientId: getRequestClientId(request),
appOrigin: getRequestAppOrigin(request),
autoExecuteDelaySeconds: parsed.autoExecuteDelaySeconds,
@@ -198,7 +255,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.post('/api/server-commands/restart-reservation/confirm', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}
@@ -209,7 +268,9 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
});
app.delete('/api/server-commands/restart-reservation', async (request, reply) => {
if (!ensureAuthorized(request, reply)) {
const accessContext = await resolveServerCommandAccessContext(request);
if (!accessContext) {
sendAccessDenied(reply);
return;
}

View File

@@ -5,10 +5,12 @@ import { ChatService } from './services/chat-service.js';
import { ensureChatConversationTables } from './services/chat-room-service.js';
import { shutdownNotificationProvider } from './services/notification-service.js';
import { ServerRestartReservationWorker } from './services/server-restart-reservation-service.js';
import { BaseballTicketBayWorker } from './workers/baseball-ticket-bay-worker.js';
import { PlanWorker } from './workers/plan-worker.js';
const app = createApp();
const planWorker = new PlanWorker(app.log);
const baseballTicketBayWorker = new BaseballTicketBayWorker(app.log);
const serverRestartReservationWorker = new ServerRestartReservationWorker(app.log);
const chatService = new ChatService(app.log);
const startedAt = Date.now();
@@ -24,6 +26,7 @@ async function start() {
port: env.PORT,
});
planWorker.start();
baseballTicketBayWorker.start();
serverRestartReservationWorker.start();
} catch (error) {
app.log.error(error);
@@ -46,6 +49,7 @@ async function shutdown(signal: string) {
try {
await planWorker.stop();
await baseballTicketBayWorker.stop();
await serverRestartReservationWorker.stop();
chatService.close();
await app.close();

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,12 @@
export type ChatComposerAttachment = {
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
};
export type ChatMessagePart =
| {
type: 'link_card';
@@ -48,6 +57,7 @@ export type ChatMessagePart =
resolvedBy?: 'user' | 'timeout' | 'system' | null;
resolvedAt?: string | null;
resultText?: string | null;
attachments?: ChatComposerAttachment[];
options: Array<{
value: string;
label: string;
@@ -231,6 +241,53 @@ function normalizePromptSelectedValues(value: unknown) {
.filter((item, index, array) => array.indexOf(item) === index);
}
function normalizePromptAttachment(value: unknown): ChatComposerAttachment | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeText(record.id);
const name = normalizeText(record.name);
const path = normalizeText(record.path);
const publicUrl = normalizeText(record.publicUrl);
const size = Number(record.size);
const mimeType = normalizeText(record.mimeType);
if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) {
return null;
}
return {
id,
name,
path,
publicUrl,
size,
mimeType,
};
}
function normalizePromptAttachments(value: unknown) {
if (!Array.isArray(value)) {
return [] as ChatComposerAttachment[];
}
const seen = new Set<string>();
return value
.map((item) => normalizePromptAttachment(item))
.filter((item): item is ChatComposerAttachment => Boolean(item))
.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
function normalizePromptSteps(value: unknown): PromptStep[] {
if (!Array.isArray(value)) {
return [];
@@ -436,6 +493,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
resolvedBy,
resolvedAt: normalizeText(record.resolvedAt) || null,
resultText: normalizeText(record.resultText) || null,
attachments: normalizePromptAttachments(record.attachments),
options,
};
}

View File

@@ -202,6 +202,16 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s
},
],
summaryText: '범위: UI',
attachments: [
{
id: 'attachment-1',
name: 'spec.png',
path: 'public/.codex_chat/test/resource/uploads/spec.png',
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
size: 128,
mimeType: 'image/png',
},
],
},
'2026-05-18T08:20:00.000Z',
);
@@ -213,6 +223,7 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s
assert.equal(patched?.[0]?.resolvedBy, 'user');
assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
assert.equal(patched?.[0]?.resultText, '범위: UI');
assert.equal(patched?.[0]?.attachments?.[0]?.name, 'spec.png');
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
});

View File

@@ -80,6 +80,7 @@ export type ChatConversationItem = {
roomScope: Record<string, unknown> | null;
notifyOffline: boolean;
hasUnreadResponse: boolean;
hasPendingAttention: boolean;
currentRequestId: string | null;
currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null;
currentJobMessage: string | null;
@@ -175,6 +176,14 @@ type ChatPromptSelectionPatch = {
freeText?: string | null;
stepSelections?: ChatPromptStepSelectionPatch[];
summaryText?: string | null;
attachments?: Array<{
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
}>;
};
export type ChatSourceChangeSnapshotItem = {
@@ -625,6 +634,7 @@ export function applyChatPromptSelectionPatch(
resolvedBy: 'user',
resolvedAt,
resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null,
attachments: Array.isArray(selection.attachments) ? selection.attachments : [],
};
return nextParts;
@@ -1184,6 +1194,161 @@ function resolvePendingWorkState(args: {
};
}
function isPendingAttentionPromptPart(
part: NonNullable<ChatMessagePart>,
): part is Extract<ChatMessagePart, { type: 'prompt' }> {
return (
part.type === 'prompt'
&& part.readOnly !== true
&& part.resolvedBy == null
&& !(part.resolvedAt?.trim() ?? '')
);
}
function hasPendingAttentionPromptMessageParts(parts: ChatMessagePart[] | undefined) {
return (parts ?? []).some((part) => isPendingAttentionPromptPart(part));
}
function hasPendingAttentionVerificationTarget(text: string | null | undefined) {
const normalized = String(text ?? '').trim();
if (!normalized) {
return false;
}
if (normalized.length > 720) {
return true;
}
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized);
}
function isConversationAttentionPending(options: {
request: ChatConversationRequestItem;
relatedMessages: StoredChatMessage[];
childRequestCountByParentId: Map<string, number>;
}) {
const { request, relatedMessages, childRequestCountByParentId } = options;
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),
);
if (hasOpenPrompt) {
return true;
}
}
if ((childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0) {
return false;
}
const hasVerificationTarget = relatedMessages.some(
(message) =>
(message.author === 'codex' || message.author === 'system')
&& hasPendingAttentionVerificationTarget(message.text),
);
if (!hasVerificationTarget) {
return false;
}
return !request.manualVerificationCompletedAt;
}
async function getConversationPendingAttentionMap(sessionIds: string[]) {
const normalizedSessionIds = Array.from(new Set(sessionIds.map((item) => item.trim()).filter(Boolean)));
if (normalizedSessionIds.length === 0) {
return new Map<string, boolean>();
}
const [requestRows, messageRows] = await Promise.all([
db(CHAT_CONVERSATION_REQUEST_TABLE)
.select('*')
.whereIn('session_id', normalizedSessionIds)
.orderBy('created_at', 'asc')
.orderBy('request_id', 'asc'),
db(CHAT_CONVERSATION_MESSAGE_TABLE)
.select('session_id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'display_timestamp')
.whereIn('session_id', normalizedSessionIds)
.andWhere((builder) => {
applyVisibleConversationMessageCondition(builder);
})
.orderBy('created_at', 'asc')
.orderBy('message_id', 'asc')
.orderBy('id', 'asc'),
]);
const requestRowsBySession = new Map<string, ChatConversationRequestItem[]>();
requestRows.forEach((row) => {
const request = mapRequestRow(row);
const current = requestRowsBySession.get(request.sessionId) ?? [];
current.push(request);
requestRowsBySession.set(request.sessionId, current);
});
const messageRowsBySession = new Map<string, StoredChatMessage[]>();
messageRows.forEach((row) => {
const message = mapMessageRow(row);
const sessionId = String(row.session_id ?? '').trim();
if (!sessionId) {
return;
}
const current = messageRowsBySession.get(sessionId) ?? [];
current.push(message);
messageRowsBySession.set(sessionId, current);
});
return normalizedSessionIds.reduce<Map<string, boolean>>((result, sessionId) => {
const requests = requestRowsBySession.get(sessionId) ?? [];
const messages = messageRowsBySession.get(sessionId) ?? [];
const childRequestCountByParentId = requests.reduce<Map<string, number>>((map, request) => {
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() || '';
if (!requestId) {
return map;
}
const current = map.get(requestId) ?? [];
current.push(message);
map.set(requestId, current);
return map;
}, new Map());
result.set(
sessionId,
requests.some((request) =>
isConversationAttentionPending({
request,
relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [],
childRequestCountByParentId,
}),
),
);
return result;
}, new Map());
}
const CONTEXT_DEPENDENT_REQUEST_PATTERNS = [
/\s*(||)/u,
/\s*/u,
@@ -1363,6 +1528,7 @@ function mapConversationRow(row: Record<string, unknown>): ChatConversationItem
roomScope: deriveIsolatedChatRoomScopeFromContextDescription(contextDescription),
notifyOffline: Boolean(row.notify_offline),
hasUnreadResponse: Boolean(row.has_unread_response),
hasPendingAttention: false,
currentRequestId: row.current_request_id == null ? null : String(row.current_request_id),
currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'],
currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message),
@@ -2912,6 +3078,9 @@ export async function listChatConversations(
const latestResponsePreviewMap = await getLatestResponsePreviewMap(
rows.map((row) => String(row.session_id ?? '')),
);
const pendingAttentionBySessionId = await getConversationPendingAttentionMap(
rows.map((row) => String(row.session_id ?? '')),
);
const latestResponseMessageIdMap = await getLatestResponseMessageIdMap(
rows.map((row) => String(row.session_id ?? '')),
);
@@ -2942,6 +3111,7 @@ export async function listChatConversations(
lastRequestPreview: createPreview(latestRequestPreviewMap.get(mapped.sessionId)?.text ?? ''),
lastResponsePreview: createPreview(latestResponsePreviewMap.get(mapped.sessionId)?.text ?? ''),
hasUnreadResponse: false,
hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
};
})
.sort((left, right) =>
@@ -2993,6 +3163,7 @@ export async function listChatConversations(
hasUnreadResponse:
(latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) >
(preference?.lastReadResponseMessageId ?? 0),
hasPendingAttention: pendingAttentionBySessionId.get(mapped.sessionId) === true,
};
})
.sort((left, right) =>
@@ -4510,6 +4681,51 @@ export async function deleteUnansweredChatConversationRequest(sessionId: string,
return { deleted: true, reason: null as null };
}
export async function cancelUnansweredChatConversationRequest(
sessionId: string,
requestId: string,
statusMessage = '사용자 요청으로 중단된 요청을 취소 처리했습니다.',
) {
const normalizedSessionId = sessionId.trim();
const normalizedRequestId = requestId.trim();
const current = await db(CHAT_CONVERSATION_REQUEST_TABLE)
.where({
session_id: normalizedSessionId,
request_id: normalizedRequestId,
})
.first();
if (!current) {
return { cancelled: false, reason: 'not_found' as const, item: null };
}
const conversation = await db(CHAT_CONVERSATION_TABLE)
.where({ session_id: normalizedSessionId })
.first();
const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation);
if (mapped.hasResponse) {
return { cancelled: false, reason: 'answered' as const, item: null };
}
if (mapped.status === 'queued' || mapped.status === 'started') {
return { cancelled: false, reason: 'active' as const, item: null };
}
if (mapped.status === 'cancelled' || mapped.status === 'removed') {
return { cancelled: false, reason: 'already_terminal' as const, item: mapped };
}
const item = await upsertChatConversationRequest(normalizedSessionId, {
requestId: normalizedRequestId,
status: 'cancelled',
statusMessage,
});
await refreshConversationPreview(normalizedSessionId);
return { cancelled: Boolean(item), reason: item ? null : ('not_found' as const), item };
}
export async function clearAllChatConversationJobStates() {
await ensureChatConversationTables();

View File

@@ -45,6 +45,7 @@ import {
} from './notification-message-service.js';
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 {
findLatestPlanItem,
findPlanItemByPreviewUrl,
@@ -322,6 +323,14 @@ export function getActiveChatService() {
return activeChatService;
}
type ChatServiceRuntimeSnapshot = {
activeRequestCount: number;
queuedRequestCount: number;
connectedSessionCount: number;
activeSocketCount: number;
canAcceptNewRequests: boolean;
};
function getSessionSocketReadyState(session: ChatSessionState) {
for (const socket of session.sockets) {
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
@@ -4362,6 +4371,38 @@ export class ChatService {
};
}
getRuntimeSnapshot(): ChatServiceRuntimeSnapshot {
let activeRequestCount = 0;
let queuedRequestCount = 0;
let activeSocketCount = 0;
let connectedSessionCount = 0;
for (const session of this.sessions.values()) {
activeRequestCount += session.activeRequestCount;
queuedRequestCount += session.queue.length;
let sessionHasOpenSocket = false;
for (const socket of session.sockets) {
if (socket.readyState === SOCKET_READY_STATE_OPEN) {
activeSocketCount += 1;
sessionHasOpenSocket = true;
}
}
if (sessionHasOpenSocket) {
connectedSessionCount += 1;
}
}
return {
activeRequestCount,
queuedRequestCount,
connectedSessionCount,
activeSocketCount,
canAcceptNewRequests: !isRuntimeDraining(),
};
}
close() {
activeRuntimeController = null;
if (activeChatService === this) {
@@ -5267,6 +5308,7 @@ export class ChatService {
session.sockets.add(socket);
session.lastSeenAt = Date.now();
this.clientStates.set(socket, session);
trackWebSocketConnectionOpened();
socket.on('message', (raw: RawData) => {
this.handleMessage(socket, raw);
@@ -5275,12 +5317,14 @@ export class ChatService {
socket.on('close', () => {
this.clientStates.delete(socket);
session.sockets.delete(socket);
trackWebSocketConnectionClosed();
});
socket.on('error', (error: Error) => {
this.logger.error(error, 'chat websocket error');
this.clientStates.delete(socket);
session.sockets.delete(socket);
trackWebSocketConnectionClosed();
});
await this.initializeSession(session);
@@ -5640,6 +5684,16 @@ export class ChatService {
return null;
}
if (isRuntimeDraining()) {
this.sendToSession(state, {
type: 'chat:error',
payload: {
message: '현재 서버가 배포 전환 중이라 새 AI 요청을 받지 않습니다. 잠시 후 다시 시도해 주세요.',
},
});
return null;
}
if (contextOverride) {
const mergedContext = {
...(state.context ?? {

View File

@@ -186,7 +186,7 @@ test('syncMainProjectBranchForReservedRestart commits local changes and pushes t
}
});
test('syncMainProjectBranchForReservedRestart keeps reserved restart local when local main mode is enabled', async () => {
test('syncMainProjectBranchForReservedRestart skips git sync when local main mode is enabled', async () => {
const { repoPath } = await createRepo();
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
@@ -195,25 +195,26 @@ test('syncMainProjectBranchForReservedRestart keeps reserved restart local when
await runGit(repoPath, ['switch', 'main']);
await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8');
const headBefore = await runGit(repoPath, ['rev-parse', 'HEAD']);
const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']);
const statusBefore = await runGit(repoPath, ['status', '--porcelain']);
const result = await syncMainProjectBranchForReservedRestart(
repoPath,
'main',
'chore: sync main before reserved restart',
);
const head = await runGit(repoPath, ['rev-parse', 'HEAD']);
const headAfter = await runGit(repoPath, ['rev-parse', 'HEAD']);
const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']);
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']);
const statusAfter = await runGit(repoPath, ['status', '--porcelain']);
assert.equal(result.committed, true);
assert.equal(result.commitMessage, 'chore: sync main before reserved restart');
assert.equal(result.head, head);
assert.equal(result.committed, false);
assert.equal(result.commitMessage, null);
assert.equal(result.head, null);
assert.equal(result.syncMode, 'local');
assert.equal(remoteHeadAfter, remoteHeadBefore);
assert.notEqual(remoteHeadAfter, head);
assert.equal(mainMessage, 'chore: sync main before reserved restart');
assert.equal(noteContent, 'hello local reserved restart');
assert.equal(headAfter, headBefore);
assert.equal(statusBefore, '?? note.txt');
assert.equal(statusAfter, '?? note.txt');
} finally {
if (previousLocalMainMode === undefined) {
delete process.env.PLAN_LOCAL_MAIN_MODE;

View File

@@ -174,22 +174,25 @@ export async function syncMainProjectBranchForReservedRestart(
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
if (useLocalMainMode) {
await assertBranchExists(repoPath, branchName);
await runGit(repoPath, ['switch', branchName]);
} else {
await runGit(repoPath, ['fetch', 'origin', branchName]);
await ensureLocalBranchFromRemote(repoPath, branchName);
return {
branchName,
commitMessage: null,
committed: false,
head: null,
syncMode: 'local' as const,
};
}
await runGit(repoPath, ['fetch', 'origin', branchName]);
await ensureLocalBranchFromRemote(repoPath, branchName);
const hadChanges = await hasWorkingTreeChanges(repoPath);
if (hadChanges) {
await commitAllChanges(repoPath, commitMessage);
}
if (!useLocalMainMode) {
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
await pushBranch(repoPath, branchName);
}
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
await pushBranch(repoPath, branchName);
const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']);
@@ -198,7 +201,7 @@ export async function syncMainProjectBranchForReservedRestart(
commitMessage: hadChanges ? commitMessage : null,
committed: hadChanges,
head,
syncMode: useLocalMainMode ? 'local' as const : 'remote' as const,
syncMode: 'remote' as const,
};
}

View File

@@ -565,6 +565,7 @@ export async function listWebPushSubscriptions() {
id: row.id,
endpoint: String(row.endpoint ?? ''),
deviceId: row.device_id ? String(row.device_id) : '',
clientId: row.client_id ? String(row.client_id) : '',
userAgent: row.user_agent ? String(row.user_agent) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
@@ -1031,6 +1032,13 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
isAllowedTargetClientId(row, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
const matchedSubscriptions = subscriptions.map((row) => ({
endpoint: row.endpoint,
deviceId: row.deviceId,
clientId: row.clientId,
appOrigin: row.appOrigin,
appDomain: row.appDomain,
}));
if (!subscriptions.length) {
return {
@@ -1039,6 +1047,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
reason: '등록된 Web Push 구독이 없습니다.',
sentCount: 0,
failedCount: 0,
matchedCount: 0,
matchedSubscriptions,
};
}
@@ -1106,6 +1116,8 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
sentCount,
failedCount,
invalidEndpoints,
matchedCount: matchedSubscriptions.length,
matchedSubscriptions,
};
}

View File

@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
beginRuntimeDrain,
endRuntimeDrain,
getRuntimeDrainSnapshot,
isRuntimeDraining,
trackHttpRequestFinished,
trackHttpRequestStarted,
trackWebSocketConnectionClosed,
trackWebSocketConnectionOpened,
} from './runtime-drain-service.js';
test('runtime drain service tracks drain and connection counters without going negative', () => {
endRuntimeDrain();
trackHttpRequestFinished();
trackWebSocketConnectionClosed();
assert.equal(isRuntimeDraining(), false);
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 0);
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 0);
beginRuntimeDrain();
trackHttpRequestStarted();
trackHttpRequestStarted();
trackWebSocketConnectionOpened();
assert.equal(isRuntimeDraining(), true);
assert.equal(getRuntimeDrainSnapshot().activeHttpRequestCount, 2);
assert.equal(getRuntimeDrainSnapshot().activeWebSocketConnectionCount, 1);
trackHttpRequestFinished();
trackHttpRequestFinished();
trackHttpRequestFinished();
trackWebSocketConnectionClosed();
trackWebSocketConnectionClosed();
endRuntimeDrain();
const snapshot = getRuntimeDrainSnapshot();
assert.equal(snapshot.draining, false);
assert.equal(snapshot.drainStartedAt, null);
assert.equal(snapshot.activeHttpRequestCount, 0);
assert.equal(snapshot.activeWebSocketConnectionCount, 0);
});

View File

@@ -0,0 +1,56 @@
type RuntimeDrainState = {
draining: boolean;
drainStartedAt: string | null;
activeHttpRequestCount: number;
activeWebSocketConnectionCount: number;
};
const state: RuntimeDrainState = {
draining: false,
drainStartedAt: null,
activeHttpRequestCount: 0,
activeWebSocketConnectionCount: 0,
};
function clampCount(value: number) {
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0;
}
export function beginRuntimeDrain() {
state.draining = true;
state.drainStartedAt = new Date().toISOString();
}
export function endRuntimeDrain() {
state.draining = false;
state.drainStartedAt = null;
}
export function isRuntimeDraining() {
return state.draining;
}
export function trackHttpRequestStarted() {
state.activeHttpRequestCount += 1;
}
export function trackHttpRequestFinished() {
state.activeHttpRequestCount = clampCount(state.activeHttpRequestCount - 1);
}
export function trackWebSocketConnectionOpened() {
state.activeWebSocketConnectionCount += 1;
}
export function trackWebSocketConnectionClosed() {
state.activeWebSocketConnectionCount = clampCount(state.activeWebSocketConnectionCount - 1);
}
export function getRuntimeDrainSnapshot() {
return {
draining: state.draining,
drainStartedAt: state.drainStartedAt,
activeHttpRequestCount: state.activeHttpRequestCount,
activeWebSocketConnectionCount: state.activeWebSocketConnectionCount,
};
}

View File

@@ -71,7 +71,7 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
assert.match(
testScript,
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --no-deps --force-recreate "\$SERVER_COMMAND_SERVICE"/,
);
assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
@@ -96,9 +96,21 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/);
assert.match(
workServerScript,
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
/ACTIVE_SLOT_FILE="\$\{WORK_SERVER_ACTIVE_SLOT_FILE:-\$REPO_ROOT\/etc\/servers\/work-server\/\.docker\/runtime\/active-slot\}"/,
);
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
assert.match(
workServerScript,
/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, /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, /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/);
});

View File

@@ -117,6 +117,8 @@ type BuildInspectionResult = {
updateSummary: string | null;
};
type WorkServerSlot = 'blue' | 'green';
const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
const DEFERRED_RESTART_DELAY_MS = 2_000;
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
@@ -484,6 +486,41 @@ export function resolveDockerSocketPath(source: NodeJS.ProcessEnv | Record<strin
return '/var/run/docker.sock';
}
function getWorkServerActiveSlotFileCandidates() {
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
return [
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim() || null,
path.join(mainProjectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
path.join(projectRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
path.join(projectRoot, '.docker', 'runtime', 'active-slot'),
].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index);
}
async function readWorkServerActiveSlot(): Promise<WorkServerSlot> {
for (const candidate of getWorkServerActiveSlotFileCandidates()) {
try {
const value = (await readFile(candidate, 'utf8')).trim();
if (value === 'blue' || value === 'green') {
return value;
}
} catch {
continue;
}
}
return 'blue';
}
function resolveWorkServerContainerName(slot: WorkServerSlot) {
return slot === 'green' ? 'work-server-green' : 'work-server-blue';
}
function appendComposeDetails(detailParts: Array<string | null | undefined>) {
return trimPreview(detailParts.filter(Boolean).join(' '));
}
function shouldRetryWithDockerSocket(error: unknown) {
const failure = error instanceof Error ? (error as ExecFileFailure) : null;
const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n');
@@ -1137,11 +1174,16 @@ async function inspectComposeStatus(definition: ServerDefinition) {
}
}
async function inspectContainerRuntime(definition: ServerDefinition): Promise<RuntimeInspectionResult> {
async function inspectContainerRuntime(
definition: ServerDefinition,
containerNameOverride?: string,
): Promise<RuntimeInspectionResult> {
const containerName = containerNameOverride ?? definition.containerName;
try {
const { stdout } = await execFileAsync(
'docker',
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', definition.containerName],
['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', containerName],
{
cwd: definition.commandWorkingDirectory,
timeout: 8000,
@@ -1158,7 +1200,7 @@ async function inspectContainerRuntime(definition: ServerDefinition): Promise<Ru
} catch (error) {
if (shouldRetryWithDockerSocket(error)) {
try {
const inspected = await inspectContainerViaSocket(definition.containerName);
const inspected = await inspectContainerViaSocket(containerName);
return {
startedAt: normalizeDateTimeValue(inspected.State?.StartedAt ?? null),
composeStatus: inspected.State?.Status?.trim() || null,
@@ -1298,10 +1340,27 @@ async function inspectRuntime(definition: ServerDefinition): Promise<RuntimeInsp
}
if (definition.key === 'work-server') {
const primarySlot = await readWorkServerActiveSlot();
const candidateSlots: WorkServerSlot[] = primarySlot === 'green' ? ['green', 'blue'] : ['blue', 'green'];
for (const slot of candidateSlots) {
const runtimeInfo = await inspectContainerRuntime(definition, resolveWorkServerContainerName(slot));
if (runtimeInfo.startedAt) {
return {
...runtimeInfo,
composeDetails: appendComposeDetails([`slot:${slot}`, runtimeInfo.composeDetails]),
};
}
}
const runtimeInfo = await inspectContainerRuntime(definition);
if (runtimeInfo.startedAt) {
return runtimeInfo;
return {
...runtimeInfo,
composeDetails: appendComposeDetails(['slot:proxy', runtimeInfo.composeDetails]),
};
}
return inspectCurrentProcessRuntime();

View File

@@ -128,6 +128,34 @@ function normalizeExecutionPhase(value: unknown): RestartReservationExecutionPha
: 'idle';
}
function normalizeReservationTarget(value: unknown): RestartReservationTarget {
return value === 'test' || value === 'work-server' ? value : 'all';
}
function getReservationTargetKeys(target: RestartReservationTarget): Array<'test' | 'work-server'> {
if (target === 'test') {
return ['test'];
}
if (target === 'work-server') {
return ['work-server'];
}
return ['test', 'work-server'];
}
function getReservationTargetLabel(target: RestartReservationTarget) {
if (target === 'test') {
return 'TEST 서버';
}
if (target === 'work-server') {
return 'WORK 서버';
}
return 'TEST / WORK 서버';
}
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
return {
codexRunningCount: 0,
@@ -331,7 +359,7 @@ function mapReservationRow(
return {
enabled: Boolean(row?.enabled),
target: row?.target === 'all' ? 'all' : 'all',
target: normalizeReservationTarget(row?.target),
status: row?.status ?? 'idle',
requestedAt: row?.requested_at ?? null,
requestedByClientId: row?.requested_by_client_id ?? null,
@@ -917,13 +945,19 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
const statuses = await listServerCommands();
const testServer = statuses.find((item) => item.key === 'test') ?? null;
const workServer = statuses.find((item) => item.key === 'work-server') ?? null;
const testVerified = hasReservedRestartVerification('test', testServer, row.started_at);
const workVerified = hasReservedRestartVerification('work-server', workServer, row.started_at);
const target = normalizeReservationTarget(row.target);
const targetKeys = getReservationTargetKeys(target);
const verificationResults = {
test: targetKeys.includes('test') ? hasReservedRestartVerification('test', testServer, row.started_at) : true,
'work-server': targetKeys.includes('work-server')
? hasReservedRestartVerification('work-server', workServer, row.started_at)
: true,
};
if (!testVerified || !workVerified) {
if (!verificationResults.test || !verificationResults['work-server']) {
const waitingTargets = [
!testVerified ? 'TEST 서버' : null,
!workVerified ? 'WORK 서버' : null,
!verificationResults.test ? 'TEST 서버' : null,
!verificationResults['work-server'] ? 'WORK 서버' : null,
].filter((value): value is string => Boolean(value));
await updateReservationRow({
@@ -1081,6 +1115,9 @@ export async function requestImmediateRestartRecovery(
}
async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartReservationRow) {
const target = normalizeReservationTarget(row.target);
const targetLabel = getReservationTargetLabel(target);
const targetKeys = getReservationTargetKeys(target);
const activeClients = await listActiveClients();
await updateReservationRow({
enabled: true,
@@ -1088,7 +1125,7 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
started_at: row.started_at ?? db.fn.now(),
last_checked_at: db.fn.now(),
execution_phase: 'commit-main-worktree',
waiting_reason: 'main 작업트리 커밋 단계를 확인한 뒤 예약된 재기동을 이어갑니다.',
waiting_reason: `${targetLabel} 무중단 재기동을 위해 main 작업트리 상태를 확인합니다.`,
active_client_count: activeClients.length,
last_error: null,
});
@@ -1096,12 +1133,12 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
if (activeClients.length > 0) {
await createNotificationMessage({
title: '예약된 재기동 시작',
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 TEST / WORK 서버 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
body: `활성 클라이언트 ${activeClients.length}건이 감지되어 예약된 ${targetLabel} 무중단 재기동을 시작합니다. 잠시 후 화면이 새로고침될 수 있습니다.`,
category: 'system',
source: SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE,
priority: 'high',
metadata: {
previewText: `예약된 재기동 시작 · 활성 클라이언트 ${activeClients.length}`,
previewText: `${targetLabel} 재기동 시작 · 활성 클라이언트 ${activeClients.length}`,
linkUrl: '/?topMenu=plans',
linkLabel: '작업 화면 열기',
},
@@ -1128,31 +1165,40 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'restart-test',
execution_phase: targetKeys.includes('test') ? 'restart-test' : 'restart-work-server',
waiting_reason: syncResult.committed
? 'main 변경을 정리한 뒤 TEST 서버 재기동을 시작합니다.'
: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
? `main 변경을 정리한 뒤 ${targetLabel} 재기동을 시작합니다.`
: `main 작업트리 상태를 확인한 뒤 ${targetLabel} 재기동을 시작합니다.`,
last_checked_at: db.fn.now(),
});
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
if (targetKeys.includes('test')) {
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
}
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'restart-work-server',
waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
last_checked_at: db.fn.now(),
});
if (targetKeys.includes('test') && targetKeys.includes('work-server')) {
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
}
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
if (targetKeys.includes('work-server')) {
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'restart-work-server',
waiting_reason: target === 'work-server'
? 'WORK 서버 무중단 재기동 후 정상 기동을 확인하는 중입니다.'
: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
last_checked_at: db.fn.now(),
});
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
}
await updateReservationRow({
enabled: true,
status: 'executing',
execution_phase: 'verify-runtime',
waiting_reason: 'TEST / WORK 서버 새 런타임과 정상 기동을 확인하는 중입니다.',
waiting_reason: `${targetLabel} 새 런타임과 정상 기동을 확인하는 중입니다.`,
last_checked_at: db.fn.now(),
});
}
@@ -1172,14 +1218,16 @@ export async function getServerRestartReservation() {
}
export async function scheduleServerRestartReservation(options?: {
target?: RestartReservationTarget | null;
clientId?: string | null;
appOrigin?: string | null;
autoExecuteDelaySeconds?: number | null;
}) {
const autoExecuteDelaySeconds = resolveAutoExecuteDelaySeconds(options?.autoExecuteDelaySeconds);
const target = normalizeReservationTarget(options?.target);
const row = await updateReservationRow({
enabled: true,
target: 'all',
target,
status: 'waiting',
requested_at: db.fn.now(),
requested_by_client_id: options?.clientId?.trim() || null,
@@ -1241,7 +1289,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
status: 'executing',
started_at: db.fn.now(),
last_checked_at: db.fn.now(),
waiting_reason: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
waiting_reason: `${getReservationTargetLabel(normalizeReservationTarget(row.target))} 재기동 준비를 시작합니다.`,
last_error: null,
auto_execute_at: null,
execution_phase: 'commit-main-worktree',
@@ -1327,6 +1375,7 @@ export class ServerRestartReservationWorker {
}
const autoExecuteDelaySeconds = await resolveReservationAutoExecuteDelaySeconds(row);
const targetLabel = getReservationTargetLabel(normalizeReservationTarget(row.target));
const autoExecuteAt = buildAutoExecuteAt(
row.status === 'ready' && row.auto_execute_at ? row.auto_execute_at : new Date().toISOString(),
autoExecuteDelaySeconds,
@@ -1336,7 +1385,7 @@ export class ServerRestartReservationWorker {
status: waitingReason ? 'waiting' : 'ready',
last_checked_at: db.fn.now(),
waiting_reason: waitingReason
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 TEST/WORK 서버 재기동을 자동 시작합니다.`,
?? `진행 중인 작업이 모두 끝났습니다. ${autoExecuteDelaySeconds}초 뒤 ${targetLabel} 무중단 재기동을 자동 시작합니다.`,
workload_summary_json: workloadSummary,
auto_execute_at: waitingReason
? null

View File

@@ -0,0 +1,57 @@
import type { FastifyBaseLogger } from 'fastify';
import { processDueBaseballTicketBayAlerts } from '../services/baseball-ticket-bay-service.js';
const DEFAULT_INTERVAL_MS = 60_000;
export class BaseballTicketBayWorker {
private readonly logger: FastifyBaseLogger;
private timer: ReturnType<typeof setInterval> | null = null;
private running = false;
constructor(logger: FastifyBaseLogger) {
this.logger = logger;
}
start() {
if (this.timer) {
return;
}
this.timer = setInterval(() => {
void this.tick();
}, DEFAULT_INTERVAL_MS);
this.timer.unref?.();
this.logger.info({ intervalMs: DEFAULT_INTERVAL_MS }, 'Baseball Ticket Bay worker started');
}
async stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
private async tick() {
if (this.running) {
return;
}
this.running = true;
try {
const results = await processDueBaseballTicketBayAlerts(new Date());
const executed = results.length;
const failed = results.filter((item) => !item.ok).length;
if (executed > 0) {
this.logger.info({ executed, failed }, 'Baseball Ticket Bay batch processed');
}
} catch (error) {
this.logger.error({ error }, 'Baseball Ticket Bay worker tick failed');
} finally {
this.running = false;
}
}
}