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