chore: test deploy snapshot
This commit is contained in:
51
etc/commands/server-command/deploy-test.sh
Normal file
51
etc/commands/server-command/deploy-test.sh
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
REPO_ROOT="${REPO_ROOT:-$MAIN_PROJECT_ROOT}"
|
||||
TEST_DEPLOY_GIT_REMOTE="${TEST_DEPLOY_GIT_REMOTE:-origin}"
|
||||
TEST_DEPLOY_GIT_BRANCH="${TEST_DEPLOY_GIT_BRANCH:-main}"
|
||||
TEST_BUILD_COMMAND="${TEST_BUILD_COMMAND:-npm run build:test-app}"
|
||||
TEST_SERVER_RESTART_SCRIPT="${TEST_SERVER_RESTART_SCRIPT:-$SCRIPT_DIR/restart-test.sh}"
|
||||
TEST_DEPLOY_COMMIT_MESSAGE="${TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot}"
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "git CLI not found" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "npm CLI not found" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
||||
|
||||
if [ "$CURRENT_BRANCH" != "$TEST_DEPLOY_GIT_BRANCH" ]; then
|
||||
echo "expected branch ${TEST_DEPLOY_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::step::commit-main-worktree"
|
||||
git add -A -- . ':(exclude).server-command-test-app-built-at' ':(exclude,glob)tmp-*' ':(exclude,glob)tmp-verification/**'
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "no commit needed; main worktree already committed"
|
||||
else
|
||||
echo "staged files for TEST deploy commit:"
|
||||
git diff --cached --name-status
|
||||
git commit -m "$TEST_DEPLOY_COMMIT_MESSAGE"
|
||||
fi
|
||||
|
||||
echo "::step::push-origin-main"
|
||||
git push "$TEST_DEPLOY_GIT_REMOTE" "$TEST_DEPLOY_GIT_BRANCH"
|
||||
|
||||
echo "::step::build-test-app"
|
||||
sh -lc "$TEST_BUILD_COMMAND"
|
||||
|
||||
echo "::step::deploy-test-server"
|
||||
REPO_ROOT="$REPO_ROOT" sh "$TEST_SERVER_RESTART_SCRIPT"
|
||||
@@ -15,11 +15,195 @@ ACTIVE_SLOT_FILE="${WORK_SERVER_ACTIVE_SLOT_FILE:-$REPO_ROOT/etc/servers/work-se
|
||||
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}"
|
||||
RECOVERY_ENDPOINT="${WORK_SERVER_RECOVERY_ENDPOINT:-http://127.0.0.1:3100/api/runtime/recover-interrupted-chat}"
|
||||
PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS="${WORK_SERVER_PREVIOUS_SLOT_DRAIN_TIMEOUT_SECONDS:-900}"
|
||||
LOCK_FILE="${WORK_SERVER_RESTART_LOCK_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/restart-in-progress.json}"
|
||||
DEPLOY_STATE_FILE="${WORK_SERVER_DEPLOY_STATE_FILE:-$REPO_ROOT/etc/servers/work-server/.docker/runtime/deployment-state.json}"
|
||||
DEPLOY_FINISHED="false"
|
||||
LAST_DEPLOY_ERROR=""
|
||||
LAST_DEPLOY_LOG=""
|
||||
PREVIOUS_ACTIVE_COUNT=""
|
||||
PREVIOUS_QUEUED_COUNT=""
|
||||
RECOVERED_SESSION_COUNT=""
|
||||
RECOVERED_RESTARTED_COUNT=""
|
||||
RECOVERED_REQUEUED_COUNT=""
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")"
|
||||
mkdir -p "$(dirname "$ACTIVE_SLOT_FILE")" "$(dirname "$PROXY_CONFIG_FILE")" "$(dirname "$LOCK_FILE")" "$(dirname "$DEPLOY_STATE_FILE")"
|
||||
write_deploy_state() {
|
||||
DEPLOY_STATUS="$1"
|
||||
DEPLOY_PHASE="$2"
|
||||
DEPLOY_SUMMARY="$3"
|
||||
DEPLOY_STEP_KEY="${4:-}"
|
||||
DEPLOY_STEP_STATUS="${5:-}"
|
||||
DEPLOY_STEP_DETAIL="${6:-}"
|
||||
DEPLOY_LAST_ERROR="${7:-}"
|
||||
DEPLOY_LOG_EXCERPT="${8:-}"
|
||||
DEPLOY_ACTIVE_SLOT_VALUE="${ACTIVE_SLOT:-}"
|
||||
DEPLOY_TARGET_SLOT_VALUE="${TARGET_SLOT:-}"
|
||||
DEPLOY_PREVIOUS_SLOT_VALUE="${PREVIOUS_SLOT:-}"
|
||||
DEPLOY_TARGET_CONTAINER_VALUE="${TARGET_CONTAINER:-}"
|
||||
DEPLOY_PREVIOUS_CONTAINER_VALUE="${PREVIOUS_CONTAINER:-}"
|
||||
DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE="${PREVIOUS_ACTIVE_COUNT:-}"
|
||||
DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE="${PREVIOUS_QUEUED_COUNT:-}"
|
||||
DEPLOY_RECOVERED_SESSION_COUNT_VALUE="${RECOVERED_SESSION_COUNT:-}"
|
||||
DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE="${RECOVERED_RESTARTED_COUNT:-}"
|
||||
DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE="${RECOVERED_REQUEUED_COUNT:-}"
|
||||
export \
|
||||
DEPLOY_STATUS \
|
||||
DEPLOY_PHASE \
|
||||
DEPLOY_SUMMARY \
|
||||
DEPLOY_STEP_KEY \
|
||||
DEPLOY_STEP_STATUS \
|
||||
DEPLOY_STEP_DETAIL \
|
||||
DEPLOY_LAST_ERROR \
|
||||
DEPLOY_LOG_EXCERPT \
|
||||
DEPLOY_ACTIVE_SLOT_VALUE \
|
||||
DEPLOY_TARGET_SLOT_VALUE \
|
||||
DEPLOY_PREVIOUS_SLOT_VALUE \
|
||||
DEPLOY_TARGET_CONTAINER_VALUE \
|
||||
DEPLOY_PREVIOUS_CONTAINER_VALUE \
|
||||
DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE \
|
||||
DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE \
|
||||
DEPLOY_RECOVERED_SESSION_COUNT_VALUE \
|
||||
DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE \
|
||||
DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE
|
||||
node - "$DEPLOY_STATE_FILE" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const filePath = process.argv[2];
|
||||
const env = process.env;
|
||||
const stepKeys = [
|
||||
'build-target-slot',
|
||||
'verify-target-health',
|
||||
'switch-proxy',
|
||||
'drain-previous-slot',
|
||||
'rebuild-previous-slot',
|
||||
'recover-interrupted-chat',
|
||||
];
|
||||
const readJson = () => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const parseIso = (value) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
};
|
||||
const parseSlot = (value) => (value === 'blue' || value === 'green' ? value : null);
|
||||
const parseCount = (value) => {
|
||||
if (value == null || value === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
const current = readJson() || {};
|
||||
const shouldResetForNewRun =
|
||||
env.DEPLOY_STATUS === 'running'
|
||||
&& env.DEPLOY_PHASE === 'build-target-slot'
|
||||
&& !env.DEPLOY_STEP_KEY;
|
||||
const now = new Date().toISOString();
|
||||
const stepsByKey = new Map();
|
||||
for (const stepKey of stepKeys) {
|
||||
const existing = !shouldResetForNewRun && Array.isArray(current.steps)
|
||||
? current.steps.find((item) => item && item.key === stepKey)
|
||||
: null;
|
||||
stepsByKey.set(stepKey, {
|
||||
key: stepKey,
|
||||
status:
|
||||
existing?.status === 'running' || existing?.status === 'completed' || existing?.status === 'failed'
|
||||
? existing.status
|
||||
: 'pending',
|
||||
detail: typeof existing?.detail === 'string' ? existing.detail : null,
|
||||
updatedAt: parseIso(existing?.updatedAt) || null,
|
||||
});
|
||||
}
|
||||
if (env.DEPLOY_STEP_KEY && stepsByKey.has(env.DEPLOY_STEP_KEY)) {
|
||||
const target = stepsByKey.get(env.DEPLOY_STEP_KEY);
|
||||
target.status =
|
||||
env.DEPLOY_STEP_STATUS === 'running'
|
||||
|| env.DEPLOY_STEP_STATUS === 'completed'
|
||||
|| env.DEPLOY_STEP_STATUS === 'failed'
|
||||
? env.DEPLOY_STEP_STATUS
|
||||
: 'pending';
|
||||
target.detail = env.DEPLOY_STEP_DETAIL || target.detail || null;
|
||||
target.updatedAt = now;
|
||||
}
|
||||
const payload = {
|
||||
status:
|
||||
env.DEPLOY_STATUS === 'running' || env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed'
|
||||
? env.DEPLOY_STATUS
|
||||
: 'idle',
|
||||
phase:
|
||||
env.DEPLOY_PHASE === 'build-target-slot'
|
||||
|| env.DEPLOY_PHASE === 'verify-target-health'
|
||||
|| env.DEPLOY_PHASE === 'switch-proxy'
|
||||
|| env.DEPLOY_PHASE === 'drain-previous-slot'
|
||||
|| env.DEPLOY_PHASE === 'rebuild-previous-slot'
|
||||
|| env.DEPLOY_PHASE === 'recover-interrupted-chat'
|
||||
|| env.DEPLOY_PHASE === 'completed'
|
||||
|| env.DEPLOY_PHASE === 'failed'
|
||||
? env.DEPLOY_PHASE
|
||||
: 'idle',
|
||||
summary: env.DEPLOY_SUMMARY || (!shouldResetForNewRun ? current.summary : null) || null,
|
||||
startedAt: shouldResetForNewRun ? now : parseIso(current.startedAt) || now,
|
||||
updatedAt: now,
|
||||
completedAt:
|
||||
shouldResetForNewRun
|
||||
? null
|
||||
: env.DEPLOY_STATUS === 'completed' || env.DEPLOY_STATUS === 'failed'
|
||||
? now
|
||||
: parseIso(current.completedAt),
|
||||
activeSlot: parseSlot(env.DEPLOY_ACTIVE_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.activeSlot) : null),
|
||||
targetSlot: parseSlot(env.DEPLOY_TARGET_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.targetSlot) : null),
|
||||
previousSlot: parseSlot(env.DEPLOY_PREVIOUS_SLOT_VALUE) || (!shouldResetForNewRun ? parseSlot(current.previousSlot) : null),
|
||||
targetContainer: env.DEPLOY_TARGET_CONTAINER_VALUE || (!shouldResetForNewRun ? current.targetContainer : null) || null,
|
||||
previousContainer: env.DEPLOY_PREVIOUS_CONTAINER_VALUE || (!shouldResetForNewRun ? current.previousContainer : null) || null,
|
||||
previousSlotActiveChatRequestCount:
|
||||
parseCount(env.DEPLOY_PREVIOUS_ACTIVE_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.previousSlotActiveChatRequestCount) : null),
|
||||
previousSlotQueuedChatRequestCount:
|
||||
parseCount(env.DEPLOY_PREVIOUS_QUEUED_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.previousSlotQueuedChatRequestCount) : null),
|
||||
recoveredSessionCount:
|
||||
parseCount(env.DEPLOY_RECOVERED_SESSION_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.recoveredSessionCount) : null),
|
||||
recoveredRestartedCount:
|
||||
parseCount(env.DEPLOY_RECOVERED_RESTARTED_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.recoveredRestartedCount) : null),
|
||||
recoveredRequeuedCount:
|
||||
parseCount(env.DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE)
|
||||
?? (!shouldResetForNewRun ? parseCount(current.recoveredRequeuedCount) : null),
|
||||
lastError: env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null,
|
||||
logExcerpt: env.DEPLOY_LOG_EXCERPT || (!shouldResetForNewRun ? current.logExcerpt : null) || null,
|
||||
steps: stepKeys.map((stepKey) => stepsByKey.get(stepKey)),
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload) + '\n', 'utf8');
|
||||
NODE
|
||||
}
|
||||
cleanup_restart_lock() {
|
||||
EXIT_CODE="$1"
|
||||
if [ "$DEPLOY_FINISHED" != "true" ]; then
|
||||
if [ "$EXIT_CODE" -ne 0 ]; then
|
||||
SUMMARY="WORK-SERVER 배포가 중단되었습니다."
|
||||
DETAIL="${LAST_DEPLOY_LOG:-알 수 없는 오류로 배포가 중단되었습니다.}"
|
||||
ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}"
|
||||
else
|
||||
SUMMARY="WORK-SERVER 배포 완료 표기 전에 스크립트가 종료되었습니다."
|
||||
DETAIL="${LAST_DEPLOY_LOG:-completed 상태를 기록하기 전에 스크립트가 종료되었습니다.}"
|
||||
ERROR_TEXT="${LAST_DEPLOY_ERROR:-$SUMMARY}"
|
||||
fi
|
||||
write_deploy_state failed failed "$SUMMARY" "" "" "" "$ERROR_TEXT" "$DETAIL"
|
||||
fi
|
||||
rm -f "$LOCK_FILE"
|
||||
}
|
||||
trap 'cleanup_restart_lock "$?"' EXIT INT TERM
|
||||
|
||||
read_active_slot() {
|
||||
if [ -f "$ACTIVE_SLOT_FILE" ]; then
|
||||
@@ -125,6 +309,12 @@ set_container_draining() {
|
||||
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"
|
||||
}
|
||||
|
||||
recover_interrupted_chat_requests() {
|
||||
TARGET_CONTAINER="$1"
|
||||
|
||||
docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1], { method: 'POST' }).then(async (response) => { if (!response.ok) { process.stderr.write(await response.text()); process.exit(1); } process.stdout.write(await response.text()); }).catch((error) => { process.stderr.write(String(error)); process.exit(1); });" "$RECOVERY_ENDPOINT"
|
||||
}
|
||||
|
||||
wait_for_previous_slot_drain() {
|
||||
TARGET_CONTAINER="$1"
|
||||
ELAPSED=0
|
||||
@@ -132,6 +322,9 @@ wait_for_previous_slot_drain() {
|
||||
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')
|
||||
PREVIOUS_ACTIVE_COUNT="${ACTIVE_COUNT:-0}"
|
||||
PREVIOUS_QUEUED_COUNT="${QUEUED_COUNT:-0}"
|
||||
write_deploy_state running drain-previous-slot "이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다." "drain-previous-slot" running "active ${PREVIOUS_ACTIVE_COUNT} · queued ${PREVIOUS_QUEUED_COUNT}"
|
||||
|
||||
if [ "${ACTIVE_COUNT:-0}" = "0" ] && [ "${QUEUED_COUNT:-0}" = "0" ]; then
|
||||
return 0
|
||||
@@ -156,6 +349,7 @@ TARGET_SERVICE="$GREEN_SERVICE"
|
||||
TARGET_CONTAINER="$GREEN_CONTAINER"
|
||||
PREVIOUS_SERVICE="$BLUE_SERVICE"
|
||||
PREVIOUS_CONTAINER="$BLUE_CONTAINER"
|
||||
PREVIOUS_SLOT="blue"
|
||||
|
||||
if [ "$ACTIVE_SLOT" = "green" ]; then
|
||||
TARGET_SLOT="blue"
|
||||
@@ -163,19 +357,96 @@ if [ "$ACTIVE_SLOT" = "green" ]; then
|
||||
TARGET_CONTAINER="$BLUE_CONTAINER"
|
||||
PREVIOUS_SERVICE="$GREEN_SERVICE"
|
||||
PREVIOUS_CONTAINER="$GREEN_CONTAINER"
|
||||
PREVIOUS_SLOT="green"
|
||||
fi
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE"
|
||||
wait_for_container_health "$TARGET_CONTAINER"
|
||||
write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 시작했습니다."
|
||||
|
||||
if BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$TARGET_SERVICE" 2>&1); then
|
||||
[ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT"
|
||||
write_deploy_state running build-target-slot "비활성 슬롯 빌드와 기동을 완료했습니다." "build-target-slot" completed "대상 슬롯 ${TARGET_SLOT} 준비 완료"
|
||||
else
|
||||
BUILD_STATUS=$?
|
||||
LAST_DEPLOY_ERROR="대기 슬롯 빌드에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="${BUILD_OUTPUT:-docker compose build failed}"
|
||||
[ -n "$BUILD_OUTPUT" ] && printf '%s\n' "$BUILD_OUTPUT" >&2
|
||||
write_deploy_state failed failed "대기 슬롯 빌드에 실패했습니다." "build-target-slot" failed "대상 슬롯 ${TARGET_SLOT} 빌드 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit "$BUILD_STATUS"
|
||||
fi
|
||||
|
||||
write_deploy_state running verify-target-health "새 슬롯 health 확인을 진행합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}"
|
||||
if wait_for_container_health "$TARGET_CONTAINER"; then
|
||||
write_deploy_state running verify-target-health "새 슬롯 health 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} 정상 응답"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="새 슬롯 health 확인에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="health check failed for ${TARGET_CONTAINER}"
|
||||
write_deploy_state failed failed "새 슬롯 health 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_deploy_state running switch-proxy "프록시를 새 슬롯으로 전환합니다." "switch-proxy" running "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}"
|
||||
write_proxy_config "$TARGET_SLOT"
|
||||
ensure_proxy_running
|
||||
if ensure_proxy_running; then
|
||||
write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="프록시 전환에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="nginx reload failed"
|
||||
write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE"
|
||||
ACTIVE_SLOT="$TARGET_SLOT"
|
||||
|
||||
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"
|
||||
if wait_for_previous_slot_drain "$PREVIOUS_CONTAINER"; then
|
||||
write_deploy_state running drain-previous-slot "이전 슬롯 요청 이관이 완료되었습니다." "drain-previous-slot" completed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="이전 슬롯 드레인 대기 시간이 초과되었습니다."
|
||||
LAST_DEPLOY_LOG="drain timeout reached for ${PREVIOUS_CONTAINER}"
|
||||
write_deploy_state failed failed "이전 슬롯 요청 이관이 시간 안에 끝나지 않았습니다." "drain-previous-slot" failed "active ${PREVIOUS_ACTIVE_COUNT:-0} · queued ${PREVIOUS_QUEUED_COUNT:-0}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구합니다." "rebuild-previous-slot" running "대상 컨테이너 ${PREVIOUS_CONTAINER}"
|
||||
if PREVIOUS_BUILD_OUTPUT=$(docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps "$PREVIOUS_SERVICE" 2>&1); then
|
||||
[ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT"
|
||||
else
|
||||
PREVIOUS_BUILD_STATUS=$?
|
||||
LAST_DEPLOY_ERROR="이전 슬롯 대기 복구 빌드에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="${PREVIOUS_BUILD_OUTPUT:-docker compose rebuild failed}"
|
||||
[ -n "$PREVIOUS_BUILD_OUTPUT" ] && printf '%s\n' "$PREVIOUS_BUILD_OUTPUT" >&2
|
||||
write_deploy_state failed failed "이전 슬롯 대기 복구 빌드에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} 복구 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit "$PREVIOUS_BUILD_STATUS"
|
||||
fi
|
||||
|
||||
if wait_for_container_health "$PREVIOUS_CONTAINER"; then
|
||||
write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} 정상 응답"
|
||||
else
|
||||
LAST_DEPLOY_ERROR="이전 슬롯 복구 health 확인에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="health check failed for ${PREVIOUS_CONTAINER}"
|
||||
write_deploy_state failed failed "이전 슬롯 복구 health 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'work-server zero-downtime switch completed: %s -> %s\n' "$ACTIVE_SLOT" "$TARGET_SLOT"
|
||||
write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구를 확인합니다." "recover-interrupted-chat" running "대상 슬롯 ${TARGET_SLOT}"
|
||||
if RECOVERY_JSON=$(recover_interrupted_chat_requests "$TARGET_CONTAINER" 2>&1); then
|
||||
[ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON"
|
||||
RECOVERY_COUNTS=$(printf '%s' "$RECOVERY_JSON" | node -e "let raw=''; process.stdin.on('data', (chunk) => raw += chunk); process.stdin.on('end', () => { try { const parsed = JSON.parse(raw); const recovered = parsed?.recovered ?? {}; process.stdout.write([recovered.sessionCount ?? '', recovered.restartedCount ?? '', recovered.requeuedCount ?? ''].join('\t')); } catch { process.stdout.write('\t\t'); } });")
|
||||
RECOVERED_SESSION_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $1}')
|
||||
RECOVERED_RESTARTED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $2}')
|
||||
RECOVERED_REQUEUED_COUNT=$(printf '%s' "$RECOVERY_COUNTS" | awk -F '\t' '{print $3}')
|
||||
write_deploy_state running recover-interrupted-chat "중단된 채팅 요청 복구 확인이 완료되었습니다." "recover-interrupted-chat" completed "session ${RECOVERED_SESSION_COUNT:-0} · restarted ${RECOVERED_RESTARTED_COUNT:-0} · requeued ${RECOVERED_REQUEUED_COUNT:-0}"
|
||||
else
|
||||
RECOVERY_STATUS=$?
|
||||
LAST_DEPLOY_ERROR="중단된 채팅 요청 복구 확인에 실패했습니다."
|
||||
LAST_DEPLOY_LOG="${RECOVERY_JSON:-recover interrupted chat failed}"
|
||||
[ -n "$RECOVERY_JSON" ] && printf '%s\n' "$RECOVERY_JSON" >&2
|
||||
write_deploy_state failed failed "중단된 채팅 요청 복구 확인에 실패했습니다." "recover-interrupted-chat" failed "대상 슬롯 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG"
|
||||
exit "$RECOVERY_STATUS"
|
||||
fi
|
||||
|
||||
DEPLOY_FINISHED="true"
|
||||
write_deploy_state completed completed "WORK-SERVER 무중단 배포를 완료했습니다."
|
||||
printf 'work-server zero-downtime switch completed: %s -> %s\n' "$PREVIOUS_SLOT" "$TARGET_SLOT"
|
||||
|
||||
@@ -19,6 +19,12 @@ docker compose logs -f work-server
|
||||
|
||||
`work-server`는 `3100` 포트를 점유하는 nginx 프록시이고, 실제 API 런타임은 `work-server-blue` / `work-server-green` 슬롯으로 동작합니다. 재기동은 비활성 슬롯을 먼저 새로 빌드해 `/health` 확인 후 프록시 업스트림을 전환하고, 마지막에 이전 슬롯을 내리는 방식으로 무중단 전환합니다.
|
||||
|
||||
운영 기본 규칙:
|
||||
|
||||
- `work-server` 재기동은 기존 활성 슬롯을 바로 내리는 단일 컨테이너 재시작으로 처리하지 않습니다.
|
||||
- 항상 `비활성 슬롯 기동 -> /health 확인 -> nginx upstream 전환 -> 이전 슬롯 정리` 순서를 유지합니다.
|
||||
- 문서, 스크립트, 운영 가이드에 재기동 예시를 추가할 때도 무중단 전환 절차를 기본값으로 적고, 연결이 끊기는 재시작은 장애 대응이나 예외 상황으로만 취급합니다.
|
||||
|
||||
슬롯 로그까지 같이 보려면 아래처럼 확인합니다.
|
||||
|
||||
```bash
|
||||
@@ -123,8 +129,9 @@ npm run server-command:runner
|
||||
|
||||
## 웹푸쉬 호출 메모
|
||||
|
||||
- `POST /api/notifications/send`는 `title`, `body`, `data`, `threadId` 외에 `targetClientIds`도 받을 수 있습니다.
|
||||
- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다.
|
||||
- `POST /api/notifications/send`는 `title`, `body`, `data`, `threadId` 외에 `targetDeviceIds`도 받을 수 있습니다.
|
||||
- `targetDeviceIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 기기에만 알림을 보냅니다.
|
||||
- 기존 `targetClientIds`도 호환 입력으로는 허용되지만, 새 호출은 `targetDeviceIds` 사용을 기준으로 합니다.
|
||||
- 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다.
|
||||
- `POST /api/notifications/send`에 `targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다.
|
||||
- `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,6 +22,8 @@ import {
|
||||
upsertTokenSettingsConfig,
|
||||
type TokenSettingRecord,
|
||||
} from '../services/token-setting-config-service.js';
|
||||
import { listTokenSettingActivities } from '../services/token-setting-activity-service.js';
|
||||
import { extractRequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||
|
||||
@@ -388,7 +390,10 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
|
||||
const savedTokenSettings =
|
||||
accessContext.scope === 'full'
|
||||
? await upsertTokenSettingsConfig(nextTokenSettingsInput)
|
||||
? await upsertTokenSettingsConfig(nextTokenSettingsInput, {
|
||||
actorLabel: 'manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
})
|
||||
: await (async () => {
|
||||
const authorizedSettingId = accessContext.tokenSetting.id;
|
||||
const requestedSetting = nextTokenSettingsInput.find(
|
||||
@@ -403,7 +408,10 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
const nextTokenSettings = currentTokenSettings.map((item) =>
|
||||
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
|
||||
);
|
||||
return upsertTokenSettingsConfig(nextTokenSettings);
|
||||
return upsertTokenSettingsConfig(nextTokenSettings, {
|
||||
actorLabel: 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
})();
|
||||
|
||||
return {
|
||||
@@ -417,6 +425,26 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/token-settings/:settingId/activities', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const settingId = z.string().trim().min(1).parse((request.params as { settingId: string }).settingId);
|
||||
if (accessContext.scope === 'shared' && accessContext.tokenSetting.id !== settingId) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 토큰 설정 이력만 볼 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
activities: await listTokenSettingActivities(settingId),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
if (!accessContext) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { getSharedResourceTokenDetailByShareToken } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
createBaseballTicketBayAlert,
|
||||
createBaseballTicketBayLog,
|
||||
@@ -39,46 +41,115 @@ function readHeader(request: { headers: Record<string, string | string[] | undef
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
}
|
||||
|
||||
function hasBaseballTicketBayGlobalAccess(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
return hasErrorLogViewAccessToken(request.headers['x-access-token']);
|
||||
}
|
||||
|
||||
type BaseballTicketBayRouteAccessContext =
|
||||
| { scope: 'all' }
|
||||
| { scope: 'client'; clientId: string }
|
||||
| { scope: 'shared-token'; clientId: string; tokenId: string };
|
||||
|
||||
function toOwnerScope(accessContext: Exclude<BaseballTicketBayRouteAccessContext, { scope: 'all' }> | { scope: 'all' }) {
|
||||
if (accessContext.scope === 'all') {
|
||||
return { kind: 'all' } as const;
|
||||
}
|
||||
|
||||
if (accessContext.scope === 'shared-token') {
|
||||
return { kind: 'owner', ownerType: 'shared-token', ownerId: accessContext.tokenId } as const;
|
||||
}
|
||||
|
||||
return { kind: 'owner', ownerType: 'client', ownerId: accessContext.clientId } as const;
|
||||
}
|
||||
|
||||
async function resolveBaseballTicketBayAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) : Promise<BaseballTicketBayRouteAccessContext | null> {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
|
||||
if (hasBaseballTicketBayGlobalAccess(request)) {
|
||||
return { scope: 'all' };
|
||||
}
|
||||
|
||||
const accessToken = readHeader(request, 'x-access-token');
|
||||
|
||||
if (accessToken) {
|
||||
const sharedTokenDetail = await getSharedResourceTokenDetailByShareToken(accessToken);
|
||||
|
||||
if (
|
||||
sharedTokenDetail
|
||||
&& sharedTokenDetail.token.enabled !== false
|
||||
&& !sharedTokenDetail.token.revokedAt
|
||||
&& sharedTokenDetail.token.allowedAppIds.some((item) => item.trim().toLowerCase() === 'baseball-ticket-bay')
|
||||
) {
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared-token',
|
||||
clientId,
|
||||
tokenId: sharedTokenDetail.token.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'client',
|
||||
clientId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
app.post('/api/baseball-ticket-bay/search', async (request) => searchBaseballTicketBayListings(request.body ?? {}));
|
||||
|
||||
app.get('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림 목록을 불러올 수 없습니다.' });
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 알림 목록을 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: await listBaseballTicketBayAlerts(clientId),
|
||||
includeAllClients: accessContext.scope === 'all',
|
||||
accessScope: accessContext.scope,
|
||||
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
|
||||
items: await listBaseballTicketBayAlerts(toOwnerScope(accessContext)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/baseball-ticket-bay/logs', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 불러올 수 없습니다.' });
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 로그를 불러올 수 없습니다.' });
|
||||
}
|
||||
|
||||
const query = z.object({ alertId: z.string().trim().min(1).optional() }).parse(request.query ?? {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: await listBaseballTicketBayLogs(clientId, query.alertId),
|
||||
includeAllClients: accessContext.scope === 'all',
|
||||
accessScope: accessContext.scope,
|
||||
scopeOwnerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.scope === 'client' ? accessContext.clientId : null,
|
||||
items: await listBaseballTicketBayLogs(toOwnerScope(accessContext), query.alertId),
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/logs/:id', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 로그를 삭제할 수 없습니다.' });
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 로그를 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayLog(params.id, clientId);
|
||||
const item = await deleteBaseballTicketBayLog(params.id, toOwnerScope(accessContext));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 로그를 찾지 못했습니다.' });
|
||||
@@ -91,20 +162,24 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 저장할 수 없습니다.' });
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 저장할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const payload = alertPayloadSchema.parse(request.body ?? {});
|
||||
const item = await createBaseballTicketBayAlert(payload, {
|
||||
clientId,
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
await createBaseballTicketBayLog({
|
||||
clientId,
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'create',
|
||||
@@ -120,21 +195,25 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.patch('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 수정할 수 없습니다.' });
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 수정할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = alertPayloadSchema.partial().parse(request.body ?? {});
|
||||
const item = await updateBaseballTicketBayAlert(params.id, payload, {
|
||||
clientId,
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
appOrigin: readHeader(request, 'x-app-origin'),
|
||||
appDomain: readHeader(request, 'x-app-domain'),
|
||||
});
|
||||
await createBaseballTicketBayLog({
|
||||
clientId,
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: payload.active === false ? 'pause' : payload.active === true ? 'resume' : 'run',
|
||||
@@ -155,21 +234,23 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.delete('/api/baseball-ticket-bay/alerts/:id', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 알림을 삭제할 수 없습니다.' });
|
||||
if (!accessContext || accessContext.scope === 'all') {
|
||||
return reply.code(400).send({ message: '현재 접근 범위에서는 알림을 삭제할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const item = await deleteBaseballTicketBayAlert(params.id, clientId);
|
||||
const item = await deleteBaseballTicketBayAlert(params.id, toOwnerScope(accessContext));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: '삭제할 알림을 찾지 못했습니다.' });
|
||||
}
|
||||
|
||||
await createBaseballTicketBayLog({
|
||||
clientId,
|
||||
clientId: accessContext.clientId,
|
||||
ownerType: accessContext.scope === 'shared-token' ? 'shared-token' : 'client',
|
||||
ownerId: accessContext.scope === 'shared-token' ? accessContext.tokenId : accessContext.clientId,
|
||||
alertId: item.id,
|
||||
alertTitle: item.title,
|
||||
action: 'delete',
|
||||
@@ -185,14 +266,22 @@ export async function registerBaseballTicketBayRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/baseball-ticket-bay/alerts/:id/run', async (request, reply) => {
|
||||
const clientId = readHeader(request, 'x-client-id');
|
||||
const accessContext = await resolveBaseballTicketBayAccessContext(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({ message: '클라이언트 ID가 없어 즉시 실행할 수 없습니다.' });
|
||||
if (!accessContext) {
|
||||
return reply.code(400).send({ message: '접근 식별값이 없어 즉시 실행할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const result = await runBaseballTicketBayAlert(params.id, { ignoreTimeWindow: true });
|
||||
|
||||
if (accessContext.scope === 'all') {
|
||||
return reply.code(403).send({ message: '전체 보기 범위에서는 즉시 실행할 수 없습니다.' });
|
||||
}
|
||||
|
||||
const result = await runBaseballTicketBayAlert(params.id, {
|
||||
ignoreTimeWindow: true,
|
||||
scope: toOwnerScope(accessContext),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js';
|
||||
import { resolvePromptFollowupMode, resolveStaticContentType } from './chat.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
@@ -12,40 +12,9 @@ test('resolveStaticContentType keeps plain text content type for code resources'
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
test('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => {
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: 101,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: null,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: 102,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
test('resolvePromptFollowupMode defaults to queue and preserves direct mode', () => {
|
||||
assert.equal(resolvePromptFollowupMode(undefined), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode(null), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode('queue'), 'queue');
|
||||
assert.equal(resolvePromptFollowupMode('direct'), 'direct');
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ensureChatSessionResourceDirectories,
|
||||
getActiveChatService,
|
||||
getChatRuntimeController,
|
||||
shouldAutoCompleteReplyParentVerification,
|
||||
} from '../services/chat-service.js';
|
||||
import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js';
|
||||
import {
|
||||
@@ -43,10 +42,12 @@ import {
|
||||
listChatConversationDetailPage,
|
||||
listChatConversations,
|
||||
markChatConversationRequestManualCompletion,
|
||||
ChatConversationManualCompletionBlockedError,
|
||||
markChatConversationResponsesRead,
|
||||
persistChatConversationPromptSelection,
|
||||
upsertChatConversationRequest,
|
||||
updateChatConversationContext,
|
||||
hasPendingAttentionVerificationRequest,
|
||||
} from '../services/chat-room-service.js';
|
||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||
import { resolveMainProjectRoot } from '../services/main-project-root-service.js';
|
||||
@@ -123,6 +124,10 @@ async function findExistingActivePromptFollowupRequest(
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function resolvePromptFollowupMode(mode?: 'queue' | 'direct' | null) {
|
||||
return mode === 'direct' ? 'direct' : 'queue';
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string) {
|
||||
return Buffer.from(value, 'utf-8').toString('base64url');
|
||||
}
|
||||
@@ -223,19 +228,6 @@ function resolveChatShareTokenSettingSnapshot(tokenPayload: ChatShareTokenPayloa
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldAutoCompleteShareReplyParentVerification(request: {
|
||||
responseMessageId?: number | null;
|
||||
responseText?: string | null;
|
||||
manualVerificationCompletedAt?: string | null;
|
||||
} | null | undefined) {
|
||||
return shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: request?.responseMessageId ?? null,
|
||||
responseText: request?.responseText ?? '',
|
||||
manualVerificationCompletedAt: request?.manualVerificationCompletedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function createManagedChatShareTokenId() {
|
||||
return `chat_share_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
|
||||
}
|
||||
@@ -802,36 +794,40 @@ function hasUnresolvedPromptPart(message: ListedChatConversationMessage) {
|
||||
function hasPendingPromptRequest(
|
||||
request: ListedChatConversationRequest,
|
||||
relatedMessages: ListedChatConversationMessage[],
|
||||
promptSubmittedCount = 0,
|
||||
) {
|
||||
if (request.manualPromptCompletedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return relatedMessages.some(
|
||||
(message) => (message.author === 'codex' || message.author === 'system') && hasUnresolvedPromptPart(message),
|
||||
);
|
||||
}
|
||||
const unresolvedPromptCount = relatedMessages.reduce((count, message) => {
|
||||
if (message.author !== 'codex' && message.author !== 'system') {
|
||||
return count;
|
||||
}
|
||||
|
||||
function hasVerificationTargetMessage(message: ListedChatConversationMessage) {
|
||||
if (message.author !== 'codex' && message.author !== 'system') {
|
||||
const promptParts = (message.parts ?? []).filter(
|
||||
(
|
||||
part: NonNullable<ListedChatConversationMessage['parts']>[number],
|
||||
): part is Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }> => part.type === 'prompt',
|
||||
);
|
||||
|
||||
return count + promptParts.filter(
|
||||
(part: Extract<NonNullable<ListedChatConversationMessage['parts']>[number], { type: 'prompt' }>) => !isPromptPartResolved(part),
|
||||
).length;
|
||||
}, 0);
|
||||
|
||||
if (unresolvedPromptCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = String(message.text ?? '').trim();
|
||||
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.length > 720) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(text);
|
||||
return unresolvedPromptCount > Math.max(0, promptSubmittedCount);
|
||||
}
|
||||
|
||||
function hasVerificationTargetRequest(relatedMessages: ListedChatConversationMessage[]) {
|
||||
return relatedMessages.some((message) => hasVerificationTargetMessage(message));
|
||||
function hasVerificationTargetRequest(
|
||||
request: ListedChatConversationRequest,
|
||||
relatedMessages: ListedChatConversationMessage[],
|
||||
) {
|
||||
return hasPendingAttentionVerificationRequest(request, relatedMessages);
|
||||
}
|
||||
|
||||
function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) {
|
||||
@@ -850,6 +846,30 @@ function buildChildRequestCountMap(requests: ListedChatConversationRequest[]) {
|
||||
return nextMap;
|
||||
}
|
||||
|
||||
function buildPromptFollowupCountMap(requests: ListedChatConversationRequest[]) {
|
||||
const nextMap = new Map<string, number>();
|
||||
|
||||
requests.forEach((request) => {
|
||||
if (request.requestOrigin !== 'prompt') {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentRequestId = request.parentRequestId?.trim() || '';
|
||||
|
||||
if (!parentRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return nextMap;
|
||||
}
|
||||
|
||||
function isPromptFollowupRoomRequest(request: ListedChatConversationRequest) {
|
||||
return request.requestOrigin === 'prompt';
|
||||
}
|
||||
|
||||
function isRequestInFlight(status: ListedChatConversationRequest['status']) {
|
||||
return status === 'accepted' || status === 'queued' || status === 'started';
|
||||
}
|
||||
@@ -858,20 +878,29 @@ function isPendingCompletionRoomRequest(
|
||||
request: ListedChatConversationRequest,
|
||||
relatedMessages: ListedChatConversationMessage[],
|
||||
childRequestCountByParentId?: Map<string, number>,
|
||||
promptFollowupCountByParentId?: Map<string, number>,
|
||||
) {
|
||||
if (isPromptFollowupRoomRequest(request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isRequestInFlight(request.status)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasPendingPromptRequest(request, relatedMessages)) {
|
||||
if (hasPendingPromptRequest(request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.manualPromptCompletedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasVerificationTargetRequest(relatedMessages)) {
|
||||
if (!hasVerificationTargetRequest(request, relatedMessages)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -884,6 +913,7 @@ function buildRoomRequestCounts(
|
||||
) {
|
||||
const requestMessagesById = new Map<string, ListedChatConversationMessage[]>();
|
||||
const childRequestCountByParentId = buildChildRequestCountMap(requests);
|
||||
const promptFollowupCountByParentId = buildPromptFollowupCountMap(requests);
|
||||
|
||||
messages.forEach((message) => {
|
||||
const requestId = message.clientRequestId?.trim() || '';
|
||||
@@ -897,12 +927,15 @@ function buildRoomRequestCounts(
|
||||
requestMessagesById.set(requestId, current);
|
||||
});
|
||||
|
||||
const processingCount = requests.filter((request) => isRequestInFlight(request.status)).length;
|
||||
const processingCount = requests.filter(
|
||||
(request) => !isPromptFollowupRoomRequest(request) && isRequestInFlight(request.status),
|
||||
).length;
|
||||
const unansweredCount = requests.filter((request) =>
|
||||
isPendingCompletionRoomRequest(
|
||||
request,
|
||||
requestMessagesById.get(request.requestId.trim()) ?? [],
|
||||
childRequestCountByParentId,
|
||||
promptFollowupCountByParentId,
|
||||
),
|
||||
).length;
|
||||
|
||||
@@ -937,8 +970,10 @@ function buildManagedSharePlaceholderRequest(tokenPayload: ChatShareTokenPayload
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: tokenPayload.managedResourceTokenId?.trim() || null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'completed',
|
||||
statusMessage: '공유 채팅방 시작 요청을 복원했습니다.',
|
||||
retryCount: 0,
|
||||
userMessageId: null,
|
||||
userText: '',
|
||||
responseMessageId: null,
|
||||
@@ -1554,6 +1589,9 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
const payload = z.object({
|
||||
accessPin: z.string().regex(/^\d{4}$/u).optional().nullable(),
|
||||
accessPinPromptTtlMinutes: z.number().int().min(0).max(7 * 24 * 60).optional().nullable(),
|
||||
chatTypeId: z.string().trim().min(1).max(120).optional().nullable(),
|
||||
chatTypeLabel: z.string().trim().min(1).max(200).optional().nullable(),
|
||||
notifyOffline: z.boolean().optional().nullable(),
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token);
|
||||
@@ -1615,10 +1653,35 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
let updatedConversation = await getChatConversation(tokenPayload.sessionId, getRequestClientId(request));
|
||||
|
||||
if (payload.chatTypeId || payload.notifyOffline != null) {
|
||||
updatedConversation = await updateChatConversationContext(tokenPayload.sessionId, {
|
||||
clientId: getRequestClientId(request),
|
||||
chatTypeId: payload.chatTypeId?.trim() || undefined,
|
||||
lastChatTypeId: payload.chatTypeId?.trim() || undefined,
|
||||
contextLabel: payload.chatTypeLabel?.trim() || undefined,
|
||||
contextDescription: payload.chatTypeId ? null : undefined,
|
||||
notifyOffline: payload.notifyOffline ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
hasAccessPin: saved.token.hasAccessPin,
|
||||
accessPinPromptTtlMinutes: saved.token.accessPinPromptTtlMinutes,
|
||||
conversation: updatedConversation
|
||||
? {
|
||||
sessionId: updatedConversation.sessionId,
|
||||
title: updatedConversation.title,
|
||||
requestBadgeLabel: updatedConversation.requestBadgeLabel ?? null,
|
||||
chatTypeId: updatedConversation.chatTypeId ?? null,
|
||||
lastChatTypeId: updatedConversation.lastChatTypeId ?? null,
|
||||
contextLabel: updatedConversation.contextLabel ?? null,
|
||||
contextDescription: updatedConversation.contextDescription ?? null,
|
||||
notifyOffline: updatedConversation.notifyOffline,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1866,6 +1929,11 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
sessionId: shareSnapshot.conversation?.sessionId ?? tokenPayload.sessionId,
|
||||
title: shareSnapshot.conversation?.title ?? '공유 채팅',
|
||||
requestBadgeLabel: shareSnapshot.conversation?.requestBadgeLabel ?? null,
|
||||
chatTypeId: shareSnapshot.conversation?.chatTypeId ?? null,
|
||||
lastChatTypeId: shareSnapshot.conversation?.lastChatTypeId ?? null,
|
||||
contextLabel: shareSnapshot.conversation?.contextLabel ?? null,
|
||||
contextDescription: shareSnapshot.conversation?.contextDescription ?? null,
|
||||
notifyOffline: shareSnapshot.conversation?.notifyOffline === true,
|
||||
},
|
||||
rootRequestId: shareSnapshot.rootRequestId,
|
||||
targetRequest: shareSnapshot.targetRequest,
|
||||
@@ -1955,6 +2023,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
text: z.string().trim().min(1).max(20000),
|
||||
mode: z.enum(['queue', 'direct']).optional(),
|
||||
parentRequestId: z.string().trim().min(1).max(120).optional().nullable(),
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
@@ -2012,11 +2081,13 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const requestedParentRequestId = payload.parentRequestId?.trim() || '';
|
||||
const resolvedParentRequestId = resolveRecoveredShareParentRequestId(
|
||||
shareSnapshot,
|
||||
requestedParentRequestId,
|
||||
[shareSnapshot.targetRequest.requestId],
|
||||
);
|
||||
const resolvedParentRequestId = requestedParentRequestId
|
||||
? resolveRecoveredShareParentRequestId(
|
||||
shareSnapshot,
|
||||
requestedParentRequestId,
|
||||
[shareSnapshot.targetRequest.requestId],
|
||||
)
|
||||
: null;
|
||||
|
||||
if (
|
||||
resolvedParentRequestId
|
||||
@@ -2028,7 +2099,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.text, {
|
||||
mode: 'direct',
|
||||
mode: payload.mode === 'direct' ? 'direct' : 'queue',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
|
||||
parentRequestId: resolvedParentRequestId,
|
||||
@@ -2041,22 +2112,6 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const parentRequest = resolvedParentRequestId
|
||||
? shareSnapshot.requests.find((request) => request.requestId.trim() === resolvedParentRequestId) ?? null
|
||||
: null;
|
||||
|
||||
if (resolvedParentRequestId && shouldAutoCompleteShareReplyParentVerification(parentRequest)) {
|
||||
const updatedParentRequest = await markChatConversationRequestManualCompletion(
|
||||
tokenPayload.sessionId,
|
||||
resolvedParentRequestId,
|
||||
'verification',
|
||||
);
|
||||
|
||||
if (updatedParentRequest) {
|
||||
getActiveChatService()?.broadcastRequestUpdate(tokenPayload.sessionId, updatedParentRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (managedContext.managedResource) {
|
||||
await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, {
|
||||
actorLabel: 'share-viewer',
|
||||
@@ -2179,6 +2234,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
summaryText: z.string().max(10000).optional().nullable(),
|
||||
attachments: z.array(chatComposerAttachmentSchema).max(20).optional(),
|
||||
followupText: z.string().trim().min(1).max(20000),
|
||||
mode: z.enum(['queue', 'direct']).optional(),
|
||||
contextRef: chatPromptContextRefSchema,
|
||||
}).parse(request.body ?? {});
|
||||
const managedContext = await resolveManagedChatShareContext(params.token);
|
||||
@@ -2261,7 +2317,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, payload.followupText, {
|
||||
mode: 'direct',
|
||||
mode: resolvePromptFollowupMode(payload.mode),
|
||||
requestOrigin: 'prompt',
|
||||
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
|
||||
parentRequestId: normalizedParentRequestId,
|
||||
@@ -2363,11 +2419,23 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const item = await markChatConversationRequestManualCompletion(
|
||||
tokenPayload.sessionId,
|
||||
normalizedParentRequestId,
|
||||
payload.type,
|
||||
);
|
||||
let item = null;
|
||||
|
||||
try {
|
||||
item = await markChatConversationRequestManualCompletion(
|
||||
tokenPayload.sessionId,
|
||||
normalizedParentRequestId,
|
||||
payload.type,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ChatConversationManualCompletionBlockedError) {
|
||||
return reply.code(409).send({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
@@ -2587,6 +2655,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const queuedRequestId = await getActiveChatService()?.submitExternalMessage(tokenPayload.sessionId, normalizedUserText, {
|
||||
requestId: normalizedParentRequestId,
|
||||
mode: 'direct',
|
||||
requestOrigin: targetRequest.requestOrigin === 'prompt' ? 'prompt' : 'composer',
|
||||
sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null,
|
||||
@@ -2928,11 +2997,23 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
type: z.enum(['prompt', 'verification']),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const item = await markChatConversationRequestManualCompletion(
|
||||
params.sessionId,
|
||||
params.requestId,
|
||||
payload.type,
|
||||
);
|
||||
let item = null;
|
||||
|
||||
try {
|
||||
item = await markChatConversationRequestManualCompletion(
|
||||
params.sessionId,
|
||||
params.requestId,
|
||||
payload.type,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ChatConversationManualCompletionBlockedError) {
|
||||
return reply.code(409).send({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
@@ -3044,7 +3125,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
const queuedRequestId = existingPromptRequest?.requestId ?? await getActiveChatService()?.submitExternalMessage(params.sessionId, payload.followupText, {
|
||||
mode: payload.mode === 'direct' ? 'direct' : 'queue',
|
||||
mode: resolvePromptFollowupMode(payload.mode),
|
||||
requestOrigin: 'prompt',
|
||||
parentRequestId: params.requestId,
|
||||
promptContextRef: payload.contextRef ?? null,
|
||||
|
||||
@@ -28,6 +28,19 @@ function buildRuntimeResponse() {
|
||||
export async function registerRuntimeRoutes(app: FastifyInstance) {
|
||||
app.get('/api/runtime', async () => buildRuntimeResponse());
|
||||
|
||||
app.post('/api/runtime/recover-interrupted-chat', async () => {
|
||||
const recovered = await getActiveChatService()?.recoverInterruptedSessions();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
recovered: recovered ?? {
|
||||
sessionCount: 0,
|
||||
restartedCount: 0,
|
||||
requeuedCount: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/runtime/drain', async (request) => {
|
||||
const { draining } = runtimeDrainBodySchema.parse(request.body ?? {});
|
||||
|
||||
|
||||
@@ -2,7 +2,15 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
||||
import {
|
||||
deployTestServerCommand,
|
||||
deployWorkServerCommand,
|
||||
listServerCommands,
|
||||
readWorkServerDeploymentState,
|
||||
restartServerCommand,
|
||||
serverCommandKeys,
|
||||
} from '../services/server-command-service.js';
|
||||
import { readTestServerDeploymentState } from '../services/test-server-deployment-service.js';
|
||||
import {
|
||||
cancelServerRestartReservation,
|
||||
confirmServerRestartReservation,
|
||||
@@ -43,16 +51,7 @@ function getImmediateRestartBlockInfo(
|
||||
}
|
||||
|
||||
if (key === 'work-server') {
|
||||
const pendingCount = codexPendingCount + automationPendingCount;
|
||||
|
||||
if (pendingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pendingCount,
|
||||
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -92,7 +91,7 @@ async function resolveSharedServerCommandAccessContext(request: FastifyRequest)
|
||||
|
||||
return {
|
||||
scope: 'shared' as const,
|
||||
allowedKeys: new Set<string>(['work-server']),
|
||||
allowedKeys: new Set<string>(['work-server', 'test']),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,6 +181,12 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '서버 재기동에 실패했습니다.';
|
||||
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
|
||||
|
||||
if (statusCode === 409) {
|
||||
reply.status(409);
|
||||
return { ok: false, message };
|
||||
}
|
||||
|
||||
if (key !== 'test' && key !== 'work-server') {
|
||||
throw error;
|
||||
@@ -207,6 +212,99 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/work-server/deployment', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버 배포 상태를 확인할 수 없습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: (await readWorkServerDeploymentState()) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/work-server/actions/deploy', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('work-server')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 WORK 서버를 배포할 수 없습니다.' };
|
||||
}
|
||||
|
||||
const result = await deployWorkServerCommand();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
deployment: result.deployment ?? result.server.deployment ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/test/deployment', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버 배포 상태를 확인할 수 없습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: (await readTestServerDeploymentState()) ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/test/actions/deploy', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessContext.scope !== 'full' && !accessContext.allowedKeys.has('test')) {
|
||||
reply.status(403);
|
||||
return { ok: false, message: '현재 공유채팅 링크로는 TEST 서버를 배포할 수 없습니다.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deployTestServerCommand();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
testDeployment: result.testDeployment ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? Number((error as { statusCode?: unknown }).statusCode) : null;
|
||||
|
||||
if (statusCode === 409) {
|
||||
reply.status(409);
|
||||
return { ok: false, message: error instanceof Error ? error.message : 'TEST 서버 배포가 이미 진행 중입니다.' };
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/server-commands/restart-reservation', async (request, reply) => {
|
||||
const accessContext = await resolveServerCommandAccessContext(request);
|
||||
if (!accessContext) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
sharedResourceTokenSchema,
|
||||
upsertSharedResourceToken,
|
||||
} from '../services/shared-resource-token-service.js';
|
||||
import { extractRequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
@@ -142,7 +143,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await upsertSharedResourceToken(payload);
|
||||
const saved = await upsertSharedResourceToken(payload, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
@@ -174,7 +178,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason);
|
||||
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -201,7 +208,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await revokeSharedResourceToken(tokenId, payload.reason);
|
||||
const saved = await revokeSharedResourceToken(tokenId, payload.reason, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
@@ -229,7 +239,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await restoreSharedResourceToken(tokenId);
|
||||
const saved = await restoreSharedResourceToken(tokenId, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
@@ -265,7 +278,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await recordSharedResourceTokenUsage(tokenId, payload);
|
||||
const saved = await recordSharedResourceTokenUsage(tokenId, {
|
||||
...payload,
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
@@ -298,7 +314,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deleteSharedResourceTokens(payload.tokenIds);
|
||||
const result = await deleteSharedResourceTokens(payload.tokenIds, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -320,7 +339,10 @@ export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteSharedResourceToken(tokenId);
|
||||
const deleted = await deleteSharedResourceToken(tokenId, {
|
||||
actorLabel: accessContext.scope === 'full' ? 'manager' : 'shared-link-manager',
|
||||
audit: extractRequestAuditContext(request),
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { sendNotifications } from './notification-service.js';
|
||||
@@ -8,6 +9,7 @@ const MAX_CATEGORY_COUNT = 16;
|
||||
const MAX_RESULT_COUNT = 12;
|
||||
const TICKET_BAY_FETCH_TIMEOUT_MS = 12_000;
|
||||
const TICKET_BAY_PRODUCT_PAGE_SIZE = 100;
|
||||
const BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY = 741_205_261;
|
||||
|
||||
const teamKeywordMap: Record<string, string> = {
|
||||
LG: 'LG',
|
||||
@@ -690,6 +692,9 @@ export type BaseballTicketBayTimeWindow = {
|
||||
|
||||
export type BaseballTicketBayAlertItem = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
ownerType: BaseballTicketBayOwnerType;
|
||||
ownerId: string;
|
||||
title: string;
|
||||
eventDate: string;
|
||||
team: string;
|
||||
@@ -714,6 +719,9 @@ export type BaseballTicketBayAlertLogStatus = 'info' | 'success' | 'warning' | '
|
||||
|
||||
export type BaseballTicketBayAlertLogItem = {
|
||||
id: string;
|
||||
clientId: string;
|
||||
ownerType: BaseballTicketBayOwnerType;
|
||||
ownerId: string;
|
||||
alertId: string | null;
|
||||
alertTitle: string;
|
||||
action: BaseballTicketBayAlertLogAction;
|
||||
@@ -759,9 +767,21 @@ export type BaseballTicketBayAlertMutation = {
|
||||
timeWindows: BaseballTicketBayTimeWindow[];
|
||||
};
|
||||
|
||||
type BaseballTicketBayOwnerType = 'client' | 'shared-token';
|
||||
|
||||
type BaseballTicketBayOwnerScope =
|
||||
| { kind: 'all' }
|
||||
| {
|
||||
kind: 'owner';
|
||||
ownerType: BaseballTicketBayOwnerType;
|
||||
ownerId: string;
|
||||
};
|
||||
|
||||
type BaseballTicketBayAlertRow = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
owner_type: BaseballTicketBayOwnerType;
|
||||
owner_id: string;
|
||||
app_origin: string | null;
|
||||
app_domain: string | null;
|
||||
title: string;
|
||||
@@ -786,6 +806,8 @@ type BaseballTicketBayAlertRow = {
|
||||
type BaseballTicketBayLogRow = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
owner_type: BaseballTicketBayOwnerType;
|
||||
owner_id: string;
|
||||
alert_id: string | null;
|
||||
alert_title: string;
|
||||
action: BaseballTicketBayAlertLogAction;
|
||||
@@ -810,6 +832,25 @@ function createId(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
function normalizeOwnerType(value: unknown): BaseballTicketBayOwnerType {
|
||||
return normalizeText(value) === 'shared-token' ? 'shared-token' : 'client';
|
||||
}
|
||||
|
||||
function normalizeOwnerId(row: { owner_id?: unknown; client_id?: unknown }) {
|
||||
return normalizeText(row.owner_id) || normalizeText(row.client_id);
|
||||
}
|
||||
|
||||
function applyOwnerScope(query: Knex.QueryBuilder, scope: BaseballTicketBayOwnerScope) {
|
||||
if (scope.kind === 'all') {
|
||||
return query;
|
||||
}
|
||||
|
||||
return query.where({
|
||||
owner_type: scope.ownerType,
|
||||
owner_id: scope.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNumericValue(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
@@ -858,6 +899,9 @@ function parseTimeWindows(value: string) {
|
||||
function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem {
|
||||
return {
|
||||
id: normalizeText(row.id),
|
||||
clientId: normalizeText(row.client_id),
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
title: normalizeText(row.title),
|
||||
eventDate: normalizeText(row.event_date),
|
||||
team: normalizeText(row.team) || '전체',
|
||||
@@ -891,6 +935,9 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
|
||||
|
||||
return {
|
||||
id: normalizeText(row.id),
|
||||
clientId: normalizeText(row.client_id),
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
|
||||
alertTitle: normalizeText(row.alert_title),
|
||||
action: row.action,
|
||||
@@ -978,6 +1025,8 @@ export async function ensureBaseballTicketBayTables() {
|
||||
await db.schema.createTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
|
||||
table.string('id', 120).primary();
|
||||
table.string('client_id', 200).notNullable().index();
|
||||
table.string('owner_type', 40).notNullable().defaultTo('client').index();
|
||||
table.string('owner_id', 200).notNullable().defaultTo('').index();
|
||||
table.text('app_origin').nullable();
|
||||
table.string('app_domain', 255).nullable();
|
||||
table.string('title', 255).notNullable();
|
||||
@@ -1006,6 +1055,8 @@ export async function ensureBaseballTicketBayTables() {
|
||||
await db.schema.createTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
|
||||
table.string('id', 120).primary();
|
||||
table.string('client_id', 200).notNullable().index();
|
||||
table.string('owner_type', 40).notNullable().defaultTo('client').index();
|
||||
table.string('owner_id', 200).notNullable().defaultTo('').index();
|
||||
table.string('alert_id', 120).nullable().index();
|
||||
table.string('alert_title', 255).notNullable();
|
||||
table.string('action', 40).notNullable();
|
||||
@@ -1025,6 +1076,60 @@ export async function ensureBaseballTicketBayTables() {
|
||||
});
|
||||
}
|
||||
|
||||
const hasAlertOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_type');
|
||||
|
||||
if (!hasAlertOwnerTypeColumn) {
|
||||
await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
|
||||
table.string('owner_type', 40).notNullable().defaultTo('client').index();
|
||||
});
|
||||
}
|
||||
|
||||
const hasAlertOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_id');
|
||||
|
||||
if (!hasAlertOwnerIdColumn) {
|
||||
await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
|
||||
table.string('owner_id', 200).notNullable().defaultTo('').index();
|
||||
});
|
||||
}
|
||||
|
||||
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.where((builder) => {
|
||||
builder.whereNull('owner_type').orWhere('owner_type', '');
|
||||
})
|
||||
.update({ owner_type: 'client' });
|
||||
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.where((builder) => {
|
||||
builder.whereNull('owner_id').orWhere('owner_id', '');
|
||||
})
|
||||
.update({ owner_id: db.ref('client_id') });
|
||||
|
||||
const hasLogOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_type');
|
||||
|
||||
if (!hasLogOwnerTypeColumn) {
|
||||
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
|
||||
table.string('owner_type', 40).notNullable().defaultTo('client').index();
|
||||
});
|
||||
}
|
||||
|
||||
const hasLogOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_id');
|
||||
|
||||
if (!hasLogOwnerIdColumn) {
|
||||
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
|
||||
table.string('owner_id', 200).notNullable().defaultTo('').index();
|
||||
});
|
||||
}
|
||||
|
||||
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
|
||||
.where((builder) => {
|
||||
builder.whereNull('owner_type').orWhere('owner_type', '');
|
||||
})
|
||||
.update({ owner_type: 'client' });
|
||||
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
|
||||
.where((builder) => {
|
||||
builder.whereNull('owner_id').orWhere('owner_id', '');
|
||||
})
|
||||
.update({ owner_id: db.ref('client_id') });
|
||||
|
||||
const hasSeenTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE);
|
||||
|
||||
if (!hasSeenTable) {
|
||||
@@ -1046,33 +1151,39 @@ export async function ensureBaseballTicketBayTables() {
|
||||
return baseballTicketBayTableSetupPromise;
|
||||
}
|
||||
|
||||
export async function listBaseballTicketBayAlerts(clientId: string) {
|
||||
export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerScope) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ client_id: clientId })
|
||||
.orderBy([{ column: 'event_date', order: 'asc' }, { column: 'created_at', order: 'desc' }]);
|
||||
return rows.map((row) => mapAlertRow(row as BaseballTicketBayAlertRow));
|
||||
const query = applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*'), scope);
|
||||
|
||||
const rows = (await query.orderBy([
|
||||
{ column: 'event_date', order: 'asc' },
|
||||
{ column: 'owner_type', order: 'asc' },
|
||||
{ column: 'owner_id', order: 'asc' },
|
||||
{ column: 'client_id', order: 'asc' },
|
||||
{ column: 'created_at', order: 'desc' },
|
||||
])) as BaseballTicketBayAlertRow[];
|
||||
return rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
|
||||
}
|
||||
|
||||
export async function listBaseballTicketBayLogs(clientId: string, alertId?: string) {
|
||||
export async function listBaseballTicketBayLogs(
|
||||
scope: BaseballTicketBayOwnerScope,
|
||||
alertId?: string,
|
||||
) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
let query = db(BASEBALL_TICKET_BAY_LOG_TABLE)
|
||||
.select('*')
|
||||
.where({ client_id: clientId })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(200);
|
||||
let query = applyOwnerScope(db(BASEBALL_TICKET_BAY_LOG_TABLE).select('*'), scope).orderBy('created_at', 'desc').limit(200);
|
||||
|
||||
if (alertId) {
|
||||
query = query.andWhere({ alert_id: alertId });
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
return rows.map((row) => mapLogRow(row as BaseballTicketBayLogRow));
|
||||
const rows = (await query) as BaseballTicketBayLogRow[];
|
||||
return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
|
||||
}
|
||||
|
||||
export async function createBaseballTicketBayLog(args: {
|
||||
clientId: string;
|
||||
ownerType: BaseballTicketBayOwnerType;
|
||||
ownerId: string;
|
||||
alertId?: string | null;
|
||||
alertTitle: string;
|
||||
action: BaseballTicketBayAlertLogAction;
|
||||
@@ -1085,6 +1196,8 @@ export async function createBaseballTicketBayLog(args: {
|
||||
const row: BaseballTicketBayLogRow = {
|
||||
id: createId('log'),
|
||||
client_id: args.clientId,
|
||||
owner_type: args.ownerType,
|
||||
owner_id: args.ownerId,
|
||||
alert_id: args.alertId ?? null,
|
||||
alert_title: args.alertTitle,
|
||||
action: args.action,
|
||||
@@ -1098,40 +1211,43 @@ export async function createBaseballTicketBayLog(args: {
|
||||
return mapLogRow(row);
|
||||
}
|
||||
|
||||
export async function deleteBaseballTicketBayLog(logId: string, clientId: string) {
|
||||
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
|
||||
const existing = await db(BASEBALL_TICKET_BAY_LOG_TABLE)
|
||||
.select('*')
|
||||
.where({
|
||||
id: logId,
|
||||
client_id: clientId,
|
||||
})
|
||||
.first();
|
||||
const existing = await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_LOG_TABLE)
|
||||
.select('*')
|
||||
.where({
|
||||
id: logId,
|
||||
}),
|
||||
scope,
|
||||
).first();
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
|
||||
.where({
|
||||
await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_LOG_TABLE).where({
|
||||
id: logId,
|
||||
client_id: clientId,
|
||||
})
|
||||
.delete();
|
||||
}),
|
||||
scope,
|
||||
).delete();
|
||||
|
||||
return mapLogRow(existing as BaseballTicketBayLogRow);
|
||||
}
|
||||
|
||||
export async function createBaseballTicketBayAlert(
|
||||
payload: BaseballTicketBayAlertMutation,
|
||||
context: { clientId: string; appOrigin?: string; appDomain?: string },
|
||||
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
|
||||
) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const now = new Date().toISOString();
|
||||
const row: BaseballTicketBayAlertRow = {
|
||||
id: createId('alert'),
|
||||
client_id: context.clientId,
|
||||
owner_type: context.ownerType,
|
||||
owner_id: context.ownerId,
|
||||
app_origin: normalizeText(context.appOrigin) || null,
|
||||
app_domain: normalizeText(context.appDomain) || null,
|
||||
title: payload.title.trim(),
|
||||
@@ -1159,13 +1275,15 @@ export async function createBaseballTicketBayAlert(
|
||||
export async function updateBaseballTicketBayAlert(
|
||||
alertId: string,
|
||||
payload: Partial<BaseballTicketBayAlertMutation>,
|
||||
context: { clientId: string; appOrigin?: string; appDomain?: string },
|
||||
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
|
||||
) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const current = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId, client_id: context.clientId })
|
||||
.first();
|
||||
const current = await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId }),
|
||||
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
|
||||
).first();
|
||||
|
||||
if (!current) {
|
||||
throw new Error('수정할 알림을 찾지 못했습니다.');
|
||||
@@ -1191,37 +1309,53 @@ export async function updateBaseballTicketBayAlert(
|
||||
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
|
||||
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
|
||||
|
||||
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.where({ id: alertId, client_id: context.clientId })
|
||||
.update(patch);
|
||||
await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
|
||||
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
|
||||
).update(patch);
|
||||
|
||||
const updated = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId, client_id: context.clientId })
|
||||
.first();
|
||||
const updated = await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId }),
|
||||
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
|
||||
).first();
|
||||
return mapAlertRow(updated as BaseballTicketBayAlertRow);
|
||||
}
|
||||
|
||||
export async function deleteBaseballTicketBayAlert(alertId: string, clientId: string) {
|
||||
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId, client_id: clientId })
|
||||
.first();
|
||||
const row = await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId }),
|
||||
scope,
|
||||
).first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.where({ id: alertId, client_id: clientId })
|
||||
.delete();
|
||||
await applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
|
||||
scope,
|
||||
).delete();
|
||||
return mapAlertRow(row as BaseballTicketBayAlertRow);
|
||||
}
|
||||
|
||||
async function getAlertRow(alertId: string) {
|
||||
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ id: alertId }).first();
|
||||
const scopedQuery = scope
|
||||
? applyOwnerScope(
|
||||
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId }),
|
||||
scope,
|
||||
)
|
||||
: db(BASEBALL_TICKET_BAY_ALERT_TABLE)
|
||||
.select('*')
|
||||
.where({ id: alertId });
|
||||
const row = await scopedQuery.first();
|
||||
return row ? (row as BaseballTicketBayAlertRow) : null;
|
||||
}
|
||||
|
||||
@@ -1268,8 +1402,11 @@ async function updateAlertRunTimestamp(alertId: string, patch: { lastRunAt: stri
|
||||
});
|
||||
}
|
||||
|
||||
export async function runBaseballTicketBayAlert(alertId: string, options?: { ignoreTimeWindow?: boolean }) {
|
||||
const row = await getAlertRow(alertId);
|
||||
export async function runBaseballTicketBayAlert(
|
||||
alertId: string,
|
||||
options?: { ignoreTimeWindow?: boolean; scope?: BaseballTicketBayOwnerScope },
|
||||
) {
|
||||
const row = await getAlertRow(alertId, options?.scope);
|
||||
|
||||
if (!row) {
|
||||
throw new Error('실행할 알림을 찾지 못했습니다.');
|
||||
@@ -1281,6 +1418,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
|
||||
if (!options?.ignoreTimeWindow && isWithinBlockedTime(alert.timeWindows, now)) {
|
||||
const log = await createBaseballTicketBayLog({
|
||||
clientId: row.client_id,
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
alertId: alert.id,
|
||||
alertTitle: alert.title,
|
||||
action: 'run',
|
||||
@@ -1328,6 +1467,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
|
||||
: [];
|
||||
const log = await createBaseballTicketBayLog({
|
||||
clientId: row.client_id,
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
alertId: alert.id,
|
||||
alertTitle: alert.title,
|
||||
action: 'run',
|
||||
@@ -1372,6 +1513,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
|
||||
].join('\n');
|
||||
const log = await createBaseballTicketBayLog({
|
||||
clientId: row.client_id,
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
alertId: alert.id,
|
||||
alertTitle: alert.title,
|
||||
action: 'run',
|
||||
@@ -1414,36 +1557,61 @@ function isAlertDue(alert: BaseballTicketBayAlertItem, now: Date) {
|
||||
return now.getTime() - lastRunAt >= alert.batchIntervalMinutes * 60 * 1000;
|
||||
}
|
||||
|
||||
function readBooleanLikeValue(value: unknown) {
|
||||
return value === true || value === 't' || value === 'true' || value === 1 || value === '1';
|
||||
}
|
||||
|
||||
async function tryAcquireBaseballTicketBayBatchLock() {
|
||||
const result = (await db.raw('select pg_try_advisory_lock(?) as locked', [
|
||||
BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY,
|
||||
])) as { rows?: Array<{ locked?: unknown }> };
|
||||
return readBooleanLikeValue(result.rows?.[0]?.locked);
|
||||
}
|
||||
|
||||
async function releaseBaseballTicketBayBatchLock() {
|
||||
await db.raw('select pg_advisory_unlock(?)', [BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY]);
|
||||
}
|
||||
|
||||
export async function processDueBaseballTicketBayAlerts(now = new Date()) {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true });
|
||||
const results: Array<{ alertId: string; ok: boolean; message?: string }> = [];
|
||||
|
||||
for (const row of rows as BaseballTicketBayAlertRow[]) {
|
||||
const alert = mapAlertRow(row);
|
||||
|
||||
if (!isAlertDue(alert, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await runBaseballTicketBayAlert(alert.id);
|
||||
results.push({ alertId: alert.id, ok: true });
|
||||
} catch (error) {
|
||||
const handledError = error instanceof Error ? error : new Error(String(error));
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: row.client_id,
|
||||
alertId: alert.id,
|
||||
alertTitle: alert.title,
|
||||
action: 'run',
|
||||
status: 'error',
|
||||
message: handledError.message || '배치 실행에 실패했습니다.',
|
||||
detail: '',
|
||||
});
|
||||
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
|
||||
results.push({ alertId: alert.id, ok: false, message: handledError.message });
|
||||
}
|
||||
if (!(await tryAcquireBaseballTicketBayBatchLock())) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return results;
|
||||
try {
|
||||
await ensureBaseballTicketBayTables();
|
||||
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true });
|
||||
const results: Array<{ alertId: string; ok: boolean; message?: string }> = [];
|
||||
|
||||
for (const row of rows as BaseballTicketBayAlertRow[]) {
|
||||
const alert = mapAlertRow(row);
|
||||
|
||||
if (!isAlertDue(alert, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await runBaseballTicketBayAlert(alert.id);
|
||||
results.push({ alertId: alert.id, ok: true });
|
||||
} catch (error) {
|
||||
const handledError = error instanceof Error ? error : new Error(String(error));
|
||||
await createBaseballTicketBayLog({
|
||||
clientId: row.client_id,
|
||||
ownerType: normalizeOwnerType(row.owner_type),
|
||||
ownerId: normalizeOwnerId(row),
|
||||
alertId: alert.id,
|
||||
alertTitle: alert.title,
|
||||
action: 'run',
|
||||
status: 'error',
|
||||
message: handledError.message || '배치 실행에 실패했습니다.',
|
||||
detail: '',
|
||||
});
|
||||
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
|
||||
results.push({ alertId: alert.id, ok: false, message: handledError.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
await releaseBaseballTicketBayBatchLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
selectStaleOfflineNotificationClientIds,
|
||||
resolveNextConversationContextValue,
|
||||
resolveNextConversationChatTypeId,
|
||||
hasPendingAttentionVerificationRequest,
|
||||
isConversationAttentionPending,
|
||||
shouldClearConversationJobState,
|
||||
selectChatConversationResponseCandidate,
|
||||
} from './chat-room-service.js';
|
||||
@@ -97,6 +99,69 @@ test('buildChatConversationContextUpdateFields ignores undefined payload keys so
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields updates shared room chat type metadata together', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '공유채팅',
|
||||
chat_type_id: 'general-request',
|
||||
last_chat_type_id: 'general-request',
|
||||
context_label: '일반 요청',
|
||||
context_description: 'old',
|
||||
notify_offline: true,
|
||||
},
|
||||
payload: {
|
||||
chatTypeId: 'codex-live-default',
|
||||
lastChatTypeId: 'codex-live-default',
|
||||
contextLabel: 'Codex Live 기본',
|
||||
contextDescription: null,
|
||||
},
|
||||
}),
|
||||
{
|
||||
chat_type_id: 'codex-live-default',
|
||||
last_chat_type_id: 'codex-live-default',
|
||||
context_label: 'Codex Live 기본',
|
||||
context_description: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields writes global notify flag only when no client is bound', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '공유채팅',
|
||||
chat_type_id: 'general-request',
|
||||
last_chat_type_id: 'general-request',
|
||||
notify_offline: false,
|
||||
},
|
||||
payload: {
|
||||
notifyOffline: true,
|
||||
},
|
||||
}),
|
||||
{
|
||||
notify_offline: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '공유채팅',
|
||||
chat_type_id: 'general-request',
|
||||
last_chat_type_id: 'general-request',
|
||||
client_id: 'client-1',
|
||||
notify_offline: false,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-1',
|
||||
notifyOffline: true,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
|
||||
assert.deepEqual(
|
||||
selectStaleOfflineNotificationClientIds(
|
||||
@@ -147,6 +212,149 @@ test('collectRegisteredNotificationClientIds keeps both web push client ids and
|
||||
);
|
||||
});
|
||||
|
||||
test('hasPendingAttentionVerificationRequest keeps 일반 답변 in pending attention until manual completion', () => {
|
||||
assert.equal(
|
||||
hasPendingAttentionVerificationRequest(
|
||||
{
|
||||
status: 'completed',
|
||||
responseMessageId: 101,
|
||||
responseText: '일반 답변입니다.',
|
||||
requestOrigin: 'composer',
|
||||
},
|
||||
[],
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasPendingAttentionVerificationRequest(
|
||||
{
|
||||
status: 'completed',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
requestOrigin: 'composer',
|
||||
},
|
||||
[
|
||||
{
|
||||
author: 'codex',
|
||||
text: '```diff\n+ hello\n```',
|
||||
},
|
||||
],
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasPendingAttentionVerificationRequest(
|
||||
{
|
||||
status: 'completed',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
requestOrigin: 'composer',
|
||||
},
|
||||
[
|
||||
{
|
||||
author: 'codex',
|
||||
text: '짧은 진행 로그',
|
||||
},
|
||||
],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConversationAttentionPending clears verification attention when 답변하기 child request exists', () => {
|
||||
assert.equal(
|
||||
isConversationAttentionPending({
|
||||
request: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'parent-request',
|
||||
requesterClientId: null,
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '기본처리',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'completed',
|
||||
statusMessage: null,
|
||||
retryCount: 0,
|
||||
userMessageId: 1,
|
||||
userText: '원본 질문',
|
||||
responseMessageId: 2,
|
||||
responseText: '원본 답변',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: true,
|
||||
canDelete: false,
|
||||
manualPromptCompletedAt: null,
|
||||
manualVerificationCompletedAt: null,
|
||||
createdAt: '2026-05-26T00:00:00.000Z',
|
||||
updatedAt: '2026-05-26T00:01:00.000Z',
|
||||
answeredAt: '2026-05-26T00:01:00.000Z',
|
||||
terminalAt: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
relatedMessages: [
|
||||
{
|
||||
id: 2,
|
||||
author: 'codex',
|
||||
text: '원본 답변',
|
||||
timestamp: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
childRequestCountByParentId: new Map([['parent-request', 1]]),
|
||||
promptFollowupCountByParentId: new Map(),
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isConversationAttentionPending keeps completed 일반 답변 visible when no child request exists', () => {
|
||||
assert.equal(
|
||||
isConversationAttentionPending({
|
||||
request: {
|
||||
sessionId: 'session-1',
|
||||
requestId: 'standalone-request',
|
||||
requesterClientId: null,
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '기본처리',
|
||||
requestOrigin: 'composer',
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'completed',
|
||||
statusMessage: null,
|
||||
retryCount: 0,
|
||||
userMessageId: 1,
|
||||
userText: '독립 질문',
|
||||
responseMessageId: 2,
|
||||
responseText: '독립 답변',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: true,
|
||||
canDelete: false,
|
||||
manualPromptCompletedAt: null,
|
||||
manualVerificationCompletedAt: null,
|
||||
createdAt: '2026-05-26T00:00:00.000Z',
|
||||
updatedAt: '2026-05-26T00:01:00.000Z',
|
||||
answeredAt: '2026-05-26T00:01:00.000Z',
|
||||
terminalAt: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
relatedMessages: [
|
||||
{
|
||||
id: 2,
|
||||
author: 'codex',
|
||||
text: '독립 답변',
|
||||
timestamp: '2026-05-26T00:01:00.000Z',
|
||||
},
|
||||
],
|
||||
childRequestCountByParentId: new Map(),
|
||||
promptFollowupCountByParentId: new Map(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||
assert.equal(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
@@ -227,6 +435,38 @@ test('applyChatPromptSelectionPatch resolves the matched prompt with persisted s
|
||||
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
|
||||
});
|
||||
|
||||
test('applyChatPromptSelectionPatch keeps followup text for free-text-only prompt submissions', () => {
|
||||
const promptPart = {
|
||||
type: 'prompt' as const,
|
||||
title: '다음 단계 선택',
|
||||
description: '원하는 작업을 고르세요.',
|
||||
submitLabel: '선택 전달',
|
||||
mode: 'queue' as const,
|
||||
selectedValues: [],
|
||||
options: [],
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const patched = applyChatPromptSelectionPatch(
|
||||
[promptPart],
|
||||
{
|
||||
promptIndex: 0,
|
||||
promptTitle: promptPart.title,
|
||||
promptSignature: buildChatPromptTargetSignature(promptPart),
|
||||
selectedValues: [],
|
||||
freeText: '',
|
||||
followupText: '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.',
|
||||
summaryText: '',
|
||||
},
|
||||
'2026-05-18T08:25:00.000Z',
|
||||
);
|
||||
|
||||
assert.ok(patched);
|
||||
assert.equal(patched?.[0]?.type, 'prompt');
|
||||
assert.equal(patched?.[0]?.resolvedBy, 'user');
|
||||
assert.equal(patched?.[0]?.resultText, '선택 없이 기타 요청 기준으로 이어서 진행해 주세요.');
|
||||
});
|
||||
|
||||
test('collectPromptSelectionCandidateRequestIds includes descendant requests and prefers recent responses first', () => {
|
||||
assert.deepEqual(
|
||||
collectPromptSelectionCandidateRequestIds(
|
||||
@@ -552,6 +792,28 @@ test('shouldClearConversationJobState keeps placeholder-only started responses w
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears in-progress state immediately after process restart when runtime is gone', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-9',
|
||||
currentJobStatus: 'started',
|
||||
currentStatusUpdatedAt: '2026-05-27T00:57:53.000Z',
|
||||
runtimeActive: false,
|
||||
nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
|
||||
processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
|
||||
request: {
|
||||
requestId: 'chat-req-9',
|
||||
status: 'started',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
terminalAt: null,
|
||||
updatedAt: '2026-05-27T00:58:10.000Z',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => {
|
||||
assert.deepEqual(
|
||||
normalizeStaleRequestItem(
|
||||
@@ -562,8 +824,10 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
@@ -591,6 +855,7 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
parentRequestId: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
@@ -606,3 +871,68 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStaleRequestItem marks detached queued requests as failed after process restart when runtime is gone', () => {
|
||||
assert.deepEqual(
|
||||
normalizeStaleRequestItem(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-detached',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 12,
|
||||
userText: '끊긴 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-27T00:57:53.000Z',
|
||||
updatedAt: '2026-05-27T00:58:10.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
{
|
||||
current_request_id: 'chat-req-running',
|
||||
current_job_status: 'started',
|
||||
current_status_updated_at: '2026-05-27T01:03:05.000Z',
|
||||
},
|
||||
{
|
||||
runtimeActive: false,
|
||||
nowMs: Date.parse('2026-05-27T01:03:10.000Z'),
|
||||
processStartedAtMs: Date.parse('2026-05-27T01:03:00.000Z'),
|
||||
},
|
||||
),
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-detached',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
promptContextRef: null,
|
||||
status: 'failed',
|
||||
statusMessage: '대기열 1건',
|
||||
retryCount: 0,
|
||||
userMessageId: 12,
|
||||
userText: '끊긴 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: true,
|
||||
createdAt: '2026-05-27T00:57:53.000Z',
|
||||
updatedAt: '2026-05-27T00:58:10.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: '2026-05-27T00:58:10.000Z',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@ export const CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES = [
|
||||
'chat_type_label',
|
||||
'request_origin',
|
||||
'parent_request_id',
|
||||
'prompt_context_ref',
|
||||
'status',
|
||||
'status_message',
|
||||
'retry_count',
|
||||
'user_message_id',
|
||||
'user_text',
|
||||
'response_message_id',
|
||||
@@ -38,6 +40,7 @@ export const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-';
|
||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000;
|
||||
const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const PROCESS_RESTART_STALE_GRACE_MS = 10_000;
|
||||
const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const;
|
||||
|
||||
const conversationPayloadSchema = z.object({
|
||||
@@ -125,6 +128,16 @@ export type ChatConversationRequestUsageSnapshot = {
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export class ChatConversationManualCompletionBlockedError extends Error {
|
||||
readonly code: 'child-followup-exists';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ChatConversationManualCompletionBlockedError';
|
||||
this.code = 'child-followup-exists';
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatConversationRequestItem = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
@@ -134,8 +147,15 @@ export type ChatConversationRequestItem = {
|
||||
requestOrigin: 'composer' | 'prompt' | null;
|
||||
sharedResourceTokenId: string | null;
|
||||
parentRequestId: string | null;
|
||||
promptContextRef: {
|
||||
key: 'prompt_parent_question';
|
||||
promptTitle: string;
|
||||
promptDescription?: string | null;
|
||||
parentQuestionText?: string | null;
|
||||
} | null;
|
||||
status: ChatConversationRequestStatus;
|
||||
statusMessage: string | null;
|
||||
retryCount: number;
|
||||
userMessageId: number | null;
|
||||
userText: string;
|
||||
responseMessageId: number | null;
|
||||
@@ -174,6 +194,7 @@ type ChatPromptSelectionPatch = {
|
||||
sourceMessageId?: number;
|
||||
selectedValues: string[];
|
||||
freeText?: string | null;
|
||||
followupText?: string | null;
|
||||
stepSelections?: ChatPromptStepSelectionPatch[];
|
||||
summaryText?: string | null;
|
||||
attachments?: Array<{
|
||||
@@ -624,6 +645,9 @@ export function applyChatPromptSelectionPatch(
|
||||
.map((step) => String(step.stepKey ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
const normalizedSummaryText = String(selection.summaryText ?? '').trim();
|
||||
const normalizedFreeText = String(selection.freeText ?? '').trim();
|
||||
const normalizedFollowupText = String(selection.followupText ?? '').trim();
|
||||
|
||||
nextParts[matchedPrompt.index] = {
|
||||
...currentPart,
|
||||
@@ -633,7 +657,7 @@ export function applyChatPromptSelectionPatch(
|
||||
readOnly: true,
|
||||
resolvedBy: 'user',
|
||||
resolvedAt,
|
||||
resultText: String(selection.summaryText ?? '').trim() || String(selection.freeText ?? '').trim() || null,
|
||||
resultText: normalizedSummaryText || normalizedFreeText || normalizedFollowupText || null,
|
||||
attachments: Array.isArray(selection.attachments) ? selection.attachments : [],
|
||||
};
|
||||
|
||||
@@ -667,6 +691,58 @@ function normalizePromptSelectionSourceMessageId(selection: ChatPromptSelectionP
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizePromptContextText(value: string | null | undefined, maxLength = 1000) {
|
||||
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized.slice(0, maxLength).trimEnd() + '...';
|
||||
}
|
||||
|
||||
function normalizeStoredPromptContextRef(
|
||||
value: unknown,
|
||||
): ChatConversationRequestItem['promptContextRef'] {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue =
|
||||
typeof value === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: value;
|
||||
|
||||
if (!parsedValue || typeof parsedValue !== 'object' || (parsedValue as { key?: unknown }).key !== 'prompt_parent_question') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const promptTitle = normalizePromptContextText((parsedValue as { promptTitle?: string }).promptTitle, 500);
|
||||
|
||||
if (!promptTitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'prompt_parent_question',
|
||||
promptTitle,
|
||||
promptDescription:
|
||||
normalizePromptContextText((parsedValue as { promptDescription?: string | null }).promptDescription, 1000) || null,
|
||||
parentQuestionText:
|
||||
normalizePromptContextText((parsedValue as { parentQuestionText?: string | null }).parentQuestionText, 1000) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPromptSelectionCandidateRequestIds(
|
||||
requestRows: Array<{
|
||||
request_id?: unknown;
|
||||
@@ -1223,38 +1299,77 @@ function hasPendingAttentionVerificationTarget(text: string | null | undefined)
|
||||
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized);
|
||||
}
|
||||
|
||||
function isConversationAttentionPending(options: {
|
||||
export function hasPendingAttentionVerificationRequest(
|
||||
request: Pick<ChatConversationRequestItem, 'status' | 'responseMessageId' | 'responseText' | 'requestOrigin'>,
|
||||
relatedMessages: Pick<StoredChatMessage, 'author' | 'text'>[],
|
||||
) {
|
||||
if (request.responseMessageId != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (String(request.responseText ?? '').trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.status === 'completed' && request.requestOrigin === 'composer') {
|
||||
return relatedMessages.some(
|
||||
(message) =>
|
||||
(message.author === 'codex' || message.author === 'system')
|
||||
&& hasPendingAttentionVerificationTarget(message.text),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasChildFollowupRequest(
|
||||
request: Pick<ChatConversationRequestItem, 'requestId'>,
|
||||
childRequestCountByParentId: Map<string, number>,
|
||||
) {
|
||||
return (childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function isConversationAttentionPending(options: {
|
||||
request: ChatConversationRequestItem;
|
||||
relatedMessages: StoredChatMessage[];
|
||||
childRequestCountByParentId: Map<string, number>;
|
||||
promptFollowupCountByParentId: Map<string, number>;
|
||||
}) {
|
||||
const { request, relatedMessages, childRequestCountByParentId } = options;
|
||||
const { request, relatedMessages, childRequestCountByParentId, promptFollowupCountByParentId } = options;
|
||||
|
||||
if (request.requestOrigin === 'prompt') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!request.manualPromptCompletedAt) {
|
||||
const hasOpenPrompt = relatedMessages.some(
|
||||
(message) =>
|
||||
(message.author === 'codex' || message.author === 'system')
|
||||
&& hasPendingAttentionPromptMessageParts(message.parts),
|
||||
);
|
||||
const unresolvedPromptCount = relatedMessages.reduce((count, message) => {
|
||||
if (message.author !== 'codex' && message.author !== 'system') {
|
||||
return count;
|
||||
}
|
||||
|
||||
return count + (message.parts ?? []).filter((part) => isPendingAttentionPromptPart(part)).length;
|
||||
}, 0);
|
||||
const promptSubmittedCount = promptFollowupCountByParentId.get(request.requestId.trim()) ?? 0;
|
||||
const hasOpenPrompt = unresolvedPromptCount > Math.max(0, promptSubmittedCount);
|
||||
|
||||
if (hasOpenPrompt) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 0) {
|
||||
if (request.manualPromptCompletedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasVerificationTarget = relatedMessages.some(
|
||||
(message) =>
|
||||
(message.author === 'codex' || message.author === 'system')
|
||||
&& hasPendingAttentionVerificationTarget(message.text),
|
||||
);
|
||||
if (hasChildFollowupRequest(request, childRequestCountByParentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasVerificationTarget = hasPendingAttentionVerificationRequest(request, relatedMessages);
|
||||
|
||||
if (!hasVerificationTarget) {
|
||||
return false;
|
||||
@@ -1321,6 +1436,19 @@ async function getConversationPendingAttentionMap(sessionIds: string[]) {
|
||||
|
||||
return map;
|
||||
}, new Map());
|
||||
const promptFollowupCountByParentId = requests.reduce<Map<string, number>>((map, request) => {
|
||||
if (request.requestOrigin !== 'prompt') {
|
||||
return map;
|
||||
}
|
||||
|
||||
const parentRequestId = request.parentRequestId?.trim() || '';
|
||||
|
||||
if (parentRequestId) {
|
||||
map.set(parentRequestId, (map.get(parentRequestId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return map;
|
||||
}, new Map());
|
||||
const requestMessagesById = messages.reduce<Map<string, StoredChatMessage[]>>((map, message) => {
|
||||
const requestId = message.clientRequestId?.trim() || '';
|
||||
|
||||
@@ -1341,6 +1469,7 @@ async function getConversationPendingAttentionMap(sessionIds: string[]) {
|
||||
request,
|
||||
relatedMessages: requestMessagesById.get(request.requestId.trim()) ?? [],
|
||||
childRequestCountByParentId,
|
||||
promptFollowupCountByParentId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -1596,8 +1725,15 @@ function mapRequestRow(row: Record<string, unknown>): ChatConversationRequestIte
|
||||
requestOrigin: requestOrigin === 'prompt' || requestOrigin === 'composer' ? requestOrigin : null,
|
||||
sharedResourceTokenId: row.shared_resource_token_id == null ? null : String(row.shared_resource_token_id),
|
||||
parentRequestId: parentRequestId || null,
|
||||
promptContextRef: normalizeStoredPromptContextRef(row.prompt_context_ref),
|
||||
status,
|
||||
statusMessage: row.status_message == null ? null : String(row.status_message),
|
||||
retryCount:
|
||||
row.retry_count == null || row.retry_count === ''
|
||||
? 0
|
||||
: Number.isFinite(Number(row.retry_count))
|
||||
? Math.max(0, Math.round(Number(row.retry_count)))
|
||||
: 0,
|
||||
userMessageId: row.user_message_id == null ? null : Number(row.user_message_id),
|
||||
userText: String(row.user_text ?? ''),
|
||||
responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id),
|
||||
@@ -1781,6 +1917,78 @@ function isConversationRequestActive(
|
||||
return currentJobStatus === 'queued' || currentJobStatus === 'started';
|
||||
}
|
||||
|
||||
function resolveCurrentProcessStartedAtMs(nowMs = Date.now()) {
|
||||
const uptimeMs = Math.max(0, Math.floor(process.uptime() * 1000));
|
||||
return Math.max(0, nowMs - uptimeMs);
|
||||
}
|
||||
|
||||
function shouldFailStaleInProgressRequest(params: {
|
||||
currentRequestId?: string | null;
|
||||
currentJobStatus?: ChatConversationItem['currentJobStatus'];
|
||||
currentStatusUpdatedAt?: string | null;
|
||||
runtimeActive?: boolean;
|
||||
nowMs?: number;
|
||||
processStartedAtMs?: number;
|
||||
request:
|
||||
| {
|
||||
requestId?: string | null;
|
||||
status?: ChatConversationRequestStatus | null;
|
||||
responseMessageId?: number | null;
|
||||
responseText?: string | null;
|
||||
terminalAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
}) {
|
||||
const requestStatus = params.request?.status ?? null;
|
||||
const hasStoredResponse = hasStoredRequestResponse(params.request ?? {});
|
||||
const runtimeActive = params.runtimeActive === true;
|
||||
const currentRequestId = params.currentRequestId?.trim() || null;
|
||||
const requestId = params.request?.requestId?.trim() || null;
|
||||
const currentJobStatus = params.currentJobStatus ?? null;
|
||||
const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now();
|
||||
const processStartedAtMs = Number.isFinite(params.processStartedAtMs)
|
||||
? Number(params.processStartedAtMs)
|
||||
: resolveCurrentProcessStartedAtMs(nowMs);
|
||||
const isInProgressRequest =
|
||||
(requestStatus === 'accepted' || requestStatus === 'queued' || requestStatus === 'started')
|
||||
&& !hasStoredResponse
|
||||
&& !isTerminalRequestStatus(requestStatus);
|
||||
const isCurrentTrackedRequest =
|
||||
Boolean(currentRequestId)
|
||||
&& Boolean(requestId)
|
||||
&& currentRequestId === requestId
|
||||
&& (currentJobStatus === 'queued' || currentJobStatus === 'started');
|
||||
const isDetachedInProgressRequest =
|
||||
Boolean(requestId)
|
||||
&& !isCurrentTrackedRequest
|
||||
&& (requestStatus === 'queued' || requestStatus === 'started');
|
||||
const lastUpdatedAt = isCurrentTrackedRequest
|
||||
? Math.max(
|
||||
getTimeValue(params.currentStatusUpdatedAt),
|
||||
getTimeValue(params.request?.updatedAt),
|
||||
)
|
||||
: getTimeValue(params.request?.updatedAt);
|
||||
|
||||
if (!requestId || !isInProgressRequest || runtimeActive || lastUpdatedAt <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
processStartedAtMs > 0 &&
|
||||
lastUpdatedAt <= processStartedAtMs - PROCESS_RESTART_STALE_GRACE_MS &&
|
||||
(isCurrentTrackedRequest || isDetachedInProgressRequest)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS &&
|
||||
(isCurrentTrackedRequest || isDetachedInProgressRequest)
|
||||
);
|
||||
}
|
||||
|
||||
function hasConversationMetadata(
|
||||
conversation: {
|
||||
title?: unknown;
|
||||
@@ -1820,20 +2028,13 @@ export function normalizeStaleRequestItem(
|
||||
current_job_status?: unknown;
|
||||
current_status_updated_at?: unknown;
|
||||
} | null | undefined,
|
||||
options?: {
|
||||
runtimeActive?: boolean;
|
||||
nowMs?: number;
|
||||
processStartedAtMs?: number;
|
||||
},
|
||||
) {
|
||||
const runtimeActive = isRuntimeRequestActive(item.requestId);
|
||||
const lastUpdatedAt = Math.max(
|
||||
getTimeValue(conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at)),
|
||||
getTimeValue(item.updatedAt),
|
||||
);
|
||||
const isDetachedStaleInProgressState =
|
||||
!runtimeActive &&
|
||||
!isConversationRequestActive(conversation, item.requestId) &&
|
||||
(item.status === 'queued' || item.status === 'started') &&
|
||||
!hasStoredRequestResponse(item) &&
|
||||
!isTerminalRequestStatus(item.status) &&
|
||||
lastUpdatedAt > 0 &&
|
||||
Date.now() - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS;
|
||||
const runtimeActive = options?.runtimeActive ?? isRuntimeRequestActive(item.requestId);
|
||||
|
||||
if (
|
||||
shouldClearConversationJobState({
|
||||
@@ -1845,13 +2046,27 @@ export function normalizeStaleRequestItem(
|
||||
currentStatusUpdatedAt:
|
||||
conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at),
|
||||
runtimeActive,
|
||||
nowMs: options?.nowMs,
|
||||
processStartedAtMs: options?.processStartedAtMs,
|
||||
request: item,
|
||||
}) || isDetachedStaleInProgressState
|
||||
}) || shouldFailStaleInProgressRequest({
|
||||
currentRequestId: String(conversation?.current_request_id ?? ''),
|
||||
currentJobStatus:
|
||||
conversation?.current_job_status == null
|
||||
? null
|
||||
: String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'],
|
||||
currentStatusUpdatedAt:
|
||||
conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at),
|
||||
runtimeActive,
|
||||
nowMs: options?.nowMs,
|
||||
processStartedAtMs: options?.processStartedAtMs,
|
||||
request: item,
|
||||
})
|
||||
) {
|
||||
return {
|
||||
...item,
|
||||
status: 'failed' as const,
|
||||
statusMessage: item.statusMessage ?? '중단된 오래된 요청',
|
||||
statusMessage: '중단된 오래된 요청',
|
||||
canDelete: true,
|
||||
terminalAt: item.terminalAt ?? item.updatedAt,
|
||||
};
|
||||
@@ -1860,12 +2075,49 @@ export function normalizeStaleRequestItem(
|
||||
return item;
|
||||
}
|
||||
|
||||
async function reconcileStaleConversationRequests(
|
||||
sessionId: string,
|
||||
rows: Record<string, unknown>[],
|
||||
conversation: {
|
||||
current_request_id?: unknown;
|
||||
current_job_status?: unknown;
|
||||
current_status_updated_at?: unknown;
|
||||
} | null | undefined,
|
||||
) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
const normalizedItems = rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
|
||||
const staleRequestIds = normalizedItems
|
||||
.flatMap((item, index) => {
|
||||
const persistedStatus = String(rows[index]?.status ?? '').trim();
|
||||
return item.status === 'failed' && (persistedStatus === 'accepted' || persistedStatus === 'queued' || persistedStatus === 'started')
|
||||
? [item.requestId.trim()]
|
||||
: [];
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (staleRequestIds.length > 0) {
|
||||
await db(CHAT_CONVERSATION_REQUEST_TABLE)
|
||||
.where({ session_id: normalizedSessionId })
|
||||
.whereIn('request_id', staleRequestIds)
|
||||
.whereIn('status', ['accepted', 'queued', 'started'])
|
||||
.update({
|
||||
status: 'failed',
|
||||
status_message: '중단된 오래된 요청',
|
||||
terminal_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedItems;
|
||||
}
|
||||
|
||||
export function shouldClearConversationJobState(params: {
|
||||
currentRequestId?: string | null;
|
||||
currentJobStatus?: ChatConversationItem['currentJobStatus'];
|
||||
currentStatusUpdatedAt?: string | null;
|
||||
runtimeActive?: boolean;
|
||||
nowMs?: number;
|
||||
processStartedAtMs?: number;
|
||||
request:
|
||||
| {
|
||||
requestId?: string | null;
|
||||
@@ -1899,23 +2151,19 @@ export function shouldClearConversationJobState(params: {
|
||||
}
|
||||
|
||||
const runtimeActive = params.runtimeActive === true;
|
||||
const lastUpdatedAt = Math.max(
|
||||
getTimeValue(params.currentStatusUpdatedAt),
|
||||
getTimeValue(params.request?.updatedAt),
|
||||
);
|
||||
const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now();
|
||||
const isStaleInProgressState =
|
||||
!runtimeActive &&
|
||||
(currentJobStatus === 'queued' || currentJobStatus === 'started') &&
|
||||
!hasStoredRequestResponse(params.request ?? {}) &&
|
||||
!isTerminalRequestStatus(params.request?.status ?? null) &&
|
||||
lastUpdatedAt > 0 &&
|
||||
nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS;
|
||||
|
||||
return (
|
||||
(requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) ||
|
||||
hasStoredResponse ||
|
||||
isStaleInProgressState
|
||||
shouldFailStaleInProgressRequest({
|
||||
currentRequestId,
|
||||
currentJobStatus,
|
||||
currentStatusUpdatedAt: params.currentStatusUpdatedAt,
|
||||
runtimeActive,
|
||||
nowMs: params.nowMs,
|
||||
processStartedAtMs: params.processStartedAtMs,
|
||||
request: params.request,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1961,6 +2209,9 @@ function getDefaultChatConversationRequestStatusMessage(status: ChatConversation
|
||||
export function mergeChatConversationRequestStatus(
|
||||
currentStatus: ChatConversationRequestStatus | null | undefined,
|
||||
incomingStatus: ChatConversationRequestStatus | null | undefined,
|
||||
options?: {
|
||||
allowTerminalStatusReset?: boolean;
|
||||
},
|
||||
): ChatConversationRequestStatus {
|
||||
const normalizedCurrent = currentStatus ?? null;
|
||||
const normalizedIncoming = incomingStatus ?? null;
|
||||
@@ -1977,7 +2228,11 @@ export function mergeChatConversationRequestStatus(
|
||||
return normalizedCurrent;
|
||||
}
|
||||
|
||||
if (isTerminalRequestStatus(normalizedCurrent) && !isTerminalRequestStatus(normalizedIncoming)) {
|
||||
if (
|
||||
isTerminalRequestStatus(normalizedCurrent)
|
||||
&& !isTerminalRequestStatus(normalizedIncoming)
|
||||
&& options?.allowTerminalStatusReset !== true
|
||||
) {
|
||||
return normalizedCurrent;
|
||||
}
|
||||
|
||||
@@ -2455,8 +2710,10 @@ export async function ensureChatConversationTables() {
|
||||
table.string('request_origin', 40).nullable();
|
||||
table.string('shared_resource_token_id', 120).nullable().index();
|
||||
table.string('parent_request_id', 120).nullable();
|
||||
table.text('prompt_context_ref').nullable();
|
||||
table.string('status', 40).notNullable().defaultTo('accepted');
|
||||
table.text('status_message').nullable();
|
||||
table.integer('retry_count').notNullable().defaultTo(0);
|
||||
table.bigInteger('user_message_id').nullable();
|
||||
table.text('user_text').notNullable().defaultTo('');
|
||||
table.bigInteger('response_message_id').nullable();
|
||||
@@ -2482,8 +2739,10 @@ export async function ensureChatConversationTables() {
|
||||
['request_origin', (table) => table.string('request_origin', 40).nullable()],
|
||||
['shared_resource_token_id', (table) => table.string('shared_resource_token_id', 120).nullable().index()],
|
||||
['parent_request_id', (table) => table.string('parent_request_id', 120).nullable()],
|
||||
['prompt_context_ref', (table) => table.text('prompt_context_ref').nullable()],
|
||||
['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')],
|
||||
['status_message', (table) => table.text('status_message').nullable()],
|
||||
['retry_count', (table) => table.integer('retry_count').notNullable().defaultTo(0)],
|
||||
['user_message_id', (table) => table.bigInteger('user_message_id').nullable()],
|
||||
['user_text', (table) => table.text('user_text').notNullable().defaultTo('')],
|
||||
['response_message_id', (table) => table.bigInteger('response_message_id').nullable()],
|
||||
@@ -3347,7 +3606,7 @@ export async function listChatConversationDetailPage(
|
||||
.limit(normalizedLimit);
|
||||
|
||||
const orderedRequestRows = [...requestRows].reverse();
|
||||
const requests = orderedRequestRows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
|
||||
const requests = await reconcileStaleConversationRequests(normalizedSessionId, orderedRequestRows, conversation);
|
||||
const requestIds = requests.map((item) => item.requestId.trim()).filter(Boolean);
|
||||
|
||||
if (requestIds.length === 0) {
|
||||
@@ -3420,7 +3679,7 @@ export async function listChatConversationRequests(sessionId: string, limit = 20
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(Math.max(1, Math.min(1000, Math.round(limit))));
|
||||
|
||||
return rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation));
|
||||
return reconcileStaleConversationRequests(normalizedSessionId, rows, conversation);
|
||||
}
|
||||
|
||||
export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) {
|
||||
@@ -3682,7 +3941,12 @@ export async function getChatConversationRequest(sessionId: string, requestId: s
|
||||
})
|
||||
.first();
|
||||
|
||||
return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = await reconcileStaleConversationRequests(normalizedSessionId, [row], conversation);
|
||||
return item ?? null;
|
||||
}
|
||||
|
||||
async function refreshConversationPreview(sessionId: string) {
|
||||
@@ -3996,6 +4260,19 @@ export async function listRecoverableChatConversationRequests(): Promise<Recover
|
||||
terminalAt: null,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
}) && !shouldFailStaleInProgressRequest({
|
||||
currentRequestId: item.currentRequestId,
|
||||
currentJobStatus: item.currentJobStatus,
|
||||
currentStatusUpdatedAt: item.currentStatusUpdatedAt,
|
||||
runtimeActive: false,
|
||||
request: {
|
||||
requestId: item.requestId,
|
||||
status: item.status,
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
terminalAt: null,
|
||||
updatedAt: item.updatedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4059,8 +4336,12 @@ export async function upsertChatConversationRequest(
|
||||
requestOrigin?: 'composer' | 'prompt' | null;
|
||||
sharedResourceTokenId?: string | null;
|
||||
parentRequestId?: string | null;
|
||||
promptContextRef?: ChatConversationRequestItem['promptContextRef'];
|
||||
status?: ChatConversationRequestStatus;
|
||||
statusMessage?: string | null;
|
||||
retryCount?: number | null;
|
||||
incrementRetryCount?: boolean;
|
||||
allowTerminalStatusReset?: boolean;
|
||||
userMessageId?: number | null;
|
||||
userText?: string | null;
|
||||
responseMessageId?: number | null;
|
||||
@@ -4078,6 +4359,7 @@ export async function upsertChatConversationRequest(
|
||||
payload.requestOrigin === 'prompt' || payload.requestOrigin === 'composer' ? payload.requestOrigin : null;
|
||||
const normalizedSharedResourceTokenId = payload.sharedResourceTokenId?.trim() || null;
|
||||
const normalizedParentRequestId = payload.parentRequestId?.trim() || null;
|
||||
const normalizedPromptContextRef = normalizeStoredPromptContextRef(payload.promptContextRef);
|
||||
|
||||
if (!normalizedSessionId || !normalizedRequestId) {
|
||||
return null;
|
||||
@@ -4093,8 +4375,10 @@ export async function upsertChatConversationRequest(
|
||||
request_origin: 'composer' | 'prompt' | null;
|
||||
shared_resource_token_id: string | null;
|
||||
parent_request_id: string | null;
|
||||
prompt_context_ref: string | null;
|
||||
status: ChatConversationRequestStatus;
|
||||
status_message: string | null;
|
||||
retry_count: number;
|
||||
user_message_id: number | null;
|
||||
user_text: string;
|
||||
response_message_id: number | null;
|
||||
@@ -4122,7 +4406,26 @@ export async function upsertChatConversationRequest(
|
||||
nextStatus = mergeChatConversationRequestStatus(
|
||||
(current?.status as ChatConversationRequestStatus | undefined) ?? null,
|
||||
payload.status ?? null,
|
||||
{
|
||||
allowTerminalStatusReset: payload.allowTerminalStatusReset === true,
|
||||
},
|
||||
);
|
||||
const currentRetryCount =
|
||||
current?.retry_count == null || current.retry_count === ''
|
||||
? 0
|
||||
: Number.isFinite(Number(current.retry_count))
|
||||
? Math.max(0, Math.round(Number(current.retry_count)))
|
||||
: 0;
|
||||
const payloadRetryCount =
|
||||
payload.retryCount == null
|
||||
? null
|
||||
: Number.isFinite(Number(payload.retryCount))
|
||||
? Math.max(0, Math.round(Number(payload.retryCount)))
|
||||
: 0;
|
||||
const nextRetryCount =
|
||||
payload.incrementRetryCount === true
|
||||
? currentRetryCount + 1
|
||||
: payloadRetryCount ?? currentRetryCount;
|
||||
const currentStatus = (current?.status as ChatConversationRequestStatus | undefined) ?? null;
|
||||
const defaultStatusMessage =
|
||||
payload.status && payload.status !== currentStatus
|
||||
@@ -4130,7 +4433,9 @@ export async function upsertChatConversationRequest(
|
||||
: null;
|
||||
const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus)
|
||||
? db.fn.now()
|
||||
: current?.terminal_at ?? null;
|
||||
: payload.allowTerminalStatusReset === true
|
||||
? null
|
||||
: current?.terminal_at ?? null;
|
||||
const answeredAt =
|
||||
payload.responseMessageId != null || (payload.responseText?.trim() ?? '').length > 0
|
||||
? current?.answered_at ?? db.fn.now()
|
||||
@@ -4159,8 +4464,13 @@ export async function upsertChatConversationRequest(
|
||||
request_origin: normalizedRequestOrigin ?? current?.request_origin ?? null,
|
||||
shared_resource_token_id: normalizedSharedResourceTokenId ?? current?.shared_resource_token_id ?? null,
|
||||
parent_request_id: normalizedParentRequestId ?? current?.parent_request_id ?? null,
|
||||
prompt_context_ref:
|
||||
normalizedPromptContextRef != null
|
||||
? JSON.stringify(normalizedPromptContextRef)
|
||||
: current?.prompt_context_ref ?? null,
|
||||
status: nextStatus,
|
||||
status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null,
|
||||
retry_count: nextRetryCount,
|
||||
user_message_id: payload.userMessageId ?? current?.user_message_id ?? null,
|
||||
user_text: payload.userText ?? current?.user_text ?? '',
|
||||
response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null,
|
||||
@@ -4247,6 +4557,35 @@ export async function markChatConversationRequestManualCompletion(
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
|
||||
.where({
|
||||
session_id: normalizedSessionId,
|
||||
request_id: normalizedRequestId,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!existingRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (completionType === 'verification') {
|
||||
const childRequestCountRow = await db(CHAT_CONVERSATION_REQUEST_TABLE)
|
||||
.where({
|
||||
session_id: normalizedSessionId,
|
||||
parent_request_id: normalizedRequestId,
|
||||
})
|
||||
.count<{ count: string | number }[]>({ count: '*' })
|
||||
.first();
|
||||
|
||||
const childRequestCount = Number(childRequestCountRow?.count ?? 0) || 0;
|
||||
|
||||
if (childRequestCount > 0) {
|
||||
throw new ChatConversationManualCompletionBlockedError(
|
||||
'후속 요청이 있는 답변은 응답 확인 완료로 처리할 수 없습니다.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const targetColumn =
|
||||
completionType === 'prompt' ? 'manual_prompt_completed_at' : 'manual_verification_completed_at';
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
resolveChatContextAppDomain,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
summarizeActivityProgressLine,
|
||||
shouldAutoCompleteReplyParentVerification,
|
||||
shouldSendOfflineChatNotification,
|
||||
shouldUseAgenticCodexReply,
|
||||
shouldUseTemplateMacroReply,
|
||||
@@ -154,58 +153,6 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldAutoCompleteReplyParentVerification only completes answered composer followups that are not already verified', () => {
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: 101,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: null,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'prompt',
|
||||
responseMessageId: 101,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: 102,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppOrigin returns normalized origin from session page url', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppOrigin({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
appendChatConversationMessage,
|
||||
appendChatConversationActivityLine,
|
||||
getChatConversationRequest,
|
||||
markChatConversationRequestManualCompletion,
|
||||
type ChatConversationRequestItem,
|
||||
type ChatConversationRequestUsageSnapshot,
|
||||
getChatConversation,
|
||||
@@ -46,6 +45,7 @@ import {
|
||||
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
import { isRuntimeDraining, trackWebSocketConnectionClosed, trackWebSocketConnectionOpened } from './runtime-drain-service.js';
|
||||
import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js';
|
||||
import {
|
||||
findLatestPlanItem,
|
||||
findPlanItemByPreviewUrl,
|
||||
@@ -117,23 +117,6 @@ type ChatPromptContextRef = {
|
||||
parentQuestionText?: string | null;
|
||||
};
|
||||
|
||||
export function shouldAutoCompleteReplyParentVerification(options: {
|
||||
requestOrigin?: 'composer' | 'prompt' | null;
|
||||
responseMessageId?: number | null;
|
||||
responseText?: string | null;
|
||||
manualVerificationCompletedAt?: string | null;
|
||||
} | null | undefined) {
|
||||
if (!options || options.requestOrigin !== 'composer' || options.manualVerificationCompletedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.responseMessageId != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return String(options.responseText ?? '').trim().length > 0;
|
||||
}
|
||||
|
||||
type ChatInboundMessage =
|
||||
| {
|
||||
type: 'context:update';
|
||||
@@ -4428,6 +4411,21 @@ export class ChatService {
|
||||
}
|
||||
|
||||
async recoverInterruptedSessions() {
|
||||
if (!(await isCurrentWorkServerSlotActive())) {
|
||||
this.logger.info(
|
||||
{
|
||||
slot: process.env.WORK_SERVER_SLOT?.trim() || null,
|
||||
},
|
||||
'skip interrupted chat recovery on inactive work-server slot',
|
||||
);
|
||||
|
||||
return {
|
||||
sessionCount: 0,
|
||||
restartedCount: 0,
|
||||
requeuedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const recoverableRequests = await listRecoverableChatConversationRequests();
|
||||
|
||||
if (recoverableRequests.length === 0) {
|
||||
@@ -5748,31 +5746,6 @@ export class ChatService {
|
||||
omitPromptHistory: requestOptions?.omitPromptHistory === true,
|
||||
context: cloneChatContext(state.context),
|
||||
};
|
||||
const parentRequest = request.parentRequestId
|
||||
? await getChatConversationRequest(state.sessionId, request.parentRequestId)
|
||||
: null;
|
||||
const shouldAutoCompleteParentVerification = shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin,
|
||||
responseMessageId: parentRequest?.responseMessageId ?? null,
|
||||
responseText: parentRequest?.responseText ?? '',
|
||||
manualVerificationCompletedAt: parentRequest?.manualVerificationCompletedAt ?? null,
|
||||
});
|
||||
const completeParentVerificationIfNeeded = async () => {
|
||||
if (!request.parentRequestId || !shouldAutoCompleteParentVerification) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedParentRequest = await markChatConversationRequestManualCompletion(
|
||||
state.sessionId,
|
||||
request.parentRequestId,
|
||||
'verification',
|
||||
);
|
||||
|
||||
if (updatedParentRequest) {
|
||||
this.broadcastRequestUpdate(state.sessionId, updatedParentRequest);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) {
|
||||
const queuedUserMessage = {
|
||||
...createMessage('user', trimmed, nextRequestId),
|
||||
@@ -5807,6 +5780,7 @@ export class ChatService {
|
||||
requestOrigin: request.requestOrigin,
|
||||
sharedResourceTokenId: request.sharedResourceTokenId,
|
||||
parentRequestId: request.parentRequestId,
|
||||
promptContextRef: request.promptContextRef,
|
||||
status: 'queued',
|
||||
statusMessage: `대기열 ${state.queue.length}건`,
|
||||
userMessageId: queuedUserMessage.id,
|
||||
@@ -5814,11 +5788,9 @@ export class ChatService {
|
||||
}).catch((error: unknown) => {
|
||||
this.logger.error(error, 'failed to persist queued chat request');
|
||||
});
|
||||
await completeParentVerificationIfNeeded();
|
||||
return nextRequestId;
|
||||
}
|
||||
|
||||
await completeParentVerificationIfNeeded();
|
||||
void this.executeRequest(state, request).catch((error: unknown) => {
|
||||
this.logger.error(error, 'direct chat reply build failed');
|
||||
this.sendToSession(state, {
|
||||
@@ -5851,6 +5823,10 @@ export class ChatService {
|
||||
const compactActivityLineMap = new Map<number, string>();
|
||||
session.activeRequestCount += 1;
|
||||
const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId);
|
||||
const isRetryAttempt =
|
||||
existingRequest != null
|
||||
&& !existingRequest.hasResponse
|
||||
&& (existingRequest.status === 'failed' || existingRequest.status === 'cancelled');
|
||||
const hasStoredUserMessage = existingRequest?.userMessageId != null;
|
||||
let userMessageId = existingRequest?.userMessageId ?? null;
|
||||
|
||||
@@ -5873,7 +5849,11 @@ export class ChatService {
|
||||
requestOrigin: request.requestOrigin,
|
||||
sharedResourceTokenId: request.sharedResourceTokenId,
|
||||
parentRequestId: request.parentRequestId,
|
||||
promptContextRef: request.promptContextRef,
|
||||
status: request.mode === 'direct' ? 'accepted' : existingRequest?.status ?? 'queued',
|
||||
statusMessage: isRetryAttempt ? '재처리 요청 접수' : undefined,
|
||||
incrementRetryCount: isRetryAttempt,
|
||||
allowTerminalStatusReset: isRetryAttempt,
|
||||
userMessageId,
|
||||
userText: request.text,
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ export const sendIosNotificationSchema = z.object({
|
||||
body: z.string().trim().min(1),
|
||||
data: z.record(z.string(), z.string()).default({}),
|
||||
threadId: z.string().trim().min(1).optional(),
|
||||
targetDeviceIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
||||
targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
||||
targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(),
|
||||
targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(),
|
||||
@@ -81,8 +82,12 @@ type NotificationPreferenceTarget = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
|
||||
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
function normalizeTargetDeviceIds(payload: {
|
||||
targetDeviceIds?: string[];
|
||||
targetClientIds?: string[];
|
||||
}) {
|
||||
const targetDeviceIds = payload.targetDeviceIds ?? payload.targetClientIds;
|
||||
return [...new Set((targetDeviceIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
|
||||
@@ -121,21 +126,18 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
|
||||
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
function isAllowedTargetClientId(
|
||||
function isAllowedTargetDeviceId(
|
||||
target: {
|
||||
deviceId?: string;
|
||||
clientId?: string;
|
||||
},
|
||||
targetClientIds: string[],
|
||||
targetDeviceIds: string[],
|
||||
) {
|
||||
if (targetClientIds.length === 0) {
|
||||
if (targetDeviceIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [target.deviceId, target.clientId]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.some((value) => targetClientIds.includes(value));
|
||||
const deviceId = String(target.deviceId ?? '').trim();
|
||||
return Boolean(deviceId) && targetDeviceIds.includes(deviceId);
|
||||
}
|
||||
|
||||
function normalizeAppOrigin(value: unknown) {
|
||||
@@ -914,7 +916,7 @@ async function isNotificationRecipientAllowed(
|
||||
export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const provider = await getProvider();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetDeviceIds = normalizeTargetDeviceIds(payload);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
|
||||
@@ -950,7 +952,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
.filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId({ deviceId: row.deviceId }, targetClientIds) &&
|
||||
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
)
|
||||
.map((row) => row.token);
|
||||
@@ -999,7 +1001,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetDeviceIds = normalizeTargetDeviceIds(payload);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
@@ -1029,7 +1031,7 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
).filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row, targetClientIds) &&
|
||||
isAllowedTargetDeviceId({ deviceId: row.deviceId }, targetDeviceIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
);
|
||||
const matchedSubscriptions = subscriptions.map((row) => ({
|
||||
|
||||
@@ -103,12 +103,17 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$TARGET_SERVICE"/,
|
||||
);
|
||||
assert.match(workServerScript, /RUNTIME_ENDPOINT="\$\{WORK_SERVER_RUNTIME_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\}"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/RECOVERY_ENDPOINT="\$\{WORK_SERVER_RECOVERY_ENDPOINT:-http:\/\/127\.0\.0\.1:3100\/api\/runtime\/recover-interrupted-chat\}"/,
|
||||
);
|
||||
assert.match(workServerScript, /set_container_draining "\$PREVIOUS_CONTAINER" true/);
|
||||
assert.match(workServerScript, /wait_for_previous_slot_drain "\$PREVIOUS_CONTAINER"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/docker compose -f "\$COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$PREVIOUS_SERVICE"/,
|
||||
);
|
||||
assert.match(workServerScript, /recover_interrupted_chat_requests "\$TARGET_CONTAINER"/);
|
||||
assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/);
|
||||
assert.match(workServerScript, /work-server zero-downtime switch completed/);
|
||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||
@@ -132,6 +137,22 @@ test('test restart script pulls the configured remote main branch before restart
|
||||
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
});
|
||||
|
||||
test('test deploy script commits the main worktree before pushing and restarting the preview server', () => {
|
||||
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||
const deployScript = fs.readFileSync(new URL('deploy-test.sh', commandsRoot), 'utf8');
|
||||
|
||||
assert.match(deployScript, /TEST_BUILD_COMMAND="\$\{TEST_BUILD_COMMAND:-npm run build:test-app\}"/);
|
||||
assert.match(deployScript, /TEST_DEPLOY_COMMIT_MESSAGE="\$\{TEST_DEPLOY_COMMIT_MESSAGE:-chore: test deploy snapshot\}"/);
|
||||
assert.match(deployScript, /echo "::step::commit-main-worktree"/);
|
||||
assert.match(deployScript, /git add -A -- \./);
|
||||
assert.match(deployScript, /git commit -m "\$TEST_DEPLOY_COMMIT_MESSAGE"/);
|
||||
assert.match(deployScript, /echo "::step::push-origin-main"/);
|
||||
assert.match(deployScript, /git push "\$TEST_DEPLOY_GIT_REMOTE" "\$TEST_DEPLOY_GIT_BRANCH"/);
|
||||
assert.match(deployScript, /TEST_SERVER_RESTART_SCRIPT="\$\{TEST_SERVER_RESTART_SCRIPT:-\$SCRIPT_DIR\/restart-test\.sh\}"/);
|
||||
assert.doesNotMatch(deployScript, /restart-work-server\.sh/);
|
||||
assert.match(deployScript, /REPO_ROOT="\$REPO_ROOT" sh "\$TEST_SERVER_RESTART_SCRIPT"/);
|
||||
});
|
||||
|
||||
test('work-server package dev script does not use watch mode and rebuilds before start', async () => {
|
||||
const packageJsonPath = new URL('../../package.json', import.meta.url);
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
||||
@@ -416,3 +437,45 @@ test('listServerCommands ignores work-server test-only source changes when compu
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('listServerCommands keeps work-server updateAvailable false when only a standby rebuild is newer', async () => {
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-standby-build-'));
|
||||
|
||||
try {
|
||||
const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
|
||||
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
|
||||
await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
|
||||
await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
|
||||
await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
|
||||
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
|
||||
await writeFile(
|
||||
path.join(workServerRoot, 'dist', 'build-info.json'),
|
||||
JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-26T16:10:05.960Z', builtAt: '2026-05-26T16:10:05.960Z' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const sourceDate = new Date('2026-05-26T16:06:46.162Z');
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), sourceDate, sourceDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'package.json'), sourceDate, sourceDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), sourceDate, sourceDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const workServerCommand = commands.find((item) => item.key === 'work-server');
|
||||
|
||||
assert.ok(workServerCommand);
|
||||
assert.equal(workServerCommand.buildRequired, false);
|
||||
assert.equal(workServerCommand.updateAvailable, false);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import { readFile, rm, stat } from 'node:fs/promises';
|
||||
import { mkdir, open, readFile, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { env } from '../config/env.js';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
import {
|
||||
readTestServerDeploymentState,
|
||||
startTestServerDeployment,
|
||||
type TestServerDeploymentSnapshot,
|
||||
} from './test-server-deployment-service.js';
|
||||
import {
|
||||
getRuntimeWorkServerBuildInfo,
|
||||
readLatestWorkServerBuildInfo,
|
||||
@@ -66,12 +71,21 @@ export type ServerCommandSnapshot = {
|
||||
commandScript: string;
|
||||
commandWorkingDirectory: string;
|
||||
errorMessage: string | null;
|
||||
deployment: WorkServerDeploymentSnapshot | null;
|
||||
};
|
||||
|
||||
export type ServerCommandRestartResult = {
|
||||
server: ServerCommandSnapshot;
|
||||
commandOutput: string | null;
|
||||
restartState: 'completed' | 'accepted';
|
||||
deployment?: WorkServerDeploymentSnapshot | null;
|
||||
testDeployment?: TestServerDeploymentSnapshot | null;
|
||||
};
|
||||
|
||||
type ServerCommandScriptExecutionOptions = {
|
||||
commandScript?: string;
|
||||
environment?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type ExecFileFailure = Error & {
|
||||
@@ -119,6 +133,56 @@ type BuildInspectionResult = {
|
||||
|
||||
type WorkServerSlot = 'blue' | 'green';
|
||||
|
||||
export type WorkServerDeploymentStepKey =
|
||||
| 'build-target-slot'
|
||||
| 'verify-target-health'
|
||||
| 'switch-proxy'
|
||||
| 'drain-previous-slot'
|
||||
| 'rebuild-previous-slot'
|
||||
| 'recover-interrupted-chat';
|
||||
|
||||
export type WorkServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type WorkServerDeploymentStepSnapshot = {
|
||||
key: WorkServerDeploymentStepKey;
|
||||
status: WorkServerDeploymentStepStatus;
|
||||
detail: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type WorkServerDeploymentPhase =
|
||||
| 'idle'
|
||||
| 'build-target-slot'
|
||||
| 'verify-target-health'
|
||||
| 'switch-proxy'
|
||||
| 'drain-previous-slot'
|
||||
| 'rebuild-previous-slot'
|
||||
| 'recover-interrupted-chat'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type WorkServerDeploymentSnapshot = {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
phase: WorkServerDeploymentPhase;
|
||||
summary: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
activeSlot: WorkServerSlot | null;
|
||||
targetSlot: WorkServerSlot | null;
|
||||
previousSlot: WorkServerSlot | null;
|
||||
targetContainer: string | null;
|
||||
previousContainer: string | null;
|
||||
previousSlotActiveChatRequestCount: number | null;
|
||||
previousSlotQueuedChatRequestCount: number | null;
|
||||
recoveredSessionCount: number | null;
|
||||
recoveredRestartedCount: number | null;
|
||||
recoveredRequeuedCount: number | null;
|
||||
lastError: string | null;
|
||||
logExcerpt: string | null;
|
||||
steps: WorkServerDeploymentStepSnapshot[];
|
||||
};
|
||||
|
||||
const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000;
|
||||
const DEFERRED_RESTART_DELAY_MS = 2_000;
|
||||
const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500;
|
||||
@@ -141,6 +205,35 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
|
||||
const APP_BUILD_STAMP_RELATIVE_PATH = '.server-command-test-app-built-at';
|
||||
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
|
||||
const APP_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const;
|
||||
const WORK_SERVER_RESTART_LOCK_STALE_MS = 20 * 60 * 1000;
|
||||
|
||||
type WorkServerRestartLockPayload = {
|
||||
startedAt: string;
|
||||
key: ServerCommandKey;
|
||||
pid: number;
|
||||
};
|
||||
|
||||
type WorkServerDeploymentStateFilePayload = {
|
||||
status?: unknown;
|
||||
phase?: unknown;
|
||||
summary?: unknown;
|
||||
startedAt?: unknown;
|
||||
updatedAt?: unknown;
|
||||
completedAt?: unknown;
|
||||
activeSlot?: unknown;
|
||||
targetSlot?: unknown;
|
||||
previousSlot?: unknown;
|
||||
targetContainer?: unknown;
|
||||
previousContainer?: unknown;
|
||||
previousSlotActiveChatRequestCount?: unknown;
|
||||
previousSlotQueuedChatRequestCount?: unknown;
|
||||
recoveredSessionCount?: unknown;
|
||||
recoveredRestartedCount?: unknown;
|
||||
recoveredRequeuedCount?: unknown;
|
||||
lastError?: unknown;
|
||||
logExcerpt?: unknown;
|
||||
steps?: unknown;
|
||||
};
|
||||
|
||||
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
|
||||
const allowLocal = options?.allowLocal ?? false;
|
||||
@@ -642,8 +735,8 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
return [
|
||||
{
|
||||
key: 'test',
|
||||
label: 'TEST',
|
||||
summary: '메인 프로젝트의 테스트 앱 컨테이너',
|
||||
label: 'PREVIEW',
|
||||
summary: 'preview.sm-home.cloud 테스트 앱 컨테이너',
|
||||
environment: 'test',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL),
|
||||
@@ -751,6 +844,25 @@ function getServerDefinition(key: ServerCommandKey) {
|
||||
return definition;
|
||||
}
|
||||
|
||||
async function executeServerCommandScript(
|
||||
definition: ServerDefinition,
|
||||
options: ServerCommandScriptExecutionOptions = {},
|
||||
) {
|
||||
const commandScript = options.commandScript ?? definition.commandScript;
|
||||
const timeoutMs = options.timeoutMs ?? 30000;
|
||||
|
||||
return execFileAsync('sh', [commandScript], {
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
...definition.commandEnvironment,
|
||||
...options.environment,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function trimPreview(value: string | null | undefined, maxLength = 220) {
|
||||
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
@@ -772,6 +884,209 @@ function normalizeDateTimeValue(value: string | null | undefined) {
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function getWorkServerRestartLockPath() {
|
||||
return path.join(resolveMainProjectRoot(), "etc", "servers", "work-server", ".docker", "runtime", "restart-in-progress.json");
|
||||
}
|
||||
|
||||
function getWorkServerDeploymentStatePath() {
|
||||
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'deployment-state.json');
|
||||
}
|
||||
|
||||
const WORK_SERVER_DEPLOYMENT_STEP_KEYS: WorkServerDeploymentStepKey[] = [
|
||||
'build-target-slot',
|
||||
'verify-target-health',
|
||||
'switch-proxy',
|
||||
'drain-previous-slot',
|
||||
'rebuild-previous-slot',
|
||||
'recover-interrupted-chat',
|
||||
];
|
||||
|
||||
function normalizeWorkServerDeploymentStepKey(value: unknown): WorkServerDeploymentStepKey | null {
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as WorkServerDeploymentStepKey)
|
||||
? (value as WorkServerDeploymentStepKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeWorkServerSlotValue(value: unknown): WorkServerSlot | null {
|
||||
return value === 'blue' || value === 'green' ? value : null;
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentPhase(value: unknown): WorkServerDeploymentPhase {
|
||||
return value === 'build-target-slot'
|
||||
|| value === 'verify-target-health'
|
||||
|| value === 'switch-proxy'
|
||||
|| value === 'drain-previous-slot'
|
||||
|| value === 'rebuild-previous-slot'
|
||||
|| value === 'recover-interrupted-chat'
|
||||
|| value === 'completed'
|
||||
|| value === 'failed'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentStatus(value: unknown): WorkServerDeploymentSnapshot['status'] {
|
||||
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
|
||||
}
|
||||
|
||||
function normalizeNumberOrNull(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function buildEmptyWorkServerDeploymentSnapshot(): WorkServerDeploymentSnapshot {
|
||||
return {
|
||||
status: 'idle',
|
||||
phase: 'idle',
|
||||
summary: null,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
completedAt: null,
|
||||
activeSlot: null,
|
||||
targetSlot: null,
|
||||
previousSlot: null,
|
||||
targetContainer: null,
|
||||
previousContainer: null,
|
||||
previousSlotActiveChatRequestCount: null,
|
||||
previousSlotQueuedChatRequestCount: null,
|
||||
recoveredSessionCount: null,
|
||||
recoveredRestartedCount: null,
|
||||
recoveredRequeuedCount: null,
|
||||
lastError: null,
|
||||
logExcerpt: null,
|
||||
steps: WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentSteps(value: unknown) {
|
||||
const fallback = buildEmptyWorkServerDeploymentSnapshot().steps;
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalizedByKey = new Map<WorkServerDeploymentStepKey, WorkServerDeploymentStepSnapshot>();
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const key = normalizeWorkServerDeploymentStepKey(candidate.key);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status =
|
||||
candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
|| candidate.status === 'pending'
|
||||
? candidate.status
|
||||
: 'pending';
|
||||
|
||||
normalizedByKey.set(key, {
|
||||
key,
|
||||
status,
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
});
|
||||
});
|
||||
|
||||
return WORK_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
|
||||
}
|
||||
|
||||
function normalizeWorkServerDeploymentSnapshot(value: unknown): WorkServerDeploymentSnapshot {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return buildEmptyWorkServerDeploymentSnapshot();
|
||||
}
|
||||
|
||||
const candidate = value as WorkServerDeploymentStateFilePayload;
|
||||
|
||||
return {
|
||||
status: normalizeWorkServerDeploymentStatus(candidate.status),
|
||||
phase: normalizeWorkServerDeploymentPhase(candidate.phase),
|
||||
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
|
||||
activeSlot: normalizeWorkServerSlotValue(candidate.activeSlot),
|
||||
targetSlot: normalizeWorkServerSlotValue(candidate.targetSlot),
|
||||
previousSlot: normalizeWorkServerSlotValue(candidate.previousSlot),
|
||||
targetContainer: typeof candidate.targetContainer === 'string' ? candidate.targetContainer : null,
|
||||
previousContainer: typeof candidate.previousContainer === 'string' ? candidate.previousContainer : null,
|
||||
previousSlotActiveChatRequestCount: normalizeNumberOrNull(candidate.previousSlotActiveChatRequestCount),
|
||||
previousSlotQueuedChatRequestCount: normalizeNumberOrNull(candidate.previousSlotQueuedChatRequestCount),
|
||||
recoveredSessionCount: normalizeNumberOrNull(candidate.recoveredSessionCount),
|
||||
recoveredRestartedCount: normalizeNumberOrNull(candidate.recoveredRestartedCount),
|
||||
recoveredRequeuedCount: normalizeNumberOrNull(candidate.recoveredRequeuedCount),
|
||||
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
|
||||
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
|
||||
steps: normalizeWorkServerDeploymentSteps(candidate.steps),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWorkServerDeploymentState(): Promise<WorkServerDeploymentSnapshot | null> {
|
||||
try {
|
||||
const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8');
|
||||
return normalizeWorkServerDeploymentSnapshot(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireWorkServerRestartLock() {
|
||||
const lockPath = getWorkServerRestartLockPath();
|
||||
await mkdir(path.dirname(lockPath), { recursive: true });
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const handle = await open(lockPath, "wx");
|
||||
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ startedAt, key: "work-server", pid: process.pid }) + "\n", "utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let existingStartedAt: string | null = null;
|
||||
|
||||
try {
|
||||
const raw = await readFile(lockPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<WorkServerRestartLockPayload>;
|
||||
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === "string" ? parsed.startedAt : null);
|
||||
const lockStat = await stat(lockPath).catch(() => null);
|
||||
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
|
||||
|
||||
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return acquireWorkServerRestartLock();
|
||||
}
|
||||
} catch {
|
||||
// ignore read failures and keep conflict response below
|
||||
}
|
||||
|
||||
const conflictError = new Error(
|
||||
existingStartedAt
|
||||
? "WORK-SERVER 무중단 재기동이 이미 진행 중입니다. 시작 시각 " + existingStartedAt
|
||||
: "WORK-SERVER 무중단 재기동이 이미 진행 중입니다.",
|
||||
);
|
||||
(conflictError as Error & { statusCode?: number }).statusCode = 409;
|
||||
throw conflictError;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRestartCommandPreview(definition: ServerDefinition) {
|
||||
return `sh ${definition.commandScript}`;
|
||||
}
|
||||
@@ -817,6 +1132,7 @@ function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerComma
|
||||
commandScript: definition.commandScript,
|
||||
commandWorkingDirectory: definition.commandWorkingDirectory,
|
||||
errorMessage: null,
|
||||
deployment: definition.key === 'work-server' ? buildEmptyWorkServerDeploymentSnapshot() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -969,6 +1285,7 @@ async function waitForDeferredRestartResult(
|
||||
|
||||
async function restartServerCommandDeferred(definition: ServerDefinition): Promise<ServerCommandRestartResult> {
|
||||
const { logPath, statusPath } = buildDeferredRestartProbePaths(definition);
|
||||
const workServerLockPath = definition.key === "work-server" ? await acquireWorkServerRestartLock() : null;
|
||||
const shellCommand = [
|
||||
`sleep ${Math.ceil(DEFERRED_RESTART_DELAY_MS / 1000)}`,
|
||||
`sh ${JSON.stringify(definition.commandScript)} >${JSON.stringify(logPath)} 2>&1`,
|
||||
@@ -976,7 +1293,8 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
`printf '%s' \"$status\" >${JSON.stringify(statusPath)}`,
|
||||
].join('; ');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('sh', ['-c', shellCommand], {
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
detached: true,
|
||||
@@ -984,21 +1302,30 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
env: {
|
||||
...process.env,
|
||||
...definition.commandEnvironment,
|
||||
...(workServerLockPath ? { WORK_SERVER_RESTART_LOCK_FILE: workServerLockPath } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (workServerLockPath) {
|
||||
await rm(workServerLockPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (definition.deferredResponseMode === 'accept-immediately') {
|
||||
return {
|
||||
server: buildAcceptedRestartSnapshot(definition),
|
||||
commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
|
||||
restartState: 'accepted',
|
||||
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1008,6 +1335,7 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
server: buildAcceptedRestartSnapshot(definition),
|
||||
commandOutput: commandOutput ?? `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
|
||||
restartState: 'accepted',
|
||||
deployment: definition.key === 'work-server' ? await readWorkServerDeploymentState() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1463,7 +1791,12 @@ async function inspectBuild(definition: ServerDefinition): Promise<BuildInspecti
|
||||
? !latestBuild?.builtAt || latestSourceChangedAt > latestBuild.builtAt
|
||||
: false;
|
||||
const updateAvailable =
|
||||
Boolean(runningBuild?.buildId) && Boolean(latestBuild?.buildId) && runningBuild?.buildId !== latestBuild?.buildId;
|
||||
!buildRequired &&
|
||||
Boolean(runningBuild?.builtAt) &&
|
||||
Boolean(latestBuild?.builtAt) &&
|
||||
Boolean(latestSourceChangedAt) &&
|
||||
runningBuild!.builtAt < latestBuild!.builtAt &&
|
||||
runningBuild!.builtAt < latestSourceChangedAt!;
|
||||
|
||||
return {
|
||||
runningVersion: runningBuild?.buildId ?? null,
|
||||
@@ -1519,6 +1852,7 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
|
||||
|
||||
const runtimeInfo = await inspectRuntime(definition);
|
||||
const buildInfo = await inspectBuild(definition);
|
||||
const deployment = definition.key === 'work-server' ? await readWorkServerDeploymentState() : null;
|
||||
const fallbackAttempt = selectedAttempt.url !== definition.checkUrl ? `fallback health check succeeded via ${selectedAttempt.url}` : null;
|
||||
const collectedErrors = attempts
|
||||
.filter((attempt) => attempt.errorMessage)
|
||||
@@ -1557,12 +1891,26 @@ async function checkServer(definition: ServerDefinition): Promise<ServerCommandS
|
||||
updateAvailable: buildInfo.updateAvailable,
|
||||
updateSummary: buildInfo.updateSummary,
|
||||
responseTimeMs: Date.now() - startedAt,
|
||||
composeStatus: runtimeInfo.composeStatus,
|
||||
composeDetails: runtimeInfo.composeDetails,
|
||||
composeStatus:
|
||||
definition.key === 'work-server' && deployment?.status === 'running'
|
||||
? 'deploying'
|
||||
: runtimeInfo.composeStatus,
|
||||
composeDetails:
|
||||
definition.key === 'work-server' && deployment
|
||||
? appendComposeDetails([
|
||||
runtimeInfo.composeDetails,
|
||||
deployment.status !== 'idle'
|
||||
? `deploy:${deployment.status}${deployment.targetSlot ? `:${deployment.targetSlot}` : ''}`
|
||||
: null,
|
||||
])
|
||||
: runtimeInfo.composeDetails,
|
||||
lastCommand: buildRestartCommandPreview(definition),
|
||||
commandScript: definition.commandScript,
|
||||
commandWorkingDirectory: definition.commandWorkingDirectory,
|
||||
errorMessage,
|
||||
errorMessage: deployment?.status === 'failed' && deployment.lastError
|
||||
? trimPreview([deployment.lastError, errorMessage].filter(Boolean).join(' | '), 400)
|
||||
: errorMessage,
|
||||
deployment,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1591,15 +1939,7 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
|
||||
}
|
||||
|
||||
try {
|
||||
const commandResult = await execFileAsync('sh', [definition.commandScript], {
|
||||
cwd: definition.commandWorkingDirectory,
|
||||
timeout: 30000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
...definition.commandEnvironment,
|
||||
},
|
||||
});
|
||||
const commandResult = await executeServerCommandScript(definition);
|
||||
stdout = commandResult.stdout;
|
||||
stderr = commandResult.stderr;
|
||||
} catch (error) {
|
||||
@@ -1626,5 +1966,23 @@ export async function restartServerCommand(key: ServerCommandKey): Promise<Serve
|
||||
server,
|
||||
commandOutput: trimPreview([stdout, stderr].filter(Boolean).join('\n'), 400),
|
||||
restartState: 'completed',
|
||||
deployment: server.deployment,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deployWorkServerCommand(): Promise<ServerCommandRestartResult> {
|
||||
return restartServerCommand('work-server');
|
||||
}
|
||||
|
||||
export async function deployTestServerCommand(): Promise<ServerCommandRestartResult> {
|
||||
const testDefinition = getServerDefinition('test');
|
||||
const testDeployment = await startTestServerDeployment();
|
||||
const server = await checkServer(testDefinition);
|
||||
|
||||
return {
|
||||
server,
|
||||
commandOutput: 'TEST 배포를 시작했습니다. origin/main 푸시, 테스트 빌드, 테스트 배포 과정을 확인합니다.',
|
||||
restartState: 'accepted',
|
||||
testDeployment: testDeployment ?? (await readTestServerDeploymentState()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -577,7 +577,11 @@ async function requestCommandRunner(requestPath: string, init?: RequestInit) {
|
||||
throw lastError ?? new Error('command-runner에 연결하지 못했습니다.');
|
||||
}
|
||||
|
||||
function buildWaitingReason(summary: RestartReservationWorkloadSummary) {
|
||||
function buildWaitingReason(target: RestartReservationTarget, summary: RestartReservationWorkloadSummary) {
|
||||
if (target === 'work-server') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasons: string[] = [];
|
||||
|
||||
const codexPending = summary.codexRunningCount + summary.codexQueuedCount;
|
||||
@@ -1232,7 +1236,9 @@ export async function scheduleServerRestartReservation(options?: {
|
||||
requested_at: db.fn.now(),
|
||||
requested_by_client_id: options?.clientId?.trim() || null,
|
||||
last_checked_at: null,
|
||||
waiting_reason: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.',
|
||||
waiting_reason: target === 'work-server'
|
||||
? 'WORK 서버 무중단 재기동 가능 여부를 확인하는 중입니다.'
|
||||
: '진행 중인 Codex Live / 자동화 작업을 확인하는 중입니다.',
|
||||
workload_summary_json: getDefaultWorkloadSummary(),
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
@@ -1367,7 +1373,7 @@ export class ServerRestartReservationWorker {
|
||||
}
|
||||
|
||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||
const waitingReason = buildWaitingReason(workloadSummary);
|
||||
const waitingReason = buildWaitingReason(normalizeReservationTarget(row.target), workloadSummary);
|
||||
|
||||
if (!waitingReason && row.status === 'ready' && isReservationAutoExecuteDue(row)) {
|
||||
await confirmServerRestartReservation(this.logger);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listChatConversationRequests,
|
||||
} from './chat-room-service.js';
|
||||
import { getTokenSettingById, getTokenSettingsConfig, type TokenSettingRecord } from './token-setting-config-service.js';
|
||||
import type { RequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const SHARED_RESOURCE_TOKENS_TABLE = 'shared_resource_tokens';
|
||||
const SHARED_RESOURCE_TOKEN_ACTIVITIES_TABLE = 'shared_resource_token_activities';
|
||||
@@ -130,6 +131,15 @@ export type SharedResourceTokenActivityRecord = {
|
||||
summary: string;
|
||||
detail: string | null;
|
||||
usageDelta: number;
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -831,29 +841,36 @@ async function attachLinkedTokenSettings(tokens: SharedResourceTokenRecord[]) {
|
||||
});
|
||||
}
|
||||
|
||||
async function attachRequestUsageSummaries(tokens: SharedResourceTokenRecord[]) {
|
||||
async function attachRequestUsageSummaries(
|
||||
tokens: SharedResourceTokenRecord[],
|
||||
options?: {
|
||||
includeFallback?: boolean;
|
||||
},
|
||||
) {
|
||||
if (tokens.length === 0) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const summaries = await listChatConversationRequestUsageBySharedResourceTokenIds(tokens.map((token) => token.id));
|
||||
const summaryByTokenId = new Map(summaries.map((summary) => [summary.sharedResourceTokenId, summary] as const));
|
||||
const unresolvedTokens = tokens.filter((token) => !summaryByTokenId.has(token.id));
|
||||
const fallbackSummaries = await Promise.all(unresolvedTokens.map((token) => resolveChatShareFallbackUsageSummary(token)));
|
||||
if (options?.includeFallback !== false) {
|
||||
const unresolvedTokens = tokens.filter((token) => !summaryByTokenId.has(token.id));
|
||||
const fallbackSummaries = await Promise.all(unresolvedTokens.map((token) => resolveChatShareFallbackUsageSummary(token)));
|
||||
|
||||
fallbackSummaries.forEach((summary) => {
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
fallbackSummaries.forEach((summary) => {
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
|
||||
summaryByTokenId.set(summary.tokenId, {
|
||||
sharedResourceTokenId: summary.tokenId,
|
||||
requestCount: summary.usageRequestCount,
|
||||
completedRequestCount: summary.usageCompletedRequestCount,
|
||||
totalTokens: summary.usageTokenTotal,
|
||||
lastUsedAt: summary.lastTokenUsedAt,
|
||||
summaryByTokenId.set(summary.tokenId, {
|
||||
sharedResourceTokenId: summary.tokenId,
|
||||
requestCount: summary.usageRequestCount,
|
||||
completedRequestCount: summary.usageCompletedRequestCount,
|
||||
totalTokens: summary.usageTokenTotal,
|
||||
lastUsedAt: summary.lastTokenUsedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return tokens.map((token) => {
|
||||
const summary = summaryByTokenId.get(token.id);
|
||||
@@ -954,6 +971,15 @@ function mapActivityRow(row: Record<string, unknown>): SharedResourceTokenActivi
|
||||
summary: normalizeText(row.summary),
|
||||
detail: normalizeOptionalText(row.detail),
|
||||
usageDelta: normalizePositiveInteger(row.usage_delta, 0, 0, 1_000_000),
|
||||
clientIp: normalizeOptionalText(row.client_ip),
|
||||
externalIp: normalizeOptionalText(row.external_ip),
|
||||
forwardedFor: normalizeOptionalText(row.forwarded_for),
|
||||
realIp: normalizeOptionalText(row.real_ip),
|
||||
host: normalizeOptionalText(row.host),
|
||||
origin: normalizeOptionalText(row.origin),
|
||||
referer: normalizeOptionalText(row.referer),
|
||||
userAgent: normalizeOptionalText(row.user_agent),
|
||||
clientId: normalizeOptionalText(row.client_id),
|
||||
createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -1110,6 +1136,15 @@ async function ensureSharedResourceTokenTables() {
|
||||
table.string('summary', 400).notNullable();
|
||||
table.text('detail').nullable();
|
||||
table.integer('usage_delta').notNullable().defaultTo(0);
|
||||
table.string('client_ip', 120).nullable();
|
||||
table.string('external_ip', 120).nullable();
|
||||
table.text('forwarded_for').nullable();
|
||||
table.string('real_ip', 120).nullable();
|
||||
table.string('host', 255).nullable();
|
||||
table.text('origin').nullable();
|
||||
table.text('referer').nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.string('client_id', 255).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
@@ -1122,6 +1157,15 @@ async function ensureSharedResourceTokenTables() {
|
||||
['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')],
|
||||
['detail', (table) => table.text('detail').nullable()],
|
||||
['usage_delta', (table) => table.integer('usage_delta').notNullable().defaultTo(0)],
|
||||
['client_ip', (table) => table.string('client_ip', 120).nullable()],
|
||||
['external_ip', (table) => table.string('external_ip', 120).nullable()],
|
||||
['forwarded_for', (table) => table.text('forwarded_for').nullable()],
|
||||
['real_ip', (table) => table.string('real_ip', 120).nullable()],
|
||||
['host', (table) => table.string('host', 255).nullable()],
|
||||
['origin', (table) => table.text('origin').nullable()],
|
||||
['referer', (table) => table.text('referer').nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['client_id', (table) => table.string('client_id', 255).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
@@ -1243,7 +1287,11 @@ async function persistSharedResourceAccessPinSession(
|
||||
});
|
||||
}
|
||||
|
||||
async function appendActivity(trx: any, tokenId: string, input: SharedResourceActivityInput) {
|
||||
async function appendActivity(
|
||||
trx: any,
|
||||
tokenId: string,
|
||||
input: SharedResourceActivityInput & { audit?: RequestAuditContext | null },
|
||||
) {
|
||||
const payload = sharedResourceActivityInputSchema.parse(input);
|
||||
|
||||
await trx(SHARED_RESOURCE_TOKEN_ACTIVITIES_TABLE).insert({
|
||||
@@ -1253,6 +1301,15 @@ async function appendActivity(trx: any, tokenId: string, input: SharedResourceAc
|
||||
summary: payload.summary,
|
||||
detail: payload.detail ?? null,
|
||||
usage_delta: payload.usageDelta ?? 0,
|
||||
client_ip: input.audit?.clientIp ?? null,
|
||||
external_ip: input.audit?.externalIp ?? null,
|
||||
forwarded_for: input.audit?.forwardedFor ?? null,
|
||||
real_ip: input.audit?.realIp ?? null,
|
||||
host: input.audit?.host ?? null,
|
||||
origin: input.audit?.origin ?? null,
|
||||
referer: input.audit?.referer ?? null,
|
||||
user_agent: input.audit?.userAgent ?? null,
|
||||
client_id: input.audit?.clientId ?? null,
|
||||
created_at: db.fn.now(),
|
||||
});
|
||||
|
||||
@@ -1302,7 +1359,7 @@ export async function listSharedResourceTokens() {
|
||||
.map((row) => mapTokenRow(row as Record<string, unknown>))
|
||||
.filter((item): item is SharedResourceTokenRecord => Boolean(item));
|
||||
|
||||
return attachRequestUsageSummaries(await attachLinkedTokenSettings(tokens));
|
||||
return attachRequestUsageSummaries(await attachLinkedTokenSettings(tokens), { includeFallback: false });
|
||||
}
|
||||
|
||||
async function getSharedResourceTokenDetailInternal(tokenId: string, options?: { includeDeleted?: boolean }) {
|
||||
@@ -1370,6 +1427,38 @@ export async function getSharedResourceTokenDetailBySharePath(sharePath: string)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSharedResourceTokenDetailByShareToken(shareToken: string) {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const normalizedShareToken = normalizeText(shareToken);
|
||||
|
||||
if (!normalizedShareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await db(SHARED_RESOURCE_TOKENS_TABLE)
|
||||
.where({ share_token: normalizedShareToken })
|
||||
.whereNull('deleted_at')
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = mapTokenRow(row as Record<string, unknown>);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [linkedToken] = await attachRequestUsageSummaries(await attachLinkedTokenSettings([token]));
|
||||
|
||||
return {
|
||||
token: linkedToken,
|
||||
activities: await listActivitiesByTokenId(token.id),
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateSharedResourceAccessPinBySharePath(
|
||||
sharePath: string,
|
||||
providedPin?: string | null,
|
||||
@@ -1468,7 +1557,10 @@ export async function validateSharedResourceAccessPinBySharePath(
|
||||
} as const;
|
||||
}
|
||||
|
||||
export async function upsertSharedResourceToken(input: SharedResourceTokenInput) {
|
||||
export async function upsertSharedResourceToken(
|
||||
input: SharedResourceTokenInput,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const parsed = sharedResourceTokenSchema.parse(input);
|
||||
@@ -1640,9 +1732,10 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
|
||||
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: existing ? 'updated' : 'created',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: existing ? '공유 리소스 토큰을 수정했습니다.' : '공유 리소스 토큰을 생성했습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
|
||||
const previousPermissions = new Set(existing?.token.permissions ?? []);
|
||||
@@ -1651,18 +1744,20 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
|
||||
for (const permission of nextRecord.permissions.filter((item) => !previousPermissions.has(item))) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'permission-granted',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: `${permission} 권한을 추가했습니다.`,
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
for (const permission of (existing?.token.permissions ?? []).filter((item) => !nextPermissions.has(item))) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'permission-revoked',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: `${permission} 권한을 회수했습니다.`,
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1672,18 +1767,20 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
|
||||
for (const appId of nextRecord.allowedAppIds.filter((item) => !previousAllowedApps.has(item))) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: `${appId} 앱 권한을 추가했습니다.`,
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
for (const appId of (existing?.token.allowedAppIds ?? []).filter((item) => !nextAllowedApps.has(item))) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: `${appId} 앱 권한을 제거했습니다.`,
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1695,56 +1792,62 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
|
||||
if (!previousHasAccessPin && nextHasAccessPin) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 접근 비밀번호를 설정했습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
if (previousHasAccessPin && !nextHasAccessPin) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 접근 비밀번호를 해제했습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
if (previousHasAccessPin && nextHasAccessPin && typeof parsed.accessPin === 'string') {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 접근 비밀번호를 변경했습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
if (previousAccessPinPromptTtlMinutes !== nextRecord.accessPinPromptTtlMinutes) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: nextRecord.accessPinPromptTtlMinutes
|
||||
? `공유 비밀번호 재입력 유지시간을 ${nextRecord.accessPinPromptTtlMinutes}분으로 변경했습니다.`
|
||||
: '공유 비밀번호 재입력 방식을 매번 묻기로 변경했습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
if (!previousAllowAccessPinChangeWithoutManage && nextRecord.allowAccessPinChangeWithoutManage) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '관리 권한 없이 비밀번호 변경 가능한 모드를 켰습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
|
||||
if (previousAllowAccessPinChangeWithoutManage && !nextRecord.allowAccessPinChangeWithoutManage) {
|
||||
await appendActivity(trx, nextRecord.id, {
|
||||
type: 'updated',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '관리 권한 없이 비밀번호 변경 가능한 모드를 껐습니다.',
|
||||
detail: nextRecord.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1772,7 +1875,11 @@ export async function upsertSharedResourceToken(input: SharedResourceTokenInput)
|
||||
return getSharedResourceTokenDetail(savedRecord.id);
|
||||
}
|
||||
|
||||
export async function revokeSharedResourceToken(tokenId: string, reason?: string | null) {
|
||||
export async function revokeSharedResourceToken(
|
||||
tokenId: string,
|
||||
reason?: string | null,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const normalizedTokenId = normalizeText(tokenId);
|
||||
@@ -1793,16 +1900,21 @@ export async function revokeSharedResourceToken(tokenId: string, reason?: string
|
||||
|
||||
await appendActivity(trx, normalizedTokenId, {
|
||||
type: 'revoked',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 토큰을 회수했습니다.',
|
||||
detail: normalizeText(reason) || existing.token.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
});
|
||||
|
||||
return getSharedResourceTokenDetail(normalizedTokenId);
|
||||
}
|
||||
|
||||
export async function revokeSharedResourceTokens(tokenIds: string[], reason?: string | null): Promise<SharedResourceTokenBulkActionResult> {
|
||||
export async function revokeSharedResourceTokens(
|
||||
tokenIds: string[],
|
||||
reason?: string | null,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
): Promise<SharedResourceTokenBulkActionResult> {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const requestedTokenIds = Array.from(new Set(tokenIds.map((tokenId) => normalizeText(tokenId)).filter(Boolean)));
|
||||
@@ -1853,9 +1965,10 @@ export async function revokeSharedResourceTokens(tokenIds: string[], reason?: st
|
||||
|
||||
await appendActivity(trx, tokenId, {
|
||||
type: 'revoked',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 토큰을 일괄 회수했습니다.',
|
||||
detail: normalizeText(reason) || existing.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
|
||||
processedTokenIds.push(tokenId);
|
||||
@@ -1870,7 +1983,10 @@ export async function revokeSharedResourceTokens(tokenIds: string[], reason?: st
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreSharedResourceToken(tokenId: string) {
|
||||
export async function restoreSharedResourceToken(
|
||||
tokenId: string,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const normalizedTokenId = normalizeText(tokenId);
|
||||
@@ -1891,16 +2007,20 @@ export async function restoreSharedResourceToken(tokenId: string) {
|
||||
|
||||
await appendActivity(trx, normalizedTokenId, {
|
||||
type: 'restored',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 토큰을 다시 활성화했습니다.',
|
||||
detail: existing.token.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
});
|
||||
|
||||
return getSharedResourceTokenDetail(normalizedTokenId);
|
||||
}
|
||||
|
||||
export async function deleteSharedResourceToken(tokenId: string) {
|
||||
export async function deleteSharedResourceToken(
|
||||
tokenId: string,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const normalizedTokenId = normalizeText(tokenId);
|
||||
@@ -1927,16 +2047,20 @@ export async function deleteSharedResourceToken(tokenId: string) {
|
||||
|
||||
await appendActivity(trx, normalizedTokenId, {
|
||||
type: 'deleted',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 토큰을 삭제 목록에서 숨기고 사용 이력을 보존했습니다.',
|
||||
detail: existing.token.resourceLabel,
|
||||
audit: options?.audit,
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSharedResourceTokens(tokenIds: string[]): Promise<SharedResourceTokenBulkActionResult> {
|
||||
export async function deleteSharedResourceTokens(
|
||||
tokenIds: string[],
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
): Promise<SharedResourceTokenBulkActionResult> {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
const requestedTokenIds = Array.from(new Set(tokenIds.map((tokenId) => normalizeText(tokenId)).filter(Boolean)));
|
||||
@@ -1981,9 +2105,10 @@ export async function deleteSharedResourceTokens(tokenIds: string[]): Promise<Sh
|
||||
|
||||
await appendActivity(trx, tokenId, {
|
||||
type: 'deleted',
|
||||
actorLabel: 'manager',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '공유 토큰을 삭제 목록에서 숨기고 사용 이력을 보존했습니다.',
|
||||
detail: existing?.token.resourceLabel ?? null,
|
||||
audit: options?.audit,
|
||||
});
|
||||
processedTokenIds.push(tokenId);
|
||||
}
|
||||
@@ -1999,7 +2124,13 @@ export async function deleteSharedResourceTokens(tokenIds: string[]): Promise<Sh
|
||||
|
||||
export async function recordSharedResourceTokenUsage(
|
||||
tokenId: string,
|
||||
payload: { actorLabel?: string | null; summary?: string | null; detail?: string | null; usageDelta?: number | null } = {},
|
||||
payload: {
|
||||
actorLabel?: string | null;
|
||||
summary?: string | null;
|
||||
detail?: string | null;
|
||||
usageDelta?: number | null;
|
||||
audit?: RequestAuditContext | null;
|
||||
} = {},
|
||||
) {
|
||||
await ensureSharedResourceTokenTables();
|
||||
|
||||
@@ -2027,6 +2158,7 @@ export async function recordSharedResourceTokenUsage(
|
||||
summary: normalizeText(payload.summary) || '공유 URL 사용 이력을 기록했습니다.',
|
||||
detail: normalizeOptionalText(payload.detail),
|
||||
usageDelta,
|
||||
audit: payload.audit,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { resolveMainProjectRoot } from './main-project-root-service.js';
|
||||
|
||||
const TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS = 20 * 60 * 1000;
|
||||
const TEST_SERVER_DEPLOYMENT_LOG_LIMIT = 4000;
|
||||
const TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS = 15_000;
|
||||
|
||||
export type TestServerDeploymentStepKey = 'commit-main-worktree' | 'push-origin-main' | 'build-test-app' | 'deploy-test-server';
|
||||
export type TestServerDeploymentStepStatus = 'pending' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export type TestServerDeploymentStepSnapshot = {
|
||||
key: TestServerDeploymentStepKey;
|
||||
status: TestServerDeploymentStepStatus;
|
||||
detail: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type TestServerDeploymentPhase =
|
||||
| 'idle'
|
||||
| 'commit-main-worktree'
|
||||
| 'push-origin-main'
|
||||
| 'build-test-app'
|
||||
| 'deploy-test-server'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type TestServerDeploymentSnapshot = {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed';
|
||||
phase: TestServerDeploymentPhase;
|
||||
summary: string | null;
|
||||
startedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
completedAt: string | null;
|
||||
lastError: string | null;
|
||||
logExcerpt: string | null;
|
||||
steps: TestServerDeploymentStepSnapshot[];
|
||||
};
|
||||
|
||||
type TestServerDeploymentStateFilePayload = {
|
||||
status?: unknown;
|
||||
phase?: unknown;
|
||||
summary?: unknown;
|
||||
startedAt?: unknown;
|
||||
updatedAt?: unknown;
|
||||
completedAt?: unknown;
|
||||
lastError?: unknown;
|
||||
logExcerpt?: unknown;
|
||||
steps?: unknown;
|
||||
};
|
||||
|
||||
type RestartLockPayload = {
|
||||
startedAt: string;
|
||||
key: 'test';
|
||||
pid: number;
|
||||
};
|
||||
|
||||
const TEST_SERVER_DEPLOYMENT_STEP_KEYS: TestServerDeploymentStepKey[] = [
|
||||
'commit-main-worktree',
|
||||
'push-origin-main',
|
||||
'build-test-app',
|
||||
'deploy-test-server',
|
||||
];
|
||||
|
||||
function normalizeDateTimeValue(value: string | null | undefined) {
|
||||
const normalized = value?.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function trimPreview(value: string | null | undefined, maxLength = 220) {
|
||||
const normalized = value?.replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
|
||||
}
|
||||
|
||||
function getTestServerDeploymentStatePath() {
|
||||
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-state.json');
|
||||
}
|
||||
|
||||
function getTestServerDeploymentLockPath() {
|
||||
return path.join(resolveMainProjectRoot(), 'etc', 'servers', 'work-server', '.docker', 'runtime', 'test-deployment-in-progress.json');
|
||||
}
|
||||
|
||||
function buildEmptyTestServerDeploymentSnapshot(): TestServerDeploymentSnapshot {
|
||||
return {
|
||||
status: 'idle',
|
||||
phase: 'idle',
|
||||
summary: null,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
completedAt: null,
|
||||
lastError: null,
|
||||
logExcerpt: null,
|
||||
steps: TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => ({
|
||||
key,
|
||||
status: 'pending',
|
||||
detail: null,
|
||||
updatedAt: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentStepKey(value: unknown): TestServerDeploymentStepKey | null {
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.includes(value as TestServerDeploymentStepKey)
|
||||
? (value as TestServerDeploymentStepKey)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentPhase(value: unknown): TestServerDeploymentPhase {
|
||||
return value === 'commit-main-worktree'
|
||||
|| value === 'push-origin-main'
|
||||
|| value === 'build-test-app'
|
||||
|| value === 'deploy-test-server'
|
||||
|| value === 'completed'
|
||||
|| value === 'failed'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentStatus(value: unknown): TestServerDeploymentSnapshot['status'] {
|
||||
return value === 'running' || value === 'completed' || value === 'failed' ? value : 'idle';
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentSteps(value: unknown) {
|
||||
const fallback = buildEmptyTestServerDeploymentSnapshot().steps;
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalizedByKey = new Map<TestServerDeploymentStepKey, TestServerDeploymentStepSnapshot>();
|
||||
|
||||
value.forEach((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const key = normalizeTestServerDeploymentStepKey(candidate.key);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedByKey.set(key, {
|
||||
key,
|
||||
status:
|
||||
candidate.status === 'running'
|
||||
|| candidate.status === 'completed'
|
||||
|| candidate.status === 'failed'
|
||||
|| candidate.status === 'pending'
|
||||
? candidate.status
|
||||
: 'pending',
|
||||
detail: typeof candidate.detail === 'string' ? candidate.detail : null,
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
});
|
||||
});
|
||||
|
||||
return TEST_SERVER_DEPLOYMENT_STEP_KEYS.map((key) => normalizedByKey.get(key) ?? fallback.find((item) => item.key === key)!);
|
||||
}
|
||||
|
||||
function normalizeTestServerDeploymentSnapshot(value: unknown): TestServerDeploymentSnapshot {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return buildEmptyTestServerDeploymentSnapshot();
|
||||
}
|
||||
|
||||
const candidate = value as TestServerDeploymentStateFilePayload;
|
||||
|
||||
return {
|
||||
status: normalizeTestServerDeploymentStatus(candidate.status),
|
||||
phase: normalizeTestServerDeploymentPhase(candidate.phase),
|
||||
summary: typeof candidate.summary === 'string' ? candidate.summary : null,
|
||||
startedAt: normalizeDateTimeValue(typeof candidate.startedAt === 'string' ? candidate.startedAt : null),
|
||||
updatedAt: normalizeDateTimeValue(typeof candidate.updatedAt === 'string' ? candidate.updatedAt : null),
|
||||
completedAt: normalizeDateTimeValue(typeof candidate.completedAt === 'string' ? candidate.completedAt : null),
|
||||
lastError: typeof candidate.lastError === 'string' ? candidate.lastError : null,
|
||||
logExcerpt: typeof candidate.logExcerpt === 'string' ? candidate.logExcerpt : null,
|
||||
steps: normalizeTestServerDeploymentSteps(candidate.steps),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readTestServerDeploymentState(): Promise<TestServerDeploymentSnapshot | null> {
|
||||
try {
|
||||
const raw = await readFile(getTestServerDeploymentStatePath(), 'utf8');
|
||||
return normalizeTestServerDeploymentSnapshot(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTestServerDeploymentState(snapshot: TestServerDeploymentSnapshot) {
|
||||
const statePath = getTestServerDeploymentStatePath();
|
||||
await mkdir(path.dirname(statePath), { recursive: true });
|
||||
await writeFile(statePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function clearTestServerDeploymentState() {
|
||||
await rm(getTestServerDeploymentStatePath(), { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function acquireTestServerDeploymentLock() {
|
||||
const lockPath = getTestServerDeploymentLockPath();
|
||||
await mkdir(path.dirname(lockPath), { recursive: true });
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx');
|
||||
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ startedAt, key: 'test', pid: process.pid } satisfies RestartLockPayload) + '\n', 'utf8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
return lockPath;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let existingStartedAt: string | null = null;
|
||||
|
||||
try {
|
||||
const raw = await readFile(lockPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as Partial<RestartLockPayload>;
|
||||
existingStartedAt = normalizeDateTimeValue(typeof parsed.startedAt === 'string' ? parsed.startedAt : null);
|
||||
const lockStat = await stat(lockPath).catch(() => null);
|
||||
const freshnessSource = existingStartedAt ?? normalizeDateTimeValue(lockStat?.mtime.toISOString() ?? null);
|
||||
|
||||
if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > TEST_SERVER_DEPLOYMENT_LOCK_STALE_MS) {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
return acquireTestServerDeploymentLock();
|
||||
}
|
||||
} catch {
|
||||
// ignore read failures and keep conflict response below
|
||||
}
|
||||
|
||||
const conflictError = new Error(
|
||||
existingStartedAt
|
||||
? `TEST 배포가 이미 진행 중입니다. 시작 시각 ${existingStartedAt}`
|
||||
: 'TEST 배포가 이미 진행 중입니다.',
|
||||
);
|
||||
(conflictError as Error & { statusCode?: number }).statusCode = 409;
|
||||
throw conflictError;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestServerDeploymentSummary(phase: TestServerDeploymentPhase) {
|
||||
switch (phase) {
|
||||
case 'commit-main-worktree':
|
||||
return 'main 작업트리 커밋 진행 중';
|
||||
case 'push-origin-main':
|
||||
return 'origin/main 푸시 진행 중';
|
||||
case 'build-test-app':
|
||||
return '테스트 앱 빌드 진행 중';
|
||||
case 'deploy-test-server':
|
||||
return '테스트 서버 배포 진행 중';
|
||||
case 'completed':
|
||||
return 'origin/main 푸시, 테스트 빌드, 테스트 배포가 완료되었습니다.';
|
||||
case 'failed':
|
||||
return 'TEST 배포에 실패했습니다.';
|
||||
default:
|
||||
return '테스트 배포 준비 중';
|
||||
}
|
||||
}
|
||||
|
||||
function buildTestDeploymentFailureMessage(
|
||||
snapshot: Pick<TestServerDeploymentSnapshot, 'logExcerpt'>,
|
||||
error: unknown,
|
||||
) {
|
||||
const failure = error instanceof Error ? (error as Error & { code?: number | string; signal?: string | null }) : null;
|
||||
const exitInfo = [
|
||||
failure?.code != null ? `exit:${String(failure.code)}` : null,
|
||||
failure?.signal ? `signal:${String(failure.signal)}` : null,
|
||||
].filter(Boolean).join(' ');
|
||||
const logLines = (snapshot.logExcerpt ?? '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const lastMeaningfulLog = logLines.length > 0 ? logLines[logLines.length - 1] : null;
|
||||
|
||||
return trimPreview([
|
||||
lastMeaningfulLog && lastMeaningfulLog !== failure?.message ? lastMeaningfulLog : null,
|
||||
failure?.message || null,
|
||||
exitInfo || null,
|
||||
].filter(Boolean).join(' | '), 500) ?? 'TEST 배포에 실패했습니다.';
|
||||
}
|
||||
|
||||
function appendTestServerDeploymentLog(previous: string | null, chunk: string) {
|
||||
const normalizedChunk = chunk.trim();
|
||||
|
||||
if (!normalizedChunk) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const combined = [previous, normalizedChunk].filter(Boolean).join('\n');
|
||||
return combined.length > TEST_SERVER_DEPLOYMENT_LOG_LIMIT
|
||||
? combined.slice(combined.length - TEST_SERVER_DEPLOYMENT_LOG_LIMIT)
|
||||
: combined;
|
||||
}
|
||||
|
||||
function updateTestServerDeploymentStep(
|
||||
snapshot: TestServerDeploymentSnapshot,
|
||||
key: TestServerDeploymentStepKey,
|
||||
status: TestServerDeploymentStepStatus,
|
||||
detail?: string | null,
|
||||
) {
|
||||
const now = new Date().toISOString();
|
||||
snapshot.steps = snapshot.steps.map((step) => {
|
||||
if (step.key !== key) {
|
||||
return step;
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
status,
|
||||
detail: detail === undefined ? step.detail : detail,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
snapshot.updatedAt = now;
|
||||
}
|
||||
|
||||
function markPreviousRunningStepCompleted(snapshot: TestServerDeploymentSnapshot, nextKey: TestServerDeploymentStepKey) {
|
||||
const previousRunning = snapshot.steps.find((step) => step.status === 'running' && step.key !== nextKey);
|
||||
if (previousRunning) {
|
||||
updateTestServerDeploymentStep(snapshot, previousRunning.key, 'completed');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTestServerDeploymentCleanup(completedAt: string) {
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
const snapshot = await readTestServerDeploymentState();
|
||||
if (snapshot?.status === 'completed' && snapshot.completedAt === completedAt) {
|
||||
await clearTestServerDeploymentState();
|
||||
}
|
||||
})();
|
||||
}, TEST_SERVER_DEPLOYMENT_CLEANUP_DELAY_MS);
|
||||
|
||||
if (typeof timer === 'object' && timer && 'unref' in timer && typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
async function runTestServerDeployment(
|
||||
lockPath: string,
|
||||
snapshot: TestServerDeploymentSnapshot,
|
||||
persist: () => Promise<void>,
|
||||
) {
|
||||
const mainProjectRoot = resolveMainProjectRoot();
|
||||
const deployScript = path.join(mainProjectRoot, 'etc', 'commands', 'server-command', 'deploy-test.sh');
|
||||
|
||||
const moveToStep = (key: TestServerDeploymentStepKey) => {
|
||||
markPreviousRunningStepCompleted(snapshot, key);
|
||||
snapshot.phase = key;
|
||||
snapshot.summary = buildTestServerDeploymentSummary(key);
|
||||
updateTestServerDeploymentStep(snapshot, key, 'running');
|
||||
void persist();
|
||||
};
|
||||
|
||||
const appendOutput = (line: string) => {
|
||||
snapshot.logExcerpt = appendTestServerDeploymentLog(snapshot.logExcerpt, line);
|
||||
snapshot.updatedAt = new Date().toISOString();
|
||||
void persist();
|
||||
};
|
||||
|
||||
const fail = async (message: string) => {
|
||||
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key ?? 'commit-main-worktree';
|
||||
snapshot.status = 'failed';
|
||||
snapshot.phase = 'failed';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('failed');
|
||||
snapshot.lastError = message;
|
||||
snapshot.updatedAt = new Date().toISOString();
|
||||
updateTestServerDeploymentStep(snapshot, activeStep, 'failed', message);
|
||||
await persist();
|
||||
};
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn('sh', [deployScript], {
|
||||
cwd: mainProjectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
REPO_ROOT: mainProjectRoot,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
|
||||
const processLine = (line: string) => {
|
||||
const trimmed = line.trim();
|
||||
const marker = trimmed.match(/^::step::([a-z-]+)$/);
|
||||
|
||||
if (marker) {
|
||||
const nextStep = normalizeTestServerDeploymentStepKey(marker[1]);
|
||||
if (nextStep) {
|
||||
moveToStep(nextStep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
appendOutput(line);
|
||||
};
|
||||
|
||||
const flushBufferedLines = (buffer: string) => {
|
||||
const normalized = buffer.replace(/\r$/, '').trim();
|
||||
if (normalized) {
|
||||
processLine(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
const attachReader = (stream: NodeJS.ReadableStream | null, target: 'stdout' | 'stderr') => {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => {
|
||||
if (target === 'stdout') {
|
||||
stdoutBuffer += chunk;
|
||||
while (stdoutBuffer.includes('\n')) {
|
||||
const index = stdoutBuffer.indexOf('\n');
|
||||
const line = stdoutBuffer.slice(0, index).replace(/\r$/, '');
|
||||
stdoutBuffer = stdoutBuffer.slice(index + 1);
|
||||
processLine(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stderrBuffer += chunk;
|
||||
while (stderrBuffer.includes('\n')) {
|
||||
const index = stderrBuffer.indexOf('\n');
|
||||
const line = stderrBuffer.slice(0, index).replace(/\r$/, '');
|
||||
stderrBuffer = stderrBuffer.slice(index + 1);
|
||||
processLine(line);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
attachReader(child.stdout, 'stdout');
|
||||
attachReader(child.stderr, 'stderr');
|
||||
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => {
|
||||
flushBufferedLines(stdoutBuffer);
|
||||
flushBufferedLines(stderrBuffer);
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(Object.assign(new Error(`deploy-test exited with ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`), {
|
||||
code,
|
||||
signal,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const activeStep = snapshot.steps.find((step) => step.status === 'running')?.key;
|
||||
if (activeStep) {
|
||||
updateTestServerDeploymentStep(snapshot, activeStep, 'completed');
|
||||
}
|
||||
const completedAt = new Date().toISOString();
|
||||
snapshot.status = 'completed';
|
||||
snapshot.phase = 'completed';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('completed');
|
||||
snapshot.completedAt = completedAt;
|
||||
snapshot.updatedAt = completedAt;
|
||||
snapshot.lastError = null;
|
||||
await persist();
|
||||
scheduleTestServerDeploymentCleanup(completedAt);
|
||||
} catch (error) {
|
||||
const message = buildTestDeploymentFailureMessage(snapshot, error);
|
||||
await fail(message);
|
||||
} finally {
|
||||
await rm(lockPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTestServerDeployment() {
|
||||
const lockPath = await acquireTestServerDeploymentLock();
|
||||
const startedAt = new Date().toISOString();
|
||||
const snapshot = buildEmptyTestServerDeploymentSnapshot();
|
||||
snapshot.status = 'running';
|
||||
snapshot.phase = 'commit-main-worktree';
|
||||
snapshot.summary = buildTestServerDeploymentSummary('commit-main-worktree');
|
||||
snapshot.startedAt = startedAt;
|
||||
snapshot.updatedAt = startedAt;
|
||||
updateTestServerDeploymentStep(snapshot, 'commit-main-worktree', 'running', 'main 작업트리 변경을 커밋합니다.');
|
||||
await writeTestServerDeploymentState(snapshot);
|
||||
|
||||
let persistQueue = Promise.resolve();
|
||||
const persist = async () => {
|
||||
persistQueue = persistQueue.then(() => writeTestServerDeploymentState(snapshot)).catch(() => undefined);
|
||||
await persistQueue;
|
||||
};
|
||||
|
||||
void runTestServerDeployment(lockPath, snapshot, persist);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { db } from '../db/client.js';
|
||||
import type { RequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const TOKEN_SETTING_ACTIVITIES_TABLE = 'token_setting_activities';
|
||||
|
||||
export type TokenSettingActivityRecord = {
|
||||
id: number;
|
||||
settingId: string;
|
||||
activityType: 'created' | 'updated' | 'deleted';
|
||||
actorLabel: string | null;
|
||||
summary: string;
|
||||
detail: string | null;
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type TokenSettingActivityInput = {
|
||||
settingId: string;
|
||||
activityType: TokenSettingActivityRecord['activityType'];
|
||||
actorLabel?: string | null;
|
||||
summary: string;
|
||||
detail?: string | null;
|
||||
audit?: RequestAuditContext | null;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value: unknown) {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeDateTime(value: unknown) {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.parse(normalized);
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
export async function ensureTokenSettingActivityTable() {
|
||||
const hasTable = await db.schema.hasTable(TOKEN_SETTING_ACTIVITIES_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('setting_id', 120).notNullable().index();
|
||||
table.string('activity_type', 40).notNullable();
|
||||
table.string('actor_label', 120).nullable();
|
||||
table.string('summary', 400).notNullable();
|
||||
table.text('detail').nullable();
|
||||
table.string('client_ip', 120).nullable();
|
||||
table.string('external_ip', 120).nullable();
|
||||
table.text('forwarded_for').nullable();
|
||||
table.string('real_ip', 120).nullable();
|
||||
table.string('host', 255).nullable();
|
||||
table.text('origin').nullable();
|
||||
table.text('referer').nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.string('client_id', 255).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['setting_id', (table) => table.string('setting_id', 120).notNullable().defaultTo('').index()],
|
||||
['activity_type', (table) => table.string('activity_type', 40).notNullable().defaultTo('updated')],
|
||||
['actor_label', (table) => table.string('actor_label', 120).nullable()],
|
||||
['summary', (table) => table.string('summary', 400).notNullable().defaultTo('')],
|
||||
['detail', (table) => table.text('detail').nullable()],
|
||||
['client_ip', (table) => table.string('client_ip', 120).nullable()],
|
||||
['external_ip', (table) => table.string('external_ip', 120).nullable()],
|
||||
['forwarded_for', (table) => table.text('forwarded_for').nullable()],
|
||||
['real_ip', (table) => table.string('real_ip', 120).nullable()],
|
||||
['host', (table) => table.string('host', 255).nullable()],
|
||||
['origin', (table) => table.text('origin').nullable()],
|
||||
['referer', (table) => table.text('referer').nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['client_id', (table) => table.string('client_id', 255).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(TOKEN_SETTING_ACTIVITIES_TABLE, columnName);
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(TOKEN_SETTING_ACTIVITIES_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendTokenSettingActivity(input: TokenSettingActivityInput) {
|
||||
await ensureTokenSettingActivityTable();
|
||||
|
||||
await db(TOKEN_SETTING_ACTIVITIES_TABLE).insert({
|
||||
setting_id: normalizeText(input.settingId),
|
||||
activity_type: input.activityType,
|
||||
actor_label: normalizeOptionalText(input.actorLabel),
|
||||
summary: normalizeText(input.summary),
|
||||
detail: normalizeOptionalText(input.detail),
|
||||
client_ip: normalizeOptionalText(input.audit?.clientIp),
|
||||
external_ip: normalizeOptionalText(input.audit?.externalIp),
|
||||
forwarded_for: normalizeOptionalText(input.audit?.forwardedFor),
|
||||
real_ip: normalizeOptionalText(input.audit?.realIp),
|
||||
host: normalizeOptionalText(input.audit?.host),
|
||||
origin: normalizeOptionalText(input.audit?.origin),
|
||||
referer: normalizeOptionalText(input.audit?.referer),
|
||||
user_agent: normalizeOptionalText(input.audit?.userAgent),
|
||||
client_id: normalizeOptionalText(input.audit?.clientId),
|
||||
created_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTokenSettingActivities(settingId: string) {
|
||||
await ensureTokenSettingActivityTable();
|
||||
|
||||
const rows = await db(TOKEN_SETTING_ACTIVITIES_TABLE)
|
||||
.select('*')
|
||||
.where({ setting_id: normalizeText(settingId) })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(200);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
settingId: normalizeText(row.setting_id),
|
||||
activityType: (normalizeText(row.activity_type) as TokenSettingActivityRecord['activityType']) || 'updated',
|
||||
actorLabel: normalizeOptionalText(row.actor_label),
|
||||
summary: normalizeText(row.summary),
|
||||
detail: normalizeOptionalText(row.detail),
|
||||
clientIp: normalizeOptionalText(row.client_ip),
|
||||
externalIp: normalizeOptionalText(row.external_ip),
|
||||
forwardedFor: normalizeOptionalText(row.forwarded_for),
|
||||
realIp: normalizeOptionalText(row.real_ip),
|
||||
host: normalizeOptionalText(row.host),
|
||||
origin: normalizeOptionalText(row.origin),
|
||||
referer: normalizeOptionalText(row.referer),
|
||||
userAgent: normalizeOptionalText(row.user_agent),
|
||||
clientId: normalizeOptionalText(row.client_id),
|
||||
createdAt: normalizeDateTime(row.created_at) ?? new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { db } from '../db/client.js';
|
||||
import { appendTokenSettingActivity, ensureTokenSettingActivityTable } from './token-setting-activity-service.js';
|
||||
import type { RequestAuditContext } from '../utils/request-audit.js';
|
||||
|
||||
const TOKEN_SETTINGS_TABLE = 'token_settings';
|
||||
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
|
||||
@@ -173,6 +175,7 @@ async function ensureTokenSettingsTable() {
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
await ensureTokenSettingActivityTable();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,6 +203,8 @@ async function ensureTokenSettingsTable() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ensureTokenSettingActivityTable();
|
||||
}
|
||||
|
||||
function parseAllowedAppIds(row: Record<string, unknown>) {
|
||||
@@ -289,10 +294,45 @@ async function readTokenSettingsFromTable() {
|
||||
);
|
||||
}
|
||||
|
||||
async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) {
|
||||
function buildActivityDetail(previous: TokenSettingRecord | null, next: TokenSettingRecord | null) {
|
||||
if (!previous && next) {
|
||||
return `앱 ${next.allowedAppIds.join(', ') || '-'} / 기본 ${next.defaultExpiresInMinutes}분`;
|
||||
}
|
||||
|
||||
if (previous && !next) {
|
||||
return `삭제 전 앱 ${previous.allowedAppIds.join(', ') || '-'} / 기본 ${previous.defaultExpiresInMinutes}분`;
|
||||
}
|
||||
|
||||
if (!previous || !next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changedFields: string[] = [];
|
||||
if (previous.name !== next.name) changedFields.push(`이름 ${previous.name} -> ${next.name}`);
|
||||
if (previous.description !== next.description) changedFields.push('설명');
|
||||
if (previous.defaultExpiresInMinutes !== next.defaultExpiresInMinutes) changedFields.push(`기본만료 ${previous.defaultExpiresInMinutes} -> ${next.defaultExpiresInMinutes}`);
|
||||
if (previous.maxTokensPer30Days !== next.maxTokensPer30Days) changedFields.push(`30일 ${previous.maxTokensPer30Days} -> ${next.maxTokensPer30Days}`);
|
||||
if (previous.maxTokensPer7Days !== next.maxTokensPer7Days) changedFields.push(`7일 ${previous.maxTokensPer7Days} -> ${next.maxTokensPer7Days}`);
|
||||
if (previous.maxTokensPer5Hours !== next.maxTokensPer5Hours) changedFields.push(`5시간 ${previous.maxTokensPer5Hours} -> ${next.maxTokensPer5Hours}`);
|
||||
if (previous.oneTimeTokenLimit !== next.oneTimeTokenLimit) changedFields.push(`1회 ${previous.oneTimeTokenLimit} -> ${next.oneTimeTokenLimit}`);
|
||||
if (previous.enabled !== next.enabled) changedFields.push(`사용 ${previous.enabled} -> ${next.enabled}`);
|
||||
if (JSON.stringify(previous.allowedAppIds) !== JSON.stringify(next.allowedAppIds)) {
|
||||
changedFields.push(`앱 ${previous.allowedAppIds.join(', ') || '-'} -> ${next.allowedAppIds.join(', ') || '-'}`);
|
||||
}
|
||||
|
||||
return changedFields.join(' / ') || null;
|
||||
}
|
||||
|
||||
async function replaceTokenSettingsInTable(
|
||||
items: TokenSettingRecord[],
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const previousItems = await readTokenSettingsFromTable();
|
||||
const nextItems = sanitizeTokenSettings(items);
|
||||
const previousById = new Map(previousItems.map((item) => [item.id, item] as const));
|
||||
const nextById = new Map(nextItems.map((item) => [item.id, item] as const));
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(TOKEN_SETTINGS_TABLE).del();
|
||||
@@ -318,6 +358,48 @@ async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) {
|
||||
}
|
||||
});
|
||||
|
||||
const affectedIds = Array.from(new Set([...previousById.keys(), ...nextById.keys()]));
|
||||
|
||||
for (const settingId of affectedIds) {
|
||||
const previous = previousById.get(settingId) ?? null;
|
||||
const next = nextById.get(settingId) ?? null;
|
||||
|
||||
if (!previous && next) {
|
||||
await appendTokenSettingActivity({
|
||||
settingId,
|
||||
activityType: 'created',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '토큰 설정을 생성했습니다.',
|
||||
detail: buildActivityDetail(previous, next),
|
||||
audit: options?.audit,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous && !next) {
|
||||
await appendTokenSettingActivity({
|
||||
settingId,
|
||||
activityType: 'deleted',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '토큰 설정을 삭제했습니다.',
|
||||
detail: buildActivityDetail(previous, next),
|
||||
audit: options?.audit,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (previous && next && JSON.stringify(previous) !== JSON.stringify(next)) {
|
||||
await appendTokenSettingActivity({
|
||||
settingId,
|
||||
activityType: 'updated',
|
||||
actorLabel: options?.actorLabel ?? 'manager',
|
||||
summary: '토큰 설정을 수정했습니다.',
|
||||
detail: buildActivityDetail(previous, next),
|
||||
audit: options?.audit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
@@ -325,8 +407,11 @@ export async function getTokenSettingsConfig() {
|
||||
return readTokenSettingsFromTable();
|
||||
}
|
||||
|
||||
export async function upsertTokenSettingsConfig(items: Partial<TokenSettingRecord>[] | null | undefined) {
|
||||
return replaceTokenSettingsInTable(sanitizeTokenSettings(items));
|
||||
export async function upsertTokenSettingsConfig(
|
||||
items: Partial<TokenSettingRecord>[] | null | undefined,
|
||||
options?: { actorLabel?: string | null; audit?: RequestAuditContext | null },
|
||||
) {
|
||||
return replaceTokenSettingsInTable(sanitizeTokenSettings(items), options);
|
||||
}
|
||||
|
||||
export async function getTokenSettingById(id: string) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
type WorkServerSlot = 'blue' | 'green';
|
||||
|
||||
function normalizeSlot(value: string | null | undefined): WorkServerSlot | null {
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
return normalized === 'blue' || normalized === 'green' ? normalized : null;
|
||||
}
|
||||
|
||||
function buildActiveSlotFileCandidates() {
|
||||
const candidates = [
|
||||
env.SERVER_COMMAND_WORK_SERVER_ACTIVE_SLOT_FILE?.trim(),
|
||||
path.join(env.SERVER_COMMAND_MAIN_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||
path.join(env.SERVER_COMMAND_PROJECT_ROOT, 'etc', 'servers', 'work-server', '.docker', 'runtime', 'active-slot'),
|
||||
path.join(env.SERVER_COMMAND_PROJECT_ROOT, '.docker', 'runtime', 'active-slot'),
|
||||
]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
async function readActiveSlotFromFile() {
|
||||
for (const candidate of buildActiveSlotFileCandidates()) {
|
||||
try {
|
||||
const value = await readFile(candidate, 'utf8');
|
||||
const slot = normalizeSlot(value);
|
||||
|
||||
if (slot) {
|
||||
return slot;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or unreadable candidates and continue.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isCurrentWorkServerSlotActive() {
|
||||
const currentSlot = normalizeSlot(process.env.WORK_SERVER_SLOT);
|
||||
|
||||
if (!currentSlot) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeSlot = (await readActiveSlotFromFile()) ?? 'blue';
|
||||
return currentSlot === activeSlot;
|
||||
}
|
||||
105
etc/servers/work-server/src/utils/request-audit.ts
Normal file
105
etc/servers/work-server/src/utils/request-audit.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export type RequestAuditContext = {
|
||||
clientIp: string | null;
|
||||
externalIp: string | null;
|
||||
forwardedFor: string | null;
|
||||
realIp: string | null;
|
||||
host: string | null;
|
||||
origin: string | null;
|
||||
referer: string | null;
|
||||
userAgent: string | null;
|
||||
clientId: string | null;
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHeaderValue(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeText(value[0]);
|
||||
}
|
||||
|
||||
return normalizeText(value);
|
||||
}
|
||||
|
||||
function splitForwardedFor(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stripIpDecorations(value: string) {
|
||||
const normalized = value.replace(/^for=/iu, '').replace(/^"|"$/g, '').trim();
|
||||
if (normalized.startsWith('[') && normalized.includes(']')) {
|
||||
return normalized.slice(1, normalized.indexOf(']')).trim();
|
||||
}
|
||||
const ipv4PortMatch = normalized.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/u);
|
||||
if (ipv4PortMatch) {
|
||||
return ipv4PortMatch[1] ?? normalized;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPrivateOrLocalIp(value: string) {
|
||||
const normalized = stripIpDecorations(value).toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === '::1' || normalized === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('127.') || normalized.startsWith('10.') || normalized.startsWith('192.168.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('::ffff:127.') || normalized.startsWith('::ffff:10.') || normalized.startsWith('::ffff:192.168.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^::ffff:172\.(1[6-9]|2\d|3[0-1])\./u.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveExternalIp(candidates: string[]) {
|
||||
const cleaned = candidates.map((item) => stripIpDecorations(item)).filter(Boolean);
|
||||
return cleaned.find((item) => !isPrivateOrLocalIp(item)) ?? cleaned[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractRequestAuditContext(request: FastifyRequest): RequestAuditContext {
|
||||
const forwardedFor = normalizeHeaderValue(request.headers['x-forwarded-for']);
|
||||
const realIp = normalizeHeaderValue(request.headers['x-real-ip']) || normalizeHeaderValue(request.headers['cf-connecting-ip']);
|
||||
const clientIp = normalizeText(request.ip) || normalizeText(request.raw.socket.remoteAddress) || null;
|
||||
|
||||
return {
|
||||
clientIp: clientIp ? stripIpDecorations(clientIp) : null,
|
||||
externalIp: resolveExternalIp([
|
||||
...splitForwardedFor(forwardedFor),
|
||||
realIp,
|
||||
normalizeText(request.headers['x-client-ip']),
|
||||
clientIp ?? '',
|
||||
]),
|
||||
forwardedFor: forwardedFor || null,
|
||||
realIp: realIp ? stripIpDecorations(realIp) : null,
|
||||
host: normalizeHeaderValue(request.headers.host) || null,
|
||||
origin: normalizeHeaderValue(request.headers.origin) || null,
|
||||
referer: normalizeHeaderValue(request.headers.referer) || null,
|
||||
userAgent: normalizeHeaderValue(request.headers['user-agent']) || null,
|
||||
clientId: normalizeHeaderValue(request.headers['x-client-id']) || null,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { processDueBaseballTicketBayAlerts } from '../services/baseball-ticket-bay-service.js';
|
||||
import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
|
||||
@@ -38,6 +39,10 @@ export class BaseballTicketBayWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isCurrentWorkServerSlotActive())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user