Initial import
This commit is contained in:
27
etc/commands/server-command/restart-rel.sh
Executable file
27
etc/commands/server-command/restart-rel.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}"
|
||||
SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-release-app}"
|
||||
SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release}"
|
||||
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
||||
fi
|
||||
|
||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||
exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME"
|
||||
fi
|
||||
|
||||
echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2
|
||||
exit 127
|
||||
53
etc/commands/server-command/restart-server-command-runner.sh
Executable file
53
etc/commands/server-command/restart-server-command-runner.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
|
||||
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
|
||||
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
|
||||
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
|
||||
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
|
||||
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
|
||||
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
|
||||
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
|
||||
RUNNER_CPU_WATCHDOG_ENABLED="${SERVER_COMMAND_CPU_WATCHDOG_ENABLED:-true}"
|
||||
RUNNER_CPU_WATCHDOG_INTERVAL_MS="${SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS:-60000}"
|
||||
RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT="${SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT:-120}"
|
||||
RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT="${SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT:-8}"
|
||||
RUNNER_CPU_WATCHDOG_COOLDOWN_MS="${SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS:-1200000}"
|
||||
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
|
||||
|
||||
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
|
||||
# Fresh-PC shells often miss the nvm-managed Node.js path in non-login execution.
|
||||
. "$RUNNER_NVM_DIR/nvm.sh"
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
RUNNER_NODE_BIN=$(command -v node)
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
|
||||
echo "node runtime not found: $RUNNER_NODE_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
|
||||
if [ -n "$RUNNER_PIDS" ]; then
|
||||
kill $RUNNER_PIDS || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
setsid env \
|
||||
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
|
||||
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_ENABLED="$RUNNER_CPU_WATCHDOG_ENABLED" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS="$RUNNER_CPU_WATCHDOG_INTERVAL_MS" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT="$RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT="$RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT" \
|
||||
SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS="$RUNNER_CPU_WATCHDOG_COOLDOWN_MS" \
|
||||
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
||||
|
||||
echo "server-command-runner restart requested"
|
||||
27
etc/commands/server-command/restart-test.sh
Executable file
27
etc/commands/server-command/restart-test.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}"
|
||||
SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}"
|
||||
SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-app}"
|
||||
SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1}"
|
||||
SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE"
|
||||
fi
|
||||
|
||||
if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then
|
||||
exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME"
|
||||
fi
|
||||
|
||||
echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2
|
||||
exit 127
|
||||
63
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file
63
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file
@@ -0,0 +1,63 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
|
||||
function requestDocker(socketPath, requestPath, method) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request(
|
||||
{
|
||||
socketPath,
|
||||
path: requestPath,
|
||||
method,
|
||||
},
|
||||
(response) => {
|
||||
let body = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on('end', () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
body,
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.on('error', reject);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const containerName = process.argv[2]?.trim();
|
||||
const socketPath = process.env.SERVER_COMMAND_DOCKER_SOCKET?.trim() || '/var/run/docker.sock';
|
||||
|
||||
if (!containerName) {
|
||||
console.error('container name is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(socketPath)) {
|
||||
console.error(`Docker socket not found: ${socketPath}`);
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
const restartPath = `/containers/${encodeURIComponent(containerName)}/restart?t=30`;
|
||||
const response = await requestDocker(socketPath, restartPath, 'POST');
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
process.stdout.write(`${containerName} restarted via Docker socket`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode === 404) {
|
||||
console.error(`Container not found: ${containerName}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(`Docker socket restart failed (${response.statusCode}): ${response.body.trim()}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await main();
|
||||
9
etc/commands/server-command/restart-work-server.sh
Executable file
9
etc/commands/server-command/restart-work-server.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
|
||||
4
etc/db/work-db/.env.example
Executable file
4
etc/db/work-db/.env.example
Executable file
@@ -0,0 +1,4 @@
|
||||
POSTGRES_DB=work_db
|
||||
POSTGRES_USER=work_user
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_PORT=5432
|
||||
2
etc/db/work-db/.gitignore
vendored
Executable file
2
etc/db/work-db/.gitignore
vendored
Executable file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
postgres-data
|
||||
28
etc/db/work-db/README.md
Executable file
28
etc/db/work-db/README.md
Executable file
@@ -0,0 +1,28 @@
|
||||
# Work DB
|
||||
|
||||
로컬 개발용 PostgreSQL 컨테이너입니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
## 중지
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 기본 접속 정보
|
||||
|
||||
- Host: `localhost`
|
||||
- Port: `.env`의 `POSTGRES_PORT`
|
||||
- Database: `.env`의 `POSTGRES_DB`
|
||||
- User: `.env`의 `POSTGRES_USER`
|
||||
- Password: `.env`의 `POSTGRES_PASSWORD`
|
||||
|
||||
## work-server 연동
|
||||
|
||||
`etc/servers/work-server/.env`의 DB 설정과 맞춰서 사용합니다.
|
||||
41
etc/db/work-db/docker-compose.yml
Executable file
41
etc/db/work-db/docker-compose.yml
Executable file
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: work-db
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: ./.env.example
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- '${POSTGRES_PORT:-5432}:5432'
|
||||
volumes:
|
||||
- work-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- work-backend
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
work-db-data:
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
12
etc/db/work-db/sql/board-posts.sql
Executable file
12
etc/db/work-db/sql/board-posts.sql
Executable file
@@ -0,0 +1,12 @@
|
||||
create table if not exists board_posts (
|
||||
id serial primary key,
|
||||
title varchar(200) not null,
|
||||
content text not null,
|
||||
automation_plan_item_id integer null,
|
||||
automation_received_at timestamptz null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_board_posts_updated_at
|
||||
on board_posts (updated_at desc);
|
||||
16
etc/db/work-db/sql/notification-messages.sql
Executable file
16
etc/db/work-db/sql/notification-messages.sql
Executable file
@@ -0,0 +1,16 @@
|
||||
create table if not exists notification_messages (
|
||||
id serial primary key,
|
||||
title varchar(200) not null,
|
||||
body text not null,
|
||||
category varchar(60) not null default 'general',
|
||||
source varchar(80) not null default 'system',
|
||||
priority varchar(20) not null default 'normal',
|
||||
is_read boolean not null default false,
|
||||
read_at timestamptz null,
|
||||
metadata_json jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_notification_messages_unread_created_at
|
||||
on notification_messages (is_read, created_at desc, id desc);
|
||||
34
etc/db/work-db/sql/visitor-history.sql
Executable file
34
etc/db/work-db/sql/visitor-history.sql
Executable file
@@ -0,0 +1,34 @@
|
||||
-- 방문자 마스터 테이블: clientId 단위 집계 정보 보관
|
||||
create table if not exists visitor_clients (
|
||||
id serial primary key,
|
||||
client_id varchar(120) not null unique,
|
||||
nickname varchar(80) not null,
|
||||
first_visited_at timestamptz not null default now(),
|
||||
last_visited_at timestamptz not null default now(),
|
||||
visit_count integer not null default 1,
|
||||
last_visited_url varchar(2000),
|
||||
last_user_agent varchar(1000),
|
||||
last_ip varchar(120),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_visitor_clients_last_visited_at
|
||||
on visitor_clients (last_visited_at desc);
|
||||
|
||||
-- 방문 상세 이력 테이블: 중복 방문도 모두 적재
|
||||
create table if not exists visitor_visit_histories (
|
||||
id serial primary key,
|
||||
client_id varchar(120) not null,
|
||||
visited_at timestamptz not null default now(),
|
||||
url varchar(2000) not null,
|
||||
event_type varchar(80) not null default 'page_view',
|
||||
user_agent varchar(1000),
|
||||
ip varchar(120)
|
||||
);
|
||||
|
||||
create index if not exists idx_visitor_visit_histories_client_id
|
||||
on visitor_visit_histories (client_id);
|
||||
|
||||
create index if not exists idx_visitor_visit_histories_visited_at
|
||||
on visitor_visit_histories (visited_at desc);
|
||||
52
etc/servers/work-server/.env.example
Normal file
52
etc/servers/work-server/.env.example
Normal file
@@ -0,0 +1,52 @@
|
||||
NODE_VERSION=22.22.2
|
||||
PORT=3100
|
||||
APP_TIME_ZONE=Asia/Seoul
|
||||
DB_TIME_ZONE=Asia/Seoul
|
||||
DB_CLIENT=pg
|
||||
DB_HOST=work-db
|
||||
DB_PORT=5432
|
||||
DB_NAME=work_db
|
||||
DB_USER=work_user
|
||||
DB_PASSWORD=change-me
|
||||
DB_SSL=false
|
||||
PLAN_WORKER_ENABLED=true
|
||||
PLAN_WORKER_INTERVAL_MS=10000
|
||||
PLAN_WORKER_ID=
|
||||
PLAN_GIT_REPO_PATH=/workspace/auto_codex/repo
|
||||
PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project
|
||||
PLAN_RELEASE_BRANCH=release
|
||||
PLAN_MAIN_BRANCH=main
|
||||
PLAN_GIT_USER_NAME=how2ice
|
||||
PLAN_GIT_USER_EMAIL=how2ice@naver.com
|
||||
PLAN_CODEX_RUNNER_PATH=/workspace/repo-scripts/run-plan-codex-once.mjs
|
||||
PLAN_CODEX_ENABLED=true
|
||||
PLAN_LOCAL_MAIN_MODE=true
|
||||
PLAN_CODEX_BIN=codex
|
||||
IOS_NOTIFICATION_ENABLED=false
|
||||
WEB_PUSH_ENABLED=true
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY=BL1_f6BgOym_NhSs5QOmziKaYB5rTecl_2JG172w2AO_ru0hD-EG15S9F_6zgv0B6ajfzHEccgnwJAygfGDVv6Y
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY=BglyVgx-u1BnyFSIkTwbnamHnQDGxHewwmp5JLtyr3M
|
||||
WEB_PUSH_SUBJECT=mailto:how2ice@naver.com
|
||||
APNS_KEY_ID=
|
||||
APNS_TEAM_ID=
|
||||
APNS_BUNDLE_ID=
|
||||
APNS_PRIVATE_KEY=
|
||||
APNS_PRIVATE_KEY_PATH=
|
||||
APNS_PRODUCTION=false
|
||||
SERVER_COMMAND_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f
|
||||
SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api
|
||||
SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner
|
||||
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE=/api/server-commands/{key}/actions/restart
|
||||
SERVER_COMMAND_PROJECT_ROOT=/workspace/auto_codex/repo
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project
|
||||
SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock
|
||||
SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/
|
||||
SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/
|
||||
SERVER_COMMAND_WORK_SERVER_URL=http://127.0.0.1:3100/health
|
||||
SERVER_COMMAND_RUNNER_URL=http://host.docker.internal:3211/health
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN=local-server-command-runner
|
||||
SERVER_COMMAND_RUNNER_HOST=0.0.0.0
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE=/workspace/main-project/.server-command-runner-heartbeat.json
|
||||
SERVER_COMMAND_TEST_SERVICE=app
|
||||
SERVER_COMMAND_REL_SERVICE=release-app
|
||||
SERVER_COMMAND_WORK_SERVER_SERVICE=work-server
|
||||
3
etc/servers/work-server/.gitignore
vendored
Executable file
3
etc/servers/work-server/.gitignore
vendored
Executable file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
14
etc/servers/work-server/Dockerfile
Normal file
14
etc/servers/work-server/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22.22.2-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install -g @openai/codex && npm ci --legacy-peer-deps
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY scripts ./scripts
|
||||
COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
115
etc/servers/work-server/README.md
Normal file
115
etc/servers/work-server/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Work Server
|
||||
|
||||
`Fastify + Knex + PostgreSQL` 기반의 범용 작업용 API 서버입니다.
|
||||
|
||||
## 추천 DB
|
||||
|
||||
- `PostgreSQL`
|
||||
- 이유:
|
||||
- Node 생태계에서 검증된 조합
|
||||
- `Knex`로 CRUD와 DDL을 함께 다루기 편함
|
||||
- 운영/확장/마이그레이션 측면에서 무난함
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f work-server
|
||||
```
|
||||
|
||||
`work-server`는 HMR/watch 없이 빌드 산출물(`dist`)을 실행합니다. 컨테이너 재기동은 `docker compose up -d --build --force-recreate --no-deps work-server` 기준으로 최신 소스를 다시 빌드한 뒤 새 컨테이너를 띄웁니다.
|
||||
|
||||
호스트 프로젝트 루트와 동일한 문맥으로 서버 재기동을 처리하려면 별도 host runner도 함께 켭니다.
|
||||
|
||||
```bash
|
||||
cd /home/how2ice/project/ai-code-app
|
||||
npm run server-command:runner
|
||||
```
|
||||
|
||||
## 환경 변수
|
||||
|
||||
기본 실행은 `.env.example` 값으로도 가능합니다.
|
||||
로컬 환경에 맞는 값을 덮어쓰려면 `.env.example`를 참고해서 `.env`를 추가하면 됩니다.
|
||||
|
||||
주요 항목:
|
||||
|
||||
- `APP_TIME_ZONE`: Node 서버 런타임 기준 시간대. 기본값 `Asia/Seoul`
|
||||
- `DB_TIME_ZONE`: 앱이 여는 DB 세션 시간대. 기본값 `Asia/Seoul`
|
||||
- `DB_*`: PostgreSQL 접속 정보
|
||||
- `PLAN_WORKER_ENABLED`: Plan 자동화 worker 활성화 여부
|
||||
- `PLAN_WORKER_INTERVAL_MS`: Plan polling 주기
|
||||
- `PLAN_GIT_REPO_PATH`: 브랜치 생성/병합 대상 저장소 경로
|
||||
- `PLAN_MAIN_PROJECT_REPO_PATH`: main 반영 후 pull 받을 메인 루트 프로젝트 경로. 비우면 `PLAN_GIT_REPO_PATH`를 사용
|
||||
- `PLAN_RELEASE_BRANCH`: 자동 merge 대상 release 브랜치명
|
||||
- `IOS_NOTIFICATION_ENABLED`: iOS APNs 알림 활성화 여부
|
||||
- `APNS_*`: Apple Push Notification 인증 키 정보
|
||||
- `SERVER_COMMAND_DOCKER_SOCKET`: 서버 재기동 명령이 사용할 Docker Unix socket 경로. rootless Docker면 예: `/run/user/1000/docker.sock`
|
||||
- `SERVER_COMMAND_API_BASE_URL`: `work-server`가 서버 재기동 요청을 위임할 host runner 주소
|
||||
- `SERVER_COMMAND_API_ACCESS_TOKEN`: host runner 호출 토큰
|
||||
|
||||
서버 재기동 기능을 쓰려면 `work-server` 컨테이너가 Docker에 접근할 수 있어야 합니다. 기본값은 `/var/run/docker.sock`이며, rootless Docker 환경이면 `.env`에 `SERVER_COMMAND_DOCKER_SOCKET` 또는 `DOCKER_HOST=unix:///run/user/<uid>/docker.sock`를 맞춰 준 뒤 `work-server`를 다시 올려야 합니다.
|
||||
|
||||
기본 예시는 `http://host.docker.internal:3211/api`로 맞춰져 있어서, `work-server` 컨테이너가 아니라 호스트의 현재 프로젝트 루트에서 `restart-*.sh`를 실행합니다. 즉 `Server > Command`가 직접 CLI로 재기동한 것과 최대한 비슷한 문맥을 사용합니다.
|
||||
|
||||
## Codex Live
|
||||
|
||||
`Codex Live`는 현재 프로젝트 환경의 `main_project` 경로를 기준으로 실행됩니다. 기본값은 `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`이며, 소스 수정이 필요하면 이 경로의 실제 프로젝트를 바로 수정합니다.
|
||||
|
||||
현재 운영 기준에서는 `Codex Live`, 일반 채팅, 작업메모 반영 요청 모두 **현재 프로젝트 루트의 로컬 `main` 작업본을 바로 수정**합니다. 별도 브랜치 생성이나 `release -> main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다.
|
||||
|
||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
|
||||
|
||||
채팅 첨부 파일도 같은 기준을 사용하며 `public/.codex_chat/<chat-session-id>/resource/uploads/...` 아래에 저장됩니다.
|
||||
|
||||
## Plan 자동화
|
||||
|
||||
`Plan` 게시판 항목을 작업 큐처럼 읽어 자동화할 수 있습니다.
|
||||
|
||||
현재 로컬 운영 모드에서는 아래 자동 브랜치 흐름을 기본 동작으로 강제하지 않습니다. 필요 시 사용자가 별도로 요청한 경우에만 사용합니다.
|
||||
|
||||
- `등록` 상태: worker가 읽어서 `feature/plan-{id}-{workId}` 브랜치 생성 시도
|
||||
- 성공 시: `작업중`, `브랜치준비`
|
||||
- 실패 시: `이슈`, 최근 오류 기록
|
||||
- `개발완료` 상태: worker가 `release` 브랜치 병합 시도
|
||||
- 병합 성공 시: `완료`
|
||||
- 병합 실패 시: `이슈`
|
||||
|
||||
안전 조건:
|
||||
|
||||
- Git worktree가 깨끗해야 동작
|
||||
- `release` 브랜치가 실제로 존재해야 병합 가능
|
||||
- 실패 시 자동으로 `이슈` 상태와 오류 메시지를 남김
|
||||
|
||||
## 주요 API
|
||||
|
||||
- `GET /health`
|
||||
- `GET /api/schema/tables`
|
||||
- `POST /api/ddl/create-table`
|
||||
- `POST /api/ddl/drop-table`
|
||||
- `POST /api/ddl/add-column`
|
||||
- `POST /api/ddl/drop-column`
|
||||
- `POST /api/ddl/raw`
|
||||
- `POST /api/crud/:table/select`
|
||||
- `POST /api/crud/:table/insert`
|
||||
- `PATCH /api/crud/:table/update`
|
||||
- `DELETE /api/crud/:table/delete`
|
||||
- `GET /api/plan/statuses`
|
||||
- `POST /api/plan/setup`
|
||||
- `GET /api/plan/items`
|
||||
- `GET /api/plan/items/:id`
|
||||
- `POST /api/plan/items`
|
||||
- `PATCH /api/plan/items/:id`
|
||||
- `DELETE /api/plan/items/:id`
|
||||
- `POST /api/notifications/setup`
|
||||
- `GET /api/notifications/tokens`
|
||||
- `PUT /api/notifications/tokens/ios`
|
||||
- `DELETE /api/notifications/tokens/ios`
|
||||
- `POST /api/notifications/send-test`
|
||||
|
||||
## iOS 알림 연동
|
||||
|
||||
- 프론트에서 알림 `On` 시 `PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
|
||||
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
|
||||
- Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다.
|
||||
53
etc/servers/work-server/docker-compose.yml
Normal file
53
etc/servers/work-server/docker-compose.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
services:
|
||||
work-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: work-server
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "200m"
|
||||
max-file: "2"
|
||||
user: "0:0"
|
||||
group_add:
|
||||
- "${SERVER_COMMAND_DOCKER_GID:-984}"
|
||||
cpus: 1.5
|
||||
mem_limit: 2048m
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- path: ./.env.example
|
||||
required: false
|
||||
- path: ./.env
|
||||
required: false
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ../../../:/workspace/main-project
|
||||
- ../../../.auto_codex:/workspace/auto_codex
|
||||
- ../../../scripts:/workspace/repo-scripts:ro
|
||||
- ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
- ./.docker/home:/home/how2ice
|
||||
- ./.docker/codex-home:/codex-home
|
||||
- ./.docker/codex-home-template:/codex-home-template
|
||||
environment:
|
||||
TZ: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
HOME: /home/how2ice
|
||||
CODEX_HOME: /codex-home
|
||||
PLAN_CODEX_TEMPLATE_HOME: /codex-home-template
|
||||
PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex}
|
||||
PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false}
|
||||
PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false}
|
||||
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul}
|
||||
DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul}
|
||||
NPM_CONFIG_CACHE: /home/how2ice/.npm
|
||||
SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
DOCKER_HOST: ${DOCKER_HOST:-}
|
||||
networks:
|
||||
- work-backend
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
1999
etc/servers/work-server/package-lock.json
generated
Executable file
1999
etc/servers/work-server/package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
31
etc/servers/work-server/package.json
Normal file
31
etc/servers/work-server/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "work-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && npm run start",
|
||||
"build": "tsc -p tsconfig.json && node ./scripts/write-build-info.mjs",
|
||||
"start": "node dist/server.js",
|
||||
"backfill:chat-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts",
|
||||
"backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts",
|
||||
"test": "node --import tsx --test src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@parse/node-apn": "^8.0.0",
|
||||
"dotenv": "^17.2.2",
|
||||
"fastify": "^5.6.0",
|
||||
"knex": "^3.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { db } from '../src/db/client.js';
|
||||
import { repairChatConversationRequestLinks } from '../src/services/chat-room-service.js';
|
||||
|
||||
const requestedSessionId = process.argv[2]?.trim() || null;
|
||||
|
||||
try {
|
||||
const result = await repairChatConversationRequestLinks(requestedSessionId);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { db } from '../src/db/client.js';
|
||||
import {
|
||||
CHAT_CONVERSATION_MESSAGE_TABLE,
|
||||
CHAT_CONVERSATION_REQUEST_TABLE,
|
||||
} from '../src/services/chat-room-service.js';
|
||||
|
||||
const LEGACY_CHAT_RESOURCE_PREFIX = '/.codex_chat/';
|
||||
const API_CHAT_RESOURCE_PREFIX = '/api/chat/resources/.codex_chat/';
|
||||
const requestedSessionId = process.argv[2]?.trim() || null;
|
||||
|
||||
function rewriteLegacyChatResourceUrls(text: string) {
|
||||
const normalized = String(text ?? '').replaceAll(LEGACY_CHAT_RESOURCE_PREFIX, API_CHAT_RESOURCE_PREFIX);
|
||||
|
||||
return normalized.replace(
|
||||
/\((?:\/[^)\s]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^)\s]*?)(?:\/api\/chat\/resources\/\.codex_chat\/[^)\s]*)?\)/g,
|
||||
(_match, resourcePath) => `(${resourcePath})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function backfillTable(
|
||||
tableName: string,
|
||||
textColumn: string,
|
||||
) {
|
||||
const rows = await db(tableName)
|
||||
.modify((query) => {
|
||||
if (requestedSessionId) {
|
||||
query.where('session_id', requestedSessionId);
|
||||
}
|
||||
})
|
||||
.where(textColumn, 'like', `%${LEGACY_CHAT_RESOURCE_PREFIX}%`)
|
||||
.select('id', 'session_id', textColumn);
|
||||
|
||||
let updatedCount = 0;
|
||||
const touchedSessionIds = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const currentText = String(row[textColumn] ?? '');
|
||||
const nextText = rewriteLegacyChatResourceUrls(currentText);
|
||||
|
||||
if (nextText === currentText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db(tableName)
|
||||
.where('id', row.id)
|
||||
.update({
|
||||
[textColumn]: nextText,
|
||||
});
|
||||
|
||||
updatedCount += 1;
|
||||
touchedSessionIds.add(String(row.session_id ?? ''));
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
textColumn,
|
||||
updatedCount,
|
||||
touchedSessionIds: Array.from(touchedSessionIds).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const messageResult = await backfillTable(CHAT_CONVERSATION_MESSAGE_TABLE, 'text');
|
||||
const requestResult = await backfillTable(CHAT_CONVERSATION_REQUEST_TABLE, 'response_text');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
requestedSessionId,
|
||||
updatedRowCount: messageResult.updatedCount + requestResult.updatedCount,
|
||||
tables: [messageResult, requestResult],
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
21
etc/servers/work-server/scripts/write-build-info.mjs
Executable file
21
etc/servers/work-server/scripts/write-build-info.mjs
Executable file
@@ -0,0 +1,21 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const distDirectoryPath = path.join(projectRoot, 'dist');
|
||||
const buildInfoPath = path.join(distDirectoryPath, 'build-info.json');
|
||||
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
||||
const builtAt = new Date().toISOString();
|
||||
|
||||
const buildInfo = {
|
||||
version: typeof packageJson.version === 'string' ? packageJson.version : '0.0.0',
|
||||
buildId: `${typeof packageJson.version === 'string' ? packageJson.version : '0.0.0'}@${builtAt}`,
|
||||
builtAt,
|
||||
};
|
||||
|
||||
await fs.mkdir(distDirectoryPath, { recursive: true });
|
||||
await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2));
|
||||
|
||||
console.log(`work-server build info written to ${buildInfoPath}`);
|
||||
104
etc/servers/work-server/src/app.ts
Executable file
104
etc/servers/work-server/src/app.ts
Executable file
@@ -0,0 +1,104 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify from 'fastify';
|
||||
import { registerJsonBodyParser } from './json-body.js';
|
||||
import { registerBoardRoutes } from './routes/board.js';
|
||||
import { registerCrudRoutes } from './routes/crud.js';
|
||||
import { registerDdlRoutes } from './routes/ddl.js';
|
||||
import { registerErrorLogRoutes } from './routes/error-log.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
import { createErrorLog } from './services/error-log-service.js';
|
||||
|
||||
export function createApp() {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
app.register(cors, {
|
||||
origin: true,
|
||||
});
|
||||
|
||||
registerJsonBodyParser(app);
|
||||
app.register(registerBoardRoutes);
|
||||
app.register(registerHealthRoutes);
|
||||
app.register(registerAppConfigRoutes);
|
||||
app.register(registerChatRoutes);
|
||||
app.register(registerSchemaRoutes);
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerServerCommandRoutes);
|
||||
app.register(registerVisitorHistoryRoutes);
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (shouldPersistNotFoundErrorLog(request.url)) {
|
||||
try {
|
||||
await createErrorLog({
|
||||
source: 'server',
|
||||
sourceLabel: '워크서버 API',
|
||||
errorType: 'NotFound',
|
||||
errorMessage: `Route not found: ${request.method} ${request.url}`,
|
||||
statusCode: 404,
|
||||
requestMethod: request.method,
|
||||
requestPath: request.url,
|
||||
context: {
|
||||
route: request.routeOptions.url,
|
||||
},
|
||||
});
|
||||
} catch (loggingError) {
|
||||
app.log.error(loggingError, 'Failed to persist 404 error log');
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(404);
|
||||
return {
|
||||
message: '요청한 경로를 찾을 수 없습니다.',
|
||||
};
|
||||
});
|
||||
|
||||
app.setErrorHandler(async (error, request, reply) => {
|
||||
const handledError = error instanceof Error ? error : new Error(String(error));
|
||||
const errorWithMeta = handledError as Error & { statusCode?: number; code?: string };
|
||||
const statusCode = typeof errorWithMeta.statusCode === 'number'
|
||||
? Number(errorWithMeta.statusCode)
|
||||
: 500;
|
||||
|
||||
try {
|
||||
await createErrorLog({
|
||||
source: 'server',
|
||||
sourceLabel: '워크서버 API',
|
||||
errorType: errorWithMeta.code ?? handledError.name ?? 'ServerError',
|
||||
errorName: handledError.name,
|
||||
errorMessage: handledError.message || '서버 오류가 발생했습니다.',
|
||||
stackTrace: handledError.stack,
|
||||
statusCode,
|
||||
requestMethod: request.method,
|
||||
requestPath: request.url,
|
||||
context: {
|
||||
route: request.routeOptions.url,
|
||||
params: (request.params as Record<string, unknown> | undefined) ?? null,
|
||||
query: (request.query as Record<string, unknown> | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
} catch (loggingError) {
|
||||
app.log.error(loggingError, 'Failed to persist server error log');
|
||||
}
|
||||
|
||||
app.log.error(handledError);
|
||||
reply.status(statusCode >= 400 ? statusCode : 500);
|
||||
return {
|
||||
message: handledError.message || '요청 처리에 실패했습니다.',
|
||||
};
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
103
etc/servers/work-server/src/config/env.ts
Normal file
103
etc/servers/work-server/src/config/env.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import path from 'node:path';
|
||||
import dotenv from 'dotenv';
|
||||
import { z } from 'zod';
|
||||
|
||||
dotenv.config({ override: true, quiet: true });
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3100),
|
||||
APP_TIME_ZONE: z.string().default('Asia/Seoul'),
|
||||
DB_TIME_ZONE: z.string().default('Asia/Seoul'),
|
||||
DB_CLIENT: z.string().default('pg'),
|
||||
DB_HOST: z.string().default('localhost'),
|
||||
DB_PORT: z.coerce.number().default(5432),
|
||||
DB_NAME: z.string().default('work_db'),
|
||||
DB_USER: z.string().default('work_user'),
|
||||
DB_PASSWORD: z.string().default('change-me'),
|
||||
DB_SSL: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_WORKER_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_WORKER_INTERVAL_MS: z.coerce.number().default(10000),
|
||||
PLAN_WORKER_ID: z.string().optional(),
|
||||
PLAN_GIT_REPO_PATH: z.string().default('/workspace/repo'),
|
||||
PLAN_MAIN_PROJECT_REPO_PATH: z.string().optional(),
|
||||
PLAN_RELEASE_BRANCH: z.string().default('release'),
|
||||
PLAN_MAIN_BRANCH: z.string().default('main'),
|
||||
PLAN_GIT_USER_NAME: z.string().default('how2ice'),
|
||||
PLAN_GIT_USER_EMAIL: z.string().default('how2ice@naver.com'),
|
||||
PLAN_CODEX_RUNNER_PATH: z.string().default('/workspace/repo-scripts/run-plan-codex-once.mjs'),
|
||||
PLAN_CODEX_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_LOCAL_MAIN_MODE: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
PLAN_CODEX_BIN: z.string().default('codex'),
|
||||
PLAN_CODEX_TEMPLATE_HOME: z.string().optional(),
|
||||
PLAN_PREVIEW_BASE_URL: z.string().optional(),
|
||||
PLAN_PREVIEW_URL_TEMPLATE: z.string().optional(),
|
||||
IOS_NOTIFICATION_ENABLED: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
WEB_PUSH_ENABLED: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(),
|
||||
WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(),
|
||||
WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'),
|
||||
APNS_KEY_ID: z.string().optional(),
|
||||
APNS_TEAM_ID: z.string().optional(),
|
||||
APNS_BUNDLE_ID: z.string().optional(),
|
||||
APNS_PRIVATE_KEY: z.string().optional(),
|
||||
APNS_PRIVATE_KEY_PATH: z.string().optional(),
|
||||
APNS_PRODUCTION: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
SERVER_COMMAND_ACCESS_TOKEN: z.string().default('usr_7f3a9c2d8e1b4a6f'),
|
||||
SERVER_COMMAND_API_BASE_URL: z.string().optional(),
|
||||
SERVER_COMMAND_API_ACCESS_TOKEN: z.string().optional(),
|
||||
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: z.string().default('/api/server-commands/{key}/actions/restart'),
|
||||
SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')),
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
|
||||
SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'),
|
||||
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(),
|
||||
SERVER_COMMAND_TEST_SERVICE: z.string().default('app'),
|
||||
SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'),
|
||||
SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'),
|
||||
});
|
||||
|
||||
function parseEnv() {
|
||||
dotenv.config({ override: true, quiet: true });
|
||||
const parsedEnv = envSchema.parse(process.env);
|
||||
|
||||
return {
|
||||
...parsedEnv,
|
||||
PLAN_MAIN_PROJECT_REPO_PATH: parsedEnv.PLAN_MAIN_PROJECT_REPO_PATH ?? parsedEnv.PLAN_GIT_REPO_PATH,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEnv() {
|
||||
const parsedEnv = parseEnv();
|
||||
|
||||
if (!process.env.TZ?.trim()) {
|
||||
process.env.TZ = parsedEnv.APP_TIME_ZONE;
|
||||
}
|
||||
|
||||
return parsedEnv;
|
||||
}
|
||||
|
||||
export const env = getEnv();
|
||||
37
etc/servers/work-server/src/db/client.ts
Executable file
37
etc/servers/work-server/src/db/client.ts
Executable file
@@ -0,0 +1,37 @@
|
||||
import knex from 'knex';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
export const db = knex({
|
||||
client: env.DB_CLIENT,
|
||||
connection: {
|
||||
host: env.DB_HOST,
|
||||
port: env.DB_PORT,
|
||||
database: env.DB_NAME,
|
||||
user: env.DB_USER,
|
||||
password: env.DB_PASSWORD,
|
||||
ssl: env.DB_SSL ? { rejectUnauthorized: false } : false,
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: 10,
|
||||
afterCreate(connection: any, done: (error: Error | null, connection: any) => void) {
|
||||
const clientName = String(env.DB_CLIENT ?? '').toLowerCase();
|
||||
|
||||
if (clientName === 'pg' || clientName === 'postgres' || clientName === 'postgresql') {
|
||||
connection.query(`SET TIME ZONE '${env.DB_TIME_ZONE}'`, (error: Error | null) => {
|
||||
done(error, connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientName === 'mysql' || clientName === 'mysql2') {
|
||||
connection.query('SET time_zone = "+09:00"', (error: Error | null) => {
|
||||
done(error, connection);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
done(null, connection);
|
||||
},
|
||||
},
|
||||
});
|
||||
25
etc/servers/work-server/src/json-body.ts
Executable file
25
etc/servers/work-server/src/json-body.ts
Executable file
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export function registerJsonBodyParser(app: FastifyInstance) {
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (request, body, done) => {
|
||||
const rawBody = typeof body === 'string' ? body : '';
|
||||
const normalizedBody = rawBody.trim();
|
||||
|
||||
if (!normalizedBody) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
done(null, JSON.parse(normalizedBody));
|
||||
} catch {
|
||||
const error = new Error('Body is not valid JSON.') as Error & {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
};
|
||||
error.statusCode = 400;
|
||||
error.code = 'FST_ERR_CTP_INVALID_JSON_BODY';
|
||||
done(error, undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
9
etc/servers/work-server/src/lib/identifier.ts
Executable file
9
etc/servers/work-server/src/lib/identifier.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
export function assertIdentifier(value: string, label = 'identifier') {
|
||||
if (!IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
22
etc/servers/work-server/src/not-found.test.ts
Executable file
22
etc/servers/work-server/src/not-found.test.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
|
||||
test('shouldPersistNotFoundErrorLog only keeps work-server API paths', () => {
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api'), true);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/notifications/preferences/automation'), true);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/notifications/preferences/automation?targetKind=client&targetId=abc'), true);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/1234567890abcdef1234567890abcdef'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/docs'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/docs/index.html'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/env'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/config'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/debug'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/debug/pprof'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/.env'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/api/.git/config'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/apis/components'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/apis/widgets?widgetId=dashboard-report-card'), false);
|
||||
assert.equal(shouldPersistNotFoundErrorLog('/plans/release-review'), false);
|
||||
});
|
||||
61
etc/servers/work-server/src/not-found.ts
Executable file
61
etc/servers/work-server/src/not-found.ts
Executable file
@@ -0,0 +1,61 @@
|
||||
function normalizeRequestPath(requestUrl: string) {
|
||||
try {
|
||||
return new URL(requestUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return requestUrl.split('?')[0] ?? requestUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function hasHiddenPathSegment(pathname: string) {
|
||||
return pathname
|
||||
.split('/')
|
||||
.some((segment, index) => index > 1 && segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function isIgnoredScannerProbePath(pathname: string) {
|
||||
return pathname === '/api/docs' ||
|
||||
pathname.startsWith('/api/docs/') ||
|
||||
pathname === '/api/env' ||
|
||||
pathname === '/api/config' ||
|
||||
pathname === '/api/debug' ||
|
||||
pathname.startsWith('/api/debug/');
|
||||
}
|
||||
|
||||
function isOpaqueScannerToken(segment: string) {
|
||||
if (segment.length < 24 || !/^[a-z0-9_-]+$/i.test(segment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^(.)\1+$/.test(segment)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !/[/-]/.test(segment);
|
||||
}
|
||||
|
||||
function isIgnoredOpaqueApiProbePath(pathname: string) {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
|
||||
if (segments.length !== 2 || segments[0] !== 'api') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isOpaqueScannerToken(segments[1] ?? '');
|
||||
}
|
||||
|
||||
export function shouldPersistNotFoundErrorLog(requestUrl: string) {
|
||||
const pathname = normalizeRequestPath(String(requestUrl ?? ''));
|
||||
if (!(pathname === '/api' || pathname.startsWith('/api/'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasHiddenPathSegment(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIgnoredScannerProbePath(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !isIgnoredOpaqueApiProbePath(pathname);
|
||||
}
|
||||
48
etc/servers/work-server/src/routes/app-config.ts
Executable file
48
etc/servers/work-server/src/routes/app-config.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { getAppConfig, upsertAppConfig } from '../services/app-config-service.js';
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async () => {
|
||||
const config = await getAppConfig();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: config ?? {},
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== 'object' || !('config' in payload)) {
|
||||
throw new Error('저장할 설정 값이 비어 있습니다.');
|
||||
}
|
||||
|
||||
const config = (payload as { config: unknown }).config;
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('설정 값 형식이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const savedConfig = await upsertAppConfig(config as Record<string, unknown>);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
config: savedConfig,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
172
etc/servers/work-server/src/routes/board.ts
Executable file
172
etc/servers/work-server/src/routes/board.ts
Executable file
@@ -0,0 +1,172 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import {
|
||||
BoardPostAutomationLockedError,
|
||||
boardPostPayloadSchema,
|
||||
createBoardPost,
|
||||
deleteBoardPost,
|
||||
ensureBoardPostsTable,
|
||||
getBoardPost,
|
||||
listBoardPosts,
|
||||
receiveBoardPostAutomation,
|
||||
updateBoardPost,
|
||||
} from '../services/board-service.js';
|
||||
|
||||
export async function registerBoardRoutes(app: FastifyInstance) {
|
||||
function isLoopbackAddress(value: string | null | undefined) {
|
||||
const normalizedValue = String(value ?? '').trim();
|
||||
return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1';
|
||||
}
|
||||
|
||||
function hasBoardAutomationAccess(request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) {
|
||||
if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress);
|
||||
}
|
||||
|
||||
function requireBoardAutomationAccess(
|
||||
request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } },
|
||||
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
|
||||
) {
|
||||
if (hasBoardAutomationAccess(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 자동화 접수를 할 수 있습니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async function respondWithBoardSetup() {
|
||||
await ensureBoardPostsTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table: 'board_posts',
|
||||
};
|
||||
}
|
||||
|
||||
async function respondWithBoardPosts() {
|
||||
const items = await listBoardPosts();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/board/setup', async () => respondWithBoardSetup());
|
||||
app.post('/api/board/setup', async () => {
|
||||
return respondWithBoardSetup();
|
||||
});
|
||||
|
||||
app.get('/api/board/posts', async () => respondWithBoardPosts());
|
||||
app.get('/api/board/items', async () => respondWithBoardPosts());
|
||||
|
||||
app.get('/api/board/posts/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getBoardPost(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/board/posts', async (request) => {
|
||||
const item = await createBoardPost(boardPostPayloadSchema.parse(request.body ?? {}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => {
|
||||
if (!requireBoardAutomationAccess(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const result = await receiveBoardPostAutomation(id);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({
|
||||
message: '자동화 접수할 게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.item,
|
||||
planItemId: result.planItemId,
|
||||
alreadyReceived: result.alreadyReceived,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/board/posts/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
let item;
|
||||
|
||||
try {
|
||||
item = await updateBoardPost(id, boardPostPayloadSchema.parse(request.body ?? {}));
|
||||
} catch (error) {
|
||||
if (error instanceof BoardPostAutomationLockedError) {
|
||||
return reply.code(409).send({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/board/posts/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
let deleted;
|
||||
|
||||
try {
|
||||
deleted = await deleteBoardPost(id);
|
||||
} catch (error) {
|
||||
if (error instanceof BoardPostAutomationLockedError) {
|
||||
return reply.code(409).send({
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 게시글을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
};
|
||||
});
|
||||
}
|
||||
500
etc/servers/work-server/src/routes/chat.ts
Executable file
500
etc/servers/work-server/src/routes/chat.ts
Executable file
@@ -0,0 +1,500 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { access, mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { getChatRuntimeController } from '../services/chat-service.js';
|
||||
import {
|
||||
createChatConversation,
|
||||
deleteUnansweredChatConversationRequest,
|
||||
deleteChatConversation,
|
||||
ensureChatConversationTables,
|
||||
getChatConversation,
|
||||
listChatConversationActivityLogs,
|
||||
listChatConversationMessages,
|
||||
listChatConversationRequests,
|
||||
listChatConversations,
|
||||
markChatConversationResponsesRead,
|
||||
updateChatConversationContext,
|
||||
} from '../services/chat-room-service.js';
|
||||
import { chatRuntimeService } from '../services/chat-runtime-service.js';
|
||||
|
||||
const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
|
||||
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
||||
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources';
|
||||
|
||||
function resolveStaticContentType(filePath: string) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case '.ts':
|
||||
case '.tsx':
|
||||
case '.js':
|
||||
case '.jsx':
|
||||
case '.mjs':
|
||||
case '.cjs':
|
||||
case '.json':
|
||||
case '.css':
|
||||
case '.html':
|
||||
case '.md':
|
||||
case '.txt':
|
||||
case '.diff':
|
||||
return 'text/plain; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function buildChatResourcePublicUrl(relativePath: string) {
|
||||
const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, '');
|
||||
return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')}`;
|
||||
}
|
||||
|
||||
function normalizeChatResourceWildcard(wildcard: string) {
|
||||
const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, '');
|
||||
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (cleaned.startsWith('.codex_chat/')) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
return path.posix.join('.codex_chat', cleaned);
|
||||
}
|
||||
|
||||
async function serveChatPublicResource(
|
||||
repoPath: string,
|
||||
wildcard: string,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const requestedRelativePath = normalizeChatResourceWildcard(wildcard);
|
||||
|
||||
if (!requestedRelativePath) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅 리소스를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const publicRoot = path.join(repoPath, 'public');
|
||||
const absolutePath = path.resolve(publicRoot, requestedRelativePath);
|
||||
|
||||
if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) {
|
||||
return reply.code(403).send({
|
||||
message: '허용되지 않은 경로입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await access(absolutePath);
|
||||
const fileStat = await stat(absolutePath);
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅 리소스를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return reply.code(404).send({
|
||||
message: '채팅 리소스를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.type(resolveStaticContentType(absolutePath));
|
||||
return reply.send(createReadStream(absolutePath));
|
||||
}
|
||||
|
||||
function sanitizeChatAttachmentFileName(fileName: string) {
|
||||
const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' ');
|
||||
const compact = normalized || 'attachment';
|
||||
return compact.length > 120 ? compact.slice(-120) : compact;
|
||||
}
|
||||
|
||||
function resolveChatAttachmentRepoPath() {
|
||||
return path.resolve(env.PLAN_MAIN_PROJECT_REPO_PATH ?? env.PLAN_GIT_REPO_PATH);
|
||||
}
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, unknown> }) {
|
||||
const raw = request.headers['x-client-id'];
|
||||
return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim();
|
||||
}
|
||||
|
||||
function canViewAllConversations(request: { headers: Record<string, unknown> }) {
|
||||
return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined);
|
||||
}
|
||||
|
||||
export async function registerChatRoutes(app: FastifyInstance) {
|
||||
app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => {
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
||||
});
|
||||
|
||||
app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => {
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply);
|
||||
});
|
||||
|
||||
app.get('/api/chat/setup', async () => {
|
||||
await ensureChatConversationTables();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'],
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/conversations', async (request) => {
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
const viewerClientId = getClientIdHeader(request);
|
||||
const clientId = canViewAllConversations(request) ? null : viewerClientId;
|
||||
const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/runtime', async () => {
|
||||
return {
|
||||
ok: true,
|
||||
item: chatRuntimeService.getSnapshot(),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/),
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().trim().max(200).optional(),
|
||||
contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const buffer = Buffer.from(payload.contentBase64, 'base64');
|
||||
|
||||
if (buffer.byteLength === 0) {
|
||||
return reply.code(400).send({
|
||||
message: '업로드할 파일 내용을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) {
|
||||
return reply.code(413).send({
|
||||
message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const safeFileName = sanitizeChatAttachmentFileName(payload.fileName);
|
||||
const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
const relativePath = path.posix.join(
|
||||
'public',
|
||||
'.codex_chat',
|
||||
payload.sessionId,
|
||||
'resource',
|
||||
'uploads',
|
||||
`${fileToken}-${safeFileName}`,
|
||||
);
|
||||
const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/'));
|
||||
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, buffer);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: {
|
||||
id: randomUUID(),
|
||||
name: payload.fileName,
|
||||
path: relativePath,
|
||||
publicUrl: buildChatResourcePublicUrl(relativePath),
|
||||
size: buffer.byteLength,
|
||||
mimeType: payload.mimeType?.trim() || 'application/octet-stream',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const controller = getChatRuntimeController();
|
||||
|
||||
if (!controller) {
|
||||
return reply.code(503).send({
|
||||
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: controller.getJobDetail(params.requestId),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => {
|
||||
const params = z.object({
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const controller = getChatRuntimeController();
|
||||
|
||||
if (!controller) {
|
||||
return reply.code(503).send({
|
||||
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const cancelled = await controller.cancelJob(params.requestId);
|
||||
|
||||
if (!cancelled) {
|
||||
return reply.code(404).send({
|
||||
message: '취소할 실행 중 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cancelled: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => {
|
||||
const params = z.object({
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const controller = getChatRuntimeController();
|
||||
|
||||
if (!controller) {
|
||||
return reply.code(503).send({
|
||||
message: '채팅 런타임 컨트롤러가 준비되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const removed = await controller.removeQueuedJob(params.requestId);
|
||||
|
||||
if (!removed) {
|
||||
return reply.code(404).send({
|
||||
message: '제거할 대기 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/conversations', async (request) => {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
title: z.string().trim().max(200).optional(),
|
||||
contextLabel: z.string().trim().max(200).optional(),
|
||||
contextDescription: z.string().trim().max(2000).optional(),
|
||||
notifyOffline: z.boolean().optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const clientId = getClientIdHeader(request);
|
||||
const item = await createChatConversation({
|
||||
sessionId: payload.sessionId,
|
||||
clientId: clientId || null,
|
||||
title: payload.title ?? '새 대화',
|
||||
contextLabel: payload.contextLabel ?? null,
|
||||
contextDescription: payload.contextDescription ?? null,
|
||||
notifyOffline: payload.notifyOffline ?? true,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/conversations/:sessionId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
beforeMessageId: z.coerce.number().int().positive().optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const item = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const messageLimit = query.limit ?? 500;
|
||||
const messages = await listChatConversationMessages(params.sessionId, {
|
||||
limit: messageLimit,
|
||||
beforeMessageId: query.beforeMessageId ?? null,
|
||||
});
|
||||
const requests = await listChatConversationRequests(params.sessionId, 500);
|
||||
const activityLogs = await listChatConversationActivityLogs(params.sessionId, 500);
|
||||
const oldestLoadedMessageId = messages[0]?.id ?? null;
|
||||
const hasOlderMessages =
|
||||
oldestLoadedMessageId != null
|
||||
? (await listChatConversationMessages(params.sessionId, {
|
||||
limit: 1,
|
||||
beforeMessageId: oldestLoadedMessageId,
|
||||
})).length > 0
|
||||
: false;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
messages,
|
||||
requests,
|
||||
activityLogs,
|
||||
oldestLoadedMessageId,
|
||||
hasOlderMessages,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const clientId = getClientIdHeader(request);
|
||||
|
||||
if (!clientId) {
|
||||
return reply.code(400).send({
|
||||
message: '읽음 처리를 위한 clientId가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await markChatConversationResponsesRead(params.sessionId, clientId);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({
|
||||
message: '읽음 처리할 채팅방을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
requestId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId);
|
||||
|
||||
if (!result.deleted) {
|
||||
if (result.reason === 'not_found') {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 요청을 찾지 못했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (result.reason === 'answered') {
|
||||
return reply.code(409).send({
|
||||
message: '이미 답변이 연결된 요청은 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(409).send({
|
||||
message: '현재 처리 중인 요청은 삭제할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
sessionId: params.sessionId,
|
||||
requestId: params.requestId,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/chat/conversations/:sessionId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
contextLabel: z.string().trim().max(200).optional().nullable(),
|
||||
contextDescription: z.string().trim().max(2000).optional().nullable(),
|
||||
notifyOffline: z.boolean().optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const current = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!current) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await updateChatConversationContext(params.sessionId, {
|
||||
title: payload.title ?? current.title,
|
||||
clientId: current.clientId,
|
||||
contextLabel: payload.contextLabel ?? current.contextLabel,
|
||||
contextDescription: payload.contextDescription ?? current.contextDescription,
|
||||
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/chat/conversations/:sessionId', async (request, reply) => {
|
||||
const params = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
}).parse(request.params ?? {});
|
||||
|
||||
const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request);
|
||||
const current = await getChatConversation(params.sessionId, clientId || null);
|
||||
|
||||
if (!current) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 채팅방을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteChatConversation(params.sessionId);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted,
|
||||
sessionId: params.sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
91
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file
91
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import { registerJsonBodyParser } from '../json-body.js';
|
||||
import { registerBoardRoutes } from './board.js';
|
||||
import { registerNotificationRoutes } from './notification.js';
|
||||
|
||||
function createRouteRecorder() {
|
||||
const routes: Array<{ method: string; path: string }> = [];
|
||||
const record = (method: string) => (path: string) => {
|
||||
routes.push({ method, path });
|
||||
return undefined;
|
||||
};
|
||||
const app = {
|
||||
get: record('GET'),
|
||||
post: record('POST'),
|
||||
put: record('PUT'),
|
||||
patch: record('PATCH'),
|
||||
delete: record('DELETE'),
|
||||
};
|
||||
|
||||
return {
|
||||
app: app as any,
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
test('registerJsonBodyParser treats empty json body as an empty object', async () => {
|
||||
const app = Fastify();
|
||||
registerJsonBodyParser(app);
|
||||
app.post('/json', async (request) => ({
|
||||
body: request.body,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/json',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: '',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), {
|
||||
body: {},
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('registerJsonBodyParser still rejects malformed json', async () => {
|
||||
const app = Fastify();
|
||||
registerJsonBodyParser(app);
|
||||
app.post('/json', async (request) => ({
|
||||
body: request.body,
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/json',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: '{',
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.match(response.body, /valid JSON/i);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('registerBoardRoutes keeps setup and items compatibility routes', async () => {
|
||||
const { app, routes } = createRouteRecorder();
|
||||
await registerBoardRoutes(app);
|
||||
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/setup'));
|
||||
assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/board/setup'));
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/posts'));
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/items'));
|
||||
});
|
||||
|
||||
test('registerNotificationRoutes exposes notification message routes', async () => {
|
||||
const { app, routes } = createRouteRecorder();
|
||||
await registerNotificationRoutes(app);
|
||||
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages'));
|
||||
assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages/:id'));
|
||||
assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/notifications/messages'));
|
||||
assert.ok(routes.some((route) => route.method === 'PATCH' && route.path === '/api/notifications/messages/:id'));
|
||||
assert.ok(routes.some((route) => route.method === 'DELETE' && route.path === '/api/notifications/messages/:id'));
|
||||
});
|
||||
220
etc/servers/work-server/src/routes/crud.ts
Executable file
220
etc/servers/work-server/src/routes/crud.ts
Executable file
@@ -0,0 +1,220 @@
|
||||
import type { Knex } from 'knex';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { assertIdentifier } from '../lib/identifier.js';
|
||||
|
||||
const filterSchema = z.object({
|
||||
field: z.string(),
|
||||
operator: z
|
||||
.enum(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'in', 'null', 'notNull'])
|
||||
.default('eq'),
|
||||
value: z.any().optional(),
|
||||
});
|
||||
|
||||
const orderBySchema = z.object({
|
||||
field: z.string(),
|
||||
direction: z.enum(['asc', 'desc']).default('asc'),
|
||||
});
|
||||
|
||||
const selectSchema = z.object({
|
||||
columns: z.array(z.string()).optional(),
|
||||
where: z.array(filterSchema).optional(),
|
||||
orderBy: z.array(orderBySchema).optional(),
|
||||
limit: z.number().int().positive().max(500).optional(),
|
||||
offset: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
const insertSchema = z.object({
|
||||
data: z.record(z.string(), z.any()).or(z.array(z.record(z.string(), z.any()))),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
data: z.record(z.string(), z.any()),
|
||||
where: z.array(filterSchema).default([]),
|
||||
});
|
||||
|
||||
const deleteSchema = z.object({
|
||||
where: z.array(filterSchema).default([]),
|
||||
});
|
||||
|
||||
const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']);
|
||||
|
||||
function applyFilters(query: Knex.QueryBuilder, filters: z.infer<typeof filterSchema>[] = []) {
|
||||
filters.forEach((filter) => {
|
||||
const field = assertIdentifier(filter.field, 'field');
|
||||
|
||||
switch (filter.operator) {
|
||||
case 'eq':
|
||||
query.where(field, filter.value);
|
||||
break;
|
||||
case 'ne':
|
||||
query.whereNot(field, filter.value);
|
||||
break;
|
||||
case 'gt':
|
||||
query.where(field, '>', filter.value);
|
||||
break;
|
||||
case 'gte':
|
||||
query.where(field, '>=', filter.value);
|
||||
break;
|
||||
case 'lt':
|
||||
query.where(field, '<', filter.value);
|
||||
break;
|
||||
case 'lte':
|
||||
query.where(field, '<=', filter.value);
|
||||
break;
|
||||
case 'like':
|
||||
query.where(field, 'like', filter.value);
|
||||
break;
|
||||
case 'in':
|
||||
query.whereIn(field, Array.isArray(filter.value) ? filter.value : [filter.value]);
|
||||
break;
|
||||
case 'null':
|
||||
query.whereNull(field);
|
||||
break;
|
||||
case 'notNull':
|
||||
query.whereNotNull(field);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerCrudRoutes(app: FastifyInstance) {
|
||||
function getRequestTraceContext(request: FastifyRequest) {
|
||||
return {
|
||||
ip: request.ip,
|
||||
remoteAddress: request.raw.socket.remoteAddress,
|
||||
host: request.headers.host,
|
||||
origin: request.headers.origin,
|
||||
referer: request.headers.referer,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientId: request.headers['x-client-id'],
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeCrudUpdatePayload(table: string, payload: z.infer<typeof updateSchema>) {
|
||||
if (table !== 'board_posts') {
|
||||
return {
|
||||
dataKeys: Object.keys(payload.data),
|
||||
where: payload.where,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dataKeys: Object.keys(payload.data),
|
||||
where: payload.where,
|
||||
automationPlanItemId: payload.data.automation_plan_item_id ?? null,
|
||||
automationReceivedAt: payload.data.automation_received_at ?? null,
|
||||
title: typeof payload.data.title === 'string' ? payload.data.title : undefined,
|
||||
contentLength: typeof payload.data.content === 'string' ? payload.data.content.length : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/crud/:table/select', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = selectSchema.parse(request.body ?? {});
|
||||
const columns = payload.columns?.map((column) => assertIdentifier(column, 'column')) ?? ['*'];
|
||||
|
||||
const query = db(table).select(columns);
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
payload.orderBy?.forEach((order) => {
|
||||
query.orderBy(assertIdentifier(order.field, 'order field'), order.direction);
|
||||
});
|
||||
|
||||
if (payload.limit) {
|
||||
query.limit(payload.limit);
|
||||
}
|
||||
|
||||
if (payload.offset) {
|
||||
query.offset(payload.offset);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/crud/:table/insert', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = insertSchema.parse(request.body);
|
||||
const inserted = await db(table).insert(payload.data).returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
rows: inserted,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/crud/:table/update', async (request, reply) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = updateSchema.parse(request.body);
|
||||
const query = db(table);
|
||||
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
if (table === 'board_posts') {
|
||||
request.log.warn(
|
||||
{
|
||||
table,
|
||||
payload: summarizeCrudUpdatePayload(table, payload),
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Board post CRUD update requested',
|
||||
);
|
||||
|
||||
const protectedFields = Object.keys(payload.data).filter((field) => protectedBoardPostAutomationFields.has(field));
|
||||
|
||||
if (protectedFields.length) {
|
||||
request.log.warn(
|
||||
{
|
||||
table,
|
||||
protectedFields,
|
||||
payload: summarizeCrudUpdatePayload(table, payload),
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Board post CRUD update blocked from changing automation link fields',
|
||||
);
|
||||
|
||||
return reply.code(409).send({
|
||||
message: '자동화 접수 연결 필드는 일반 CRUD 수정으로 변경할 수 없습니다.',
|
||||
protectedFields,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await query.update(payload.data).returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/crud/:table/delete', async (request) => {
|
||||
const table = assertIdentifier((request.params as { table: string }).table, 'table name');
|
||||
const payload = deleteSchema.parse(request.body ?? {});
|
||||
const query = db(table);
|
||||
|
||||
applyFilters(query, payload.where);
|
||||
|
||||
const rows = await query.delete().returning('*');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table,
|
||||
count: rows.length,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
}
|
||||
119
etc/servers/work-server/src/routes/ddl.ts
Executable file
119
etc/servers/work-server/src/routes/ddl.ts
Executable file
@@ -0,0 +1,119 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { assertIdentifier } from '../lib/identifier.js';
|
||||
|
||||
const columnSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
nullable: z.boolean().optional(),
|
||||
primary: z.boolean().optional(),
|
||||
unique: z.boolean().optional(),
|
||||
defaultTo: z.any().optional(),
|
||||
});
|
||||
|
||||
const createTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
columns: z.array(columnSchema).min(1),
|
||||
});
|
||||
|
||||
const dropTableSchema = z.object({
|
||||
tableName: z.string(),
|
||||
});
|
||||
|
||||
const addColumnSchema = z.object({
|
||||
tableName: z.string(),
|
||||
column: columnSchema,
|
||||
});
|
||||
|
||||
const dropColumnSchema = z.object({
|
||||
tableName: z.string(),
|
||||
columnName: z.string(),
|
||||
});
|
||||
|
||||
const rawDdlSchema = z.object({
|
||||
sql: z.string().min(1),
|
||||
});
|
||||
|
||||
function applyColumn(tableBuilder: any, column: z.infer<typeof columnSchema>) {
|
||||
const name = assertIdentifier(column.name, 'column name');
|
||||
const definition = tableBuilder.specificType(name, column.type);
|
||||
|
||||
if (column.nullable === false) {
|
||||
definition.notNullable();
|
||||
}
|
||||
|
||||
if (column.nullable === true) {
|
||||
definition.nullable();
|
||||
}
|
||||
|
||||
if (column.primary) {
|
||||
definition.primary();
|
||||
}
|
||||
|
||||
if (column.unique) {
|
||||
definition.unique();
|
||||
}
|
||||
|
||||
if (column.defaultTo !== undefined) {
|
||||
definition.defaultTo(column.defaultTo);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerDdlRoutes(app: FastifyInstance) {
|
||||
app.post('/api/ddl/create-table', async (request) => {
|
||||
const payload = createTableSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
|
||||
await db.schema.createTable(tableName, (table) => {
|
||||
payload.columns.forEach((column) => {
|
||||
applyColumn(table, column);
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, action: 'create-table', tableName };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/drop-table', async (request) => {
|
||||
const payload = dropTableSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
|
||||
await db.schema.dropTableIfExists(tableName);
|
||||
|
||||
return { ok: true, action: 'drop-table', tableName };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/add-column', async (request) => {
|
||||
const payload = addColumnSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
|
||||
await db.schema.alterTable(tableName, (table) => {
|
||||
applyColumn(table, payload.column);
|
||||
});
|
||||
|
||||
return { ok: true, action: 'add-column', tableName, column: payload.column.name };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/drop-column', async (request) => {
|
||||
const payload = dropColumnSchema.parse(request.body);
|
||||
const tableName = assertIdentifier(payload.tableName, 'table name');
|
||||
const columnName = assertIdentifier(payload.columnName, 'column name');
|
||||
|
||||
await db.schema.alterTable(tableName, (table) => {
|
||||
table.dropColumn(columnName);
|
||||
});
|
||||
|
||||
return { ok: true, action: 'drop-column', tableName, columnName };
|
||||
});
|
||||
|
||||
app.post('/api/ddl/raw', async (request) => {
|
||||
const payload = rawDdlSchema.parse(request.body);
|
||||
const result = await db.raw(payload.sql);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
action: 'raw',
|
||||
result,
|
||||
};
|
||||
});
|
||||
}
|
||||
49
etc/servers/work-server/src/routes/error-log.ts
Executable file
49
etc/servers/work-server/src/routes/error-log.ts
Executable file
@@ -0,0 +1,49 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
createErrorLog,
|
||||
createErrorLogSchema,
|
||||
hasErrorLogViewAccessToken,
|
||||
listErrorLogs,
|
||||
setupErrorLogTable,
|
||||
} from '../services/error-log-service.js';
|
||||
|
||||
const errorLogListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export async function registerErrorLogRoutes(app: FastifyInstance) {
|
||||
app.post('/api/error-logs/setup', async () => {
|
||||
return setupErrorLogTable();
|
||||
});
|
||||
|
||||
app.get('/api/error-logs', async (request, reply) => {
|
||||
const accessToken = request.headers['x-access-token'];
|
||||
|
||||
if (!hasErrorLogViewAccessToken(accessToken)) {
|
||||
reply.status(403);
|
||||
return {
|
||||
ok: false,
|
||||
message: '에러 로그 조회 권한이 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const query = errorLogListQuerySchema.parse(request.query ?? {});
|
||||
const items = await listErrorLogs(query.limit ?? 50);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/error-logs/report', async (request) => {
|
||||
const payload = createErrorLogSchema.parse(request.body);
|
||||
const item = await createErrorLog(payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
}
|
||||
13
etc/servers/work-server/src/routes/health.ts
Executable file
13
etc/servers/work-server/src/routes/health.ts
Executable file
@@ -0,0 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance) {
|
||||
const respondHealth = async () => ({
|
||||
ok: true,
|
||||
service: 'work-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
app.get('/', respondHealth);
|
||||
app.get('/api', respondHealth);
|
||||
app.get('/health', respondHealth);
|
||||
}
|
||||
207
etc/servers/work-server/src/routes/notification.ts
Executable file
207
etc/servers/work-server/src/routes/notification.ts
Executable file
@@ -0,0 +1,207 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
listIosNotificationTokens,
|
||||
getAutomationNotificationPreference,
|
||||
getWebPushConfig,
|
||||
registerIosNotificationToken,
|
||||
registerAutomationNotificationPreferenceSchema,
|
||||
registerIosTokenSchema,
|
||||
registerWebPushSubscription,
|
||||
registerWebPushSubscriptionSchema,
|
||||
sendNotifications,
|
||||
sendIosNotificationSchema,
|
||||
setupNotificationTables,
|
||||
upsertAutomationNotificationPreference,
|
||||
unregisterIosNotificationToken,
|
||||
unregisterIosTokenSchema,
|
||||
unregisterWebPushSubscription,
|
||||
unregisterWebPushSubscriptionSchema,
|
||||
} from '../services/notification-service.js';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
deleteNotificationMessage,
|
||||
getNotificationMessage,
|
||||
listNotificationMessages,
|
||||
notificationMessageListQuerySchema,
|
||||
notificationMessagePayloadSchema,
|
||||
notificationMessageReadPayloadSchema,
|
||||
updateNotificationMessageReadState,
|
||||
} from '../services/notification-message-service.js';
|
||||
|
||||
const automationNotificationPreferenceQuerySchema = z.object({
|
||||
targetKind: z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']).optional(),
|
||||
targetId: z.string().trim().min(1).max(1000).optional(),
|
||||
});
|
||||
|
||||
type AutomationNotificationPreferenceTargetKind = NonNullable<
|
||||
z.infer<typeof automationNotificationPreferenceQuerySchema>['targetKind']
|
||||
>;
|
||||
|
||||
function getClientIdHeader(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const rawClientId = request.headers['x-client-id'];
|
||||
const clientId = Array.isArray(rawClientId) ? rawClientId[0] : rawClientId;
|
||||
return clientId?.trim() ?? '';
|
||||
}
|
||||
|
||||
export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
app.post('/api/notifications/setup', async () => setupNotificationTables());
|
||||
|
||||
app.get('/api/notifications/tokens', async () => ({
|
||||
items: await listIosNotificationTokens(),
|
||||
}));
|
||||
|
||||
app.get('/api/notifications/webpush/config', async () => getWebPushConfig());
|
||||
|
||||
app.get('/api/notifications/messages', async (request) => {
|
||||
const query = notificationMessageListQuerySchema.parse(request.query ?? {});
|
||||
return {
|
||||
ok: true,
|
||||
...(await listNotificationMessages(query)),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/notifications/messages/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getNotificationMessage(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '알림 메시지를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/notifications/messages', async (request) => {
|
||||
const item = await createNotificationMessage(notificationMessagePayloadSchema.parse(request.body ?? {}));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/notifications/messages/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await updateNotificationMessageReadState(id, notificationMessageReadPayloadSchema.parse(request.body ?? {}));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '상태를 변경할 알림 메시지를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/messages/:id', async (request, reply) => {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const deleted = await deleteNotificationMessage(id);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 알림 메시지를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/notifications/preferences/automation', async (request) => {
|
||||
const query = automationNotificationPreferenceQuerySchema.parse(request.query ?? {});
|
||||
const targetId = query.targetId || getClientIdHeader(request);
|
||||
const targetKind = query.targetId ? query.targetKind ?? 'client' : 'client';
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind),
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/notifications/preferences/automation', async (request, reply) => {
|
||||
try {
|
||||
const payload = registerAutomationNotificationPreferenceSchema.parse(request.body ?? {});
|
||||
const targetId = payload.targetId || getClientIdHeader(request);
|
||||
|
||||
if (!targetId) {
|
||||
throw new Error('알림 설정을 저장할 클라이언트 ID가 없습니다.');
|
||||
}
|
||||
|
||||
return upsertAutomationNotificationPreference({
|
||||
...payload,
|
||||
targetId,
|
||||
});
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/notifications/tokens/ios', async (request) => {
|
||||
const payload = registerIosTokenSchema.parse(request.body ?? {});
|
||||
return registerIosNotificationToken(payload);
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/tokens/ios', async (request) => {
|
||||
const payload = unregisterIosTokenSchema.parse(request.body ?? {});
|
||||
return unregisterIosNotificationToken(payload.token);
|
||||
});
|
||||
|
||||
app.put('/api/notifications/subscriptions/web', async (request) => {
|
||||
const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {});
|
||||
return registerWebPushSubscription(payload);
|
||||
});
|
||||
|
||||
app.delete('/api/notifications/subscriptions/web', async (request) => {
|
||||
const payload = unregisterWebPushSubscriptionSchema.parse(request.body ?? {});
|
||||
return unregisterWebPushSubscription(payload.endpoint);
|
||||
});
|
||||
|
||||
app.post('/api/notifications/send', async (request) => {
|
||||
const payload = sendIosNotificationSchema.parse(request.body ?? {});
|
||||
return sendNotifications(payload);
|
||||
});
|
||||
|
||||
app.post('/api/notifications/send-test', async (request) => {
|
||||
const payload = sendIosNotificationSchema.parse(request.body ?? {});
|
||||
return sendNotifications(payload);
|
||||
});
|
||||
}
|
||||
async function getAutomationNotificationPreferenceWithFallback(
|
||||
targetId: string,
|
||||
targetKind: AutomationNotificationPreferenceTargetKind,
|
||||
) {
|
||||
const automation = await getAutomationNotificationPreference(targetId, targetKind);
|
||||
|
||||
if (automation || targetKind !== 'ios-token-client') {
|
||||
return automation;
|
||||
}
|
||||
|
||||
const [token, clientId] = targetId.split('::client::');
|
||||
|
||||
if (token?.trim()) {
|
||||
const tokenAutomation = await getAutomationNotificationPreference(token.trim(), 'ios-token');
|
||||
|
||||
if (tokenAutomation) {
|
||||
return tokenAutomation;
|
||||
}
|
||||
}
|
||||
|
||||
if (clientId?.trim()) {
|
||||
return getAutomationNotificationPreference(clientId.trim(), 'client');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
981
etc/servers/work-server/src/routes/plan.ts
Executable file
981
etc/servers/work-server/src/routes/plan.ts
Executable file
@@ -0,0 +1,981 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import { notifyPlanEvent } from '../services/plan-notification-service.js';
|
||||
import { shouldNotifyPlanRestart } from '../services/plan-notification-policy.js';
|
||||
import {
|
||||
PLAN_ACTION_TABLE,
|
||||
PLAN_ISSUE_TABLE,
|
||||
PLAN_RELEASE_REVIEW_TABLE,
|
||||
PLAN_SOURCE_WORK_TABLE,
|
||||
PLAN_TABLE,
|
||||
appendLatestIssueAction,
|
||||
cancelPlanRelease,
|
||||
createPlanItem,
|
||||
createPlanActionHistory,
|
||||
createPlanSourceWorkHistory,
|
||||
createPlanSchema,
|
||||
deletePlanItem,
|
||||
ensurePlanTable,
|
||||
getPlanSourceWorkHistory,
|
||||
getBoardPostLinkedToPlanItem,
|
||||
getPlanItemById,
|
||||
formatPlanNotificationLabel,
|
||||
issueActionSchema,
|
||||
listPlanActionHistories,
|
||||
listPlanIssueHistories,
|
||||
listPlanItems,
|
||||
listPlanReleaseReviewBoardItems,
|
||||
listPlanSourceWorkHistories,
|
||||
listPlanQuerySchema,
|
||||
mapPlanActionRow,
|
||||
mapPlanIssueRow,
|
||||
mapPlanSourceWorkRow,
|
||||
markPlanAsStarted,
|
||||
planStatuses,
|
||||
markPlanAsCompleted,
|
||||
markPlanAsDevelopmentComplete,
|
||||
queuePlanRetryFromFailure,
|
||||
queuePlanRetryFromIssueAction,
|
||||
requestPlanMainMerge,
|
||||
resumePlanDevelopmentFromRelease,
|
||||
retryPlanBranch,
|
||||
retryPlanWork,
|
||||
retryPlanMerge,
|
||||
setupSchema,
|
||||
updatePlanReleaseReviewSchema,
|
||||
upsertPlanReleaseReview,
|
||||
updatePlanItem,
|
||||
updatePlanItemJangsingProcessingRequired,
|
||||
updatePlanJangsingProcessingSchema,
|
||||
updatePlanSchema,
|
||||
} from '../services/plan-service.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { recreateReleaseBranchFromMain } from '../services/git-service.js';
|
||||
import { registerErrorLogBoardPosts } from '../services/error-log-plan-registration-service.js';
|
||||
import {
|
||||
PLAN_SCHEDULED_TASK_TABLE,
|
||||
createPlanScheduledTask,
|
||||
createPlanScheduledTaskSchema,
|
||||
deletePlanScheduledTask,
|
||||
ensurePlanScheduledTaskTable,
|
||||
getPlanScheduledTaskById,
|
||||
listPlanScheduledTasks,
|
||||
mapPlanScheduledTaskRow,
|
||||
registerPlanScheduledTaskNow,
|
||||
updatePlanScheduledTask,
|
||||
updatePlanScheduledTaskSchema,
|
||||
} from '../services/plan-schedule-service.js';
|
||||
import { getVisitorClientByClientId } from '../services/visitor-history-service.js';
|
||||
|
||||
const completeActionSchema = z.object({
|
||||
note: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
const actionNoteSchema = z.object({
|
||||
actionNote: z.string().trim().min(1),
|
||||
actionType: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
const createSourceWorkSchema = z.object({
|
||||
summary: z.string().trim().min(1),
|
||||
branchName: z.string().trim().min(1),
|
||||
commitHash: z.string().trim().min(1).nullable().optional(),
|
||||
previewUrl: z.string().trim().url().nullable().optional(),
|
||||
changedFiles: z.array(z.string()).default([]),
|
||||
commandLog: z.string().nullable().optional(),
|
||||
diffText: z.string().nullable().optional(),
|
||||
sourceFiles: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string().trim().min(1),
|
||||
previousPath: z.string().trim().min(1).nullable().optional(),
|
||||
status: z.enum(['added', 'modified', 'deleted', 'renamed', 'binary', 'unknown']),
|
||||
language: z.string().trim().min(1),
|
||||
content: z.string(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export async function registerPlanRoutes(app: FastifyInstance) {
|
||||
function getRequestTraceContext(request: FastifyRequest) {
|
||||
return {
|
||||
ip: request.ip,
|
||||
remoteAddress: request.raw.socket.remoteAddress,
|
||||
host: request.headers.host,
|
||||
origin: request.headers.origin,
|
||||
referer: request.headers.referer,
|
||||
userAgent: request.headers['user-agent'],
|
||||
clientId: request.headers['x-client-id'],
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackAddress(value: string | null | undefined) {
|
||||
const normalizedValue = String(value ?? '').trim();
|
||||
return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1';
|
||||
}
|
||||
|
||||
function hasPlanAccessToken(accessToken: string | string[] | undefined) {
|
||||
return hasErrorLogViewAccessToken(accessToken);
|
||||
}
|
||||
|
||||
function hasPlanAccess(request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) {
|
||||
if (hasPlanAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress);
|
||||
}
|
||||
|
||||
function requirePlanAccessToken(
|
||||
request: { headers: Record<string, unknown>; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } },
|
||||
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
|
||||
) {
|
||||
if (hasPlanAccess(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 수정할 수 있습니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleListPlanScheduledTasks(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanScheduledTasks();
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanScheduledTaskRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCreatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
|
||||
const row = await createPlanScheduledTask(payload);
|
||||
const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function handleGetPlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 스케줄을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await getPlanScheduledTaskById(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '스케줄을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUpdatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = updatePlanScheduledTaskSchema.parse(request.body ?? {});
|
||||
const row = await updatePlanScheduledTask(id, payload);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 스케줄을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const shouldTriggerImmediateRegistration =
|
||||
row &&
|
||||
Boolean(row.enabled ?? true) &&
|
||||
Boolean(row.immediate_run_enabled ?? true) &&
|
||||
payload.enabled !== false;
|
||||
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanScheduledTaskRow(row),
|
||||
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
|
||||
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/plan/registrations/error-logs', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z.object({
|
||||
rangeStart: z.coerce.date().optional(),
|
||||
rangeEnd: z.coerce.date().optional(),
|
||||
maxGroups: z.coerce.number().int().min(1).max(24).optional(),
|
||||
}).parse(request.body ?? {});
|
||||
|
||||
const result = await registerErrorLogBoardPosts(payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rangeStart: result.rangeStart.toISOString(),
|
||||
rangeEnd: result.rangeEnd.toISOString(),
|
||||
recentLogCount: result.recentLogs.length,
|
||||
candidateCount: result.candidates.length,
|
||||
rawCandidateCount: result.rawCandidates.length,
|
||||
createdBoardPosts: result.createdPosts,
|
||||
skippedBoardPosts: result.skippedPosts,
|
||||
};
|
||||
});
|
||||
|
||||
async function handleDeletePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await deletePlanScheduledTask(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 스케줄을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/plan/statuses', async () => ({
|
||||
items: planStatuses,
|
||||
}));
|
||||
|
||||
app.post('/api/plan/setup', async (request) => {
|
||||
const payload = setupSchema.parse(request.body ?? {});
|
||||
|
||||
if (payload.recreate) {
|
||||
await db.schema.dropTableIfExists(PLAN_SCHEDULED_TASK_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_ACTION_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_ISSUE_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_SOURCE_WORK_TABLE);
|
||||
await db.schema.dropTableIfExists(PLAN_TABLE);
|
||||
}
|
||||
|
||||
await ensurePlanTable();
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table: PLAN_TABLE,
|
||||
scheduleTable: PLAN_SCHEDULED_TASK_TABLE,
|
||||
releaseReviewTable: PLAN_RELEASE_REVIEW_TABLE,
|
||||
statuses: planStatuses,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/release-reviews', async (request) => {
|
||||
const items = await listPlanReleaseReviewBoardItems({
|
||||
maskNote: !hasPlanAccess(request),
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/plan/release-reviews/:planItemId', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const planItemId = z.coerce.number().int().positive().parse((request.params as { planItemId: string }).planItemId);
|
||||
const payload = updatePlanReleaseReviewSchema.parse(request.body ?? {});
|
||||
const clientId = String(request.headers['x-client-id'] ?? '').trim();
|
||||
const visitor = clientId ? await getVisitorClientByClientId(clientId) : null;
|
||||
const review = await upsertPlanReleaseReview(planItemId, payload, {
|
||||
clientId: clientId || null,
|
||||
nickname: visitor?.nickname ?? null,
|
||||
});
|
||||
|
||||
if (!review) {
|
||||
return reply.code(404).send({
|
||||
message: '검수 대상을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: review,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/scheduled-tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/schedule/tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/schedule', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/schedules', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/scheduled-tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/schedule/tasks', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/schedule', handleListPlanScheduledTasks);
|
||||
app.get('/api/plans/schedules', handleListPlanScheduledTasks);
|
||||
app.get('/api/plan/scheduled-tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plan/schedule/tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plan/schedule/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plan/schedules/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/scheduled-tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/schedule/tasks/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/schedule/:id', handleGetPlanScheduledTask);
|
||||
app.get('/api/plans/schedules/:id', handleGetPlanScheduledTask);
|
||||
app.post('/api/plan/scheduled-tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plan/schedule/tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plan/schedule', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plan/schedules', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/scheduled-tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/schedule/tasks', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/schedule', handleCreatePlanScheduledTask);
|
||||
app.post('/api/plans/schedules', handleCreatePlanScheduledTask);
|
||||
app.patch('/api/plan/scheduled-tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plan/schedule/tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plan/schedule/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plan/schedules/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/scheduled-tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/schedule/tasks/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/schedule/:id', handleUpdatePlanScheduledTask);
|
||||
app.patch('/api/plans/schedules/:id', handleUpdatePlanScheduledTask);
|
||||
app.delete('/api/plan/scheduled-tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plan/schedule/tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plan/schedule/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plan/schedules/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/scheduled-tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/schedule/tasks/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/schedule/:id', handleDeletePlanScheduledTask);
|
||||
app.delete('/api/plans/schedules/:id', handleDeletePlanScheduledTask);
|
||||
|
||||
app.get('/api/plan/items', async (request, reply) => {
|
||||
const parsedQuery = listPlanQuerySchema.safeParse(request.query ?? {});
|
||||
|
||||
if (!parsedQuery.success) {
|
||||
return reply.code(400).send({
|
||||
message: '유효하지 않은 status 쿼리입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const query = parsedQuery.data;
|
||||
const items = await listPlanItems(query.status, {
|
||||
maskNote: !hasPlanAccess(request),
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id', async (request, reply) => {
|
||||
const hasAccess = hasPlanAccess(request);
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await getPlanItemById(id, {
|
||||
maskNote: !hasAccess,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item: row,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = createPlanSchema.parse(request.body ?? {});
|
||||
const createdRow = await createPlanItem(payload);
|
||||
const row = await getPlanItemById(Number(createdRow.id));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: row,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '작업 항목 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/plan/items/:id', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = updatePlanSchema.parse(request.body ?? {});
|
||||
const updatedRow = await updatePlanItem(id, payload);
|
||||
|
||||
if (!updatedRow) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const row = await getPlanItemById(id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: row,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '작업 항목 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/plan/items/:id/jangsing-processing', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = updatePlanJangsingProcessingSchema.parse(request.body ?? {});
|
||||
const updatedRow = await updatePlanItemJangsingProcessingRequired(id, payload.jangsingProcessingRequired);
|
||||
|
||||
if (!updatedRow) {
|
||||
return reply.code(404).send({
|
||||
message: '수정할 작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '기능동작확인 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/plan/items/:id', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
request.log.warn(
|
||||
{
|
||||
planItemId: id,
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Plan item delete requested',
|
||||
);
|
||||
const linkedBoardPost = await getBoardPostLinkedToPlanItem(id);
|
||||
|
||||
if (linkedBoardPost) {
|
||||
request.log.warn(
|
||||
{
|
||||
planItemId: id,
|
||||
boardPostId: linkedBoardPost.id,
|
||||
boardPostTitle: linkedBoardPost.title,
|
||||
trace: getRequestTraceContext(request),
|
||||
},
|
||||
'Plan item delete blocked because it is linked to a board post',
|
||||
);
|
||||
|
||||
return reply.code(409).send({
|
||||
message: `자동화 접수된 항목은 삭제할 수 없습니다. 연결 게시글 #${linkedBoardPost.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const row = await deletePlanItem(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/complete-development', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await markPlanAsDevelopmentComplete(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] release 반영 대기`,
|
||||
'수동 작업완료로 release 반영 대기 상태가 되었습니다.',
|
||||
'development-completed',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/complete', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = completeActionSchema.parse(request.body ?? {});
|
||||
const row = await markPlanAsCompleted(id, payload.note);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 완료 처리`,
|
||||
payload.note ?? '작업이 완료 처리되었습니다.',
|
||||
'plan-completed',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/start-work', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await markPlanAsStarted(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업시작`,
|
||||
'작업이 시작되었습니다.',
|
||||
'work-started',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/retry-branch', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await retryPlanBranch(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
'브랜치 재시도를 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/retry-work', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await retryPlanWork(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
'자동 작업 재처리를 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/retry-merge', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const row = await retryPlanMerge(id);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const planLabel = formatPlanNotificationLabel(String(row.work_id), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
row.worker_status === 'main반영대기' ? 'main 반영 재시도를 요청했습니다.' : 'release 반영 재시도를 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: await getPlanItemById(id),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/cancel-release', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const env = getEnv();
|
||||
const isReleaseMergeFailure = item.status === '작업완료' && item.workerStatus === 'release반영실패';
|
||||
|
||||
if (!isReleaseMergeFailure) {
|
||||
await recreateReleaseBranchFromMain(
|
||||
{
|
||||
repoPath: env.PLAN_GIT_REPO_PATH,
|
||||
releaseBranch: env.PLAN_RELEASE_BRANCH,
|
||||
mainBranch: env.PLAN_MAIN_BRANCH,
|
||||
},
|
||||
String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await cancelPlanRelease(id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result?.item ?? (await getPlanItemById(id)),
|
||||
message: result?.message,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : 'release 작업취소 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/request-main-merge', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const result = await requestPlanMainMerge(id);
|
||||
|
||||
if (!result) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.item,
|
||||
message: result.message,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/issues', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanIssueHistories(id);
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanIssueRow),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/issues/action', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = issueActionSchema.parse(request.body ?? {});
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await appendLatestIssueAction(id, payload.actionNote, payload.resolve);
|
||||
const retryResult = await queuePlanRetryFromIssueAction(id, payload.actionNote, payload.retry);
|
||||
|
||||
if (payload.resolve) {
|
||||
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 이슈 해결 처리`,
|
||||
`${row.issue_tag} 이슈가 해결 처리되었습니다.`,
|
||||
'issue-resolved',
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldNotifyPlanRestart(retryResult)) {
|
||||
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
retryResult?.message ?? '작업 재시작을 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanIssueRow(row),
|
||||
planItem: retryResult?.item ?? (await getPlanItemById(id)),
|
||||
message: retryResult?.message,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '이슈 조치 기록 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/actions', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanActionHistories(id);
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanActionRow),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/source-works', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const rows = await listPlanSourceWorkHistories(id);
|
||||
|
||||
return {
|
||||
items: rows.map(mapPlanSourceWorkRow),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/plan/items/:id/source-works/:sourceWorkId', async (request, reply) => {
|
||||
if (!hasPlanAccess(request)) {
|
||||
return reply.code(403).send({
|
||||
message: '상세 조회 권한이 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const params = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
sourceWorkId: z.coerce.number().int().positive(),
|
||||
}).parse(request.params);
|
||||
const row = await getPlanSourceWorkHistory(params.id, params.sourceWorkId);
|
||||
|
||||
if (!row) {
|
||||
return reply.code(404).send({
|
||||
message: '소스 작업 이력을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item: mapPlanSourceWorkRow(row),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/source-works', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = createSourceWorkSchema.parse(request.body ?? {});
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const row = await createPlanSourceWorkHistory(id, payload);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanSourceWorkRow(row),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/plan/items/:id/actions/note', async (request, reply) => {
|
||||
if (!requirePlanAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id);
|
||||
const payload = actionNoteSchema.parse(request.body ?? {});
|
||||
const item = await getPlanItemById(id);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '작업 항목을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!item.startedAt) {
|
||||
return reply.code(409).send({
|
||||
message: '작업시작 이후부터 조치 이력을 기록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const row = await createPlanActionHistory(id, payload.actionType ?? '추가조치', payload.actionNote);
|
||||
const releaseResumeResult = await resumePlanDevelopmentFromRelease(id, payload.actionNote);
|
||||
const retryResult = releaseResumeResult?.message
|
||||
? releaseResumeResult
|
||||
: await queuePlanRetryFromFailure(id, payload.actionNote);
|
||||
|
||||
if (shouldNotifyPlanRestart(retryResult)) {
|
||||
const planLabel = formatPlanNotificationLabel(String(item.workId), id);
|
||||
await notifyPlanEvent(
|
||||
id,
|
||||
`[${planLabel}] 작업 재시작`,
|
||||
retryResult?.message ?? '작업 재시작을 요청했습니다.',
|
||||
'plan-restarted',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: mapPlanActionRow(row),
|
||||
planItem: retryResult?.item ?? (await getPlanItemById(id)),
|
||||
message: retryResult?.message,
|
||||
};
|
||||
});
|
||||
}
|
||||
17
etc/servers/work-server/src/routes/schema.ts
Executable file
17
etc/servers/work-server/src/routes/schema.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export async function registerSchemaRoutes(app: FastifyInstance) {
|
||||
app.get('/api/schema/tables', async () => {
|
||||
const tables = await db('information_schema.tables')
|
||||
.select('table_name', 'table_schema')
|
||||
.where('table_type', 'BASE TABLE')
|
||||
.whereNotIn('table_schema', ['pg_catalog', 'information_schema'])
|
||||
.orderBy('table_schema')
|
||||
.orderBy('table_name');
|
||||
|
||||
return {
|
||||
items: tables,
|
||||
};
|
||||
});
|
||||
}
|
||||
54
etc/servers/work-server/src/routes/server-command.ts
Executable file
54
etc/servers/work-server/src/routes/server-command.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js';
|
||||
|
||||
const serverCommandParamSchema = z.object({
|
||||
key: z.enum(serverCommandKeys),
|
||||
});
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
reply.status(403);
|
||||
void reply.send({
|
||||
message: '권한 토큰이 필요합니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
app.get('/api/server-commands', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: await listServerCommands(),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/server-commands/:key/actions/restart', async (request, reply) => {
|
||||
if (!ensureAuthorized(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = serverCommandParamSchema.parse(request.params);
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item: result.server,
|
||||
commandOutput: result.commandOutput,
|
||||
restartState: result.restartState,
|
||||
};
|
||||
});
|
||||
}
|
||||
129
etc/servers/work-server/src/routes/visitor-history.ts
Executable file
129
etc/servers/work-server/src/routes/visitor-history.ts
Executable file
@@ -0,0 +1,129 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { hasErrorLogViewAccessToken } from '../services/error-log-service.js';
|
||||
import {
|
||||
ensureVisitorHistoryTables,
|
||||
getVisitorClientByClientId,
|
||||
listVisitorClients,
|
||||
listVisitorClientsQuerySchema,
|
||||
listVisitorHistories,
|
||||
trackVisit,
|
||||
trackVisitSchema,
|
||||
updateVisitorNickname,
|
||||
updateVisitorNicknameSchema,
|
||||
visitorHistoryQuerySchema,
|
||||
} from '../services/visitor-history-service.js';
|
||||
|
||||
export async function registerVisitorHistoryRoutes(app: FastifyInstance) {
|
||||
function requireHistoryAccessToken(
|
||||
request: { headers: Record<string, unknown> },
|
||||
reply: Parameters<FastifyInstance['get']>[1] extends (request: any, reply: infer T) => any ? T : any,
|
||||
) {
|
||||
if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void reply.code(403).send({
|
||||
message: '권한 토큰이 등록된 사용자만 방문 이력을 조회할 수 있습니다.',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
app.post('/api/history/setup', async () => {
|
||||
await ensureVisitorHistoryTables();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/history/track', async (request) => {
|
||||
const bodyPayload =
|
||||
request.body && typeof request.body === 'object'
|
||||
? (request.body as Record<string, unknown>)
|
||||
: {};
|
||||
const clientIdFromHeader = String(request.headers['x-client-id'] ?? '').trim();
|
||||
const payload = trackVisitSchema.parse({
|
||||
clientId: clientIdFromHeader || bodyPayload.clientId,
|
||||
url: bodyPayload.url,
|
||||
eventType: bodyPayload.eventType,
|
||||
userAgent:
|
||||
typeof bodyPayload.userAgent === 'string'
|
||||
? bodyPayload.userAgent
|
||||
: String(request.headers['user-agent'] ?? ''),
|
||||
});
|
||||
|
||||
const client = await trackVisit(payload, request.ip);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/history/visitors', async (request, reply) => {
|
||||
if (!requireHistoryAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = listVisitorClientsQuerySchema.parse(request.query ?? {});
|
||||
const items = await listVisitorClients(query.limit ?? 100, {
|
||||
search: query.search,
|
||||
clientId: query.clientId,
|
||||
nickname: query.nickname,
|
||||
path: query.path,
|
||||
visitedFrom: query.visitedFrom,
|
||||
visitedTo: query.visitedTo,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/history/visitors/:clientId', async (request, reply) => {
|
||||
if (!requireHistoryAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId);
|
||||
const query = visitorHistoryQuerySchema.parse(request.query ?? {});
|
||||
const client = await getVisitorClientByClientId(clientId);
|
||||
|
||||
if (!client) {
|
||||
return reply.code(404).send({
|
||||
message: '방문자를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const visits = await listVisitorHistories(clientId, query.limit ?? 200);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
visits,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/history/visitors/:clientId/nickname', async (request, reply) => {
|
||||
if (!requireHistoryAccessToken(request, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId);
|
||||
const payload = updateVisitorNicknameSchema.parse(request.body ?? {});
|
||||
const client = await updateVisitorNickname(clientId, payload.nickname);
|
||||
|
||||
if (!client) {
|
||||
return reply.code(404).send({
|
||||
message: '닉네임을 수정할 방문자를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
client,
|
||||
};
|
||||
});
|
||||
}
|
||||
47
etc/servers/work-server/src/server.ts
Executable file
47
etc/servers/work-server/src/server.ts
Executable file
@@ -0,0 +1,47 @@
|
||||
import { env } from './config/env.js';
|
||||
import { db } from './db/client.js';
|
||||
import { createApp } from './app.js';
|
||||
import { ChatService } from './services/chat-service.js';
|
||||
import { clearAllChatConversationJobStates } from './services/chat-room-service.js';
|
||||
import { shutdownNotificationProvider } from './services/notification-service.js';
|
||||
import { PlanWorker } from './workers/plan-worker.js';
|
||||
|
||||
const app = createApp();
|
||||
const planWorker = new PlanWorker(app.log);
|
||||
const chatService = new ChatService(app.log);
|
||||
app.server.on('upgrade', chatService.attachUpgradeHandler());
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await clearAllChatConversationJobStates();
|
||||
await app.listen({
|
||||
host: '0.0.0.0',
|
||||
port: env.PORT,
|
||||
});
|
||||
planWorker.start();
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
app.log.info(`Received ${signal}, closing server`);
|
||||
|
||||
await planWorker.stop();
|
||||
chatService.close();
|
||||
await app.close();
|
||||
await shutdownNotificationProvider();
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
void start();
|
||||
131
etc/servers/work-server/src/services/app-config-service.ts
Executable file
131
etc/servers/work-server/src/services/app-config-service.ts
Executable file
@@ -0,0 +1,131 @@
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const APP_CONFIG_TABLE = 'app_configs';
|
||||
|
||||
async function ensureAppConfigTable() {
|
||||
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(APP_CONFIG_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.jsonb('config_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['config_json', (table) => table.jsonb('config_json').notNullable().defaultTo('{}')],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(APP_CONFIG_TABLE, columnName);
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(APP_CONFIG_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppConfig() {
|
||||
await ensureAppConfigTable();
|
||||
|
||||
const row = await db(APP_CONFIG_TABLE).first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof row.config_json === 'string') {
|
||||
try {
|
||||
return JSON.parse(row.config_json) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return row.config_json ?? {};
|
||||
}
|
||||
|
||||
export type AppConfigSnapshot = {
|
||||
chat?: {
|
||||
maxContextMessages?: number;
|
||||
maxContextChars?: number;
|
||||
};
|
||||
automation?: {
|
||||
autoRefreshEnabled?: boolean;
|
||||
autoRefreshIntervalSeconds?: number;
|
||||
autoReceiveScheduleType?: 'interval' | 'daily' | 'weekly';
|
||||
autoReceiveIntervalSeconds?: number;
|
||||
autoReceiveDailyTime?: string;
|
||||
autoReceiveWeeklyDay?: 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
||||
autoReceiveWeeklyTime?: string;
|
||||
notifyOnAutomationStart?: boolean;
|
||||
notifyOnAutomationProgress?: boolean;
|
||||
notifyOnAutomationCompletion?: boolean;
|
||||
notifyOnAutomationRelease?: boolean;
|
||||
notifyOnAutomationMain?: boolean;
|
||||
notifyOnAutomationFailure?: boolean;
|
||||
notifyOnAutomationRestart?: boolean;
|
||||
notifyOnAutomationIssueResolved?: boolean;
|
||||
};
|
||||
worklogAutomation?: {
|
||||
autoCreateDailyWorklog?: boolean;
|
||||
dailyCreateTime?: string;
|
||||
repeatRequestEnabled?: boolean;
|
||||
repeatIntervalMinutes?: number;
|
||||
includeScreenshots?: boolean;
|
||||
includeChangedFiles?: boolean;
|
||||
includeCommandLogs?: boolean;
|
||||
template?: 'simple' | 'detailed';
|
||||
};
|
||||
};
|
||||
|
||||
export async function getAppConfigSnapshot(): Promise<AppConfigSnapshot> {
|
||||
const raw = await getAppConfig();
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const snapshot = raw as AppConfigSnapshot;
|
||||
|
||||
if (snapshot.worklogAutomation) {
|
||||
return {
|
||||
...snapshot,
|
||||
worklogAutomation: {
|
||||
...snapshot.worklogAutomation,
|
||||
repeatRequestEnabled: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function upsertAppConfig(config: Record<string, unknown>) {
|
||||
await ensureAppConfigTable();
|
||||
|
||||
const existing = await db(APP_CONFIG_TABLE).first();
|
||||
|
||||
if (!existing) {
|
||||
const rows = await db(APP_CONFIG_TABLE)
|
||||
.insert({
|
||||
config_json: config,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
return rows[0]?.config_json ?? config;
|
||||
}
|
||||
|
||||
const rows = await db(APP_CONFIG_TABLE)
|
||||
.update({
|
||||
config_json: config,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return rows[0]?.config_json ?? config;
|
||||
}
|
||||
23
etc/servers/work-server/src/services/board-service.test.ts
Normal file
23
etc/servers/work-server/src/services/board-service.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-service.js';
|
||||
|
||||
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n'),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
'- 게시판 제목: 알림 개선',
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
'본문 첫 줄\n본문 둘째 줄',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
|
||||
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
|
||||
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
||||
});
|
||||
347
etc/servers/work-server/src/services/board-service.ts
Executable file
347
etc/servers/work-server/src/services/board-service.ts
Executable file
@@ -0,0 +1,347 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
ensurePlanTable,
|
||||
normalizePlanAutomationType,
|
||||
PLAN_TABLE,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-service.js';
|
||||
|
||||
export const BOARD_POSTS_TABLE = 'board_posts';
|
||||
|
||||
export const boardPostPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
content: z.string().min(1).max(200000),
|
||||
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
||||
});
|
||||
|
||||
export type BoardPostItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
preview: string;
|
||||
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export class BoardPostAutomationLockedError extends Error {
|
||||
constructor(action: 'update' | 'delete') {
|
||||
super(
|
||||
action === 'delete'
|
||||
? '자동화 접수된 작업메모는 삭제할 수 없습니다.'
|
||||
: '자동화 접수된 작업메모는 수정할 수 없습니다.',
|
||||
);
|
||||
this.name = 'BoardPostAutomationLockedError';
|
||||
}
|
||||
}
|
||||
|
||||
function createPreview(content: string) {
|
||||
const normalized = content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
||||
const content = String(row.content ?? '');
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
content,
|
||||
preview: createPreview(content),
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
|
||||
? null
|
||||
: Number(row.automation_plan_item_id),
|
||||
automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined
|
||||
? null
|
||||
: String(row.automation_received_at),
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function isBoardPostAutomationLocked(row: Record<string, unknown>) {
|
||||
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(title: string, content: string) {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedContent = content.trim();
|
||||
|
||||
return [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
`- 게시판 제목: ${normalizedTitle}`,
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveInsertedId(result: unknown): number | null {
|
||||
if (typeof result === 'number' && Number.isInteger(result) && result > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const first = result[0];
|
||||
|
||||
if (typeof first === 'number' && Number.isInteger(first) && first > 0) {
|
||||
return first;
|
||||
}
|
||||
|
||||
if (first && typeof first === 'object' && 'id' in first) {
|
||||
const id = Number((first as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (result && typeof result === 'object' && 'id' in result) {
|
||||
const id = Number((result as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsReturning() {
|
||||
const clientName = String(db.client.config.client ?? '').toLowerCase();
|
||||
return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName);
|
||||
}
|
||||
|
||||
function isDuplicateSchemaError(error: unknown, codes: string[], patterns: RegExp[]) {
|
||||
const candidate = error as { code?: unknown; message?: unknown };
|
||||
const code = typeof candidate?.code === 'string' ? candidate.code : '';
|
||||
const message = typeof candidate?.message === 'string' ? candidate.message : '';
|
||||
|
||||
return codes.includes(code) || patterns.some((pattern) => pattern.test(message));
|
||||
}
|
||||
|
||||
function isDuplicateTableError(error: unknown) {
|
||||
return isDuplicateSchemaError(error, ['42P07'], [/already exists/i]);
|
||||
}
|
||||
|
||||
function isDuplicateColumnError(error: unknown) {
|
||||
return isDuplicateSchemaError(error, ['42701'], [/already exists/i, /duplicate column/i]);
|
||||
}
|
||||
|
||||
export async function ensureBoardPostsTable() {
|
||||
const hasTable = await db.schema.hasTable(BOARD_POSTS_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
try {
|
||||
await db.schema.createTable(BOARD_POSTS_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('title', 200).notNullable();
|
||||
table.text('content').notNullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isDuplicateTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['title', (table) => table.string('title', 200).notNullable().defaultTo('제목 없음')],
|
||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
||||
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(BOARD_POSTS_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
try {
|
||||
await db.schema.alterTable(BOARD_POSTS_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isDuplicateColumnError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db(BOARD_POSTS_TABLE)
|
||||
.where({ automation_type: 'plan_registration' })
|
||||
.update({ automation_type: 'plan' });
|
||||
await db(BOARD_POSTS_TABLE)
|
||||
.where({ automation_type: 'general_development' })
|
||||
.update({ automation_type: 'auto_worker' });
|
||||
}
|
||||
|
||||
export async function listBoardPosts() {
|
||||
await ensureBoardPostsTable();
|
||||
|
||||
const rows = await db(BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc');
|
||||
return rows.map((row) => mapBoardPostRow(row));
|
||||
}
|
||||
|
||||
export async function getBoardPost(id: number) {
|
||||
await ensureBoardPostsTable();
|
||||
|
||||
const row = await db(BOARD_POSTS_TABLE).where({ id }).first();
|
||||
return row ? mapBoardPostRow(row) : null;
|
||||
}
|
||||
|
||||
export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSchema>) {
|
||||
await ensureBoardPostsTable();
|
||||
const parsedPayload = boardPostPayloadSchema.parse(payload);
|
||||
const insertQuery = db(BOARD_POSTS_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery;
|
||||
|
||||
const insertedId = resolveInsertedId(insertResult);
|
||||
|
||||
if (!insertedId) {
|
||||
throw new Error('게시글 저장 후 ID를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const row = await db(BOARD_POSTS_TABLE).where({ id: insertedId }).first();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('저장된 게시글을 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return mapBoardPostRow(row);
|
||||
}
|
||||
|
||||
export async function receiveBoardPostAutomation(id: number) {
|
||||
await ensureBoardPostsTable();
|
||||
await ensurePlanTable();
|
||||
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
if (!currentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentRow.automation_received_at || currentRow.automation_plan_item_id) {
|
||||
return {
|
||||
item: mapBoardPostRow(currentRow),
|
||||
planItemId:
|
||||
currentRow.automation_plan_item_id === null || currentRow.automation_plan_item_id === undefined
|
||||
? null
|
||||
: Number(currentRow.automation_plan_item_id),
|
||||
alreadyReceived: true,
|
||||
};
|
||||
}
|
||||
|
||||
const title = String(currentRow.title ?? '').trim();
|
||||
const content = String(currentRow.content ?? '').trim();
|
||||
const workId = `board-post-${id}`;
|
||||
const insertQuery = trx(PLAN_TABLE).insert({
|
||||
work_id: workId,
|
||||
note: buildBoardPostPlanNote(title, content),
|
||||
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
||||
status: '등록',
|
||||
release_target: 'release',
|
||||
jangsing_processing_required: true,
|
||||
auto_deploy_to_main: false,
|
||||
worker_status: '대기',
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery;
|
||||
const planItemId = resolveInsertedId(insertResult);
|
||||
|
||||
if (!planItemId) {
|
||||
throw new Error('자동화 접수 후 Plan ID를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const updateQuery = trx(BOARD_POSTS_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
automation_plan_item_id: planItemId,
|
||||
automation_received_at: trx.fn.now(),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
const updatedRows = supportsReturning() ? await updateQuery.returning('*') : [];
|
||||
if (!supportsReturning()) {
|
||||
await updateQuery;
|
||||
}
|
||||
|
||||
const updatedRow = updatedRows[0] ?? (await trx(BOARD_POSTS_TABLE).where({ id }).first());
|
||||
|
||||
if (!updatedRow) {
|
||||
throw new Error('자동화 접수된 게시글을 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
item: mapBoardPostRow(updatedRow),
|
||||
planItemId,
|
||||
alreadyReceived: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateBoardPost(id: number, payload: z.infer<typeof boardPostPayloadSchema>) {
|
||||
await ensureBoardPostsTable();
|
||||
const parsedPayload = boardPostPayloadSchema.parse(payload);
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
if (!currentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isBoardPostAutomationLocked(currentRow)) {
|
||||
throw new BoardPostAutomationLockedError('update');
|
||||
}
|
||||
|
||||
await trx(BOARD_POSTS_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
automation_type: parsedPayload.automationType,
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
const row = await trx(BOARD_POSTS_TABLE).where({ id }).first();
|
||||
return row ? mapBoardPostRow(row) : null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBoardPost(id: number) {
|
||||
await ensureBoardPostsTable();
|
||||
return db.transaction(async (trx) => {
|
||||
const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first();
|
||||
|
||||
if (!currentRow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBoardPostAutomationLocked(currentRow)) {
|
||||
throw new BoardPostAutomationLockedError('delete');
|
||||
}
|
||||
|
||||
const deletedCount = await trx(BOARD_POSTS_TABLE).where({ id }).del();
|
||||
return deletedCount > 0;
|
||||
});
|
||||
}
|
||||
211
etc/servers/work-server/src/services/chat-room-service.test.ts
Normal file
211
etc/servers/work-server/src/services/chat-room-service.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildChatConversationRequestPatchFromMessage,
|
||||
mergeChatConversationRequestStatus,
|
||||
shouldClearConversationJobState,
|
||||
selectChatConversationResponseCandidate,
|
||||
} from './chat-room-service.js';
|
||||
|
||||
test('mergeChatConversationRequestStatus keeps terminal states from being downgraded', () => {
|
||||
assert.equal(mergeChatConversationRequestStatus('completed', 'accepted'), 'completed');
|
||||
assert.equal(mergeChatConversationRequestStatus('started', 'accepted'), 'started');
|
||||
assert.equal(mergeChatConversationRequestStatus('queued', 'accepted'), 'queued');
|
||||
assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed');
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||
assert.equal(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
id: 10,
|
||||
author: 'system',
|
||||
text: '요청 실행 중입니다.',
|
||||
clientRequestId: 'chat-req-1',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
id: 11,
|
||||
author: 'user',
|
||||
text: '질문',
|
||||
clientRequestId: 'chat-req-2',
|
||||
}),
|
||||
{
|
||||
requestId: 'chat-req-2',
|
||||
status: 'accepted',
|
||||
userMessageId: 11,
|
||||
userText: '질문',
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
id: 12,
|
||||
author: 'codex',
|
||||
text: '답변',
|
||||
clientRequestId: 'chat-req-2',
|
||||
}),
|
||||
{
|
||||
requestId: 'chat-req-2',
|
||||
status: 'started',
|
||||
responseMessageId: 12,
|
||||
responseText: '답변',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('selectChatConversationResponseCandidate falls back to codex replies in the request window', () => {
|
||||
const candidate = selectChatConversationResponseCandidate(
|
||||
{
|
||||
requestId: 'chat-req-3',
|
||||
createdAt: '2026-04-18T14:00:00.000Z',
|
||||
responseMessageId: null,
|
||||
},
|
||||
{
|
||||
createdAt: '2026-04-18T14:10:00.000Z',
|
||||
},
|
||||
[
|
||||
{
|
||||
id: 1,
|
||||
messageId: 1001,
|
||||
author: 'codex',
|
||||
text: '이전 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T13:59:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
messageId: 1002,
|
||||
author: 'codex',
|
||||
text: '현재 요청 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T14:05:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
messageId: 1003,
|
||||
author: 'codex',
|
||||
text: '다음 요청 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T14:11:00.000Z',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(candidate, {
|
||||
id: 2,
|
||||
messageId: 1002,
|
||||
author: 'codex',
|
||||
text: '현재 요청 답변',
|
||||
clientRequestId: null,
|
||||
createdAt: '2026-04-18T14:05:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears stale job state when terminal request already has a response', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-4',
|
||||
currentJobStatus: 'started',
|
||||
request: {
|
||||
requestId: 'chat-req-4',
|
||||
status: 'completed',
|
||||
responseMessageId: 101,
|
||||
responseText: '답변',
|
||||
terminalAt: '2026-04-19T01:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears orphaned job state without a current request id', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: null,
|
||||
currentJobStatus: 'started',
|
||||
request: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState keeps active job state when request is still running without a response', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-5',
|
||||
currentJobStatus: 'started',
|
||||
request: {
|
||||
requestId: 'chat-req-5',
|
||||
status: 'started',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
terminalAt: null,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState does not clear placeholder-only started responses', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-6',
|
||||
currentJobStatus: 'started',
|
||||
request: {
|
||||
requestId: 'chat-req-6',
|
||||
status: 'started',
|
||||
responseMessageId: 301,
|
||||
responseText: '응답을 준비하고 있습니다...',
|
||||
terminalAt: null,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState clears stale placeholder-only started responses when runtime is gone', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-7',
|
||||
currentJobStatus: 'started',
|
||||
currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z',
|
||||
runtimeActive: false,
|
||||
nowMs: Date.parse('2026-04-19T08:11:36.000Z'),
|
||||
request: {
|
||||
requestId: 'chat-req-7',
|
||||
status: 'started',
|
||||
responseMessageId: 302,
|
||||
responseText: '응답을 준비하고 있습니다...',
|
||||
terminalAt: null,
|
||||
updatedAt: '2026-04-19T08:08:56.086Z',
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldClearConversationJobState keeps placeholder-only started responses while runtime is active', () => {
|
||||
assert.equal(
|
||||
shouldClearConversationJobState({
|
||||
currentRequestId: 'chat-req-8',
|
||||
currentJobStatus: 'started',
|
||||
currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z',
|
||||
runtimeActive: true,
|
||||
nowMs: Date.parse('2026-04-19T08:11:36.000Z'),
|
||||
request: {
|
||||
requestId: 'chat-req-8',
|
||||
status: 'started',
|
||||
responseMessageId: 303,
|
||||
responseText: '응답을 준비하고 있습니다...',
|
||||
terminalAt: null,
|
||||
updatedAt: '2026-04-19T08:08:56.086Z',
|
||||
},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
1967
etc/servers/work-server/src/services/chat-room-service.ts
Normal file
1967
etc/servers/work-server/src/services/chat-room-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
392
etc/servers/work-server/src/services/chat-runtime-service.ts
Executable file
392
etc/servers/work-server/src/services/chat-runtime-service.ts
Executable file
@@ -0,0 +1,392 @@
|
||||
type ChatRuntimeJobMode = 'queue' | 'direct';
|
||||
type ChatRuntimeLifecycleStatus = 'queued' | 'running';
|
||||
type ChatRuntimeTerminalStatus = 'completed' | 'failed' | 'cancelled' | 'removed';
|
||||
|
||||
type RuntimeJobControl = {
|
||||
cancel?: () => Promise<boolean> | boolean;
|
||||
remove?: () => Promise<boolean> | boolean;
|
||||
};
|
||||
|
||||
type RuntimeJobRecord = ChatRuntimeJobItem & {
|
||||
logs: string[];
|
||||
lastUpdatedAt: string;
|
||||
terminalStatus: ChatRuntimeTerminalStatus | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeJobItem = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
mode: ChatRuntimeJobMode;
|
||||
status: ChatRuntimeLifecycleStatus;
|
||||
summary: string;
|
||||
enqueuedAt: string;
|
||||
startedAt: string | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSessionSummary = {
|
||||
sessionId: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
latestRequestId: string | null;
|
||||
latestStatus: ChatRuntimeLifecycleStatus | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSnapshot = {
|
||||
generatedAt: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
sessionCount: number;
|
||||
running: ChatRuntimeJobItem[];
|
||||
queued: ChatRuntimeJobItem[];
|
||||
sessions: ChatRuntimeSessionSummary[];
|
||||
recent: Array<ChatRuntimeJobItem & { terminalStatus: ChatRuntimeTerminalStatus; lastUpdatedAt: string }>;
|
||||
};
|
||||
|
||||
export type ChatRuntimeJobDetail = {
|
||||
item: ChatRuntimeJobItem | null;
|
||||
logs: string[];
|
||||
lastUpdatedAt: string | null;
|
||||
terminalStatus: ChatRuntimeTerminalStatus | null;
|
||||
availableActions: {
|
||||
cancel: boolean;
|
||||
remove: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type RuntimeSubscriber = (snapshot: ChatRuntimeSnapshot) => void;
|
||||
|
||||
const MAX_LOG_LINES = 80;
|
||||
const MAX_ARCHIVED_JOBS = 40;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function summarizeText(text: string) {
|
||||
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 120 ? `${normalized.slice(0, 117).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function normalizeLogLine(line: string) {
|
||||
return String(line ?? '').replace(/\r/g, '').trimEnd();
|
||||
}
|
||||
|
||||
class ChatRuntimeService {
|
||||
private readonly queuedJobs = new Map<string, RuntimeJobRecord>();
|
||||
private readonly runningJobs = new Map<string, RuntimeJobRecord>();
|
||||
private readonly archivedJobs = new Map<string, RuntimeJobRecord>();
|
||||
private readonly controls = new Map<string, RuntimeJobControl>();
|
||||
private readonly subscribers = new Set<RuntimeSubscriber>();
|
||||
|
||||
subscribe(listener: RuntimeSubscriber) {
|
||||
this.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(): ChatRuntimeSnapshot {
|
||||
const running = [...this.runningJobs.values()].sort((left, right) =>
|
||||
(left.startedAt ?? left.enqueuedAt).localeCompare(right.startedAt ?? right.enqueuedAt),
|
||||
);
|
||||
const queued = [...this.queuedJobs.values()].sort((left, right) => left.enqueuedAt.localeCompare(right.enqueuedAt));
|
||||
const sessionMap = new Map<string, ChatRuntimeSessionSummary>();
|
||||
|
||||
for (const item of [...running, ...queued]) {
|
||||
const current = sessionMap.get(item.sessionId) ?? {
|
||||
sessionId: item.sessionId,
|
||||
runningCount: 0,
|
||||
queuedCount: 0,
|
||||
latestRequestId: null,
|
||||
latestStatus: null,
|
||||
};
|
||||
|
||||
if (item.status === 'running') {
|
||||
current.runningCount += 1;
|
||||
} else {
|
||||
current.queuedCount += 1;
|
||||
}
|
||||
|
||||
current.latestRequestId = item.requestId;
|
||||
current.latestStatus = item.status;
|
||||
sessionMap.set(item.sessionId, current);
|
||||
}
|
||||
|
||||
const sessions = [...sessionMap.values()].sort((left, right) => {
|
||||
const loadDiff = right.runningCount + right.queuedCount - (left.runningCount + left.queuedCount);
|
||||
return loadDiff !== 0 ? loadDiff : left.sessionId.localeCompare(right.sessionId);
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: nowIso(),
|
||||
runningCount: running.length,
|
||||
queuedCount: queued.length,
|
||||
sessionCount: sessions.length,
|
||||
running: running.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item),
|
||||
queued: queued.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item),
|
||||
sessions,
|
||||
recent: [...this.archivedJobs.values()]
|
||||
.sort((left, right) => right.lastUpdatedAt.localeCompare(left.lastUpdatedAt))
|
||||
.slice(0, 12)
|
||||
.map(({ logs: _logs, ...item }) => ({
|
||||
...item,
|
||||
terminalStatus: item.terminalStatus ?? 'completed',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
getJobDetail(requestId: string): ChatRuntimeJobDetail {
|
||||
const current =
|
||||
this.runningJobs.get(requestId) ??
|
||||
this.queuedJobs.get(requestId) ??
|
||||
this.archivedJobs.get(requestId) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
item: current
|
||||
? {
|
||||
requestId: current.requestId,
|
||||
sessionId: current.sessionId,
|
||||
mode: current.mode,
|
||||
status: current.status,
|
||||
summary: current.summary,
|
||||
enqueuedAt: current.enqueuedAt,
|
||||
startedAt: current.startedAt,
|
||||
pid: current.pid,
|
||||
}
|
||||
: null,
|
||||
logs: current?.logs ?? [],
|
||||
lastUpdatedAt: current?.lastUpdatedAt ?? null,
|
||||
terminalStatus: current?.terminalStatus ?? null,
|
||||
availableActions: {
|
||||
cancel: this.runningJobs.has(requestId) && this.controls.has(requestId),
|
||||
remove: this.queuedJobs.has(requestId) && this.controls.has(requestId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerQueuedControl(requestId: string, control: RuntimeJobControl) {
|
||||
this.controls.set(requestId, control);
|
||||
}
|
||||
|
||||
registerRunningControl(requestId: string, control: RuntimeJobControl) {
|
||||
this.controls.set(requestId, control);
|
||||
}
|
||||
|
||||
enqueueJob(args: {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
mode: ChatRuntimeJobMode;
|
||||
text: string;
|
||||
}) {
|
||||
const existingRunning = this.runningJobs.get(args.requestId);
|
||||
|
||||
if (existingRunning) {
|
||||
return existingRunning;
|
||||
}
|
||||
|
||||
const item: RuntimeJobRecord = {
|
||||
requestId: args.requestId,
|
||||
sessionId: args.sessionId,
|
||||
mode: args.mode,
|
||||
status: 'queued',
|
||||
summary: summarizeText(args.text),
|
||||
enqueuedAt: nowIso(),
|
||||
startedAt: null,
|
||||
pid: null,
|
||||
logs: ['큐에 등록되었습니다.'],
|
||||
lastUpdatedAt: nowIso(),
|
||||
terminalStatus: null,
|
||||
};
|
||||
|
||||
this.archivedJobs.delete(args.requestId);
|
||||
this.queuedJobs.set(args.requestId, item);
|
||||
this.emit();
|
||||
return item;
|
||||
}
|
||||
|
||||
startJob(args: {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
mode: ChatRuntimeJobMode;
|
||||
text: string;
|
||||
pid?: number | null;
|
||||
}) {
|
||||
const queuedItem = this.queuedJobs.get(args.requestId);
|
||||
const runningItem: RuntimeJobRecord = {
|
||||
requestId: args.requestId,
|
||||
sessionId: args.sessionId,
|
||||
mode: args.mode,
|
||||
status: 'running',
|
||||
summary: summarizeText(args.text),
|
||||
enqueuedAt: queuedItem?.enqueuedAt ?? nowIso(),
|
||||
startedAt: nowIso(),
|
||||
pid: args.pid == null ? null : Math.round(args.pid),
|
||||
logs: queuedItem?.logs ?? [],
|
||||
lastUpdatedAt: nowIso(),
|
||||
terminalStatus: null,
|
||||
};
|
||||
|
||||
runningItem.logs = [...runningItem.logs, '실행이 시작되었습니다.'].slice(-MAX_LOG_LINES);
|
||||
|
||||
this.queuedJobs.delete(args.requestId);
|
||||
this.archivedJobs.delete(args.requestId);
|
||||
this.runningJobs.set(args.requestId, runningItem);
|
||||
this.emit();
|
||||
return runningItem;
|
||||
}
|
||||
|
||||
attachProcess(requestId: string, pid?: number | null) {
|
||||
const current = this.runningJobs.get(requestId);
|
||||
|
||||
if (!current || pid == null) {
|
||||
return current ?? null;
|
||||
}
|
||||
|
||||
const next: RuntimeJobRecord = {
|
||||
...current,
|
||||
pid: Math.round(pid),
|
||||
lastUpdatedAt: nowIso(),
|
||||
logs: [...current.logs, `프로세스가 연결되었습니다. pid=${Math.round(pid)}`].slice(-MAX_LOG_LINES),
|
||||
};
|
||||
|
||||
this.runningJobs.set(requestId, next);
|
||||
this.emit();
|
||||
return next;
|
||||
}
|
||||
|
||||
appendLog(requestId: string, line: string) {
|
||||
const normalizedLine = normalizeLogLine(line);
|
||||
|
||||
if (!normalizedLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.runningJobs.get(requestId) ?? this.queuedJobs.get(requestId) ?? this.archivedJobs.get(requestId);
|
||||
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next: RuntimeJobRecord = {
|
||||
...current,
|
||||
logs: [...current.logs, normalizedLine].slice(-MAX_LOG_LINES),
|
||||
lastUpdatedAt: nowIso(),
|
||||
};
|
||||
|
||||
if (this.runningJobs.has(requestId)) {
|
||||
this.runningJobs.set(requestId, next);
|
||||
} else if (this.queuedJobs.has(requestId)) {
|
||||
this.queuedJobs.set(requestId, next);
|
||||
} else {
|
||||
this.archivedJobs.set(requestId, next);
|
||||
}
|
||||
|
||||
this.emit();
|
||||
}
|
||||
|
||||
async cancelJob(requestId: string) {
|
||||
const control = this.controls.get(requestId);
|
||||
|
||||
if (!this.runningJobs.has(requestId) || !control?.cancel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await control.cancel();
|
||||
return result === true;
|
||||
}
|
||||
|
||||
async removeQueuedJob(requestId: string) {
|
||||
const control = this.controls.get(requestId);
|
||||
|
||||
if (!this.queuedJobs.has(requestId) || !control?.remove) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await control.remove();
|
||||
return result === true;
|
||||
}
|
||||
|
||||
finishJob(requestId: string, terminalStatus: ChatRuntimeTerminalStatus = 'completed') {
|
||||
const removedRunning = this.runningJobs.get(requestId);
|
||||
const removedQueued = this.queuedJobs.get(requestId);
|
||||
const removed = removedRunning ?? removedQueued ?? null;
|
||||
|
||||
this.runningJobs.delete(requestId);
|
||||
this.queuedJobs.delete(requestId);
|
||||
this.controls.delete(requestId);
|
||||
|
||||
if (!removed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archived: RuntimeJobRecord = {
|
||||
...removed,
|
||||
lastUpdatedAt: nowIso(),
|
||||
terminalStatus,
|
||||
logs: [...removed.logs, this.buildTerminalLog(terminalStatus)].slice(-MAX_LOG_LINES),
|
||||
};
|
||||
|
||||
this.archivedJobs.delete(requestId);
|
||||
this.archivedJobs.set(requestId, archived);
|
||||
this.trimArchivedJobs();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
if (
|
||||
this.runningJobs.size === 0 &&
|
||||
this.queuedJobs.size === 0 &&
|
||||
this.archivedJobs.size === 0 &&
|
||||
this.controls.size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runningJobs.clear();
|
||||
this.queuedJobs.clear();
|
||||
this.archivedJobs.clear();
|
||||
this.controls.clear();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private buildTerminalLog(status: ChatRuntimeTerminalStatus) {
|
||||
if (status === 'completed') {
|
||||
return '실행이 완료되었습니다.';
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return '실행이 실패로 종료되었습니다.';
|
||||
}
|
||||
|
||||
if (status === 'cancelled') {
|
||||
return '실행이 강제 취소되었습니다.';
|
||||
}
|
||||
|
||||
return '대기열에서 제거되었습니다.';
|
||||
}
|
||||
|
||||
private trimArchivedJobs() {
|
||||
while (this.archivedJobs.size > MAX_ARCHIVED_JOBS) {
|
||||
const firstKey = this.archivedJobs.keys().next().value;
|
||||
|
||||
if (!firstKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.archivedJobs.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
private emit() {
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
this.subscribers.forEach((listener) => {
|
||||
listener(snapshot);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const chatRuntimeService = new ChatRuntimeService();
|
||||
260
etc/servers/work-server/src/services/chat-service.test.ts
Normal file
260
etc/servers/work-server/src/services/chat-service.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
collectOfflineNotificationClientIds,
|
||||
createActivityLogMessage,
|
||||
extractDiffCodeBlocks,
|
||||
fitActivityLogLines,
|
||||
isAutomationRegistrationCountRequest,
|
||||
resolveResponseTimestamp,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
shouldUseAgenticCodexReply,
|
||||
shouldUseTemplateMacroReply,
|
||||
validateAgenticCodexRuntime,
|
||||
} from './chat-service.js';
|
||||
|
||||
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
|
||||
assert.deepEqual(
|
||||
collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
['client-b', 'client-a', 'client-c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => {
|
||||
assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('자동화 등록 기준이 뭐야'), false);
|
||||
});
|
||||
|
||||
test('shouldUseAgenticCodexReply routes read and modify style requests to real Codex execution', () => {
|
||||
assert.equal(shouldUseAgenticCodexReply('src/app/main/MainChatPanel.tsx 읽어서 구조 설명해줘'), true);
|
||||
assert.equal(shouldUseAgenticCodexReply('DB 직접 조회해서 오늘 오류 건수 확인해줘'), true);
|
||||
assert.equal(shouldUseAgenticCodexReply('MainChatPanel.hotfix.css 수정해줘'), true);
|
||||
});
|
||||
|
||||
test('shouldUseAgenticCodexReply keeps fast-path responses for automation registration count questions', () => {
|
||||
assert.equal(shouldUseAgenticCodexReply('오늘 자동화 등록 총 건수'), false);
|
||||
});
|
||||
|
||||
test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => {
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
},
|
||||
'이 템플릿 예시 보여줘',
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API 요청 템플릿',
|
||||
chatTypeDescription: 'API 요청 본문을 정리하는 템플릿',
|
||||
chatTypeIsTemplate: true,
|
||||
},
|
||||
'아이패드 말풍선 폰트 조금 줄여줘',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldUseTemplateMacroReply(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청',
|
||||
chatTypeIsTemplate: false,
|
||||
},
|
||||
'템플릿 예시 보여줘',
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
|
||||
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
|
||||
assert.deepEqual(fitActivityLogLines(lines), lines);
|
||||
});
|
||||
|
||||
test('fitActivityLogLines keeps full activity history when it is within the configured limits', () => {
|
||||
const lines = Array.from({ length: 80 }, (_, index) => `# 진행: step ${index + 1}`);
|
||||
const fitted = fitActivityLogLines(lines);
|
||||
|
||||
assert.equal(fitted.length, 80);
|
||||
assert.equal(fitted[0], '# 진행: step 1');
|
||||
assert.equal(fitted.at(-1), '# 진행: step 80');
|
||||
});
|
||||
|
||||
test('createActivityLogMessage keeps fitted activity history instead of the latest line only', () => {
|
||||
const lines = ['# 상태: 요청을 처리합니다.', '# 진행: 분석 중입니다.', '# 상태: 응답 생성이 완료되었습니다.'];
|
||||
const message = createActivityLogMessage('req-activity', lines);
|
||||
|
||||
assert.ok(message);
|
||||
assert.equal(
|
||||
message?.text,
|
||||
'[[activity-log]]\n# 상태: 요청을 처리합니다.\n\n# 진행: 분석 중입니다.\n\n# 상태: 응답 생성이 완료되었습니다.',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp keeps the real time when reply is already later', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 5)), '2026-04-16 09:00:05');
|
||||
});
|
||||
|
||||
test('extractDiffCodeBlocks collects fenced diff bodies', () => {
|
||||
const output = ['설명', '', '```diff', 'diff --git a/a.ts b/a.ts', '+hello', '```', '', '마무리'].join('\n');
|
||||
|
||||
assert.deepEqual(extractDiffCodeBlocks(output), ['diff --git a/a.ts b/a.ts\n+hello']);
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
await mkdir(path.join(repoPath, 'public'), { recursive: true });
|
||||
|
||||
const output = ['변경사항입니다.', '', '```diff', 'diff --git a/src/a.ts b/src/a.ts', '+hello', '```'].join('\n');
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
const expectedUrl = '/api/chat/resources/.codex_chat/chat-room/resource/_generated/response.diff';
|
||||
const savedDiffPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'_generated',
|
||||
'response.diff',
|
||||
);
|
||||
|
||||
assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
||||
assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx');
|
||||
const stagedPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'src',
|
||||
'app',
|
||||
'main',
|
||||
'MainChatPanel.tsx',
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await mkdir(path.dirname(stagedPath), { recursive: true });
|
||||
await writeFile(originalPath, 'export const value = 1;\n', 'utf8');
|
||||
await writeFile(stagedPath, 'export const value = 1;\n', 'utf8');
|
||||
|
||||
const output =
|
||||
'리소스 경로는 public/.codex_chat/chat-room/resource/src/app/main/MainChatPanel.tsx 입니다.';
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/app\/main\/MainChatPanel\.tsx/);
|
||||
assert.doesNotMatch(rewritten, /resource\/public\/\.codex_chat/);
|
||||
assert.equal(await readFile(stagedPath, 'utf8'), 'export const value = 1;\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources stages repo root files linked with a leading slash', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'docker-compose.yml');
|
||||
const stagedPath = path.join(
|
||||
repoPath,
|
||||
'public',
|
||||
'.codex_chat',
|
||||
'chat-room',
|
||||
'resource',
|
||||
'docker-compose.yml',
|
||||
);
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await writeFile(originalPath, 'services:\n app:\n image: node:22\n', 'utf8');
|
||||
|
||||
const output = '파일은 /docker-compose.yml 에 있습니다.';
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/docker-compose\.yml/);
|
||||
assert.equal(await readFile(stagedPath, 'utf8'), 'services:\n app:\n image: node:22\n');
|
||||
});
|
||||
|
||||
test('rewriteCodexOutputWithChatResources prefers absolute path replacements before nested relative paths', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-'));
|
||||
const originalPath = path.join(repoPath, 'etc', 'servers', 'work-server', 'package.json');
|
||||
|
||||
await mkdir(path.dirname(originalPath), { recursive: true });
|
||||
await mkdir(path.join(repoPath, 'public'), { recursive: true });
|
||||
await writeFile(originalPath, '{\n "name": "work-server"\n}\n', 'utf8');
|
||||
|
||||
const output =
|
||||
'변경 파일: [/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/package.json](' +
|
||||
`${originalPath})`;
|
||||
const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room');
|
||||
|
||||
assert.match(
|
||||
rewritten,
|
||||
/\[\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\]\(\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\)/,
|
||||
);
|
||||
assert.doesNotMatch(rewritten, /\/home\/.+\/api\/chat\/resources/);
|
||||
});
|
||||
|
||||
test('validateAgenticCodexRuntime explains missing runtime paths clearly', async () => {
|
||||
const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health';
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error('connect ECONNREFUSED');
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
validateAgenticCodexRuntime('/tmp/chat-missing-repo-path', '/tmp/chat-missing-codex-bin'),
|
||||
/채팅 실행 환경이 준비되지 않았습니다\..*PLAN_MAIN_PROJECT_REPO_PATH.*SERVER_COMMAND_RUNNER_URL/s,
|
||||
);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('validateAgenticCodexRuntime accepts reachable command-runner api', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-repo-'));
|
||||
const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL;
|
||||
const originalFetch = globalThis.fetch;
|
||||
env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health';
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await assert.doesNotReject(validateAgenticCodexRuntime(repoPath, 'codex'));
|
||||
} finally {
|
||||
env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
3274
etc/servers/work-server/src/services/chat-service.ts
Normal file
3274
etc/servers/work-server/src/services/chat-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
460
etc/servers/work-server/src/services/error-log-plan-registration-service.ts
Executable file
460
etc/servers/work-server/src/services/error-log-plan-registration-service.ts
Executable file
@@ -0,0 +1,460 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/client.js';
|
||||
import { listBoardPosts, createBoardPost } from './board-service.js';
|
||||
import { listErrorLogs } from './error-log-service.js';
|
||||
import { ensurePlanTable, PLAN_TABLE } from './plan-service.js';
|
||||
|
||||
const DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT = 6;
|
||||
const ERROR_LOG_BOARD_POST_MARKER_PREFIX = '<!-- error-log-plan-work-id:';
|
||||
|
||||
type ErrorLogCandidate = {
|
||||
fingerprint: string;
|
||||
workId: string;
|
||||
source: string;
|
||||
sourceLabel: string | null;
|
||||
errorType: string;
|
||||
errorName: string | null;
|
||||
errorMessage: string;
|
||||
requestPath: string | null;
|
||||
requestPathGroup: string;
|
||||
requestPaths: string[];
|
||||
statusCode: number | null;
|
||||
count: number;
|
||||
firstCreatedAt: unknown;
|
||||
lastCreatedAt: unknown;
|
||||
sampleLogId: number | null;
|
||||
sampleLogIds: number[];
|
||||
errorNames: string[];
|
||||
representativeMessages: string[];
|
||||
groupedScopes?: string[];
|
||||
groupedCandidateCount?: number;
|
||||
};
|
||||
|
||||
type ErrorLogRegistrationSkip = {
|
||||
workId: string;
|
||||
reason: string;
|
||||
boardPostId?: number;
|
||||
planId?: number;
|
||||
};
|
||||
|
||||
type ErrorLogBoardPostRegistration = {
|
||||
postId: number;
|
||||
title: string;
|
||||
workId: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
function normalizeDateBoundary(value: unknown) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatIsoTimestamp(value: unknown) {
|
||||
const date = normalizeDateBoundary(value);
|
||||
return date ? date.toISOString() : String(value ?? '');
|
||||
}
|
||||
|
||||
function normalizeRequestPathGroup(requestPath: unknown) {
|
||||
const normalized = String(requestPath ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\?.*$/u, '')
|
||||
.replace(/\/\d+(?=\/|$)/gu, '/:id')
|
||||
.replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id');
|
||||
|
||||
if (!normalized) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const segments = normalized
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
|
||||
return segments.length > 0 ? `/${segments.join('/')}` : normalized;
|
||||
}
|
||||
|
||||
function buildErrorLogPlanFingerprint(log: Record<string, unknown>) {
|
||||
return createHash('sha1')
|
||||
.update(
|
||||
[
|
||||
String(log.source ?? '').trim().toLowerCase(),
|
||||
String(log.errorType ?? '').trim().toLowerCase(),
|
||||
log.statusCode == null ? '' : String(log.statusCode),
|
||||
].join('||'),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function buildErrorLogPlanWorkId(log: Record<string, unknown>) {
|
||||
return `error-fix-${buildErrorLogPlanFingerprint(log)}`;
|
||||
}
|
||||
|
||||
function buildErrorLogPlanCandidates(logs: Array<Record<string, unknown>>) {
|
||||
const grouped = new Map<string, any>();
|
||||
|
||||
for (const log of logs) {
|
||||
const fingerprint = createHash('sha1')
|
||||
.update(
|
||||
[
|
||||
String(log.source ?? '').trim().toLowerCase(),
|
||||
String(log.errorType ?? '').trim().toLowerCase(),
|
||||
String(log.errorName ?? '').trim().toLowerCase(),
|
||||
log.statusCode == null ? '' : String(log.statusCode),
|
||||
normalizeRequestPathGroup(log.requestPath),
|
||||
].join('||'),
|
||||
)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
|
||||
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime() ?? 0;
|
||||
const existing = grouped.get(fingerprint);
|
||||
const requestPathGroup = normalizeRequestPathGroup(log.requestPath);
|
||||
const errorMessage = String(log.errorMessage ?? '').trim();
|
||||
|
||||
if (!existing) {
|
||||
grouped.set(fingerprint, {
|
||||
sample: log,
|
||||
count: 1,
|
||||
firstCreatedAt: log.createdAt,
|
||||
firstTimeMs: createdAtMs,
|
||||
lastCreatedAt: log.createdAt,
|
||||
lastTimeMs: createdAtMs,
|
||||
requestPathGroup,
|
||||
requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(),
|
||||
errorMessages: errorMessage ? new Set([errorMessage]) : new Set(),
|
||||
errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(),
|
||||
sampleLogIds: log.id != null ? [Number(log.id)] : [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
|
||||
if (log.requestPath) {
|
||||
existing.requestPaths.add(String(log.requestPath).trim());
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
existing.errorMessages.add(errorMessage);
|
||||
}
|
||||
|
||||
if (log.errorName) {
|
||||
existing.errorNames.add(String(log.errorName).trim());
|
||||
}
|
||||
|
||||
if (log.id != null && existing.sampleLogIds.length < 5) {
|
||||
existing.sampleLogIds.push(Number(log.id));
|
||||
}
|
||||
|
||||
if (createdAtMs <= existing.firstTimeMs) {
|
||||
existing.firstCreatedAt = log.createdAt;
|
||||
existing.firstTimeMs = createdAtMs;
|
||||
}
|
||||
|
||||
if (createdAtMs >= existing.lastTimeMs) {
|
||||
existing.sample = log;
|
||||
existing.lastCreatedAt = log.createdAt;
|
||||
existing.lastTimeMs = createdAtMs;
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([fingerprint, entry]) => ({
|
||||
fingerprint,
|
||||
workId: buildErrorLogPlanWorkId(entry.sample),
|
||||
source: String(entry.sample.source ?? ''),
|
||||
sourceLabel: entry.sample.sourceLabel ? String(entry.sample.sourceLabel) : null,
|
||||
errorType: String(entry.sample.errorType ?? ''),
|
||||
errorName: entry.sample.errorName ? String(entry.sample.errorName) : null,
|
||||
errorMessage: String(entry.sample.errorMessage ?? ''),
|
||||
requestPath: entry.sample.requestPath ? String(entry.sample.requestPath) : null,
|
||||
requestPathGroup: entry.requestPathGroup,
|
||||
requestPaths: [...entry.requestPaths].filter(Boolean).slice(0, 5),
|
||||
statusCode: entry.sample.statusCode == null ? null : Number(entry.sample.statusCode),
|
||||
count: entry.count,
|
||||
firstCreatedAt: entry.firstCreatedAt,
|
||||
lastCreatedAt: entry.lastCreatedAt,
|
||||
sampleLogId: entry.sample.id == null ? null : Number(entry.sample.id),
|
||||
sampleLogIds: entry.sampleLogIds,
|
||||
errorNames: [...entry.errorNames].filter(Boolean).slice(0, 5),
|
||||
representativeMessages: [...entry.errorMessages].filter(Boolean).slice(0, 5),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
const leftLastTime = normalizeDateBoundary(left.lastCreatedAt)?.getTime() ?? 0;
|
||||
const rightLastTime = normalizeDateBoundary(right.lastCreatedAt)?.getTime() ?? 0;
|
||||
|
||||
if (rightLastTime !== leftLastTime) {
|
||||
return rightLastTime - leftLastTime;
|
||||
}
|
||||
|
||||
return Number(right.sampleLogId ?? 0) - Number(left.sampleLogId ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeErrorLogPlanCandidateBucket(bucket: ErrorLogCandidate[], bucketIndex: number): ErrorLogCandidate {
|
||||
const sortedBucket = [...bucket].sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
|
||||
});
|
||||
|
||||
const representative = sortedBucket[0];
|
||||
const uniqueFingerprints = [...new Set(sortedBucket.map((candidate) => candidate.fingerprint).filter(Boolean))].sort();
|
||||
const mergedFingerprint = createHash('sha1')
|
||||
.update(uniqueFingerprints.join('||'))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
const firstCreatedAt = sortedBucket
|
||||
.map((candidate) => normalizeDateBoundary(candidate.firstCreatedAt)?.getTime() ?? Number.POSITIVE_INFINITY)
|
||||
.reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY);
|
||||
const lastCreatedAt = sortedBucket
|
||||
.map((candidate) => normalizeDateBoundary(candidate.lastCreatedAt)?.getTime() ?? 0)
|
||||
.reduce((max, value) => Math.max(max, value), 0);
|
||||
const requestPaths = [...new Set(sortedBucket.flatMap((candidate) => candidate.requestPaths ?? []).filter(Boolean))].slice(0, 8);
|
||||
const representativeMessages = [...new Set(sortedBucket.flatMap((candidate) => candidate.representativeMessages ?? []).filter(Boolean))].slice(0, 8);
|
||||
const errorNames = [...new Set(sortedBucket.flatMap((candidate) => candidate.errorNames ?? []).filter(Boolean))].slice(0, 8);
|
||||
const groupedScopes = sortedBucket
|
||||
.slice(0, 8)
|
||||
.map((candidate) => {
|
||||
const parts = [candidate.sourceLabel || candidate.source, candidate.errorType];
|
||||
|
||||
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
||||
parts.push(candidate.requestPathGroup);
|
||||
}
|
||||
|
||||
return parts.filter(Boolean).join(' / ');
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
fingerprint: mergedFingerprint,
|
||||
workId: `error-fix-bundle-${mergedFingerprint}`,
|
||||
source: representative.source,
|
||||
sourceLabel: representative.sourceLabel,
|
||||
errorType: sortedBucket.length > 1 ? '다중 에러 묶음' : representative.errorType,
|
||||
errorName: representative.errorName,
|
||||
errorMessage: representative.errorMessage,
|
||||
requestPath: representative.requestPath,
|
||||
requestPathGroup: representative.requestPathGroup,
|
||||
requestPaths,
|
||||
statusCode: representative.statusCode,
|
||||
count: sortedBucket.reduce((sum, candidate) => sum + Number(candidate.count ?? 0), 0),
|
||||
firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt,
|
||||
lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt,
|
||||
sampleLogId: representative.sampleLogId,
|
||||
sampleLogIds: [...new Set(sortedBucket.flatMap((candidate) => candidate.sampleLogIds ?? []).filter((value) => value != null))].slice(0, 8),
|
||||
errorNames,
|
||||
representativeMessages,
|
||||
groupedScopes,
|
||||
groupedCandidateCount: sortedBucket.length,
|
||||
};
|
||||
}
|
||||
|
||||
function coalesceErrorLogPlanCandidates(candidates: ErrorLogCandidate[], maxGroups = DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT) {
|
||||
const sortedCandidates = [...candidates].sort((left, right) => {
|
||||
if (right.count !== left.count) {
|
||||
return right.count - left.count;
|
||||
}
|
||||
|
||||
return String(left.workId ?? '').localeCompare(String(right.workId ?? ''));
|
||||
});
|
||||
|
||||
if (sortedCandidates.length <= maxGroups) {
|
||||
return sortedCandidates;
|
||||
}
|
||||
|
||||
const bucketSize = Math.ceil(sortedCandidates.length / maxGroups);
|
||||
const merged: ErrorLogCandidate[] = [];
|
||||
|
||||
for (let index = 0; index < sortedCandidates.length; index += bucketSize) {
|
||||
const bucket = sortedCandidates.slice(index, index + bucketSize);
|
||||
|
||||
if (bucket.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1));
|
||||
}
|
||||
|
||||
return merged.slice(0, maxGroups);
|
||||
}
|
||||
|
||||
function filterLogsWithinRange(logs: Array<Record<string, unknown>>, rangeStart: Date, rangeEnd: Date) {
|
||||
const startTime = rangeStart.getTime();
|
||||
const endTime = rangeEnd.getTime();
|
||||
|
||||
return logs.filter((log) => {
|
||||
const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime();
|
||||
return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime;
|
||||
});
|
||||
}
|
||||
|
||||
function formatErrorLogPlanNote(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) {
|
||||
const lines = [
|
||||
`조회 구간: ${formatIsoTimestamp(rangeStart)} ~ ${formatIsoTimestamp(rangeEnd)}`,
|
||||
`발생 건수: ${candidate.count}건`,
|
||||
`최근 발생: ${formatIsoTimestamp(candidate.lastCreatedAt)}`,
|
||||
`최초 발생: ${formatIsoTimestamp(candidate.firstCreatedAt)}`,
|
||||
`에러 유형: ${candidate.errorType}`,
|
||||
];
|
||||
|
||||
if (candidate.errorName) {
|
||||
lines.push(`에러 이름: ${candidate.errorName}`);
|
||||
}
|
||||
|
||||
if (candidate.sourceLabel || candidate.source) {
|
||||
lines.push(`발생 위치: ${candidate.sourceLabel || candidate.source}`);
|
||||
}
|
||||
|
||||
if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) {
|
||||
lines.push(`묶인 에러 그룹: ${candidate.groupedCandidateCount}개`);
|
||||
}
|
||||
|
||||
if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') {
|
||||
lines.push(`주요 경로 그룹: ${candidate.requestPathGroup}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) {
|
||||
lines.push(`대표 경로: ${candidate.requestPaths.join(', ')}`);
|
||||
}
|
||||
|
||||
if (candidate.statusCode != null) {
|
||||
lines.push(`상태 코드: ${candidate.statusCode}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) {
|
||||
lines.push('대표 메시지:');
|
||||
lines.push(...candidate.representativeMessages.map((message, index) => `${index + 1}. ${message}`));
|
||||
} else {
|
||||
lines.push(`대표 메시지: ${String(candidate.errorMessage ?? '').trim()}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) {
|
||||
lines.push(`대표 로그 ID: ${candidate.sampleLogIds.join(', ')}`);
|
||||
} else {
|
||||
lines.push(`대표 로그 ID: ${candidate.sampleLogId}`);
|
||||
}
|
||||
|
||||
if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) {
|
||||
lines.push('묶인 에러 범위:');
|
||||
lines.push(...candidate.groupedScopes.map((scope, index) => `${index + 1}. ${scope}`));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('처리 요청:');
|
||||
lines.push('1. 재현 경로와 영향 범위를 확인합니다.');
|
||||
lines.push('2. 수정이 필요한 경우 별도 Plan으로 소스 작업을 진행합니다.');
|
||||
lines.push('3. 테스트와 재발 방지 필요 여부를 검토합니다.');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildBoardPostMarker(workId: string) {
|
||||
return `${ERROR_LOG_BOARD_POST_MARKER_PREFIX}${workId} -->`;
|
||||
}
|
||||
|
||||
function buildErrorLogBoardPostTitle(candidate: ErrorLogCandidate) {
|
||||
const scope = candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown'
|
||||
? ` ${candidate.requestPathGroup}`
|
||||
: '';
|
||||
const title = `에러로그 조치 계획: ${candidate.errorType}${scope}`.replace(/\s+/g, ' ').trim();
|
||||
return title.length > 200 ? `${title.slice(0, 197).trimEnd()}...` : title;
|
||||
}
|
||||
|
||||
function buildErrorLogBoardPostContent(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) {
|
||||
const detailNote = formatErrorLogPlanNote(candidate, rangeStart, rangeEnd);
|
||||
return [
|
||||
buildBoardPostMarker(candidate.workId),
|
||||
`# ${buildErrorLogBoardPostTitle(candidate)}`,
|
||||
'',
|
||||
detailNote,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export async function registerErrorLogBoardPosts(args?: {
|
||||
rangeStart?: Date;
|
||||
rangeEnd?: Date;
|
||||
maxGroups?: number;
|
||||
}) {
|
||||
await ensurePlanTable();
|
||||
|
||||
const rangeEnd = args?.rangeEnd ?? new Date();
|
||||
const rangeStart = args?.rangeStart ?? new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000);
|
||||
const maxGroups = args?.maxGroups ?? DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT;
|
||||
const [errorLogs, existingBoardPosts] = await Promise.all([
|
||||
listErrorLogs(200),
|
||||
listBoardPosts(),
|
||||
]);
|
||||
|
||||
const recentLogs = filterLogsWithinRange(errorLogs as Array<Record<string, unknown>>, rangeStart, rangeEnd);
|
||||
const rawCandidates = buildErrorLogPlanCandidates(recentLogs as Array<Record<string, unknown>>);
|
||||
const candidates = coalesceErrorLogPlanCandidates(rawCandidates, maxGroups);
|
||||
const createdPosts: ErrorLogBoardPostRegistration[] = [];
|
||||
const skippedPosts: ErrorLogRegistrationSkip[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const marker = buildBoardPostMarker(candidate.workId);
|
||||
const existingBoardPost = existingBoardPosts.find((post) => String(post.content ?? '').includes(marker));
|
||||
|
||||
if (existingBoardPost) {
|
||||
skippedPosts.push({
|
||||
workId: candidate.workId,
|
||||
boardPostId: existingBoardPost.id,
|
||||
reason: `기존 게시글 #${existingBoardPost.id}가 있습니다.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const latestOpenPlan = await db(PLAN_TABLE)
|
||||
.select(['id', 'status'])
|
||||
.where({ work_id: candidate.workId })
|
||||
.whereNot({ status: '완료' as never })
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
if (latestOpenPlan) {
|
||||
skippedPosts.push({
|
||||
workId: candidate.workId,
|
||||
planId: Number(latestOpenPlan.id),
|
||||
reason: `기존 미완료 Plan #${latestOpenPlan.id}가 있습니다.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdPost = await createBoardPost({
|
||||
title: buildErrorLogBoardPostTitle(candidate),
|
||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||
automationType: 'none',
|
||||
});
|
||||
|
||||
createdPosts.push({
|
||||
postId: createdPost.id,
|
||||
title: createdPost.title,
|
||||
workId: candidate.workId,
|
||||
count: candidate.count,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
recentLogs,
|
||||
rawCandidates,
|
||||
candidates,
|
||||
createdPosts,
|
||||
skippedPosts,
|
||||
};
|
||||
}
|
||||
164
etc/servers/work-server/src/services/error-log-service.ts
Executable file
164
etc/servers/work-server/src/services/error-log-service.ts
Executable file
@@ -0,0 +1,164 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const ERROR_LOG_TABLE = 'error_logs';
|
||||
export const ERROR_LOG_VIEW_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
|
||||
|
||||
export const createErrorLogSchema = z.object({
|
||||
source: z.enum(['server', 'client', 'automation']).default('server'),
|
||||
sourceLabel: z.string().trim().max(80).optional().nullable(),
|
||||
errorType: z.string().trim().min(1).max(120),
|
||||
errorName: z.string().trim().max(255).optional().nullable(),
|
||||
errorMessage: z.string().trim().min(1).max(10000),
|
||||
detail: z.string().trim().max(50000).optional().nullable(),
|
||||
stackTrace: z.string().trim().max(50000).optional().nullable(),
|
||||
statusCode: z.number().int().min(100).max(599).optional().nullable(),
|
||||
requestMethod: z.string().trim().max(10).optional().nullable(),
|
||||
requestPath: z.string().trim().max(1000).optional().nullable(),
|
||||
relatedPlanId: z.number().int().positive().optional().nullable(),
|
||||
relatedWorkId: z.string().trim().max(120).optional().nullable(),
|
||||
context: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
});
|
||||
|
||||
export type ErrorLogPayload = z.infer<typeof createErrorLogSchema>;
|
||||
|
||||
async function ensureErrorLogTable() {
|
||||
const hasTable = await db.schema.hasTable(ERROR_LOG_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(ERROR_LOG_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('source', 20).notNullable().defaultTo('server');
|
||||
table.string('source_label', 80).nullable();
|
||||
table.string('error_type', 120).notNullable();
|
||||
table.string('error_name', 255).nullable();
|
||||
table.text('error_message').notNullable();
|
||||
table.text('detail').nullable();
|
||||
table.text('stack_trace').nullable();
|
||||
table.integer('status_code').nullable();
|
||||
table.string('request_method', 10).nullable();
|
||||
table.string('request_path', 1000).nullable();
|
||||
table.integer('related_plan_id').nullable();
|
||||
table.string('related_work_id', 120).nullable();
|
||||
table.jsonb('context_json').nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['source', (table) => table.string('source', 20).notNullable().defaultTo('server')],
|
||||
['source_label', (table) => table.string('source_label', 80).nullable()],
|
||||
['error_type', (table) => table.string('error_type', 120).notNullable().defaultTo('unknown')],
|
||||
['error_name', (table) => table.string('error_name', 255).nullable()],
|
||||
['error_message', (table) => table.text('error_message').notNullable().defaultTo('')],
|
||||
['detail', (table) => table.text('detail').nullable()],
|
||||
['stack_trace', (table) => table.text('stack_trace').nullable()],
|
||||
['status_code', (table) => table.integer('status_code').nullable()],
|
||||
['request_method', (table) => table.string('request_method', 10).nullable()],
|
||||
['request_path', (table) => table.string('request_path', 1000).nullable()],
|
||||
['related_plan_id', (table) => table.integer('related_plan_id').nullable()],
|
||||
['related_work_id', (table) => table.string('related_work_id', 120).nullable()],
|
||||
['context_json', (table) => table.jsonb('context_json').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(ERROR_LOG_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(ERROR_LOG_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(payload: ErrorLogPayload) {
|
||||
const parsedPayload = createErrorLogSchema.parse(payload);
|
||||
const sourceLabel =
|
||||
parsedPayload.sourceLabel ??
|
||||
(parsedPayload.source === 'client'
|
||||
? '프론트엔드'
|
||||
: parsedPayload.source === 'automation'
|
||||
? 'Plan 자동화'
|
||||
: '워크서버 API');
|
||||
|
||||
return {
|
||||
source: parsedPayload.source,
|
||||
source_label: sourceLabel,
|
||||
error_type: parsedPayload.errorType,
|
||||
error_name: parsedPayload.errorName ?? null,
|
||||
error_message: parsedPayload.errorMessage,
|
||||
detail: parsedPayload.detail ?? null,
|
||||
stack_trace: parsedPayload.stackTrace ?? null,
|
||||
status_code: parsedPayload.statusCode ?? null,
|
||||
request_method: parsedPayload.requestMethod ?? null,
|
||||
request_path: parsedPayload.requestPath ?? null,
|
||||
related_plan_id: parsedPayload.relatedPlanId ?? null,
|
||||
related_work_id: parsedPayload.relatedWorkId ?? null,
|
||||
context_json: parsedPayload.context ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapErrorLogRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
source: String(row.source ?? 'server'),
|
||||
sourceLabel: row.source_label ? String(row.source_label) : null,
|
||||
errorType: String(row.error_type ?? ''),
|
||||
errorName: row.error_name ? String(row.error_name) : null,
|
||||
errorMessage: String(row.error_message ?? ''),
|
||||
detail: row.detail ? String(row.detail) : null,
|
||||
stackTrace: row.stack_trace ? String(row.stack_trace) : null,
|
||||
statusCode: typeof row.status_code === 'number' ? row.status_code : row.status_code ? Number(row.status_code) : null,
|
||||
requestMethod: row.request_method ? String(row.request_method) : null,
|
||||
requestPath: row.request_path ? String(row.request_path) : null,
|
||||
relatedPlanId:
|
||||
typeof row.related_plan_id === 'number'
|
||||
? row.related_plan_id
|
||||
: row.related_plan_id
|
||||
? Number(row.related_plan_id)
|
||||
: null,
|
||||
relatedWorkId: row.related_work_id ? String(row.related_work_id) : null,
|
||||
context: row.context_json && typeof row.context_json === 'object' ? (row.context_json as Record<string, unknown>) : null,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupErrorLogTable() {
|
||||
await ensureErrorLogTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
table: ERROR_LOG_TABLE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createErrorLog(payload: ErrorLogPayload) {
|
||||
await ensureErrorLogTable();
|
||||
|
||||
const [row] = await db(ERROR_LOG_TABLE)
|
||||
.insert({
|
||||
...normalizePayload(payload),
|
||||
created_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return mapErrorLogRow(row);
|
||||
}
|
||||
|
||||
export async function listErrorLogs(limit = 50) {
|
||||
await ensureErrorLogTable();
|
||||
|
||||
const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 50;
|
||||
const rows = await db(ERROR_LOG_TABLE).select('*').orderBy('created_at', 'desc').limit(safeLimit);
|
||||
|
||||
return rows.map((row) => mapErrorLogRow(row));
|
||||
}
|
||||
|
||||
export function hasErrorLogViewAccessToken(token: string | string[] | undefined) {
|
||||
const normalizedToken = Array.isArray(token) ? token[0] : token;
|
||||
return String(normalizedToken ?? '').trim() === ERROR_LOG_VIEW_TOKEN;
|
||||
}
|
||||
150
etc/servers/work-server/src/services/git-service.test.ts
Executable file
150
etc/servers/work-server/src/services/git-service.test.ts
Executable file
@@ -0,0 +1,150 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import {
|
||||
ensureBranchExists,
|
||||
mergeBranchToRelease,
|
||||
mergeReleaseToMain,
|
||||
type GitAutomationConfig,
|
||||
} from './git-service.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function runGit(repoPath: string, args: string[]) {
|
||||
const { stdout } = await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'test',
|
||||
GIT_AUTHOR_EMAIL: 'test@example.com',
|
||||
GIT_COMMITTER_NAME: 'test',
|
||||
GIT_COMMITTER_EMAIL: 'test@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function createRepo() {
|
||||
const rootPath = await mkdtemp(path.join(tmpdir(), 'git-service-test-'));
|
||||
const remotePath = path.join(rootPath, 'remote.git');
|
||||
const repoPath = path.join(rootPath, 'work');
|
||||
const config: GitAutomationConfig = {
|
||||
repoPath,
|
||||
mainBranch: 'main',
|
||||
releaseBranch: 'release',
|
||||
};
|
||||
|
||||
await execFileAsync('git', ['init', '--bare', '--initial-branch=main', remotePath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
await execFileAsync('git', ['clone', remotePath, repoPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
},
|
||||
});
|
||||
|
||||
await runGit(repoPath, ['config', 'user.name', 'test']);
|
||||
await runGit(repoPath, ['config', 'user.email', 'test@example.com']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main root']);
|
||||
await runGit(repoPath, ['push', '-u', 'origin', 'main']);
|
||||
|
||||
await runGit(repoPath, ['switch', '-c', 'release']);
|
||||
await runGit(repoPath, ['push', '-u', 'origin', 'release']);
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
|
||||
return { repoPath, config };
|
||||
}
|
||||
|
||||
test('ensureBranchExists creates feature branch from main even when release diverged', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
const mainHead = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
await runGit(repoPath, ['switch', 'release']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release only']);
|
||||
await runGit(repoPath, ['push', 'origin', 'release']);
|
||||
const releaseHead = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
await ensureBranchExists(config, 'feature/test-branch', 'release');
|
||||
|
||||
const featureHead = await runGit(repoPath, ['rev-parse', 'feature/test-branch']);
|
||||
|
||||
assert.equal(featureHead, mainHead);
|
||||
assert.notEqual(featureHead, releaseHead, 'feature branch should point to main head, not release head');
|
||||
});
|
||||
|
||||
test('ensureBranchExists recreates missing local main from origin/main', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
const mainHead = await runGit(repoPath, ['rev-parse', 'main']);
|
||||
|
||||
await runGit(repoPath, ['branch', '-D', 'main']);
|
||||
|
||||
await ensureBranchExists(config, 'feature/recreated-from-origin-main');
|
||||
|
||||
const recreatedMainHead = await runGit(repoPath, ['rev-parse', 'main']);
|
||||
const featureHead = await runGit(repoPath, ['rev-parse', 'feature/recreated-from-origin-main']);
|
||||
|
||||
assert.equal(recreatedMainHead, mainHead);
|
||||
assert.equal(featureHead, mainHead);
|
||||
});
|
||||
|
||||
test('mergeBranchToRelease squashes hotfix changes into release', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
|
||||
await runGit(repoPath, ['switch', 'release']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'release']);
|
||||
|
||||
await runGit(repoPath, ['switch', '-c', 'hotfix/test']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'hotfix change']);
|
||||
await runGit(repoPath, ['push', '-u', 'origin', 'hotfix/test']);
|
||||
const hotfixHead = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
await mergeBranchToRelease(config, 'hotfix/test', 'release');
|
||||
|
||||
const releaseMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'release']);
|
||||
const parentCount = await runGit(repoPath, ['rev-list', '--parents', '-n', '1', 'release']);
|
||||
const releaseHistory = await runGit(repoPath, ['rev-list', 'release']);
|
||||
|
||||
assert.equal(releaseMessage, 'merge: hotfix/test -> release (squash)');
|
||||
assert.equal(parentCount.split(' ').length, 2);
|
||||
assert.ok(!releaseHistory.split('\n').includes(hotfixHead));
|
||||
});
|
||||
|
||||
test('mergeReleaseToMain keeps release to main as a normal merge commit', async () => {
|
||||
const { repoPath, config } = await createRepo();
|
||||
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']);
|
||||
await runGit(repoPath, ['push', 'origin', 'main']);
|
||||
|
||||
await runGit(repoPath, ['switch', 'release']);
|
||||
await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release change']);
|
||||
await runGit(repoPath, ['push', 'origin', 'release']);
|
||||
|
||||
await mergeReleaseToMain(config, 'release');
|
||||
|
||||
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
|
||||
const parentCount = await runGit(repoPath, ['rev-list', '--parents', '-n', '1', 'main']);
|
||||
|
||||
assert.equal(mainMessage, 'merge: release -> main');
|
||||
assert.equal(parentCount.split(' ').length, 3);
|
||||
});
|
||||
267
etc/servers/work-server/src/services/git-service.ts
Executable file
267
etc/servers/work-server/src/services/git-service.ts
Executable file
@@ -0,0 +1,267 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { access, copyFile } from 'node:fs/promises';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { promisify } from 'node:util';
|
||||
import { getEnv } from '../config/env.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gitCredentialSourcePath = '/root/.git-credentials';
|
||||
const gitCredentialCachePath = '/tmp/work-server-git-credentials';
|
||||
|
||||
async function prepareWritableCredentialStore() {
|
||||
try {
|
||||
await access(gitCredentialSourcePath, fsConstants.R_OK);
|
||||
await copyFile(gitCredentialSourcePath, gitCredentialCachePath);
|
||||
return gitCredentialCachePath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type GitAutomationConfig = {
|
||||
repoPath: string;
|
||||
releaseBranch: string;
|
||||
mainBranch: string;
|
||||
};
|
||||
|
||||
async function runGit(repoPath: string, args: string[]) {
|
||||
const credentialStorePath = await prepareWritableCredentialStore();
|
||||
const gitArgs = ['-c', `safe.directory=${repoPath}`];
|
||||
const env = getEnv();
|
||||
|
||||
if (credentialStorePath) {
|
||||
gitArgs.push('-c', `credential.helper=store --file=${credentialStorePath}`);
|
||||
}
|
||||
|
||||
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', env.PLAN_GIT_USER_NAME], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.email', env.PLAN_GIT_USER_EMAIL], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
const { stdout, stderr } = await execFileAsync('git', [...gitArgs, '-C', repoPath, ...args], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertBranchExists(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['rev-parse', '--verify', branchName]);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`${branchName} 브랜치를 찾을 수 없습니다. 먼저 ${branchName} 브랜치를 생성한 뒤 다시 시도해 주세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasLocalBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['rev-parse', '--verify', '--quiet', branchName]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasRemoteBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branchName}`]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBranchWithRemote(repoPath: string, branchName: string) {
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
|
||||
if (!(await hasRemoteBranch(repoPath, branchName))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await hasLocalBranch(repoPath, branchName))) {
|
||||
await runGit(repoPath, ['branch', branchName, `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
try {
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
} catch {
|
||||
await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
await runGit(repoPath, ['reset', '--hard', `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
export async function assertCleanWorktree(repoPath: string) {
|
||||
const { stdout } = await runGit(repoPath, ['status', '--porcelain']);
|
||||
|
||||
if (stdout) {
|
||||
throw new Error('Git 작업 디렉터리가 깨끗하지 않습니다. 변경 사항을 정리한 뒤 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanAutomationWorktree(repoPath: string) {
|
||||
await runGit(repoPath, ['reset', '--hard']);
|
||||
await runGit(repoPath, ['clean', '-fd']);
|
||||
}
|
||||
|
||||
export async function ensureBranchExists(config: GitAutomationConfig, branchName: string, releaseTarget?: string) {
|
||||
const baseBranch = config.mainBranch;
|
||||
|
||||
await assertCleanWorktree(config.repoPath);
|
||||
await syncBranchWithRemote(config.repoPath, baseBranch);
|
||||
await assertBranchExists(config.repoPath, baseBranch);
|
||||
await runGit(config.repoPath, ['switch', baseBranch]);
|
||||
await runGit(config.repoPath, ['switch', '-C', branchName]);
|
||||
}
|
||||
|
||||
export async function pushBranch(repoPath: string, branchName: string, setUpstream = false) {
|
||||
await runGit(repoPath, setUpstream ? ['push', '-u', 'origin', branchName] : ['push', 'origin', branchName]);
|
||||
}
|
||||
|
||||
async function deleteLocalBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['branch', '-D', branchName]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRemoteBranch(repoPath: string, branchName: string) {
|
||||
try {
|
||||
await runGit(repoPath, ['push', 'origin', '--delete', branchName]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitAllChanges(repoPath: string, message: string) {
|
||||
await runGit(repoPath, ['add', '-A']);
|
||||
await runGit(repoPath, ['commit', '-m', message]);
|
||||
}
|
||||
|
||||
export async function hasWorkingTreeChanges(repoPath: string) {
|
||||
const { stdout } = await runGit(repoPath, ['status', '--porcelain']);
|
||||
return Boolean(stdout);
|
||||
}
|
||||
|
||||
function isHotfixBranch(branchName: string) {
|
||||
return /^hotfix\//.test(branchName);
|
||||
}
|
||||
|
||||
function isReleaseBranch(branchName: string, config: GitAutomationConfig) {
|
||||
return branchName === config.releaseBranch || /^release([/-]|$)/.test(branchName);
|
||||
}
|
||||
|
||||
function shouldSquashMerge(sourceBranch: string, targetBranch: string, config: GitAutomationConfig) {
|
||||
return isHotfixBranch(sourceBranch) && (
|
||||
targetBranch === config.mainBranch || isReleaseBranch(targetBranch, config)
|
||||
);
|
||||
}
|
||||
|
||||
function assertAllowedMergeDirection(
|
||||
config: GitAutomationConfig,
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
) {
|
||||
if (
|
||||
targetBranch === config.mainBranch &&
|
||||
sourceBranch !== config.releaseBranch &&
|
||||
sourceBranch !== config.mainBranch &&
|
||||
!isHotfixBranch(sourceBranch)
|
||||
) {
|
||||
throw new Error(
|
||||
`브랜치 전략 위반: ${sourceBranch} -> ${targetBranch} 직접 머지는 허용되지 않습니다. release 반영 후 main에 반영해 주세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeBranch(
|
||||
config: GitAutomationConfig,
|
||||
sourceBranch: string,
|
||||
targetBranch: string,
|
||||
) {
|
||||
assertAllowedMergeDirection(config, sourceBranch, targetBranch);
|
||||
await assertCleanWorktree(config.repoPath);
|
||||
await syncBranchWithRemote(config.repoPath, targetBranch);
|
||||
await assertBranchExists(config.repoPath, targetBranch);
|
||||
await assertBranchExists(config.repoPath, sourceBranch);
|
||||
|
||||
if (sourceBranch === config.releaseBranch || sourceBranch === config.mainBranch) {
|
||||
await syncBranchWithRemote(config.repoPath, sourceBranch);
|
||||
}
|
||||
|
||||
await runGit(config.repoPath, ['switch', targetBranch]);
|
||||
if (shouldSquashMerge(sourceBranch, targetBranch, config)) {
|
||||
await runGit(config.repoPath, ['merge', '--squash', sourceBranch]);
|
||||
await runGit(config.repoPath, ['commit', '-m', `merge: ${sourceBranch} -> ${targetBranch} (squash)`]);
|
||||
return;
|
||||
}
|
||||
|
||||
await runGit(config.repoPath, ['merge', '--no-ff', sourceBranch, '-m', `merge: ${sourceBranch} -> ${targetBranch}`]);
|
||||
}
|
||||
|
||||
export async function mergeBranchToRelease(
|
||||
config: GitAutomationConfig,
|
||||
branchName: string,
|
||||
releaseTarget?: string,
|
||||
) {
|
||||
const baseBranch = releaseTarget || config.releaseBranch;
|
||||
await mergeBranch(config, branchName, baseBranch);
|
||||
}
|
||||
|
||||
export async function mergeReleaseToMain(
|
||||
config: GitAutomationConfig,
|
||||
releaseTarget?: string,
|
||||
) {
|
||||
const baseBranch = releaseTarget || config.releaseBranch;
|
||||
await mergeBranch(config, baseBranch, config.mainBranch);
|
||||
}
|
||||
|
||||
export async function mergeIssueBranchToMain(
|
||||
config: GitAutomationConfig,
|
||||
branchName: string,
|
||||
) {
|
||||
throw new Error(
|
||||
`브랜치 전략 위반: ${branchName} -> ${config.mainBranch} 직접 머지는 비활성화되었습니다. release 브랜치를 통해 반영해 주세요.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pullMainProjectBranch(repoPath: string, branchName: string) {
|
||||
await assertCleanWorktree(repoPath);
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
|
||||
try {
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
} catch {
|
||||
await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]);
|
||||
}
|
||||
|
||||
await runGit(repoPath, ['pull', '--ff-only', 'origin', branchName]);
|
||||
}
|
||||
|
||||
export async function recreateReleaseBranchFromMain(
|
||||
config: GitAutomationConfig,
|
||||
releaseTarget?: string,
|
||||
) {
|
||||
const targetBranch = releaseTarget || config.releaseBranch;
|
||||
|
||||
if (targetBranch === config.mainBranch) {
|
||||
throw new Error('release 브랜치를 main 브랜치와 동일하게 재생성할 수 없습니다.');
|
||||
}
|
||||
|
||||
await assertCleanWorktree(config.repoPath);
|
||||
await syncBranchWithRemote(config.repoPath, config.mainBranch);
|
||||
await assertBranchExists(config.repoPath, config.mainBranch);
|
||||
await runGit(config.repoPath, ['switch', config.mainBranch]);
|
||||
await deleteLocalBranch(config.repoPath, targetBranch);
|
||||
await deleteRemoteBranch(config.repoPath, targetBranch);
|
||||
await runGit(config.repoPath, ['switch', '-C', targetBranch, config.mainBranch]);
|
||||
await pushBranch(config.repoPath, targetBranch, true);
|
||||
}
|
||||
244
etc/servers/work-server/src/services/notification-message-service.ts
Executable file
244
etc/servers/work-server/src/services/notification-message-service.ts
Executable file
@@ -0,0 +1,244 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const NOTIFICATION_MESSAGE_TABLE = 'notification_messages';
|
||||
|
||||
const notificationMessagePrioritySchema = z.enum(['low', 'normal', 'high', 'urgent']);
|
||||
const notificationMessageListStatusSchema = z.enum(['all', 'unread']);
|
||||
|
||||
export const notificationMessageListQuerySchema = z.object({
|
||||
status: notificationMessageListStatusSchema.default('all'),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export const notificationMessagePayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
body: z.string().trim().min(1).max(20000),
|
||||
category: z.string().trim().min(1).max(60).default('general'),
|
||||
source: z.string().trim().min(1).max(80).default('system'),
|
||||
priority: notificationMessagePrioritySchema.default('normal'),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const notificationMessageReadPayloadSchema = z.object({
|
||||
read: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type NotificationMessageItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
preview: string;
|
||||
category: string;
|
||||
source: string;
|
||||
priority: z.infer<typeof notificationMessagePrioritySchema>;
|
||||
read: boolean;
|
||||
readAt: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function normalizePreviewText(value: string) {
|
||||
const normalized = value
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function mapNotificationMessageRow(row: Record<string, unknown>): NotificationMessageItem {
|
||||
const body = String(row.body ?? '');
|
||||
const metadata =
|
||||
typeof row.metadata_json === 'object' && row.metadata_json ? (row.metadata_json as Record<string, unknown>) : {};
|
||||
const metadataPreview =
|
||||
typeof metadata.previewText === 'string'
|
||||
? metadata.previewText
|
||||
: typeof metadata.listPreviewText === 'string'
|
||||
? metadata.listPreviewText
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
body,
|
||||
preview: normalizePreviewText(metadataPreview || body),
|
||||
category: String(row.category ?? 'general'),
|
||||
source: String(row.source ?? 'system'),
|
||||
priority: notificationMessagePrioritySchema.catch('normal').parse(row.priority),
|
||||
read: Boolean(row.is_read),
|
||||
readAt: row.read_at === null || row.read_at === undefined ? null : String(row.read_at),
|
||||
metadata,
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInsertedId(result: unknown): number | null {
|
||||
if (typeof result === 'number' && Number.isInteger(result) && result > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
const first = result[0];
|
||||
|
||||
if (typeof first === 'number' && Number.isInteger(first) && first > 0) {
|
||||
return first;
|
||||
}
|
||||
|
||||
if (first && typeof first === 'object' && 'id' in first) {
|
||||
const id = Number((first as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (result && typeof result === 'object' && 'id' in result) {
|
||||
const id = Number((result as { id?: unknown }).id);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function supportsReturning() {
|
||||
const clientName = String(db.client.config.client ?? '').toLowerCase();
|
||||
return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName);
|
||||
}
|
||||
|
||||
export async function ensureNotificationMessagesTable() {
|
||||
const hasTable = await db.schema.hasTable(NOTIFICATION_MESSAGE_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(NOTIFICATION_MESSAGE_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('title', 200).notNullable();
|
||||
table.text('body').notNullable();
|
||||
table.string('category', 60).notNullable().defaultTo('general');
|
||||
table.string('source', 80).notNullable().defaultTo('system');
|
||||
table.string('priority', 20).notNullable().defaultTo('normal');
|
||||
table.boolean('is_read').notNullable().defaultTo(false);
|
||||
table.timestamp('read_at', { useTz: true }).nullable();
|
||||
table.jsonb('metadata_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['title', (table) => table.string('title', 200).notNullable().defaultTo('알림')],
|
||||
['body', (table) => table.text('body').notNullable().defaultTo('')],
|
||||
['category', (table) => table.string('category', 60).notNullable().defaultTo('general')],
|
||||
['source', (table) => table.string('source', 80).notNullable().defaultTo('system')],
|
||||
['priority', (table) => table.string('priority', 20).notNullable().defaultTo('normal')],
|
||||
['is_read', (table) => table.boolean('is_read').notNullable().defaultTo(false)],
|
||||
['read_at', (table) => table.timestamp('read_at', { useTz: true }).nullable()],
|
||||
['metadata_json', (table) => table.jsonb('metadata_json').notNullable().defaultTo('{}')],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(NOTIFICATION_MESSAGE_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(NOTIFICATION_MESSAGE_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listNotificationMessages(query: z.infer<typeof notificationMessageListQuerySchema>) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const parsedQuery = notificationMessageListQuerySchema.parse(query);
|
||||
const builder = db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.select('*')
|
||||
.orderBy('is_read', 'asc')
|
||||
.orderBy('created_at', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(parsedQuery.limit);
|
||||
|
||||
if (parsedQuery.status === 'unread') {
|
||||
builder.where({ is_read: false });
|
||||
}
|
||||
|
||||
const rows = await builder;
|
||||
const unreadCountResult = await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.where({ is_read: false })
|
||||
.count<{ count: string | number }>({ count: '*' })
|
||||
.first();
|
||||
|
||||
return {
|
||||
items: rows.map((row) => mapNotificationMessageRow(row)),
|
||||
unreadCount: Number(unreadCountResult?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getNotificationMessage(id: number) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first();
|
||||
return row ? mapNotificationMessageRow(row) : null;
|
||||
}
|
||||
|
||||
export async function createNotificationMessage(payload: z.infer<typeof notificationMessagePayloadSchema>) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const parsedPayload = notificationMessagePayloadSchema.parse(payload);
|
||||
const insertQuery = db(NOTIFICATION_MESSAGE_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
body: parsedPayload.body,
|
||||
category: parsedPayload.category,
|
||||
source: parsedPayload.source,
|
||||
priority: parsedPayload.priority,
|
||||
metadata_json: parsedPayload.metadata,
|
||||
is_read: false,
|
||||
read_at: null,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery;
|
||||
const insertedId = resolveInsertedId(insertResult);
|
||||
|
||||
if (!insertedId) {
|
||||
throw new Error('알림 메시지 저장 후 ID를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id: insertedId }).first();
|
||||
|
||||
if (!row) {
|
||||
throw new Error('저장된 알림 메시지를 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return mapNotificationMessageRow(row);
|
||||
}
|
||||
|
||||
export async function updateNotificationMessageReadState(
|
||||
id: number,
|
||||
payload: z.infer<typeof notificationMessageReadPayloadSchema>,
|
||||
) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const parsedPayload = notificationMessageReadPayloadSchema.parse(payload);
|
||||
const updatedCount = await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
is_read: parsedPayload.read,
|
||||
read_at: parsedPayload.read ? db.fn.now() : null,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (!updatedCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first();
|
||||
return row ? mapNotificationMessageRow(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteNotificationMessage(id: number) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const deletedCount = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).del();
|
||||
return deletedCount > 0;
|
||||
}
|
||||
900
etc/servers/work-server/src/services/notification-service.ts
Executable file
900
etc/servers/work-server/src/services/notification-service.ts
Executable file
@@ -0,0 +1,900 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Notification, Provider } from '@parse/node-apn';
|
||||
import webpush from 'web-push';
|
||||
import { z } from 'zod';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
import { ensureNotificationMessagesTable } from './notification-message-service.js';
|
||||
|
||||
export const NOTIFICATION_TOKEN_TABLE = 'notification_tokens';
|
||||
export const WEB_PUSH_SUBSCRIPTION_TABLE = 'web_push_subscriptions';
|
||||
export const NOTIFICATION_PREFERENCE_TABLE = 'notification_preferences';
|
||||
|
||||
const automationNotificationPreferenceSchema = z.object({
|
||||
notifyOnAutomationStart: z.boolean().optional(),
|
||||
notifyOnAutomationProgress: z.boolean().optional(),
|
||||
notifyOnAutomationCompletion: z.boolean().optional(),
|
||||
notifyOnAutomationRelease: z.boolean().optional(),
|
||||
notifyOnAutomationMain: z.boolean().optional(),
|
||||
notifyOnAutomationFailure: z.boolean().optional(),
|
||||
notifyOnAutomationRestart: z.boolean().optional(),
|
||||
notifyOnAutomationIssueResolved: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const notificationTargetKindSchema = z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']);
|
||||
|
||||
export const registerAutomationNotificationPreferenceSchema = z.object({
|
||||
targetKind: notificationTargetKindSchema.default('client'),
|
||||
targetId: z.string().trim().min(1).max(1000).optional(),
|
||||
automation: automationNotificationPreferenceSchema,
|
||||
});
|
||||
|
||||
export const registerIosTokenSchema = z.object({
|
||||
token: z.string().trim().min(1),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const unregisterIosTokenSchema = z.object({
|
||||
token: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const registerWebPushSubscriptionSchema = z.object({
|
||||
subscription: z.object({
|
||||
endpoint: z.string().trim().url(),
|
||||
expirationTime: z.number().nullable().optional(),
|
||||
keys: z.object({
|
||||
p256dh: z.string().trim().min(1),
|
||||
auth: z.string().trim().min(1),
|
||||
}),
|
||||
}),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
userAgent: z.string().trim().max(500).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const unregisterWebPushSubscriptionSchema = z.object({
|
||||
endpoint: z.string().trim().url(),
|
||||
});
|
||||
|
||||
export const sendIosNotificationSchema = z.object({
|
||||
title: z.string().trim().min(1),
|
||||
body: z.string().trim().min(1),
|
||||
data: z.record(z.string(), z.string()).default({}),
|
||||
threadId: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
type IosNotificationPayload = z.infer<typeof sendIosNotificationSchema>;
|
||||
type WebPushSubscriptionPayload = z.infer<typeof registerWebPushSubscriptionSchema>['subscription'];
|
||||
type AutomationNotificationPreference = z.infer<typeof automationNotificationPreferenceSchema>;
|
||||
type NotificationTargetKind = z.infer<typeof notificationTargetKindSchema>;
|
||||
type NotificationPreferenceTarget = {
|
||||
kind: NotificationTargetKind;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type WebPushFailureDetail = {
|
||||
endpoint: string;
|
||||
statusCode?: number;
|
||||
detail?: string;
|
||||
code?: string;
|
||||
attemptCount: number;
|
||||
};
|
||||
|
||||
function buildScopedPwaNotificationTargetId(token: string, clientId: string) {
|
||||
return [token.trim(), clientId.trim()].filter(Boolean).join('::client::');
|
||||
}
|
||||
|
||||
let providerPromise: Promise<Provider | null> | null = null;
|
||||
let providerSignature: string | null = null;
|
||||
|
||||
function normalizePrivateKey(privateKey: string) {
|
||||
return privateKey.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
async function loadPrivateKey(env: ReturnType<typeof getEnv>) {
|
||||
if (env.APNS_PRIVATE_KEY?.trim()) {
|
||||
return normalizePrivateKey(env.APNS_PRIVATE_KEY.trim());
|
||||
}
|
||||
|
||||
if (env.APNS_PRIVATE_KEY_PATH?.trim()) {
|
||||
const file = await readFile(env.APNS_PRIVATE_KEY_PATH.trim(), 'utf8');
|
||||
return file.trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasWebPushConfig(env: ReturnType<typeof getEnv>) {
|
||||
return Boolean(
|
||||
env.WEB_PUSH_ENABLED &&
|
||||
env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() &&
|
||||
env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() &&
|
||||
env.WEB_PUSH_SUBJECT?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureWebPushConfigured(env: ReturnType<typeof getEnv>) {
|
||||
if (!hasWebPushConfig(env)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(
|
||||
env.WEB_PUSH_SUBJECT,
|
||||
env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim(),
|
||||
env.WEB_PUSH_VAPID_PRIVATE_KEY!.trim(),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeNotificationDetailText(text?: string | null) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function isChatNotificationPayload(payload: IosNotificationPayload) {
|
||||
const category = String(payload.data?.category ?? '').trim().toLowerCase();
|
||||
const threadId = String(payload.threadId ?? '').trim().toLowerCase();
|
||||
return category === 'chat' || threadId.startsWith('chat:');
|
||||
}
|
||||
|
||||
function isRetryableWebPushError(error: any) {
|
||||
const statusCode = Number(error?.statusCode ?? 0);
|
||||
|
||||
if ([408, 425, 429, 500, 502, 503, 504].includes(statusCode)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const code = String(error?.code ?? '').trim().toUpperCase();
|
||||
return ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(code);
|
||||
}
|
||||
|
||||
async function waitForRetry(delayMs: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
async function sendWebPushWithRetry(subscription: WebPushSubscriptionPayload, payloadText: string) {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
||||
try {
|
||||
await webpush.sendNotification(subscription, payloadText);
|
||||
return { attemptCount: attempt };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt >= 2 || !isRetryableWebPushError(error)) {
|
||||
throw {
|
||||
error,
|
||||
attemptCount: attempt,
|
||||
};
|
||||
}
|
||||
|
||||
await waitForRetry(250 * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
throw {
|
||||
error: lastError,
|
||||
attemptCount: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderSignature(env: ReturnType<typeof getEnv>) {
|
||||
return [
|
||||
env.IOS_NOTIFICATION_ENABLED,
|
||||
env.APNS_KEY_ID,
|
||||
env.APNS_TEAM_ID,
|
||||
env.APNS_BUNDLE_ID,
|
||||
env.APNS_PRIVATE_KEY,
|
||||
env.APNS_PRIVATE_KEY_PATH,
|
||||
env.APNS_PRODUCTION,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
async function createProvider(env: ReturnType<typeof getEnv>) {
|
||||
if (
|
||||
!env.IOS_NOTIFICATION_ENABLED ||
|
||||
!env.APNS_KEY_ID ||
|
||||
!env.APNS_TEAM_ID ||
|
||||
!env.APNS_BUNDLE_ID
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const privateKey = await loadPrivateKey(env);
|
||||
|
||||
if (!privateKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Provider({
|
||||
token: {
|
||||
key: privateKey,
|
||||
keyId: env.APNS_KEY_ID,
|
||||
teamId: env.APNS_TEAM_ID,
|
||||
},
|
||||
production: env.APNS_PRODUCTION,
|
||||
});
|
||||
}
|
||||
|
||||
async function getProvider() {
|
||||
const env = getEnv();
|
||||
const signature = buildProviderSignature(env);
|
||||
|
||||
if (signature !== providerSignature) {
|
||||
providerSignature = signature;
|
||||
providerPromise = null;
|
||||
}
|
||||
|
||||
if (!providerPromise) {
|
||||
providerPromise = createProvider(env);
|
||||
}
|
||||
|
||||
return providerPromise;
|
||||
}
|
||||
|
||||
async function ensureNotificationTokenTable() {
|
||||
const hasTable = await db.schema.hasTable(NOTIFICATION_TOKEN_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(NOTIFICATION_TOKEN_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('platform', 20).notNullable().defaultTo('ios');
|
||||
table.string('device_token', 255).notNullable().unique();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.boolean('is_enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')],
|
||||
['device_token', (table) => table.string('device_token', 255).notNullable()],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
|
||||
[
|
||||
'last_registered_at',
|
||||
(table) => table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()),
|
||||
],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(NOTIFICATION_TOKEN_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(NOTIFICATION_TOKEN_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWebPushSubscriptionTable() {
|
||||
const hasTable = await db.schema.hasTable(WEB_PUSH_SUBSCRIPTION_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(WEB_PUSH_SUBSCRIPTION_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('endpoint', 1000).notNullable().unique();
|
||||
table.jsonb('subscription_json').notNullable();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.boolean('is_enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['endpoint', (table) => table.string('endpoint', 1000).notNullable()],
|
||||
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
|
||||
[
|
||||
'last_registered_at',
|
||||
(table) => table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()),
|
||||
],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(WEB_PUSH_SUBSCRIPTION_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(WEB_PUSH_SUBSCRIPTION_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNotificationPreferenceTable() {
|
||||
const hasTable = await db.schema.hasTable(NOTIFICATION_PREFERENCE_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(NOTIFICATION_PREFERENCE_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('target_kind', 40).notNullable();
|
||||
table.string('target_id', 1000).notNullable();
|
||||
table.jsonb('config_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.unique(['target_kind', 'target_id']);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['target_kind', (table) => table.string('target_kind', 40).notNullable().defaultTo('client')],
|
||||
['target_id', (table) => table.string('target_id', 1000).notNullable().defaultTo('')],
|
||||
['config_json', (table) => table.jsonb('config_json').notNullable().defaultTo('{}')],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(NOTIFICATION_PREFERENCE_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(NOTIFICATION_PREFERENCE_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupNotificationTables() {
|
||||
await ensureNotificationTokenTable();
|
||||
await ensureWebPushSubscriptionTable();
|
||||
await ensureNotificationPreferenceTable();
|
||||
await ensureNotificationMessagesTable();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tables: [NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE, NOTIFICATION_PREFERENCE_TABLE, 'notification_messages'],
|
||||
};
|
||||
}
|
||||
|
||||
export function getWebPushConfig() {
|
||||
const env = getEnv();
|
||||
return {
|
||||
enabled: hasWebPushConfig(env),
|
||||
publicKey: hasWebPushConfig(env) ? env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function listIosNotificationTokens() {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
const rows = await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.where({ platform: 'ios' })
|
||||
.orderBy('updated_at', 'desc');
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
platform: row.platform,
|
||||
token: row.device_token,
|
||||
deviceId: row.device_id,
|
||||
enabled: row.is_enabled,
|
||||
lastRegisteredAt: row.last_registered_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function registerIosNotificationToken(payload: z.infer<typeof registerIosTokenSchema>) {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterIosNotificationToken(payload.token);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
token: payload.token,
|
||||
};
|
||||
}
|
||||
|
||||
await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.insert({
|
||||
platform: 'ios',
|
||||
device_token: payload.token,
|
||||
device_id: payload.deviceId ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.onConflict('device_token')
|
||||
.merge({
|
||||
platform: 'ios',
|
||||
device_id: payload.deviceId ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
token: payload.token,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAutomationNotificationPreference(raw: unknown): AutomationNotificationPreference {
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return automationNotificationPreferenceSchema.parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
return automationNotificationPreferenceSchema.parse(raw ?? {});
|
||||
}
|
||||
|
||||
export async function getAutomationNotificationPreference(
|
||||
targetId: string,
|
||||
targetKind: NotificationTargetKind = 'client',
|
||||
) {
|
||||
const normalizedTargetId = targetId.trim();
|
||||
|
||||
if (!normalizedTargetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureNotificationPreferenceTable();
|
||||
|
||||
const row = await db(NOTIFICATION_PREFERENCE_TABLE)
|
||||
.where({
|
||||
target_kind: targetKind,
|
||||
target_id: normalizedTargetId,
|
||||
})
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseAutomationNotificationPreference(row.config_json);
|
||||
}
|
||||
|
||||
export async function upsertAutomationNotificationPreference(
|
||||
payload: z.infer<typeof registerAutomationNotificationPreferenceSchema> & { targetId: string },
|
||||
) {
|
||||
const targetId = payload.targetId.trim();
|
||||
await ensureNotificationPreferenceTable();
|
||||
|
||||
await db(NOTIFICATION_PREFERENCE_TABLE)
|
||||
.insert({
|
||||
target_kind: payload.targetKind,
|
||||
target_id: targetId,
|
||||
config_json: payload.automation,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.onConflict(['target_kind', 'target_id'])
|
||||
.merge({
|
||||
config_json: payload.automation,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
targetKind: payload.targetKind,
|
||||
targetId,
|
||||
automation: payload.automation,
|
||||
};
|
||||
}
|
||||
|
||||
export async function unregisterIosNotificationToken(token: string) {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
const deletedCount = await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.where({
|
||||
platform: 'ios',
|
||||
device_token: token,
|
||||
})
|
||||
.delete();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: deletedCount > 0,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerWebPushSubscription(
|
||||
payload: z.infer<typeof registerWebPushSubscriptionSchema>,
|
||||
) {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterWebPushSubscription(payload.subscription.endpoint);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: true,
|
||||
endpoint: payload.subscription.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.insert({
|
||||
endpoint: payload.subscription.endpoint,
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.onConflict('endpoint')
|
||||
.merge({
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (payload.deviceId?.trim()) {
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.where({ device_id: payload.deviceId.trim() })
|
||||
.whereNot({ endpoint: payload.subscription.endpoint })
|
||||
.delete();
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
endpoint: payload.subscription.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export async function unregisterWebPushSubscription(endpoint: string) {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
const deletedCount = await db(WEB_PUSH_SUBSCRIPTION_TABLE).where({ endpoint }).delete();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removed: deletedCount > 0,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
async function getEnabledIosTokens() {
|
||||
await ensureNotificationTokenTable();
|
||||
|
||||
const rows = await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.where({
|
||||
platform: 'ios',
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('device_token', 'device_id');
|
||||
|
||||
return rows.map((row) => ({
|
||||
token: String(row.device_token),
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function getEnabledWebPushSubscriptions() {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.where({
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('endpoint', 'subscription_json', 'device_id');
|
||||
|
||||
return rows.map((row) => ({
|
||||
endpoint: String(row.endpoint),
|
||||
subscription: row.subscription_json as WebPushSubscriptionPayload,
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
async function removeInvalidIosTokens(tokens: string[]) {
|
||||
if (!tokens.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(NOTIFICATION_TOKEN_TABLE)
|
||||
.whereIn('device_token', tokens)
|
||||
.delete();
|
||||
}
|
||||
|
||||
async function removeInvalidWebPushSubscriptions(endpoints: string[]) {
|
||||
if (!endpoints.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.whereIn('endpoint', endpoints)
|
||||
.delete();
|
||||
}
|
||||
|
||||
function shouldNotifyAutomationEvent(
|
||||
automation: AutomationNotificationPreference | null | undefined,
|
||||
eventType: string,
|
||||
) {
|
||||
if (eventType === 'work-started') {
|
||||
return automation?.notifyOnAutomationStart ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'work-progress') {
|
||||
return automation?.notifyOnAutomationProgress ?? true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === 'work-completed' ||
|
||||
eventType === 'work-noop-complete' ||
|
||||
eventType === 'development-completed' ||
|
||||
eventType === 'plan-completed'
|
||||
) {
|
||||
return automation?.notifyOnAutomationCompletion ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'release-merged') {
|
||||
return automation?.notifyOnAutomationRelease ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'main-merged') {
|
||||
return automation?.notifyOnAutomationMain ?? true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === 'branch-failed' ||
|
||||
eventType === 'work-failed' ||
|
||||
eventType === 'release-failed' ||
|
||||
eventType === 'main-failed'
|
||||
) {
|
||||
return automation?.notifyOnAutomationFailure ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'plan-restarted') {
|
||||
return automation?.notifyOnAutomationRestart ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'issue-resolved') {
|
||||
return automation?.notifyOnAutomationIssueResolved ?? true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function isNotificationRecipientAllowed(
|
||||
preferenceTargets: NotificationPreferenceTarget[],
|
||||
payload: IosNotificationPayload,
|
||||
) {
|
||||
const eventType = payload.data.eventType?.trim();
|
||||
|
||||
if (!eventType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const target of preferenceTargets) {
|
||||
if (!target.id.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const automation = await getAutomationNotificationPreference(target.id, target.kind);
|
||||
|
||||
if (automation) {
|
||||
return shouldNotifyAutomationEvent(automation, eventType);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const provider = await getProvider();
|
||||
|
||||
if (!provider || !env.APNS_BUNDLE_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: 'APNs 설정이 비어 있습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenRows = await getEnabledIosTokens();
|
||||
const tokens = (
|
||||
await Promise.all(
|
||||
tokenRows.map(async (row) => ({
|
||||
token: row.token,
|
||||
allowed: await isNotificationRecipientAllowed(
|
||||
[
|
||||
{ kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) },
|
||||
{ kind: 'ios-token', id: row.token },
|
||||
{ kind: 'client', id: row.deviceId },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
})),
|
||||
)
|
||||
)
|
||||
.filter((row) => row.allowed)
|
||||
.map((row) => row.token);
|
||||
|
||||
if (!tokens.length) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '등록된 iOS 알림 토큰이 없습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const notification = new Notification();
|
||||
notification.topic = env.APNS_BUNDLE_ID;
|
||||
notification.pushType = 'alert';
|
||||
notification.priority = 10;
|
||||
notification.expiry = Math.floor(Date.now() / 1000) + 3600;
|
||||
notification.alert = {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
};
|
||||
notification.sound = 'default';
|
||||
notification.badge = 1;
|
||||
notification.payload = payload.data;
|
||||
if (payload.threadId) {
|
||||
notification.threadId = payload.threadId;
|
||||
}
|
||||
|
||||
const response = await provider.send(notification, tokens);
|
||||
const invalidTokens = response.failed
|
||||
.map((result) => result.device)
|
||||
.filter((device): device is string => Boolean(device));
|
||||
|
||||
await removeInvalidIosTokens(invalidTokens);
|
||||
|
||||
return {
|
||||
ok: response.failed.length === 0,
|
||||
skipped: false,
|
||||
sentCount: response.sent.length,
|
||||
failedCount: response.failed.length,
|
||||
invalidTokens,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: 'Web Push 설정이 비어 있습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const subscriptions = (
|
||||
await Promise.all(
|
||||
(await getEnabledWebPushSubscriptions()).map(async (row) => ({
|
||||
...row,
|
||||
allowed: await isNotificationRecipientAllowed(
|
||||
[
|
||||
{ kind: 'web-endpoint', id: row.endpoint },
|
||||
{ kind: 'client', id: row.deviceId },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
})),
|
||||
)
|
||||
).filter((row) => row.allowed);
|
||||
|
||||
if (!subscriptions.length) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '등록된 Web Push 구독이 없습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const payloadText = JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
threadId: payload.threadId,
|
||||
});
|
||||
const preserveSubscriptions = isChatNotificationPayload(payload);
|
||||
|
||||
const invalidEndpoints: string[] = [];
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
const failures: WebPushFailureDetail[] = [];
|
||||
|
||||
await Promise.all(
|
||||
subscriptions.map(async ({ endpoint, subscription }) => {
|
||||
try {
|
||||
await sendWebPushWithRetry(subscription, payloadText);
|
||||
sentCount += 1;
|
||||
} catch (error: any) {
|
||||
const deliveryError = error?.error ?? error;
|
||||
const attemptCount = Number(error?.attemptCount ?? 1);
|
||||
failedCount += 1;
|
||||
const statusCode = Number(deliveryError?.statusCode ?? 0);
|
||||
const detail =
|
||||
normalizeNotificationDetailText(deliveryError?.body) ?? normalizeNotificationDetailText(deliveryError?.message);
|
||||
const code = normalizeNotificationDetailText(deliveryError?.code);
|
||||
|
||||
if (!preserveSubscriptions && (statusCode === 404 || statusCode === 410)) {
|
||||
invalidEndpoints.push(endpoint);
|
||||
}
|
||||
|
||||
failures.push({
|
||||
endpoint,
|
||||
statusCode: statusCode || undefined,
|
||||
detail,
|
||||
code,
|
||||
attemptCount,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!preserveSubscriptions) {
|
||||
await removeInvalidWebPushSubscriptions(invalidEndpoints);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.warn(
|
||||
'[notification-service] web push delivery failed',
|
||||
JSON.stringify({
|
||||
failedCount,
|
||||
preserveSubscriptions,
|
||||
invalidEndpointCount: invalidEndpoints.length,
|
||||
failures,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: failedCount === 0,
|
||||
skipped: false,
|
||||
sentCount,
|
||||
failedCount,
|
||||
invalidEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendNotifications(payload: IosNotificationPayload) {
|
||||
const [ios, web] = await Promise.all([
|
||||
sendIosNotifications(payload),
|
||||
sendWebPushNotifications(payload),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: ios.ok || web.ok,
|
||||
ios,
|
||||
web,
|
||||
};
|
||||
}
|
||||
|
||||
export async function shutdownNotificationProvider() {
|
||||
const provider = await getProvider();
|
||||
provider?.shutdown();
|
||||
providerPromise = null;
|
||||
}
|
||||
3
etc/servers/work-server/src/services/plan-notification-policy.ts
Executable file
3
etc/servers/work-server/src/services/plan-notification-policy.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export function shouldNotifyPlanRestart(result: { didScheduleRetry?: boolean } | null | undefined) {
|
||||
return Boolean(result?.didScheduleRetry);
|
||||
}
|
||||
114
etc/servers/work-server/src/services/plan-notification-service.ts
Executable file
114
etc/servers/work-server/src/services/plan-notification-service.ts
Executable file
@@ -0,0 +1,114 @@
|
||||
import { sendNotifications } from './notification-service.js';
|
||||
import type { AppConfigSnapshot } from './app-config-service.js';
|
||||
import { getPlanItemById } from './plan-service.js';
|
||||
|
||||
function buildPlanNotificationTargetUrl(planId: number, workId: string | null | undefined) {
|
||||
const targetUrl = new URL('https://sm-home.cloud/');
|
||||
|
||||
targetUrl.searchParams.set('topMenu', 'plans');
|
||||
targetUrl.searchParams.set('planId', String(planId));
|
||||
|
||||
if (workId?.trim()) {
|
||||
targetUrl.searchParams.set('workId', workId.trim());
|
||||
}
|
||||
|
||||
return targetUrl.toString();
|
||||
}
|
||||
|
||||
export function shouldNotifyPlanEventType(
|
||||
automation: AppConfigSnapshot['automation'] | undefined,
|
||||
eventType: string,
|
||||
) {
|
||||
if (eventType === 'work-started') {
|
||||
return automation?.notifyOnAutomationStart ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'work-progress') {
|
||||
return automation?.notifyOnAutomationProgress ?? true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === 'work-completed' ||
|
||||
eventType === 'work-noop-complete' ||
|
||||
eventType === 'development-completed' ||
|
||||
eventType === 'plan-completed'
|
||||
) {
|
||||
return automation?.notifyOnAutomationCompletion ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'release-merged') {
|
||||
return automation?.notifyOnAutomationRelease ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'main-merged') {
|
||||
return automation?.notifyOnAutomationMain ?? true;
|
||||
}
|
||||
|
||||
if (
|
||||
eventType === 'branch-failed' ||
|
||||
eventType === 'work-failed' ||
|
||||
eventType === 'release-failed' ||
|
||||
eventType === 'main-failed'
|
||||
) {
|
||||
return automation?.notifyOnAutomationFailure ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'plan-restarted') {
|
||||
return automation?.notifyOnAutomationRestart ?? true;
|
||||
}
|
||||
|
||||
if (eventType === 'issue-resolved') {
|
||||
return automation?.notifyOnAutomationIssueResolved ?? true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function shouldSendPlanNotification(eventType: string) {
|
||||
void eventType;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildPlanNotificationData(planId: number, workId: string | null | undefined, eventType: string) {
|
||||
return {
|
||||
category: 'automation',
|
||||
planId: String(planId),
|
||||
workId: String(workId ?? ''),
|
||||
eventType,
|
||||
notificationScope: 'automation',
|
||||
targetUrl: buildPlanNotificationTargetUrl(planId, workId),
|
||||
notificationKey: `plan:${planId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function notifyPlanEvent(
|
||||
planId: number,
|
||||
title: string,
|
||||
body: string,
|
||||
eventType: string,
|
||||
) {
|
||||
if (!(await shouldSendPlanNotification(eventType))) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '앱 설정에서 이 알림 항목이 꺼져 있습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const item = await getPlanItemById(planId);
|
||||
|
||||
if (!item) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
reason: '작업 항목을 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return sendNotifications({
|
||||
title,
|
||||
body,
|
||||
threadId: `plan-${planId}`,
|
||||
data: buildPlanNotificationData(planId, String(item.workId), eventType),
|
||||
});
|
||||
}
|
||||
54
etc/servers/work-server/src/services/plan-policy.test.ts
Executable file
54
etc/servers/work-server/src/services/plan-policy.test.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildPlanNotificationData } from './plan-notification-service.js';
|
||||
import { shouldNotifyPlanRestart } from './plan-notification-policy.js';
|
||||
import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js';
|
||||
import { issueActionSchema } from './plan-service.js';
|
||||
|
||||
test('shouldTriggerRetryFromActionNote detects missing-fix and verification follow-up requests', () => {
|
||||
assert.equal(shouldTriggerRetryFromActionNote('누락된 거 다시 고쳐서 테스트해 줘'), true);
|
||||
assert.equal(shouldTriggerRetryFromActionNote('빠진 내용 보완하고 검증해줘'), true);
|
||||
});
|
||||
|
||||
test('shouldTriggerRetryFromActionNote ignores simple status comments', () => {
|
||||
assert.equal(shouldTriggerRetryFromActionNote('확인했습니다. 메모만 남깁니다.'), false);
|
||||
});
|
||||
|
||||
test('issueActionSchema defaults retry to false', () => {
|
||||
assert.deepEqual(
|
||||
issueActionSchema.parse({
|
||||
actionNote: '원인 분석과 조치 내용을 남깁니다.',
|
||||
}),
|
||||
{
|
||||
actionNote: '원인 분석과 조치 내용을 남깁니다.',
|
||||
resolve: false,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldNotifyPlanRestart only allows restart alerts when a retry was actually scheduled', () => {
|
||||
assert.equal(
|
||||
shouldNotifyPlanRestart({
|
||||
didScheduleRetry: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldNotifyPlanRestart({
|
||||
didScheduleRetry: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldNotifyPlanRestart(null), false);
|
||||
});
|
||||
|
||||
test('buildPlanNotificationData uses stable task key per plan', () => {
|
||||
assert.deepEqual(buildPlanNotificationData(17, 'WK-123', 'plan-restarted'), {
|
||||
planId: '17',
|
||||
workId: 'WK-123',
|
||||
eventType: 'plan-restarted',
|
||||
targetUrl: 'https://sm-home.cloud/?topMenu=plans&planId=17&workId=WK-123',
|
||||
notificationKey: 'plan:17',
|
||||
});
|
||||
});
|
||||
11
etc/servers/work-server/src/services/plan-retry-policy.ts
Executable file
11
etc/servers/work-server/src/services/plan-retry-policy.ts
Executable file
@@ -0,0 +1,11 @@
|
||||
export function shouldTriggerRetryFromActionNote(actionNote: string) {
|
||||
const text = String(actionNote ?? '').trim();
|
||||
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /(재처리|다시|누락|빠진|빼먹|보완|조치해|처리해|해결해|고쳐|반영해|수정해|시도해|진행해|테스트해|검증해|부탁)/.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
540
etc/servers/work-server/src/services/plan-schedule-service.ts
Executable file
540
etc/servers/work-server/src/services/plan-schedule-service.ts
Executable file
@@ -0,0 +1,540 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
createCompletedPlanExecutionLogItem,
|
||||
createPlanActionHistory,
|
||||
createPlanItem,
|
||||
ensurePlanTable,
|
||||
normalizePlanAutomationType,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-service.js';
|
||||
import { getKstNowParts } from './worklog-automation-utils.js';
|
||||
import { registerErrorLogBoardPosts } from './error-log-plan-registration-service.js';
|
||||
|
||||
export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks';
|
||||
const scheduleModes = ['interval', 'daily'] as const;
|
||||
const repeatIntervalUnits = ['minute', 'hour', 'day', 'week', 'month'] as const;
|
||||
const DEFAULT_DAILY_RUN_TIME = '09:00';
|
||||
|
||||
export const createPlanScheduledTaskSchema = z.object({
|
||||
workId: z.string().trim().optional().default('반복작업'),
|
||||
note: z.string().default(''),
|
||||
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
||||
releaseTarget: z.string().trim().min(1).default('release'),
|
||||
jangsingProcessingRequired: z.boolean().default(true),
|
||||
autoDeployToMain: z.boolean().default(true),
|
||||
enabled: z.boolean().default(true),
|
||||
immediateRunEnabled: z.boolean().default(true),
|
||||
scheduleMode: z.enum(scheduleModes).default('interval'),
|
||||
repeatIntervalValue: z.coerce.number().int().min(1).max(525600).default(60),
|
||||
repeatIntervalUnit: z.enum(repeatIntervalUnits).default('minute'),
|
||||
repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(),
|
||||
dailyRunTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME),
|
||||
});
|
||||
|
||||
export const updatePlanScheduledTaskSchema = createPlanScheduledTaskSchema.partial();
|
||||
|
||||
function normalizeScheduledWorkId(value?: string | null) {
|
||||
const workId = String(value ?? '').trim();
|
||||
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
if (!workId || normalized === '작업id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') {
|
||||
return '반복작업';
|
||||
}
|
||||
|
||||
return workId;
|
||||
}
|
||||
|
||||
function normalizeRepeatIntervalMinutes(value: unknown) {
|
||||
const numericValue = Number(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
return Math.min(525600, Math.max(1, Math.round(numericValue)));
|
||||
}
|
||||
|
||||
function normalizeRepeatIntervalValue(value: unknown) {
|
||||
const numericValue = Number(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return 60;
|
||||
}
|
||||
|
||||
return Math.min(525600, Math.max(1, Math.round(numericValue)));
|
||||
}
|
||||
|
||||
function normalizeRepeatIntervalUnit(value: unknown): (typeof repeatIntervalUnits)[number] {
|
||||
return repeatIntervalUnits.includes(value as (typeof repeatIntervalUnits)[number])
|
||||
? (value as (typeof repeatIntervalUnits)[number])
|
||||
: 'minute';
|
||||
}
|
||||
|
||||
function normalizeScheduleMode(value: unknown): (typeof scheduleModes)[number] {
|
||||
return scheduleModes.includes(value as (typeof scheduleModes)[number])
|
||||
? (value as (typeof scheduleModes)[number])
|
||||
: 'interval';
|
||||
}
|
||||
|
||||
function normalizeDailyRunTime(value: unknown) {
|
||||
return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
|
||||
? value
|
||||
: DEFAULT_DAILY_RUN_TIME;
|
||||
}
|
||||
|
||||
function toRepeatIntervalMinutes(value: unknown, unit: unknown) {
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(value);
|
||||
const repeatIntervalUnit = normalizeRepeatIntervalUnit(unit);
|
||||
|
||||
if (repeatIntervalUnit === 'day') {
|
||||
return repeatIntervalValue * 24 * 60;
|
||||
}
|
||||
|
||||
if (repeatIntervalUnit === 'week') {
|
||||
return repeatIntervalValue * 7 * 24 * 60;
|
||||
}
|
||||
|
||||
if (repeatIntervalUnit === 'month') {
|
||||
return repeatIntervalValue * 30 * 24 * 60;
|
||||
}
|
||||
|
||||
if (repeatIntervalUnit === 'hour') {
|
||||
return repeatIntervalValue * 60;
|
||||
}
|
||||
|
||||
return repeatIntervalValue;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 0 || value === '0' || value === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value === 1 || value === '1' || value === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatScheduleWorkId(workId: string, date: Date) {
|
||||
const timestamp = [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
String(date.getHours()).padStart(2, '0'),
|
||||
String(date.getMinutes()).padStart(2, '0'),
|
||||
].join('');
|
||||
|
||||
return `${normalizeScheduledWorkId(workId)}-${timestamp}`;
|
||||
}
|
||||
|
||||
function getKstDateKey(value: unknown) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(String(value));
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getKstNowParts(date).dateKey;
|
||||
}
|
||||
|
||||
function isDailyScheduleDue(row: Record<string, unknown>, now: Date) {
|
||||
const nowParts = getKstNowParts(now);
|
||||
const [hours, minutes] = normalizeDailyRunTime(row.daily_run_time).split(':').map((value) => Number(value));
|
||||
const scheduledMinutesOfDay = hours * 60 + minutes;
|
||||
return nowParts.minutesOfDay >= scheduledMinutesOfDay && getKstDateKey(row.last_registered_at) !== nowParts.dateKey;
|
||||
}
|
||||
|
||||
function isIntervalScheduleDue(row: Record<string, unknown>, now: Date) {
|
||||
const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null;
|
||||
const intervalBaseAt = lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime())
|
||||
? lastRegisteredAt
|
||||
: new Date(String(row.created_at ?? now.toISOString()));
|
||||
|
||||
if (Number.isNaN(intervalBaseAt.getTime())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const repeatIntervalMinutes = normalizeRepeatIntervalMinutes(
|
||||
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit),
|
||||
);
|
||||
return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalMinutes * 60 * 1000;
|
||||
}
|
||||
|
||||
function isScheduleDue(row: Record<string, unknown>, now: Date) {
|
||||
if (!row.last_registered_at && normalizeBoolean(row.immediate_run_enabled, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizeScheduleMode(row.schedule_mode) === 'daily'
|
||||
? isDailyScheduleDue(row, now)
|
||||
: isIntervalScheduleDue(row, now);
|
||||
}
|
||||
|
||||
export function mapPlanScheduledTaskRow(row: Record<string, unknown>) {
|
||||
return {
|
||||
id: row.id,
|
||||
workId: row.work_id,
|
||||
note: row.note,
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
releaseTarget: row.release_target,
|
||||
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
enabled: Boolean(row.enabled ?? true),
|
||||
immediateRunEnabled: normalizeBoolean(row.immediate_run_enabled, true),
|
||||
scheduleMode: normalizeScheduleMode(row.schedule_mode),
|
||||
repeatIntervalValue: Number(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60),
|
||||
repeatIntervalUnit: normalizeRepeatIntervalUnit(row.repeat_interval_unit),
|
||||
repeatIntervalMinutes: normalizeRepeatIntervalMinutes(
|
||||
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value ?? 60, row.repeat_interval_unit),
|
||||
),
|
||||
dailyRunTime: normalizeDailyRunTime(row.daily_run_time),
|
||||
lastRegisteredAt: row.last_registered_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensurePlanScheduledTaskColumn(
|
||||
columnName: string,
|
||||
addColumn: (table: any) => void,
|
||||
) {
|
||||
const hasColumn = await db.schema.hasColumn(PLAN_SCHEDULED_TASK_TABLE, columnName);
|
||||
|
||||
if (hasColumn) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.schema.alterTable(PLAN_SCHEDULED_TASK_TABLE, (table) => {
|
||||
addColumn(table);
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensurePlanScheduledTaskTable() {
|
||||
const exists = await db.schema.hasTable(PLAN_SCHEDULED_TASK_TABLE);
|
||||
|
||||
if (!exists) {
|
||||
await db.schema.createTable(PLAN_SCHEDULED_TASK_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('work_id', 120).notNullable().defaultTo('반복작업');
|
||||
table.text('note').notNullable().defaultTo('');
|
||||
table.string('automation_type', 40).notNullable().defaultTo('none');
|
||||
table.string('release_target', 120).notNullable().defaultTo('release');
|
||||
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
|
||||
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.boolean('immediate_run_enabled').notNullable().defaultTo(true);
|
||||
table.string('schedule_mode', 20).notNullable().defaultTo('interval');
|
||||
table.integer('repeat_interval_value').notNullable().defaultTo(60);
|
||||
table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute');
|
||||
table.integer('repeat_interval_minutes').notNullable().defaultTo(60);
|
||||
table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME);
|
||||
table.timestamp('last_registered_at', { useTz: true }).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ensurePlanScheduledTaskColumn('release_target', (table) => {
|
||||
table.string('release_target', 120).notNullable().defaultTo('release');
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('automation_type', (table) => {
|
||||
table.string('automation_type', 40).notNullable().defaultTo('none');
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => {
|
||||
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('auto_deploy_to_main', (table) => {
|
||||
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('enabled', (table) => {
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('immediate_run_enabled', (table) => {
|
||||
table.boolean('immediate_run_enabled').notNullable().defaultTo(true);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('schedule_mode', (table) => {
|
||||
table.string('schedule_mode', 20).notNullable().defaultTo('interval');
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('repeat_interval_value', (table) => {
|
||||
table.integer('repeat_interval_value').notNullable().defaultTo(60);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('repeat_interval_unit', (table) => {
|
||||
table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute');
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('repeat_interval_minutes', (table) => {
|
||||
table.integer('repeat_interval_minutes').notNullable().defaultTo(60);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('daily_run_time', (table) => {
|
||||
table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME);
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('last_registered_at', (table) => {
|
||||
table.timestamp('last_registered_at', { useTz: true }).nullable();
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('created_at', (table) => {
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
await ensurePlanScheduledTaskColumn('updated_at', (table) => {
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ automation_type: 'plan_registration' })
|
||||
.update({ automation_type: 'plan' });
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ automation_type: 'general_development' })
|
||||
.update({ automation_type: 'auto_worker' });
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ repeat_interval_unit: 'minute' })
|
||||
.update({
|
||||
repeat_interval_value: db.raw('repeat_interval_minutes'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPlanScheduledTasks() {
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
return db(PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
export async function getPlanScheduledTaskById(id: number) {
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
return db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first();
|
||||
}
|
||||
|
||||
export async function createPlanScheduledTask(payload: z.infer<typeof createPlanScheduledTaskSchema>) {
|
||||
await ensurePlanScheduledTaskTable();
|
||||
const scheduleMode = normalizeScheduleMode(payload.scheduleMode);
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue);
|
||||
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit);
|
||||
|
||||
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.insert({
|
||||
work_id: normalizeScheduledWorkId(payload.workId),
|
||||
note: payload.note,
|
||||
automation_type: normalizePlanAutomationType(payload.automationType),
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
enabled: payload.enabled,
|
||||
immediate_run_enabled: payload.immediateRunEnabled,
|
||||
schedule_mode: scheduleMode,
|
||||
repeat_interval_value: repeatIntervalValue,
|
||||
repeat_interval_unit: repeatIntervalUnit,
|
||||
repeat_interval_minutes: normalizeRepeatIntervalMinutes(
|
||||
payload.repeatIntervalMinutes ?? toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
|
||||
),
|
||||
daily_run_time: normalizeDailyRunTime(payload.dailyRunTime),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updatePlanScheduledTask(id: number, payload: z.infer<typeof updatePlanScheduledTaskSchema>) {
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first();
|
||||
|
||||
if (!currentRow) {
|
||||
return null;
|
||||
}
|
||||
const scheduleMode = normalizeScheduleMode(payload.scheduleMode ?? currentRow.schedule_mode);
|
||||
const repeatIntervalValue = normalizeRepeatIntervalValue(
|
||||
payload.repeatIntervalValue ?? currentRow.repeat_interval_value ?? currentRow.repeat_interval_minutes ?? 60,
|
||||
);
|
||||
const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit ?? currentRow.repeat_interval_unit);
|
||||
|
||||
const rows = await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id })
|
||||
.update({
|
||||
work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId),
|
||||
note: payload.note ?? currentRow.note,
|
||||
automation_type: normalizePlanAutomationType(payload.automationType ?? currentRow.automation_type),
|
||||
release_target: payload.releaseTarget ?? currentRow.release_target ?? 'release',
|
||||
jangsing_processing_required:
|
||||
payload.jangsingProcessingRequired ?? currentRow.jangsing_processing_required ?? true,
|
||||
auto_deploy_to_main: payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true,
|
||||
enabled: payload.enabled ?? currentRow.enabled ?? true,
|
||||
immediate_run_enabled: payload.immediateRunEnabled ?? currentRow.immediate_run_enabled ?? true,
|
||||
schedule_mode: scheduleMode,
|
||||
repeat_interval_value: repeatIntervalValue,
|
||||
repeat_interval_unit: repeatIntervalUnit,
|
||||
repeat_interval_minutes: normalizeRepeatIntervalMinutes(
|
||||
payload.repeatIntervalMinutes
|
||||
?? toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit)
|
||||
?? currentRow.repeat_interval_minutes
|
||||
?? 60,
|
||||
),
|
||||
daily_run_time: normalizeDailyRunTime(payload.dailyRunTime ?? currentRow.daily_run_time),
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function deletePlanScheduledTask(id: number) {
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first();
|
||||
|
||||
if (!currentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).delete();
|
||||
|
||||
return currentRow;
|
||||
}
|
||||
|
||||
export async function registerDuePlanScheduledTasks(now = new Date()) {
|
||||
await ensurePlanTable();
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
const rows = await db(PLAN_SCHEDULED_TASK_TABLE).where({ enabled: true }).orderBy('id', 'asc');
|
||||
const registered = [];
|
||||
|
||||
for (const row of rows.filter((item) => isScheduleDue(item, now))) {
|
||||
const registration = await registerPlanScheduledTaskRow(row, now);
|
||||
|
||||
if (registration.createdPlan || registration.createdBoardPosts.length > 0) {
|
||||
registered.push(registration);
|
||||
}
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
async function registerPlanScheduledTaskRow(row: Record<string, unknown>, now: Date) {
|
||||
if (normalizePlanAutomationType(row.automation_type) === 'plan') {
|
||||
const rangeEnd = now;
|
||||
const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null;
|
||||
const rangeStart =
|
||||
lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime())
|
||||
? lastRegisteredAt
|
||||
: new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const registration = await registerErrorLogBoardPosts({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
});
|
||||
const executionLogNoteLines = [
|
||||
`Plan 스케줄 #${row.id} 실행 이력입니다.`,
|
||||
`조회 구간: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`,
|
||||
`신규 게시글 등록: ${registration.createdPosts.length}건`,
|
||||
`중복 제외: ${registration.skippedPosts.length}건`,
|
||||
];
|
||||
|
||||
if (registration.createdPosts.length > 0) {
|
||||
executionLogNoteLines.push('');
|
||||
executionLogNoteLines.push('등록된 게시글:');
|
||||
executionLogNoteLines.push(
|
||||
...registration.createdPosts.map((post) => `- 게시글 #${post.postId} ${post.workId} (${post.count}건)`),
|
||||
);
|
||||
}
|
||||
|
||||
if (registration.skippedPosts.length > 0) {
|
||||
executionLogNoteLines.push('');
|
||||
executionLogNoteLines.push('제외된 항목:');
|
||||
executionLogNoteLines.push(
|
||||
...registration.skippedPosts.map((post) => `- ${post.workId}: ${post.reason}`),
|
||||
);
|
||||
}
|
||||
|
||||
const executionLog = await createCompletedPlanExecutionLogItem({
|
||||
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
|
||||
note: executionLogNoteLines.join('\n'),
|
||||
automationType: 'plan',
|
||||
releaseTarget: String(row.release_target ?? 'release'),
|
||||
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: normalizeRepeatIntervalMinutes(
|
||||
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit),
|
||||
),
|
||||
});
|
||||
|
||||
await createPlanActionHistory(
|
||||
Number(executionLog.id),
|
||||
'스케줄등록',
|
||||
`Plan 스케줄 #${row.id} 실행 이력을 저장했습니다.`,
|
||||
);
|
||||
await createPlanActionHistory(
|
||||
Number(executionLog.id),
|
||||
'완료처리',
|
||||
registration.createdPosts.length > 0
|
||||
? `Plan 게시판 글 ${registration.createdPosts.length}건을 등록했습니다.`
|
||||
: '등록할 신규 Plan 게시판 글이 없었습니다.',
|
||||
);
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id: row.id })
|
||||
.update({
|
||||
last_registered_at: now,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
createdPlan: executionLog,
|
||||
createdBoardPosts: registration.createdPosts,
|
||||
};
|
||||
}
|
||||
|
||||
const repeatIntervalMinutes = normalizeRepeatIntervalMinutes(
|
||||
row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit),
|
||||
);
|
||||
const createdPlan = await createPlanItem({
|
||||
workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now),
|
||||
note: String(row.note ?? ''),
|
||||
automationType: normalizePlanAutomationType(row.automation_type),
|
||||
releaseTarget: String(row.release_target ?? 'release'),
|
||||
jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true),
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes,
|
||||
});
|
||||
|
||||
await db(PLAN_SCHEDULED_TASK_TABLE)
|
||||
.where({ id: row.id })
|
||||
.update({
|
||||
last_registered_at: now,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
await createPlanActionHistory(
|
||||
Number(createdPlan.id),
|
||||
'스케줄등록',
|
||||
`Plan 스케줄 #${row.id} 반복 작업에서 등록했습니다.`,
|
||||
);
|
||||
|
||||
return {
|
||||
createdPlan,
|
||||
createdBoardPosts: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerPlanScheduledTaskNow(id: number, now = new Date()) {
|
||||
await ensurePlanTable();
|
||||
await ensurePlanScheduledTaskTable();
|
||||
|
||||
const row = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id, enabled: true }).first();
|
||||
|
||||
if (!row || !isScheduleDue(row, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return registerPlanScheduledTaskRow(row, now);
|
||||
}
|
||||
73
etc/servers/work-server/src/services/plan-service.test.ts
Executable file
73
etc/servers/work-server/src/services/plan-service.test.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildPlanBranchName,
|
||||
filterRetryWorklogEvidencePaths,
|
||||
listPlanQuerySchema,
|
||||
maskPlanNote,
|
||||
normalizeChangedFiles,
|
||||
shouldResumePlanDevelopmentFromIssueAction,
|
||||
} from './plan-service.js';
|
||||
|
||||
test('maskPlanNote masks note like the requested preview format', () => {
|
||||
assert.equal(maskPlanNote('테스트 작업 실행'), '테** 작업 *행');
|
||||
});
|
||||
|
||||
test('maskPlanNote collapses spaces and masks single-word notes', () => {
|
||||
assert.equal(maskPlanNote(' 긴급메모 '), '긴***');
|
||||
assert.equal(maskPlanNote('가'), '*');
|
||||
});
|
||||
|
||||
test('normalizeChangedFiles removes blanks and duplicate paths while preserving order', () => {
|
||||
assert.deepEqual(normalizeChangedFiles([' a.ts ', '', 'a.ts', 'b.ts', ' ', 'b.ts']), ['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
test('filterRetryWorklogEvidencePaths keeps non-worklog files and removes repeated worklog artifacts', () => {
|
||||
const changedFiles = [
|
||||
'docs/worklogs/2026-04-07.md',
|
||||
'docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png',
|
||||
'src/features/planBoard/PlanBoardPage.tsx',
|
||||
'docs/assets/worklogs/2026-04-07/run.log',
|
||||
];
|
||||
const existingChangedFilesList = [
|
||||
['docs/worklogs/2026-04-07.md', 'docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png'],
|
||||
];
|
||||
|
||||
assert.deepEqual(filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList), [
|
||||
'src/features/planBoard/PlanBoardPage.tsx',
|
||||
'docs/assets/worklogs/2026-04-07/run.log',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shouldResumePlanDevelopmentFromIssueAction only resumes release-complete items when retry is requested', () => {
|
||||
assert.equal(shouldResumePlanDevelopmentFromIssueAction('릴리즈완료', true), true);
|
||||
assert.equal(shouldResumePlanDevelopmentFromIssueAction('릴리즈완료', false), false);
|
||||
assert.equal(shouldResumePlanDevelopmentFromIssueAction('작업중', true), false);
|
||||
});
|
||||
|
||||
test('buildPlanBranchName uses hotfix prefix for auto-worklog plans and feature prefix otherwise', () => {
|
||||
assert.equal(buildPlanBranchName('auto-worklog-2026-04-09', 187), 'hotfix/plan-187-auto-worklog-2026-04-09');
|
||||
assert.equal(buildPlanBranchName('new-play-ground', 182), 'feature/plan-182-new-play-ground');
|
||||
});
|
||||
|
||||
test('listPlanQuerySchema accepts legacy filter aliases without throwing', () => {
|
||||
assert.deepEqual(listPlanQuerySchema.parse({ status: 'in-progress' }), {
|
||||
status: undefined,
|
||||
});
|
||||
assert.deepEqual(listPlanQuerySchema.parse({ status: 'done' }), {
|
||||
status: undefined,
|
||||
});
|
||||
assert.deepEqual(listPlanQuerySchema.parse({ status: 'error' }), {
|
||||
status: undefined,
|
||||
});
|
||||
assert.deepEqual(listPlanQuerySchema.parse({ status: 'all' }), {
|
||||
status: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('listPlanQuerySchema keeps exact plan statuses and rejects unknown values', () => {
|
||||
assert.deepEqual(listPlanQuerySchema.parse({ status: '작업중' }), {
|
||||
status: '작업중',
|
||||
});
|
||||
assert.throws(() => listPlanQuerySchema.parse({ status: 'processing' }));
|
||||
});
|
||||
2933
etc/servers/work-server/src/services/plan-service.ts
Executable file
2933
etc/servers/work-server/src/services/plan-service.ts
Executable file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
buildHealthCheckUrls,
|
||||
buildRestartFailureMessage,
|
||||
buildServerCommandApiRestartUrl,
|
||||
listServerCommands,
|
||||
resolveDockerSocketPath,
|
||||
restartServerCommand,
|
||||
} from './server-command-service.js';
|
||||
|
||||
test('buildRestartFailureMessage includes exit info and stderr output', () => {
|
||||
const message = buildRestartFailureMessage(
|
||||
'TEST',
|
||||
Object.assign(new Error('Command failed'), {
|
||||
code: 1,
|
||||
stderr: 'no such service: app',
|
||||
stdout: '',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(message, /TEST 재기동에 실패했습니다\./);
|
||||
assert.match(message, /exit:1/);
|
||||
assert.match(message, /no such service: app/);
|
||||
});
|
||||
|
||||
test('listServerCommands uses app as the default test restart service', async () => {
|
||||
const commands = await listServerCommands();
|
||||
const testCommand = commands.find((item) => item.key === 'test');
|
||||
|
||||
assert.ok(testCommand);
|
||||
assert.equal(testCommand.serviceName, 'app');
|
||||
});
|
||||
|
||||
test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => {
|
||||
const commands = await listServerCommands();
|
||||
const testCommand = commands.find((item) => item.key === 'test');
|
||||
|
||||
assert.ok(testCommand);
|
||||
assert.match(testCommand.commandScript, /\/etc\/commands\/server-command\/restart-test\.sh$/);
|
||||
assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh');
|
||||
});
|
||||
|
||||
test('test and release restart scripts fall back to Docker socket when docker CLI is unavailable', () => {
|
||||
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
|
||||
const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8');
|
||||
const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8');
|
||||
const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');
|
||||
|
||||
assert.match(testScript, /command -v docker >/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(testScript, /restart-via-docker-socket\.mjs/);
|
||||
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
|
||||
assert.match(relScript, /command -v docker >/);
|
||||
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(relScript, /restart-via-docker-socket\.mjs/);
|
||||
assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/);
|
||||
assert.match(
|
||||
workServerScript,
|
||||
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
||||
);
|
||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||
});
|
||||
|
||||
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 {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
|
||||
assert.equal(packageJson.scripts?.dev, 'npm run build && npm run start');
|
||||
assert.doesNotMatch(String(packageJson.scripts?.dev ?? ''), /\bwatch\b/i);
|
||||
});
|
||||
|
||||
test('buildServerCommandApiRestartUrl replaces key placeholders on configured command api endpoint', () => {
|
||||
assert.equal(
|
||||
buildServerCommandApiRestartUrl('http://127.0.0.1:3200/', '/commands/{key}/restart', 'work-server'),
|
||||
'http://127.0.0.1:3200/commands/work-server/restart',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildServerCommandApiRestartUrl avoids duplicating an existing base path prefix', () => {
|
||||
assert.equal(
|
||||
buildServerCommandApiRestartUrl(
|
||||
'http://127.0.0.1:3211/api',
|
||||
'/api/server-commands/{key}/actions/restart',
|
||||
'test',
|
||||
),
|
||||
'http://127.0.0.1:3211/api/server-commands/test/actions/restart',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildHealthCheckUrls adds localhost fallbacks for command-runner', () => {
|
||||
assert.deepEqual(buildHealthCheckUrls('command-runner', 'http://host.docker.internal:3211/health'), [
|
||||
'http://host.docker.internal:3211/health',
|
||||
'http://127.0.0.1:3211/health',
|
||||
'http://localhost:3211/health',
|
||||
]);
|
||||
assert.deepEqual(buildHealthCheckUrls('work-server', 'http://host.docker.internal:3100/health'), [
|
||||
'http://host.docker.internal:3100/health',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveDockerSocketPath prefers explicit socket path and falls back to unix DOCKER_HOST', () => {
|
||||
assert.equal(
|
||||
resolveDockerSocketPath({
|
||||
SERVER_COMMAND_DOCKER_SOCKET: '/custom/docker.sock',
|
||||
DOCKER_HOST: 'unix:///run/user/1000/docker.sock',
|
||||
}),
|
||||
'/custom/docker.sock',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveDockerSocketPath({
|
||||
DOCKER_HOST: 'unix:///run/user/1000/docker.sock',
|
||||
}),
|
||||
'/run/user/1000/docker.sock',
|
||||
);
|
||||
});
|
||||
|
||||
test('restartServerCommand delegates to configured command api when base url is provided', async (t) => {
|
||||
const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL;
|
||||
const originalAccessToken = env.SERVER_COMMAND_API_ACCESS_TOKEN;
|
||||
const originalPathTemplate = env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE;
|
||||
|
||||
env.SERVER_COMMAND_API_BASE_URL = 'http://127.0.0.1:3200/api';
|
||||
env.SERVER_COMMAND_API_ACCESS_TOKEN = 'local-command-token';
|
||||
env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = '/commands/{key}/restart';
|
||||
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request, init?: RequestInit) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:3200/api/commands/test/restart');
|
||||
assert.equal(init?.method, 'POST');
|
||||
const headers = new Headers(init?.headers);
|
||||
assert.equal(headers.get('X-Access-Token'), 'local-command-token');
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
restartState: 'accepted',
|
||||
item: {
|
||||
key: 'test',
|
||||
label: 'TEST',
|
||||
composeStatus: 'restarting',
|
||||
},
|
||||
commandOutput: 'restart accepted',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand('test');
|
||||
|
||||
assert.equal(fetchMock.mock.callCount(), 1);
|
||||
assert.equal(result.restartState, 'accepted');
|
||||
assert.equal(result.commandOutput, 'restart accepted');
|
||||
assert.equal(result.server.key, 'test');
|
||||
assert.equal(result.server.composeStatus, 'restarting');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl;
|
||||
env.SERVER_COMMAND_API_ACCESS_TOKEN = originalAccessToken;
|
||||
env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = originalPathTemplate;
|
||||
}
|
||||
});
|
||||
|
||||
test('restartServerCommand surfaces deferred restart script failures for work-server', async (t) => {
|
||||
const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL;
|
||||
env.SERVER_COMMAND_API_BASE_URL = '';
|
||||
const childProcessModule = (await import('node:child_process')) as { spawn: (...args: unknown[]) => unknown };
|
||||
|
||||
const spawnMock = t.mock.method(
|
||||
childProcessModule,
|
||||
'spawn',
|
||||
(...spawnArgs: unknown[]) => {
|
||||
const args = Array.isArray(spawnArgs[1]) ? (spawnArgs[1] as string[]) : [];
|
||||
const shellCommand = String(args[1] ?? '');
|
||||
const logPath = shellCommand.match(/>"([^"]+\.log)"/)?.[1] ?? '';
|
||||
const statusPath = shellCommand.match(/>"([^"]+\.status)"/)?.[1] ?? '';
|
||||
|
||||
queueMicrotask(() => {
|
||||
void writeFile(statusPath, '1', 'utf8');
|
||||
void writeFile(logPath, 'docker compose failed', 'utf8');
|
||||
});
|
||||
|
||||
const child = new EventEmitter() as EventEmitter & { unref(): void };
|
||||
child.unref = () => undefined;
|
||||
queueMicrotask(() => {
|
||||
child.emit('spawn');
|
||||
});
|
||||
return child;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await assert.rejects(() => restartServerCommand('work-server'), /WORK-SERVER 재기동에 실패했습니다\./);
|
||||
assert.equal(spawnMock.mock.callCount(), 1);
|
||||
} finally {
|
||||
env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl;
|
||||
}
|
||||
});
|
||||
|
||||
test('listServerCommands marks command-runner online when localhost fallback responds', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url === 'http://host.docker.internal:3211/health') {
|
||||
throw new Error('fetch failed');
|
||||
}
|
||||
|
||||
if (url === 'http://127.0.0.1:3211/health') {
|
||||
return new Response(JSON.stringify({ ok: true, service: 'server-command-runner' }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('ok', { status: 200 });
|
||||
});
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const runnerCommand = commands.find((item) => item.key === 'command-runner');
|
||||
|
||||
assert.ok(runnerCommand);
|
||||
assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://host.docker.internal:3211/health'), true);
|
||||
assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://127.0.0.1:3211/health'), true);
|
||||
assert.equal(runnerCommand.availability, 'online');
|
||||
assert.equal(runnerCommand.httpStatus, 200);
|
||||
assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/);
|
||||
});
|
||||
1121
etc/servers/work-server/src/services/server-command-service.ts
Executable file
1121
etc/servers/work-server/src/services/server-command-service.ts
Executable file
File diff suppressed because it is too large
Load Diff
353
etc/servers/work-server/src/services/visitor-history-service.ts
Executable file
353
etc/servers/work-server/src/services/visitor-history-service.ts
Executable file
@@ -0,0 +1,353 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
export const VISITOR_CLIENT_TABLE = 'visitor_clients';
|
||||
export const VISITOR_HISTORY_TABLE = 'visitor_visit_histories';
|
||||
|
||||
export const trackVisitSchema = z.object({
|
||||
clientId: z.string().trim().min(1).max(120),
|
||||
url: z.string().trim().min(1).max(2000),
|
||||
eventType: z.string().trim().min(1).max(80).default('page_view'),
|
||||
userAgent: z.string().trim().max(1000).optional().nullable(),
|
||||
});
|
||||
|
||||
export const listVisitorClientsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
search: z.string().trim().max(120).optional(),
|
||||
clientId: z.string().trim().max(120).optional(),
|
||||
nickname: z.string().trim().max(80).optional(),
|
||||
path: z.string().trim().max(500).optional(),
|
||||
visitedFrom: z.string().trim().max(40).optional(),
|
||||
visitedTo: z.string().trim().max(40).optional(),
|
||||
});
|
||||
|
||||
export const visitorHistoryQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
export const updateVisitorNicknameSchema = z.object({
|
||||
nickname: z.string().trim().min(1).max(80),
|
||||
});
|
||||
|
||||
export type VisitorClientItem = {
|
||||
clientId: string;
|
||||
nickname: string;
|
||||
firstVisitedAt: string;
|
||||
lastVisitedAt: string;
|
||||
visitCount: number;
|
||||
lastVisitedUrl: string | null;
|
||||
lastUserAgent: string | null;
|
||||
lastIp: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type VisitorHistoryItem = {
|
||||
id: number;
|
||||
clientId: string;
|
||||
visitedAt: string;
|
||||
url: string;
|
||||
eventType: string;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
function mapVisitorClientRow(row: Record<string, unknown>): VisitorClientItem {
|
||||
return {
|
||||
clientId: String(row.client_id ?? ''),
|
||||
nickname: String(row.nickname ?? ''),
|
||||
firstVisitedAt: String(row.first_visited_at ?? ''),
|
||||
lastVisitedAt: String(row.last_visited_at ?? ''),
|
||||
visitCount: Number(row.visit_count ?? 0),
|
||||
lastVisitedUrl: row.last_visited_url ? String(row.last_visited_url) : null,
|
||||
lastUserAgent: row.last_user_agent ? String(row.last_user_agent) : null,
|
||||
lastIp: row.last_ip ? String(row.last_ip) : null,
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
function mapVisitorHistoryRow(row: Record<string, unknown>): VisitorHistoryItem {
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
clientId: String(row.client_id ?? ''),
|
||||
visitedAt: String(row.visited_at ?? ''),
|
||||
url: String(row.url ?? ''),
|
||||
eventType: String(row.event_type ?? 'page_view'),
|
||||
userAgent: row.user_agent ? String(row.user_agent) : null,
|
||||
ip: row.ip ? String(row.ip) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDateBoundary(value: string, boundary: 'start' | 'end') {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return boundary === 'start' ? `${value} 00:00:00` : `${value} 23:59:59.999`;
|
||||
}
|
||||
|
||||
async function ensureVisitorClientTable() {
|
||||
const hasTable = await db.schema.hasTable(VISITOR_CLIENT_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(VISITOR_CLIENT_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('client_id', 120).notNullable().unique();
|
||||
table.string('nickname', 80).notNullable();
|
||||
table.timestamp('first_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('last_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.integer('visit_count').notNullable().defaultTo(1);
|
||||
table.string('last_visited_url', 2000).nullable();
|
||||
table.string('last_user_agent', 1000).nullable();
|
||||
table.string('last_ip', 120).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['client_id', (table) => table.string('client_id', 120).notNullable().unique()],
|
||||
['nickname', (table) => table.string('nickname', 80).notNullable().defaultTo('방문자_0001')],
|
||||
['first_visited_at', (table) => table.timestamp('first_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['last_visited_at', (table) => table.timestamp('last_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['visit_count', (table) => table.integer('visit_count').notNullable().defaultTo(1)],
|
||||
['last_visited_url', (table) => table.string('last_visited_url', 2000).nullable()],
|
||||
['last_user_agent', (table) => table.string('last_user_agent', 1000).nullable()],
|
||||
['last_ip', (table) => table.string('last_ip', 120).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(VISITOR_CLIENT_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(VISITOR_CLIENT_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureVisitorHistoryTable() {
|
||||
const hasTable = await db.schema.hasTable(VISITOR_HISTORY_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(VISITOR_HISTORY_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('client_id', 120).notNullable().index();
|
||||
table.timestamp('visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.string('url', 2000).notNullable();
|
||||
table.string('event_type', 80).notNullable().defaultTo('page_view');
|
||||
table.string('user_agent', 1000).nullable();
|
||||
table.string('ip', 120).nullable();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['client_id', (table) => table.string('client_id', 120).notNullable().index()],
|
||||
['visited_at', (table) => table.timestamp('visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
['url', (table) => table.string('url', 2000).notNullable().defaultTo('')],
|
||||
['event_type', (table) => table.string('event_type', 80).notNullable().defaultTo('page_view')],
|
||||
['user_agent', (table) => table.string('user_agent', 1000).nullable()],
|
||||
['ip', (table) => table.string('ip', 120).nullable()],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(VISITOR_HISTORY_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(VISITOR_HISTORY_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureVisitorHistoryTables() {
|
||||
await ensureVisitorClientTable();
|
||||
await ensureVisitorHistoryTable();
|
||||
}
|
||||
|
||||
async function generateAutoNickname() {
|
||||
const result = await db.raw<{ rows?: Array<{ next_nickname_number: number | string | null }> }>(
|
||||
`
|
||||
select coalesce(max(cast(substring(nickname from '[0-9]+$') as integer)), 0) + 1 as next_nickname_number
|
||||
from ${VISITOR_CLIENT_TABLE}
|
||||
where nickname like '방문자\\_%'
|
||||
`,
|
||||
);
|
||||
|
||||
const nextNumber = Number(result.rows?.[0]?.next_nickname_number ?? 1);
|
||||
return `방문자_${String(nextNumber).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
export async function getVisitorClientByClientId(clientId: string) {
|
||||
await ensureVisitorHistoryTables();
|
||||
const row = await db(VISITOR_CLIENT_TABLE).where({ client_id: clientId }).first();
|
||||
return row ? mapVisitorClientRow(row) : null;
|
||||
}
|
||||
|
||||
export type VisitorClientListFilters = {
|
||||
search?: string;
|
||||
clientId?: string;
|
||||
nickname?: string;
|
||||
path?: string;
|
||||
visitedFrom?: string;
|
||||
visitedTo?: string;
|
||||
};
|
||||
|
||||
export async function listVisitorClients(limit = 100, filters: VisitorClientListFilters = {}) {
|
||||
await ensureVisitorHistoryTables();
|
||||
|
||||
const query = db(VISITOR_CLIENT_TABLE)
|
||||
.select('*')
|
||||
.orderBy('last_visited_at', 'desc')
|
||||
.limit(Math.min(Math.max(Math.trunc(limit), 1), 500));
|
||||
|
||||
const normalizedSearch = filters.search?.trim() ?? '';
|
||||
if (normalizedSearch) {
|
||||
query.where((builder) => {
|
||||
builder.whereILike('client_id', `%${normalizedSearch}%`).orWhereILike('nickname', `%${normalizedSearch}%`);
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedClientId = filters.clientId?.trim() ?? '';
|
||||
if (normalizedClientId) {
|
||||
query.whereILike('client_id', `%${normalizedClientId}%`);
|
||||
}
|
||||
|
||||
const normalizedNickname = filters.nickname?.trim() ?? '';
|
||||
if (normalizedNickname) {
|
||||
query.whereILike('nickname', `%${normalizedNickname}%`);
|
||||
}
|
||||
|
||||
const normalizedPath = filters.path?.trim() ?? '';
|
||||
const normalizedVisitedFrom = filters.visitedFrom?.trim() ?? '';
|
||||
const normalizedVisitedTo = filters.visitedTo?.trim() ?? '';
|
||||
|
||||
if (normalizedPath || normalizedVisitedFrom || normalizedVisitedTo) {
|
||||
query.whereIn(
|
||||
'client_id',
|
||||
db(VISITOR_HISTORY_TABLE)
|
||||
.select('client_id')
|
||||
.modify((historyQuery) => {
|
||||
if (normalizedPath) {
|
||||
historyQuery.whereILike('url', `%${normalizedPath}%`);
|
||||
}
|
||||
|
||||
if (normalizedVisitedFrom) {
|
||||
historyQuery.where('visited_at', '>=', normalizeDateBoundary(normalizedVisitedFrom, 'start'));
|
||||
}
|
||||
|
||||
if (normalizedVisitedTo) {
|
||||
historyQuery.where('visited_at', '<=', normalizeDateBoundary(normalizedVisitedTo, 'end'));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
return rows.map((row) => mapVisitorClientRow(row));
|
||||
}
|
||||
|
||||
export async function listVisitorHistories(clientId: string, limit = 100) {
|
||||
await ensureVisitorHistoryTables();
|
||||
|
||||
const rows = await db(VISITOR_HISTORY_TABLE)
|
||||
.select('*')
|
||||
.where({ client_id: clientId })
|
||||
.orderBy('visited_at', 'desc')
|
||||
.limit(Math.min(Math.max(Math.trunc(limit), 1), 500));
|
||||
|
||||
return rows.map((row) => mapVisitorHistoryRow(row));
|
||||
}
|
||||
|
||||
export async function updateVisitorNickname(clientId: string, nickname: string) {
|
||||
await ensureVisitorHistoryTables();
|
||||
|
||||
const rows = await db(VISITOR_CLIENT_TABLE)
|
||||
.where({ client_id: clientId })
|
||||
.update({
|
||||
nickname,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
const row = rows[0];
|
||||
return row ? mapVisitorClientRow(row) : null;
|
||||
}
|
||||
|
||||
export async function trackVisit(payload: z.infer<typeof trackVisitSchema>, ip: string | null | undefined) {
|
||||
await ensureVisitorHistoryTables();
|
||||
const parsedPayload = trackVisitSchema.parse(payload);
|
||||
const normalizedIp = String(ip ?? '').trim() || null;
|
||||
const normalizedUserAgent = parsedPayload.userAgent?.trim() || null;
|
||||
|
||||
const existing = await db(VISITOR_CLIENT_TABLE)
|
||||
.select('*')
|
||||
.where({ client_id: parsedPayload.clientId })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await db(VISITOR_CLIENT_TABLE)
|
||||
.where({ client_id: parsedPayload.clientId })
|
||||
.update({
|
||||
last_visited_at: db.fn.now(),
|
||||
visit_count: db.raw('coalesce(visit_count, 0) + 1'),
|
||||
last_visited_url: parsedPayload.url,
|
||||
last_user_agent: normalizedUserAgent,
|
||||
last_ip: normalizedIp,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
// 최초 방문만 자동 닉네임을 발급하고 이후에는 사용자가 수정할 수 있습니다.
|
||||
await db(VISITOR_CLIENT_TABLE).insert({
|
||||
client_id: parsedPayload.clientId,
|
||||
nickname: await generateAutoNickname(),
|
||||
first_visited_at: db.fn.now(),
|
||||
last_visited_at: db.fn.now(),
|
||||
visit_count: 1,
|
||||
last_visited_url: parsedPayload.url,
|
||||
last_user_agent: normalizedUserAgent,
|
||||
last_ip: normalizedIp,
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
const dbError = error as { code?: string };
|
||||
|
||||
if (dbError.code !== '23505') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db(VISITOR_CLIENT_TABLE)
|
||||
.where({ client_id: parsedPayload.clientId })
|
||||
.update({
|
||||
last_visited_at: db.fn.now(),
|
||||
visit_count: db.raw('coalesce(visit_count, 0) + 1'),
|
||||
last_visited_url: parsedPayload.url,
|
||||
last_user_agent: normalizedUserAgent,
|
||||
last_ip: normalizedIp,
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db(VISITOR_HISTORY_TABLE).insert({
|
||||
client_id: parsedPayload.clientId,
|
||||
visited_at: db.fn.now(),
|
||||
url: parsedPayload.url,
|
||||
event_type: parsedPayload.eventType,
|
||||
user_agent: normalizedUserAgent,
|
||||
ip: normalizedIp,
|
||||
});
|
||||
|
||||
return getVisitorClientByClientId(parsedPayload.clientId);
|
||||
}
|
||||
138
etc/servers/work-server/src/services/work-server-build-service.ts
Executable file
138
etc/servers/work-server/src/services/work-server-build-service.ts
Executable file
@@ -0,0 +1,138 @@
|
||||
import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type WorkServerBuildInfo = {
|
||||
version: string;
|
||||
buildId: string;
|
||||
builtAt: string;
|
||||
};
|
||||
|
||||
export type WorkServerSourceChangeInfo = {
|
||||
changedAt: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const MODULE_DIR_PATH = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORK_SERVER_ROOT_PATH = path.resolve(MODULE_DIR_PATH, '..', '..');
|
||||
const BUILD_INFO_FILE_PATH = path.join(WORK_SERVER_ROOT_PATH, 'dist', 'build-info.json');
|
||||
const SOURCE_TARGET_PATHS = [
|
||||
path.join(WORK_SERVER_ROOT_PATH, 'src'),
|
||||
path.join(WORK_SERVER_ROOT_PATH, 'scripts'),
|
||||
path.join(WORK_SERVER_ROOT_PATH, 'package.json'),
|
||||
path.join(WORK_SERVER_ROOT_PATH, 'tsconfig.json'),
|
||||
] as const;
|
||||
|
||||
function normalizeBuildInfo(value: unknown): WorkServerBuildInfo | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<Record<keyof WorkServerBuildInfo, unknown>>;
|
||||
|
||||
if (
|
||||
typeof candidate.version !== 'string' ||
|
||||
typeof candidate.buildId !== 'string' ||
|
||||
typeof candidate.builtAt !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builtAt = new Date(candidate.builtAt);
|
||||
|
||||
if (Number.isNaN(builtAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
version: candidate.version,
|
||||
buildId: candidate.buildId,
|
||||
builtAt: builtAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function readBuildInfoFromDiskSync(filePath: string) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeBuildInfo(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLatestWorkServerBuildInfo() {
|
||||
try {
|
||||
return normalizeBuildInfo(JSON.parse(await readFile(BUILD_INFO_FILE_PATH, 'utf8')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeWorkServerBuildInfo = readBuildInfoFromDiskSync(BUILD_INFO_FILE_PATH);
|
||||
|
||||
export function getRuntimeWorkServerBuildInfo() {
|
||||
return runtimeWorkServerBuildInfo;
|
||||
}
|
||||
|
||||
async function findLatestSourceChangeInPath(targetPath: string): Promise<WorkServerSourceChangeInfo | null> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(targetPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return {
|
||||
changedAt: stats.mtime.toISOString(),
|
||||
path: path.relative(WORK_SERVER_ROOT_PATH, targetPath) || path.basename(targetPath),
|
||||
};
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(targetPath, { withFileTypes: true });
|
||||
let latest: WorkServerSourceChangeInfo | null = null;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.docker') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const childPath = path.join(targetPath, entry.name);
|
||||
const childLatest = await findLatestSourceChangeInPath(childPath);
|
||||
|
||||
if (!childLatest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latest || childLatest.changedAt > latest.changedAt) {
|
||||
latest = childLatest;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readLatestWorkServerSourceChange() {
|
||||
let latest: WorkServerSourceChangeInfo | null = null;
|
||||
|
||||
for (const targetPath of SOURCE_TARGET_PATHS) {
|
||||
const candidate = await findLatestSourceChangeInPath(targetPath);
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latest || candidate.changedAt > latest.changedAt) {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return latest;
|
||||
}
|
||||
119
etc/servers/work-server/src/services/worklog-automation-service.test.ts
Executable file
119
etc/servers/work-server/src/services/worklog-automation-service.test.ts
Executable file
@@ -0,0 +1,119 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ensureDailyWorklogFile,
|
||||
isWorklogCreationDue,
|
||||
normalizeDailyCreateTime,
|
||||
upgradeLegacyWorklogContent,
|
||||
} from './worklog-automation-utils.js';
|
||||
|
||||
test('normalizeDailyCreateTime falls back when value is invalid', () => {
|
||||
assert.equal(normalizeDailyCreateTime(undefined), '18:00');
|
||||
assert.equal(normalizeDailyCreateTime('bad'), '18:00');
|
||||
assert.equal(normalizeDailyCreateTime('09:30'), '09:30');
|
||||
});
|
||||
|
||||
test('isWorklogCreationDue checks KST schedule once per day', () => {
|
||||
assert.equal(isWorklogCreationDue(new Date('2026-04-07T08:59:00Z'), '18:00', false), false);
|
||||
assert.equal(isWorklogCreationDue(new Date('2026-04-07T09:00:00Z'), '18:00', false), true);
|
||||
assert.equal(isWorklogCreationDue(new Date('2026-04-07T09:05:00Z'), '18:00', true), false);
|
||||
});
|
||||
|
||||
test('ensureDailyWorklogFile creates today worklog from template only once', async () => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), 'worklog-automation-test-'));
|
||||
const templatePath = path.join(repoPath, 'docs', 'templates', 'worklog-template.md');
|
||||
|
||||
try {
|
||||
await mkdir(path.dirname(templatePath), { recursive: true });
|
||||
await writeFile(templatePath, '# YYYY-MM-DD 작업일지\n\n## 오늘 작업\n\n- \n', 'utf8');
|
||||
|
||||
const worklogPath = await ensureDailyWorklogFile(repoPath, '2026-04-07');
|
||||
const firstContent = await readFile(worklogPath, 'utf8');
|
||||
assert.equal(firstContent.includes('# 2026-04-07 작업일지'), true);
|
||||
|
||||
await writeFile(worklogPath, '# keep\n', 'utf8');
|
||||
await ensureDailyWorklogFile(repoPath, '2026-04-07');
|
||||
const secondContent = await readFile(worklogPath, 'utf8');
|
||||
assert.equal(secondContent, '# keep\n');
|
||||
} finally {
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('upgradeLegacyWorklogContent replaces legacy source placeholder with file scoped format', () => {
|
||||
const legacyContent = `# 2026-04-07 작업일지
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
-
|
||||
|
||||
## 소스
|
||||
|
||||
- 요약 설명
|
||||
- \`path/to/file.tsx\`: 변경 또는 신규 추가 목적과 핵심 내용을 한 줄로 정리
|
||||
|
||||
\`\`\`diff
|
||||
# 핵심 diff를 1~3개 블록으로 기록
|
||||
- before
|
||||
+ after
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const upgraded = upgradeLegacyWorklogContent(legacyContent);
|
||||
assert.equal(upgraded.includes('### 파일 1: `path/to/file.tsx`'), true);
|
||||
assert.equal(upgraded.includes('# 이 파일의 raw diff'), true);
|
||||
assert.equal(upgraded.includes('`상세 작업 내역`에는 파일 목록이나 raw diff를 다시 쓰지 않음'), true);
|
||||
assert.equal(upgraded.includes('`전체소스 / raw diff`'), true);
|
||||
});
|
||||
|
||||
test('upgradeLegacyWorklogContent upgrades screenshot guidance to require full screenshot', () => {
|
||||
const legacyContent = `# 2026-04-07 작업일지
|
||||
|
||||
## 스크린샷
|
||||
|
||||
- 저장소 기준 연결된 스크린샷 없음
|
||||
`;
|
||||
|
||||
const upgraded = upgradeLegacyWorklogContent(legacyContent);
|
||||
assert.equal(upgraded.includes('- 전체 화면 스크린샷 1장은 필수'), true);
|
||||
assert.equal(upgraded.includes('- 위젯/컴포넌트 단위 부분 스크린샷은 필요한 만큼 추가'), true);
|
||||
});
|
||||
|
||||
test('upgradeLegacyWorklogContent removes obsolete auto-generated summary sections', () => {
|
||||
const legacyContent = `# 2026-04-07 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 작업 정리
|
||||
|
||||
## 커밋 목록 (2026-04-07, KST 기준)
|
||||
|
||||
- \`abc1234\` example
|
||||
|
||||
## 변경 요약
|
||||
|
||||
- 자동 수집 요약
|
||||
|
||||
## 변경 통계 (파일별, KST 기준)
|
||||
|
||||
- src/example.ts (+1/-1)
|
||||
|
||||
## 라인 통계 (KST 기준)
|
||||
|
||||
- 추가: 1줄
|
||||
|
||||
## 변경/신규 파일
|
||||
|
||||
- M src/example.ts
|
||||
`;
|
||||
|
||||
const upgraded = upgradeLegacyWorklogContent(legacyContent);
|
||||
assert.equal(upgraded.includes('## 커밋 목록'), false);
|
||||
assert.equal(upgraded.includes('## 변경 요약'), false);
|
||||
assert.equal(upgraded.includes('## 변경 통계'), false);
|
||||
assert.equal(upgraded.includes('## 라인 통계'), false);
|
||||
assert.equal(upgraded.includes('## 변경/신규 파일'), true);
|
||||
});
|
||||
77
etc/servers/work-server/src/services/worklog-automation-service.ts
Executable file
77
etc/servers/work-server/src/services/worklog-automation-service.ts
Executable file
@@ -0,0 +1,77 @@
|
||||
import { db } from '../db/client.js';
|
||||
export {
|
||||
DEFAULT_DAILY_CREATE_TIME,
|
||||
ensureDailyWorklogFile,
|
||||
getKstNowParts,
|
||||
isValidDailyCreateTime,
|
||||
isWorklogCreationDue,
|
||||
normalizeDailyCreateTime,
|
||||
renderWorklogTemplate,
|
||||
} from './worklog-automation-utils.js';
|
||||
import { DEFAULT_DAILY_CREATE_TIME, normalizeDailyCreateTime } from './worklog-automation-utils.js';
|
||||
|
||||
export const WORKLOG_AUTOMATION_RUN_TABLE = 'worklog_automation_runs';
|
||||
|
||||
async function ensureWorklogAutomationRunTable() {
|
||||
const hasTable = await db.schema.hasTable(WORKLOG_AUTOMATION_RUN_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(WORKLOG_AUTOMATION_RUN_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('run_date', 10).notNullable().unique();
|
||||
table.string('scheduled_time', 5).notNullable();
|
||||
table.string('worklog_path').notNullable();
|
||||
table.timestamp('executed_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['run_date', (table) => table.string('run_date', 10).notNullable().unique()],
|
||||
['scheduled_time', (table) => table.string('scheduled_time', 5).notNullable().defaultTo(DEFAULT_DAILY_CREATE_TIME)],
|
||||
['worklog_path', (table) => table.string('worklog_path').notNullable().defaultTo('')],
|
||||
['executed_at', (table) => table.timestamp('executed_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(WORKLOG_AUTOMATION_RUN_TABLE, columnName);
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(WORKLOG_AUTOMATION_RUN_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasWorklogAutomationRunForDate(runDate: string) {
|
||||
await ensureWorklogAutomationRunTable();
|
||||
const row = await db(WORKLOG_AUTOMATION_RUN_TABLE).where({ run_date: runDate }).first();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function markWorklogAutomationRun(args: {
|
||||
runDate: string;
|
||||
scheduledTime: string;
|
||||
worklogPath: string;
|
||||
}) {
|
||||
await ensureWorklogAutomationRunTable();
|
||||
|
||||
const existing = await db(WORKLOG_AUTOMATION_RUN_TABLE).where({ run_date: args.runDate }).first();
|
||||
if (existing) {
|
||||
await db(WORKLOG_AUTOMATION_RUN_TABLE)
|
||||
.where({ run_date: args.runDate })
|
||||
.update({
|
||||
scheduled_time: args.scheduledTime,
|
||||
worklog_path: args.worklogPath,
|
||||
executed_at: db.fn.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db(WORKLOG_AUTOMATION_RUN_TABLE).insert({
|
||||
run_date: args.runDate,
|
||||
scheduled_time: args.scheduledTime,
|
||||
worklog_path: args.worklogPath,
|
||||
executed_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
232
etc/servers/work-server/src/services/worklog-automation-utils.ts
Executable file
232
etc/servers/work-server/src/services/worklog-automation-utils.ts
Executable file
@@ -0,0 +1,232 @@
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
export const DEFAULT_DAILY_CREATE_TIME = '18:00';
|
||||
const DEFAULT_WORKLOG_TEMPLATE = `# YYYY-MM-DD 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
-
|
||||
|
||||
## 이슈 및 해결
|
||||
|
||||
-
|
||||
|
||||
## 결정 사항
|
||||
|
||||
-
|
||||
|
||||
## 상세 작업 내역
|
||||
|
||||
-
|
||||
- 이 섹션에는 파일 목록, 경로 나열, raw diff를 직접 풀어쓰지 말고 작업 흐름과 판단만 정리
|
||||
- 파일 목록은 변경/신규 파일 섹션에, raw diff는 소스 섹션에만 기록
|
||||
|
||||
## 스크린샷
|
||||
|
||||
- 전체 화면 스크린샷 1장은 필수
|
||||
- 위젯/컴포넌트 단위 부분 스크린샷은 필요한 만큼 추가
|
||||
- 저장소 기준 연결된 스크린샷이 없으면 작업 종료 전 반드시 채움
|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: \`path/to/file.tsx\`
|
||||
|
||||
- 변경 또는 신규 추가 목적과 핵심 내용을 한 줄로 정리
|
||||
- \`상세 작업 내역\`에는 파일 목록이나 raw diff를 다시 쓰지 않음
|
||||
- \`소스\` 탭에서 Codex preview 스타일의 \`전체소스 / raw diff\` 전환을 제공하므로 여기에는 파일별 raw diff 위주로 남김
|
||||
|
||||
\`\`\`diff
|
||||
# 이 파일의 raw diff
|
||||
- before
|
||||
+ after
|
||||
\`\`\`
|
||||
|
||||
### 파일 2: \`path/to/another-file.ts\`
|
||||
|
||||
- 필요 없으면 이 섹션은 삭제
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
\`\`\`bash
|
||||
\`\`\`
|
||||
|
||||
## 변경/신규 파일
|
||||
|
||||
-
|
||||
`;
|
||||
|
||||
const OBSOLETE_WORKLOG_SECTION_TITLES = new Set([
|
||||
'커밋 목록',
|
||||
'변경 요약',
|
||||
'변경 통계',
|
||||
'라인 통계',
|
||||
]);
|
||||
|
||||
type KstNowParts = {
|
||||
dateKey: string;
|
||||
minutesOfDay: number;
|
||||
};
|
||||
|
||||
function extractKstParts(date: Date) {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: KST_TIME_ZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
|
||||
const values = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
|
||||
return {
|
||||
year: Number(values.year ?? '0'),
|
||||
month: Number(values.month ?? '0'),
|
||||
day: Number(values.day ?? '0'),
|
||||
hour: Number(values.hour ?? '0'),
|
||||
minute: Number(values.minute ?? '0'),
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidDailyCreateTime(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && /^\d{2}:\d{2}$/.test(value);
|
||||
}
|
||||
|
||||
export function normalizeDailyCreateTime(value: string | undefined) {
|
||||
return isValidDailyCreateTime(value) ? value : DEFAULT_DAILY_CREATE_TIME;
|
||||
}
|
||||
|
||||
export function getKstNowParts(date = new Date()): KstNowParts {
|
||||
const { year, month, day, hour, minute } = extractKstParts(date);
|
||||
return {
|
||||
dateKey: `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
|
||||
minutesOfDay: hour * 60 + minute,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWorklogCreationDue(now: Date, dailyCreateTime: string, alreadyExecutedToday: boolean) {
|
||||
if (alreadyExecutedToday) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [hours, minutes] = normalizeDailyCreateTime(dailyCreateTime).split(':').map((value) => Number(value));
|
||||
const nowParts = getKstNowParts(now);
|
||||
return nowParts.minutesOfDay >= (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
export function renderWorklogTemplate(template: string, dateKey: string) {
|
||||
return template.replaceAll('YYYY-MM-DD', dateKey);
|
||||
}
|
||||
|
||||
function normalizeLevelTwoHeading(line: string) {
|
||||
const match = line.match(/^##\s+([^\n]+)$/m);
|
||||
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return match[1]?.replace(/\s+\(.*\)\s*$/u, '').trim() ?? '';
|
||||
}
|
||||
|
||||
function stripObsoleteWorklogSections(content: string) {
|
||||
const sections = content.split(/\n(?=##\s+)/);
|
||||
const preservedSections = sections.filter((section) => {
|
||||
const normalizedHeading = normalizeLevelTwoHeading(section);
|
||||
return !OBSOLETE_WORKLOG_SECTION_TITLES.has(normalizedHeading);
|
||||
});
|
||||
|
||||
return `${preservedSections.join('\n\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
|
||||
}
|
||||
|
||||
const LEGACY_SOURCE_SECTION = `## 소스
|
||||
|
||||
- 요약 설명
|
||||
- \`path/to/file.tsx\`: 변경 또는 신규 추가 목적과 핵심 내용을 한 줄로 정리
|
||||
|
||||
\`\`\`diff
|
||||
# 핵심 diff를 1~3개 블록으로 기록
|
||||
- before
|
||||
+ after
|
||||
\`\`\``;
|
||||
|
||||
const FILE_SCOPED_SOURCE_SECTION = `## 소스
|
||||
|
||||
### 파일 1: \`path/to/file.tsx\`
|
||||
|
||||
- 변경 또는 신규 추가 목적과 핵심 내용을 한 줄로 정리
|
||||
- \`상세 작업 내역\`에는 파일 목록이나 raw diff를 다시 쓰지 않음
|
||||
- \`소스\` 탭에서 Codex preview 스타일의 \`전체소스 / raw diff\` 전환을 제공하므로 여기에는 파일별 raw diff 위주로 남김
|
||||
|
||||
\`\`\`diff
|
||||
# 이 파일의 raw diff
|
||||
- before
|
||||
+ after
|
||||
\`\`\`
|
||||
|
||||
### 파일 2: \`path/to/another-file.ts\`
|
||||
|
||||
- 필요 없으면 이 섹션은 삭제`;
|
||||
|
||||
export function upgradeLegacyWorklogContent(content: string) {
|
||||
let upgradedContent = content;
|
||||
|
||||
if (upgradedContent.includes(LEGACY_SOURCE_SECTION)) {
|
||||
upgradedContent = upgradedContent.replace(LEGACY_SOURCE_SECTION, FILE_SCOPED_SOURCE_SECTION);
|
||||
}
|
||||
|
||||
upgradedContent = upgradedContent
|
||||
.replace(
|
||||
'## 상세 작업 내역\n\n- ',
|
||||
`## 상세 작업 내역
|
||||
|
||||
-
|
||||
- 이 섹션에는 파일 목록, 경로 나열, raw diff를 직접 풀어쓰지 말고 작업 흐름과 판단만 정리
|
||||
- 파일 목록은 \`## 변경/신규 파일\`, raw diff는 \`## 소스\`에서만 기록`,
|
||||
)
|
||||
.replace(
|
||||
'## 스크린샷\n\n- 저장소 기준 연결된 스크린샷 없음',
|
||||
`## 스크린샷
|
||||
|
||||
- 전체 화면 스크린샷 1장은 필수
|
||||
- 위젯/컴포넌트 단위 부분 스크린샷은 필요한 만큼 추가
|
||||
- 저장소 기준 연결된 스크린샷이 없으면 작업 종료 전 반드시 채움`,
|
||||
)
|
||||
.replaceAll('# 이 파일의 핵심 diff', '# 이 파일의 raw diff')
|
||||
.replaceAll('`작업일지` 탭에는 중복 파일 목록을 다시 쓰지 않아도 됨', '`상세 작업 내역`에는 파일 목록이나 raw diff를 다시 쓰지 않음')
|
||||
.replaceAll('`소스` 탭에서 전체소스/diff를 전환해 보므로 여기에는 파일별 raw diff 위주로 남김', '`소스` 탭에서 Codex preview 스타일의 `전체소스 / raw diff` 전환을 제공하므로 여기에는 파일별 raw diff 위주로 남김');
|
||||
|
||||
return stripObsoleteWorklogSections(upgradedContent);
|
||||
}
|
||||
|
||||
async function readWorklogTemplate(repoPath: string) {
|
||||
const templatePath = path.join(repoPath, 'docs', 'templates', 'worklog-template.md');
|
||||
|
||||
try {
|
||||
return await readFile(templatePath, 'utf8');
|
||||
} catch {
|
||||
return DEFAULT_WORKLOG_TEMPLATE;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDailyWorklogFile(repoPath: string, dateKey: string) {
|
||||
const worklogPath = path.join(repoPath, 'docs', 'worklogs', `${dateKey}.md`);
|
||||
|
||||
try {
|
||||
await access(worklogPath);
|
||||
const existingContent = await readFile(worklogPath, 'utf8');
|
||||
const upgradedContent = upgradeLegacyWorklogContent(existingContent);
|
||||
|
||||
if (upgradedContent !== existingContent) {
|
||||
await writeFile(worklogPath, upgradedContent, 'utf8');
|
||||
}
|
||||
|
||||
return worklogPath;
|
||||
} catch {
|
||||
const template = await readWorklogTemplate(repoPath);
|
||||
await mkdir(path.dirname(worklogPath), { recursive: true });
|
||||
await writeFile(worklogPath, renderWorklogTemplate(template, dateKey), 'utf8');
|
||||
return worklogPath;
|
||||
}
|
||||
}
|
||||
1
etc/servers/work-server/src/types/web-push.d.ts
vendored
Executable file
1
etc/servers/work-server/src/types/web-push.d.ts
vendored
Executable file
@@ -0,0 +1 @@
|
||||
declare module 'web-push';
|
||||
1363
etc/servers/work-server/src/workers/plan-worker.ts
Executable file
1363
etc/servers/work-server/src/workers/plan-worker.ts
Executable file
File diff suppressed because it is too large
Load Diff
16
etc/servers/work-server/tsconfig.json
Executable file
16
etc/servers/work-server/tsconfig.json
Executable file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user