Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View 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

View 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"

View 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

View 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();

View 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
View 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
View File

@@ -0,0 +1,2 @@
.env
postgres-data

28
etc/db/work-db/README.md Executable file
View 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 설정과 맞춰서 사용합니다.

View 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

View 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);

View 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);

View 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);

View 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
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

View 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"]

View 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 알림을 전송합니다.

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -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();
}

View File

@@ -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();
}

View 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}`);

View 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;
}

View 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();

View 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);
},
},
});

View 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);
}
});
}

View 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;
}

View 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);
});

View 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);
}

View 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 : '앱 설정 저장에 실패했습니다.',
});
}
});
}

View 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,
};
});
}

View 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,
};
});
}

View 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'));
});

View 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,
};
});
}

View 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,
};
});
}

View 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,
};
});
}

View 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);
}

View 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;
}

View 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,
};
});
}

View 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,
};
});
}

View 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,
};
});
}

View 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,
};
});
}

View 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();

View 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;
}

View 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, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
});

View 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;
});
}

View 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,
);
});

File diff suppressed because it is too large Load Diff

View 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();

View 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;
}
});

File diff suppressed because it is too large Load Diff

View 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,
};
}

View 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;
}

View 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);
});

View 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);
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export function shouldNotifyPlanRestart(result: { didScheduleRetry?: boolean } | null | undefined) {
return Boolean(result?.didScheduleRetry);
}

View 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),
});
}

View 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',
});
});

View File

@@ -0,0 +1,11 @@
export function shouldTriggerRetryFromActionNote(actionNote: string) {
const text = String(actionNote ?? '').trim();
if (!text) {
return false;
}
return /(재처리|다시|누락|빠진|빼먹|보완|조치해|처리해|해결해|고쳐|반영해|수정해|시도해|진행해|테스트해|검증해|부탁)/.test(
text,
);
}

View 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);
}

View 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' }));
});

File diff suppressed because it is too large Load Diff

View File

@@ -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/);
});

File diff suppressed because it is too large Load Diff

View 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);
}

View 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;
}

View 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);
});

View 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(),
});
}

View 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;
}
}

View File

@@ -0,0 +1 @@
declare module 'web-push';

File diff suppressed because it is too large Load Diff

View 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"]
}