chore: exclude local resource artifacts from main sync
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -43,3 +43,5 @@ vite.config.d.ts
|
||||
public/.codex_chat
|
||||
.server-command-runner-heartbeat.json
|
||||
docs/assets/worklogs/
|
||||
resource/Codex Live/
|
||||
resource/To-Do List/
|
||||
|
||||
11
README.md
Executable file → Normal file
11
README.md
Executable file → Normal file
@@ -20,14 +20,16 @@ npm run dev
|
||||
|
||||
## 확인용 Preview 컨테이너
|
||||
|
||||
실제 반영 화면을 확인할 때는 바인드 마운트 없이 별도 이미지로 빌드하는 `docker-compose.preview.yml`을 사용합니다.
|
||||
실제 반영 화면을 확인할 때는 별도 preview 컨테이너를 사용합니다.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.preview.yml up -d --build
|
||||
```
|
||||
|
||||
- 기본 접속 주소: `http://127.0.0.1:4173`
|
||||
- 소스 코드는 이미지 빌드 시점에 복사되므로, 로컬 파일 변경이 컨테이너에 바로 섞이지 않습니다.
|
||||
- 로컬 preview 컨테이너 접속 주소: `http://127.0.0.1:4173`
|
||||
- 외부 검증 도메인: `https://preview.sm-home.cloud/`
|
||||
- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite build --watch`로 정적 산출물을 자동 재빌드합니다.
|
||||
- 따라서 `https://preview.sm-home.cloud/`에서는 Vite HMR처럼 즉시 DOM이 바뀌지는 않지만, 소스 저장 후 재빌드가 끝나면 브라우저 새로고침만으로 최신 화면을 확인할 수 있습니다.
|
||||
- API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다.
|
||||
- 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다.
|
||||
|
||||
@@ -35,7 +37,8 @@ docker compose -f docker-compose.preview.yml up -d --build
|
||||
|
||||
- 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다.
|
||||
- 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다.
|
||||
- `https://test.sm-home.cloud/` 운영 기준은 `화면 / -> 5174 앱 테스트 서버`, `/api/` 및 `/ws/chat` -> `127.0.0.1:3100 work-server` 입니다.
|
||||
- 운영 프록시 확인은 `https://test.sm-home.cloud/` 기준으로 유지합니다.
|
||||
- 소스 변경 검증과 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.
|
||||
- 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다.
|
||||
- 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다.
|
||||
- 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다.
|
||||
|
||||
0
docker-compose.yml
Executable file → Normal file
0
docker-compose.yml
Executable file → Normal file
0
docs/README.md
Executable file → Normal file
0
docs/README.md
Executable file → Normal file
0
docs/components/check-combo.md
Executable file → Normal file
0
docs/components/check-combo.md
Executable file → Normal file
0
docs/components/codex-diff-previewer.md
Executable file → Normal file
0
docs/components/codex-diff-previewer.md
Executable file → Normal file
0
docs/components/evidence-attachment-strip-ui.md
Executable file → Normal file
0
docs/components/evidence-attachment-strip-ui.md
Executable file → Normal file
0
docs/components/input.md
Executable file → Normal file
0
docs/components/input.md
Executable file → Normal file
0
docs/components/popup.md
Executable file → Normal file
0
docs/components/popup.md
Executable file → Normal file
0
docs/components/previewer-ui.md
Executable file → Normal file
0
docs/components/previewer-ui.md
Executable file → Normal file
0
docs/components/process-flow-ui.md
Executable file → Normal file
0
docs/components/process-flow-ui.md
Executable file → Normal file
0
docs/components/search-command.md
Executable file → Normal file
0
docs/components/search-command.md
Executable file → Normal file
0
docs/components/select.md
Executable file → Normal file
0
docs/components/select.md
Executable file → Normal file
0
docs/components/status-badge.md
Executable file → Normal file
0
docs/components/status-badge.md
Executable file → Normal file
0
docs/components/stepper.md
Executable file → Normal file
0
docs/components/stepper.md
Executable file → Normal file
0
docs/components/window-ui.md
Executable file → Normal file
0
docs/components/window-ui.md
Executable file → Normal file
0
docs/features/plan-automation.md
Executable file → Normal file
0
docs/features/plan-automation.md
Executable file → Normal file
0
docs/features/plan-board-review.md
Executable file → Normal file
0
docs/features/plan-board-review.md
Executable file → Normal file
0
docs/features/plan-schedule.md
Executable file → Normal file
0
docs/features/plan-schedule.md
Executable file → Normal file
0
docs/features/plan-usage.md
Executable file → Normal file
0
docs/features/plan-usage.md
Executable file → Normal file
0
docs/features/search-layer.md
Executable file → Normal file
0
docs/features/search-layer.md
Executable file → Normal file
0
docs/templates/feature-template.md
vendored
Executable file → Normal file
0
docs/templates/feature-template.md
vendored
Executable file → Normal file
0
docs/templates/worklog-template.md
vendored
Executable file → Normal file
0
docs/templates/worklog-template.md
vendored
Executable file → Normal file
0
docs/worklogs/2026-03-30.md
Executable file → Normal file
0
docs/worklogs/2026-03-30.md
Executable file → Normal file
0
docs/worklogs/2026-03-31.md
Executable file → Normal file
0
docs/worklogs/2026-03-31.md
Executable file → Normal file
0
docs/worklogs/2026-04-01.md
Executable file → Normal file
0
docs/worklogs/2026-04-01.md
Executable file → Normal file
0
docs/worklogs/2026-04-02.md
Executable file → Normal file
0
docs/worklogs/2026-04-02.md
Executable file → Normal file
0
docs/worklogs/2026-04-03.md
Executable file → Normal file
0
docs/worklogs/2026-04-03.md
Executable file → Normal file
0
docs/worklogs/2026-04-04.md
Executable file → Normal file
0
docs/worklogs/2026-04-04.md
Executable file → Normal file
0
docs/worklogs/2026-04-05.md
Executable file → Normal file
0
docs/worklogs/2026-04-05.md
Executable file → Normal file
0
docs/worklogs/2026-04-06.md
Executable file → Normal file
0
docs/worklogs/2026-04-06.md
Executable file → Normal file
0
docs/worklogs/2026-04-07.md
Executable file → Normal file
0
docs/worklogs/2026-04-07.md
Executable file → Normal file
0
docs/worklogs/2026-04-08.md
Executable file → Normal file
0
docs/worklogs/2026-04-08.md
Executable file → Normal file
0
docs/worklogs/2026-04-09.md
Executable file → Normal file
0
docs/worklogs/2026-04-09.md
Executable file → Normal file
0
docs/worklogs/2026-04-10.md
Executable file → Normal file
0
docs/worklogs/2026-04-10.md
Executable file → Normal file
0
docs/worklogs/2026-04-11.md
Executable file → Normal file
0
docs/worklogs/2026-04-11.md
Executable file → Normal file
34
docs/worklogs/2026-05-13.md
Normal file
34
docs/worklogs/2026-05-13.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 2026-05-13 작업일지
|
||||
|
||||
## 오늘 작업
|
||||
|
||||
- 화면 캡처 추가 예정
|
||||
|
||||
## 스크린샷
|
||||
|
||||

|
||||
|
||||
## 소스
|
||||
|
||||
### 파일 1: `path/to/file.tsx`
|
||||
|
||||
- 변경 목적과 핵심 수정 내용을 한 줄로 정리
|
||||
|
||||
```diff
|
||||
# 이 파일의 핵심 diff
|
||||
- before
|
||||
+ after
|
||||
```
|
||||
|
||||
### 파일 2: `path/to/another-file.ts`
|
||||
|
||||
- 필요 없으면 이 섹션은 삭제
|
||||
|
||||
## 실행 커맨드
|
||||
|
||||
```bash
|
||||
```
|
||||
|
||||
## 변경 파일
|
||||
|
||||
-
|
||||
0
etc/commands/server-command/restart-rel.sh
Executable file → Normal file
0
etc/commands/server-command/restart-rel.sh
Executable file → Normal file
0
etc/commands/server-command/restart-server-command-runner.sh
Executable file → Normal file
0
etc/commands/server-command/restart-server-command-runner.sh
Executable file → Normal file
11
etc/commands/server-command/restart-test.sh
Executable file → Normal file
11
etc/commands/server-command/restart-test.sh
Executable file → Normal file
@@ -7,10 +7,21 @@ SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/d
|
||||
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}"
|
||||
SERVER_COMMAND_TEST_GIT_REMOTE="${SERVER_COMMAND_TEST_GIT_REMOTE:-origin}"
|
||||
SERVER_COMMAND_TEST_GIT_BRANCH="${SERVER_COMMAND_TEST_GIT_BRANCH:-main}"
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
cd "$MAIN_PROJECT_ROOT"
|
||||
|
||||
git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
|
||||
if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then
|
||||
git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \
|
||||
|| git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
fi
|
||||
|
||||
git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH"
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE"
|
||||
fi
|
||||
|
||||
0
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file → Normal file
0
etc/commands/server-command/restart-via-docker-socket.mjs
Executable file → Normal file
0
etc/commands/server-command/restart-work-server.sh
Executable file → Normal file
0
etc/commands/server-command/restart-work-server.sh
Executable file → Normal file
0
etc/db/work-db/README.md
Executable file → Normal file
0
etc/db/work-db/README.md
Executable file → Normal file
0
etc/db/work-db/docker-compose.yml
Executable file → Normal file
0
etc/db/work-db/docker-compose.yml
Executable file → Normal file
@@ -61,7 +61,7 @@ npm run server-command:runner
|
||||
|
||||
`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥은 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다.
|
||||
|
||||
브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
브라우저 기준 운영 접속 확인은 **`https://test.sm-home.cloud/`**, 소스 변경 검증과 최종 화면 테스트는 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다.
|
||||
|
||||
채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat/<chat-session-id>/resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다.
|
||||
|
||||
|
||||
0
etc/servers/work-server/package-lock.json
generated
Executable file → Normal file
0
etc/servers/work-server/package-lock.json
generated
Executable file → Normal file
0
etc/servers/work-server/scripts/container-supervisor.sh
Executable file → Normal file
0
etc/servers/work-server/scripts/container-supervisor.sh
Executable file → Normal file
0
etc/servers/work-server/scripts/write-build-info.mjs
Executable file → Normal file
0
etc/servers/work-server/scripts/write-build-info.mjs
Executable file → Normal file
0
etc/servers/work-server/src/app.ts
Executable file → Normal file
0
etc/servers/work-server/src/app.ts
Executable file → Normal file
@@ -82,7 +82,7 @@ var envSchema = zod_1.z.object({
|
||||
SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: zod_1.z.string().default('/api/server-commands/{key}/actions/restart'),
|
||||
SERVER_COMMAND_PROJECT_ROOT: zod_1.z.string().default(node_path_1.default.resolve(process.cwd(), '../../..')),
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: zod_1.z.string().default('/workspace/main-project'),
|
||||
SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://test.sm-home.cloud/'),
|
||||
SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://preview.sm-home.cloud/'),
|
||||
SERVER_COMMAND_REL_URL: zod_1.z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_PROD_URL: zod_1.z.string().default('https://sm-home.cloud/'),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: zod_1.z.string().default('http://127.0.0.1:3100/health'),
|
||||
|
||||
@@ -69,7 +69,7 @@ const envSchema = z.object({
|
||||
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_TEST_URL: z.string().default('https://preview.sm-home.cloud/'),
|
||||
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
|
||||
0
etc/servers/work-server/src/db/client.ts
Executable file → Normal file
0
etc/servers/work-server/src/db/client.ts
Executable file → Normal file
0
etc/servers/work-server/src/json-body.ts
Executable file → Normal file
0
etc/servers/work-server/src/json-body.ts
Executable file → Normal file
0
etc/servers/work-server/src/lib/identifier.ts
Executable file → Normal file
0
etc/servers/work-server/src/lib/identifier.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.ts
Executable file → Normal file
0
etc/servers/work-server/src/not-found.ts
Executable file → Normal file
3
etc/servers/work-server/src/routes/app-config.ts
Executable file → Normal file
3
etc/servers/work-server/src/routes/app-config.ts
Executable file → Normal file
@@ -111,13 +111,12 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
const parsed = z
|
||||
.object({
|
||||
chatTypes: z.array(z.unknown()).optional(),
|
||||
customChatTypes: z.array(z.unknown()).optional(),
|
||||
})
|
||||
.parse(payload ?? {});
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const appDomain = getRequestAppDomain(request);
|
||||
const targetChatTypes = parsed.customChatTypes ?? parsed.chatTypes ?? [];
|
||||
const targetChatTypes = parsed.chatTypes ?? [];
|
||||
const savedChatTypeConfig = await upsertChatTypesConfig(targetChatTypes, appOrigin, appDomain);
|
||||
|
||||
return {
|
||||
|
||||
0
etc/servers/work-server/src/routes/board.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/board.ts
Executable file → Normal file
39
etc/servers/work-server/src/routes/chat.ts
Executable file → Normal file
39
etc/servers/work-server/src/routes/chat.ts
Executable file → Normal file
@@ -16,6 +16,7 @@ import {
|
||||
deleteChatConversation,
|
||||
ensureChatConversationTables,
|
||||
getChatConversation,
|
||||
listChatSourceChangeSnapshots,
|
||||
listChatConversationDetailPage,
|
||||
listChatConversations,
|
||||
markChatConversationResponsesRead,
|
||||
@@ -205,6 +206,21 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/source-changes', async (request) => {
|
||||
const query = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
}).parse(request.query ?? {});
|
||||
|
||||
const viewerClientId = getClientIdHeader(request);
|
||||
const clientId = canViewAllConversations(request) ? null : viewerClientId;
|
||||
const items = await listChatSourceChangeSnapshots(clientId, query.limit ?? 300);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/chat/runtime', async () => {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -380,6 +396,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
const payload = z.object({
|
||||
sessionId: z.string().trim().min(1).max(120),
|
||||
title: z.string().trim().max(200).optional(),
|
||||
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
|
||||
chatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||
lastChatTypeId: z.string().trim().max(120).nullable().optional(),
|
||||
generalSectionName: z.string().trim().max(120).optional().nullable(),
|
||||
@@ -393,6 +410,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
sessionId: payload.sessionId,
|
||||
clientId: clientId || null,
|
||||
title: payload.title ?? '새 대화',
|
||||
requestBadgeLabel: payload.requestBadgeLabel ?? null,
|
||||
chatTypeId: payload.chatTypeId ?? null,
|
||||
lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null,
|
||||
generalSectionName: payload.generalSectionName ?? null,
|
||||
@@ -509,6 +527,7 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
}).parse(request.params ?? {});
|
||||
const payload = z.object({
|
||||
title: z.string().trim().min(1).max(200).optional(),
|
||||
requestBadgeLabel: z.string().trim().max(120).optional().nullable(),
|
||||
chatTypeId: z.string().trim().max(120).optional().nullable(),
|
||||
lastChatTypeId: z.string().trim().max(120).optional().nullable(),
|
||||
generalSectionName: z.string().trim().max(120).optional().nullable(),
|
||||
@@ -528,12 +547,22 @@ export async function registerChatRoutes(app: FastifyInstance) {
|
||||
|
||||
const item = await updateChatConversationContext(params.sessionId, {
|
||||
title: payload.title ?? current.title,
|
||||
requestBadgeLabel:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'requestBadgeLabel') ? payload.requestBadgeLabel ?? null : undefined,
|
||||
clientId: current.clientId,
|
||||
chatTypeId: payload.chatTypeId ?? current.chatTypeId,
|
||||
lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId,
|
||||
generalSectionName: payload.generalSectionName ?? current.generalSectionName,
|
||||
contextLabel: payload.contextLabel ?? current.contextLabel,
|
||||
contextDescription: payload.contextDescription ?? current.contextDescription,
|
||||
chatTypeId: Object.prototype.hasOwnProperty.call(payload, 'chatTypeId') ? payload.chatTypeId ?? null : undefined,
|
||||
lastChatTypeId:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'lastChatTypeId') ? payload.lastChatTypeId ?? null : undefined,
|
||||
generalSectionName:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'generalSectionName')
|
||||
? payload.generalSectionName ?? null
|
||||
: undefined,
|
||||
contextLabel:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'contextLabel') ? payload.contextLabel ?? null : undefined,
|
||||
contextDescription:
|
||||
Object.prototype.hasOwnProperty.call(payload, 'contextDescription')
|
||||
? payload.contextDescription ?? null
|
||||
: undefined,
|
||||
notifyOffline: payload.notifyOffline ?? current.notifyOffline,
|
||||
});
|
||||
|
||||
|
||||
0
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/compatibility.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/crud.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/crud.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/ddl.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/ddl.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/error-log.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/error-log.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/health.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/health.ts
Executable file → Normal file
56
etc/servers/work-server/src/routes/notification.ts
Executable file → Normal file
56
etc/servers/work-server/src/routes/notification.ts
Executable file → Normal file
@@ -1,21 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
listIosNotificationTokens,
|
||||
listWebPushSubscriptions,
|
||||
getAutomationNotificationPreference,
|
||||
getWebPushConfig,
|
||||
registerIosNotificationToken,
|
||||
registerAutomationNotificationPreferenceSchema,
|
||||
registerIosTokenSchema,
|
||||
registerWebPushSubscription,
|
||||
registerWebPushSubscriptionSchema,
|
||||
sendNotifications,
|
||||
sendIosNotificationSchema,
|
||||
setupNotificationTables,
|
||||
upsertAutomationNotificationPreference,
|
||||
unregisterIosNotificationToken,
|
||||
unregisterIosTokenSchema,
|
||||
unregisterWebPushSubscription,
|
||||
unregisterWebPushSubscriptionSchema,
|
||||
} from '../services/notification-service.js';
|
||||
@@ -31,14 +26,10 @@ import {
|
||||
} from '../services/notification-message-service.js';
|
||||
|
||||
const automationNotificationPreferenceQuerySchema = z.object({
|
||||
targetKind: z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']).optional(),
|
||||
targetKind: z.enum(['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;
|
||||
@@ -48,10 +39,6 @@ function getClientIdHeader(request: { headers: Record<string, string | string[]
|
||||
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/subscriptions/web', async () => ({
|
||||
items: await listWebPushSubscriptions(),
|
||||
}));
|
||||
@@ -60,9 +47,10 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get('/api/notifications/messages', async (request) => {
|
||||
const query = notificationMessageListQuerySchema.parse(request.query ?? {});
|
||||
const clientId = getClientIdHeader(request);
|
||||
return {
|
||||
ok: true,
|
||||
...(await listNotificationMessages(query)),
|
||||
...(await listNotificationMessages(query, clientId)),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -130,7 +118,7 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind),
|
||||
automation: await getAutomationNotificationPreference(targetId, targetKind),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -154,16 +142,6 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -184,29 +162,3 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
|
||||
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;
|
||||
}
|
||||
|
||||
0
etc/servers/work-server/src/routes/plan.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/plan.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/schema.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/schema.ts
Executable file → Normal file
40
etc/servers/work-server/src/routes/server-command.ts
Executable file → Normal file
40
etc/servers/work-server/src/routes/server-command.ts
Executable file → Normal file
@@ -19,6 +19,36 @@ const restartReservationBodySchema = z.object({
|
||||
autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(),
|
||||
});
|
||||
|
||||
function getImmediateRestartBlockInfo(
|
||||
key: z.infer<typeof serverCommandParamSchema>['key'],
|
||||
workloadSummary: Awaited<ReturnType<typeof getRestartReservationWorkloadSummary>>,
|
||||
) {
|
||||
const codexPendingCount = workloadSummary.codexRunningCount + workloadSummary.codexQueuedCount;
|
||||
const automationPendingCount = workloadSummary.automationRunningCount + workloadSummary.automationQueuedCount;
|
||||
|
||||
if (key === 'test') {
|
||||
const pendingCount = codexPendingCount + automationPendingCount;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
return {
|
||||
pendingCount,
|
||||
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === 'work-server' && automationPendingCount > 0) {
|
||||
return {
|
||||
pendingCount: automationPendingCount,
|
||||
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
@@ -75,17 +105,13 @@ export async function registerServerCommandRoutes(app: FastifyInstance) {
|
||||
|
||||
if (key === 'test' || key === 'work-server') {
|
||||
const workloadSummary = await getRestartReservationWorkloadSummary();
|
||||
const pendingCount =
|
||||
workloadSummary.codexRunningCount
|
||||
+ workloadSummary.codexQueuedCount
|
||||
+ workloadSummary.automationRunningCount
|
||||
+ workloadSummary.automationQueuedCount;
|
||||
const blockInfo = getImmediateRestartBlockInfo(key, workloadSummary);
|
||||
|
||||
if (pendingCount > 0) {
|
||||
if (blockInfo) {
|
||||
reply.status(409);
|
||||
return {
|
||||
ok: false,
|
||||
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
message: blockInfo.message,
|
||||
workloadSummary,
|
||||
};
|
||||
}
|
||||
|
||||
0
etc/servers/work-server/src/routes/visitor-history.ts
Executable file → Normal file
0
etc/servers/work-server/src/routes/visitor-history.ts
Executable file → Normal file
0
etc/servers/work-server/src/server.ts
Executable file → Normal file
0
etc/servers/work-server/src/server.ts
Executable file → Normal file
@@ -50,16 +50,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.APP_CONFIG_TABLE = void 0;
|
||||
exports.resolveAppConfigByOrigin = resolveAppConfigByOrigin;
|
||||
exports.getAppConfig = getAppConfig;
|
||||
exports.mergeDefaultChatTypes = mergeDefaultChatTypes;
|
||||
exports.sanitizePersistedChatTypes = sanitizePersistedChatTypes;
|
||||
exports.normalizeAppConfigSnapshot = normalizeAppConfigSnapshot;
|
||||
exports.getAppConfigSnapshot = getAppConfigSnapshot;
|
||||
exports.upsertAppConfig = upsertAppConfig;
|
||||
exports.getChatTypesConfig = getChatTypesConfig;
|
||||
exports.upsertChatTypesConfig = upsertChatTypesConfig;
|
||||
var client_js_1 = require("../db/client.js");
|
||||
var chat_type_defaults_js_1 = require("./chat-type-defaults.js");
|
||||
exports.APP_CONFIG_TABLE = 'app_configs';
|
||||
var CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
||||
var CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings';
|
||||
var SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs';
|
||||
var DEFAULT_CHAT_APP_CONFIG = {
|
||||
maxContextMessages: 12,
|
||||
@@ -67,6 +67,7 @@ var DEFAULT_CHAT_APP_CONFIG = {
|
||||
codexLiveMaxExecutionSeconds: 600,
|
||||
codexLiveIdleTimeoutSeconds: 180,
|
||||
receiveRoomNotifications: true,
|
||||
restartReservationCompletionDelaySeconds: 10,
|
||||
};
|
||||
function ensureAppConfigTable() {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
@@ -251,6 +252,13 @@ function normalizePermissions(value) {
|
||||
var permissions = Array.from(new Set(value.filter(function (item) { return item === 'guest' || item === 'token-user'; })));
|
||||
return permissions.length > 0 ? permissions : ['token-user'];
|
||||
}
|
||||
function normalizePositiveSortOrder(value) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return Number.NaN;
|
||||
}
|
||||
var nextValue = Math.trunc(value);
|
||||
return nextValue > 0 ? nextValue : Number.NaN;
|
||||
}
|
||||
function normalizeChatTypeRecord(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -263,6 +271,7 @@ function normalizeChatTypeRecord(value) {
|
||||
return {
|
||||
id: normalizeText(record.id) || "chat-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)),
|
||||
name: name,
|
||||
sortOrder: normalizePositiveSortOrder(record.sortOrder),
|
||||
description: normalizeText(record.description),
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
@@ -301,19 +310,29 @@ function sanitizeChatTypes(items) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.name.localeCompare(right.name, 'ko-KR'); });
|
||||
}
|
||||
function mergeDefaultChatTypes(items) {
|
||||
var savedItems = sanitizeChatTypes(items);
|
||||
var byId = new Map(savedItems.map(function (item) { return [item.id, item]; }));
|
||||
for (var _i = 0, DEFAULT_CHAT_TYPES_1 = chat_type_defaults_js_1.DEFAULT_CHAT_TYPES; _i < DEFAULT_CHAT_TYPES_1.length; _i++) {
|
||||
var defaultItem = DEFAULT_CHAT_TYPES_1[_i];
|
||||
var savedItem = byId.get(defaultItem.id);
|
||||
if (!savedItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
return Array.from(bySemanticKey.values())
|
||||
.sort(function (left, right) {
|
||||
var leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null;
|
||||
var rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null;
|
||||
if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) {
|
||||
return leftSortOrder - rightSortOrder;
|
||||
}
|
||||
}
|
||||
return sanitizeChatTypes(Array.from(byId.values()));
|
||||
if (leftSortOrder !== null && rightSortOrder === null) {
|
||||
return -1;
|
||||
}
|
||||
if (leftSortOrder === null && rightSortOrder !== null) {
|
||||
return 1;
|
||||
}
|
||||
var nameCompare = left.name.localeCompare(right.name, 'ko-KR');
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
return compareUpdatedAt(left, right);
|
||||
})
|
||||
.map(function (item, index) { return (__assign(__assign({}, item), { sortOrder: index + 1 })); });
|
||||
}
|
||||
function sanitizePersistedChatTypes(items) {
|
||||
return sanitizeChatTypes(items);
|
||||
}
|
||||
function isSameChatTypeList(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
@@ -324,6 +343,7 @@ function isSameChatTypeList(left, right) {
|
||||
return (target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.sortOrder === target.sortOrder &&
|
||||
item.description === target.description &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt &&
|
||||
@@ -400,8 +420,7 @@ function upsertAppConfig(config, appOrigin, appDomain) {
|
||||
}
|
||||
function getChatTypesConfig(appOrigin) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var config, normalized, chatTypes, savedChatTypes, mergedChatTypes;
|
||||
var _a;
|
||||
var config, normalized, chatTypes, savedChatTypes, resolvedChatTypes;
|
||||
return __generator(this, function (_b) {
|
||||
switch (_b.label) {
|
||||
case 0: return [4 /*yield*/, getAppConfig(appOrigin)];
|
||||
@@ -413,15 +432,10 @@ function getChatTypesConfig(appOrigin) {
|
||||
return [2 /*return*/, null];
|
||||
}
|
||||
savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : [];
|
||||
mergedChatTypes = mergeDefaultChatTypes(savedChatTypes);
|
||||
if (!!isSameChatTypeList(savedChatTypes, mergedChatTypes)) return [3 /*break*/, 3];
|
||||
return [4 /*yield*/, upsertAppConfig((_a = {},
|
||||
_a[CHAT_TYPES_CONFIG_KEY] = mergedChatTypes,
|
||||
_a), appOrigin)];
|
||||
case 2:
|
||||
_b.sent();
|
||||
_b.label = 3;
|
||||
case 3: return [2 /*return*/, mergedChatTypes];
|
||||
resolvedChatTypes = sanitizePersistedChatTypes(savedChatTypes);
|
||||
return [2 /*return*/, {
|
||||
chatTypes: resolvedChatTypes,
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -437,12 +451,14 @@ function upsertChatTypesConfig(chatTypes, appOrigin, appDomain) {
|
||||
return [4 /*yield*/, getAppConfig(appOrigin)];
|
||||
case 1:
|
||||
current = _a.apply(void 0, [_c.sent()]);
|
||||
resolvedChatTypes = mergeDefaultChatTypes(chatTypes);
|
||||
resolvedChatTypes = sanitizePersistedChatTypes(chatTypes);
|
||||
nextConfig = __assign(__assign({}, current), (_b = {}, _b[CHAT_TYPES_CONFIG_KEY] = resolvedChatTypes, _b));
|
||||
return [4 /*yield*/, upsertAppConfig(nextConfig, appOrigin, appDomain)];
|
||||
case 2:
|
||||
_c.sent();
|
||||
return [2 /*return*/, resolvedChatTypes];
|
||||
return [2 /*return*/, {
|
||||
chatTypes: resolvedChatTypes,
|
||||
}];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mergeDefaultChatTypes,
|
||||
migrateLegacyChatTypeContexts,
|
||||
stripBuiltInChatTypes,
|
||||
sanitizePersistedChatTypes,
|
||||
resolveAppConfigByOrigin,
|
||||
resolveCanonicalChatTypesFromConfig,
|
||||
resolveCanonicalChatContextSettingsFromConfig,
|
||||
stripChatContextSettingsFromScopedAppConfigs,
|
||||
stripSharedContextDataFromScopedAppConfigs,
|
||||
} from './app-config-service.js';
|
||||
|
||||
test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => {
|
||||
const merged = mergeDefaultChatTypes([
|
||||
test('sanitizePersistedChatTypes keeps saved chat type edits as-is', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
sortOrder: 3,
|
||||
description: '사용자가 수정한 일반 요청 문맥',
|
||||
permissions: ['guest', 'token-user'],
|
||||
enabled: true,
|
||||
@@ -27,13 +28,15 @@ test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () =
|
||||
assert.ok(generalRequest);
|
||||
assert.equal(generalRequest.description, '사용자가 수정한 일반 요청 문맥');
|
||||
assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']);
|
||||
assert.equal(generalRequest.sortOrder, 1);
|
||||
});
|
||||
|
||||
test('mergeDefaultChatTypes preserves saved edits for layout editor execution', () => {
|
||||
const merged = mergeDefaultChatTypes([
|
||||
test('sanitizePersistedChatTypes keeps saved layout editor execution entries', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'layout-editor-execution',
|
||||
name: 'Layout editor 실행',
|
||||
sortOrder: 2,
|
||||
description: '호출 가능한 API 요청만 처리합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
@@ -45,13 +48,15 @@ test('mergeDefaultChatTypes preserves saved edits for layout editor execution',
|
||||
|
||||
assert.ok(layoutEditorExecution);
|
||||
assert.equal(layoutEditorExecution.description, '호출 가능한 API 요청만 처리합니다.');
|
||||
assert.equal(layoutEditorExecution.sortOrder, 1);
|
||||
});
|
||||
|
||||
test('mergeDefaultChatTypes preserves saved edits for guided layout editor execution', () => {
|
||||
const merged = mergeDefaultChatTypes([
|
||||
test('sanitizePersistedChatTypes keeps saved guided layout editor entries', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'layout-editor-guided-execution',
|
||||
name: 'Layout editor 단계별 실행',
|
||||
sortOrder: 4,
|
||||
description: '사용자가 정리한 단계별 Layout 실행 문맥',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
@@ -63,24 +68,48 @@ test('mergeDefaultChatTypes preserves saved edits for guided layout editor execu
|
||||
|
||||
assert.ok(guidedLayoutEditorExecution);
|
||||
assert.equal(guidedLayoutEditorExecution.description, '사용자가 정리한 단계별 Layout 실행 문맥');
|
||||
assert.equal(guidedLayoutEditorExecution.sortOrder, 1);
|
||||
});
|
||||
|
||||
test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
|
||||
const merged = mergeDefaultChatTypes([]);
|
||||
test('sanitizePersistedChatTypes returns empty list when nothing is saved', () => {
|
||||
const merged = sanitizePersistedChatTypes([]);
|
||||
|
||||
assert.ok(merged.some((item) => item.id === 'general-request'));
|
||||
assert.ok(merged.some((item) => item.id === 'layout-editor-execution'));
|
||||
assert.ok(merged.some((item) => item.id === 'api-request-template'));
|
||||
assert.ok(merged.some((item) => item.id === 'general-inquiry'));
|
||||
assert.ok(!merged.some((item) => item.id === 'plan-checklist-execution'));
|
||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
|
||||
assert.deepEqual(merged, []);
|
||||
});
|
||||
|
||||
test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () => {
|
||||
const stripped = stripBuiltInChatTypes([
|
||||
test('sanitizePersistedChatTypes keeps saved chat type list without backfilling removed entries', () => {
|
||||
const merged = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
sortOrder: 2,
|
||||
description: '사용자가 수정한 일반 요청 문맥',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'custom-support-flow',
|
||||
name: '운영 문의 전용',
|
||||
sortOrder: 1,
|
||||
description: 'custom',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution'));
|
||||
assert.ok(!merged.some((item) => item.id === 'layout-editor-execution'));
|
||||
assert.ok(merged.some((item) => item.id === 'custom-support-flow'));
|
||||
});
|
||||
|
||||
test('sanitizePersistedChatTypes keeps all saved chat types without special filtering', () => {
|
||||
const stripped = sanitizePersistedChatTypes([
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
sortOrder: 2,
|
||||
description: 'builtin',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
@@ -89,6 +118,7 @@ test('stripBuiltInChatTypes removes built-in chat type ids from saved list', ()
|
||||
{
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
sortOrder: 3,
|
||||
description: 'custom-seeded',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
@@ -97,6 +127,7 @@ test('stripBuiltInChatTypes removes built-in chat type ids from saved list', ()
|
||||
{
|
||||
id: 'custom-support-flow',
|
||||
name: '운영 문의 전용',
|
||||
sortOrder: 1,
|
||||
description: 'custom',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
@@ -104,7 +135,7 @@ test('stripBuiltInChatTypes removes built-in chat type ids from saved list', ()
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'plan-checklist-execution']);
|
||||
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'general-request', 'plan-checklist-execution']);
|
||||
});
|
||||
|
||||
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
|
||||
@@ -124,6 +155,7 @@ test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into d
|
||||
{
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
sortOrder: 1,
|
||||
description: 'legacy plan context',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
@@ -178,7 +210,7 @@ test('resolveAppConfigByOrigin falls back to legacy global config when scoped co
|
||||
receiveRoomNotifications: true,
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
'https://preview.sm-home.cloud',
|
||||
) as {
|
||||
chat?: { receiveRoomNotifications?: boolean };
|
||||
};
|
||||
@@ -208,7 +240,7 @@ test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context
|
||||
],
|
||||
},
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
@@ -225,7 +257,7 @@ test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -234,11 +266,76 @@ test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig keeps saved default context sort order and renumbers gaps', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig({
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'context-b',
|
||||
title: 'B 문맥',
|
||||
sortOrder: 3,
|
||||
content: 'b',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'context-a',
|
||||
title: 'A 문맥',
|
||||
sortOrder: 1,
|
||||
content: 'a',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => ({ id: item.id, sortOrder: item.sortOrder })),
|
||||
[
|
||||
{ id: 'context-a', sortOrder: 1 },
|
||||
{ id: 'context-b', sortOrder: 2 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig appends unsorted default contexts after sorted entries', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig({
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'context-b',
|
||||
title: 'B 문맥',
|
||||
content: 'b',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'context-a',
|
||||
title: 'A 문맥',
|
||||
sortOrder: 1,
|
||||
content: 'a',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
resolved.defaultContexts.map((item) => ({ id: item.id, sortOrder: item.sortOrder })),
|
||||
[
|
||||
{ id: 'context-a', sortOrder: 1 },
|
||||
{ id: 'context-b', sortOrder: 2 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped settings only when global settings are empty', () => {
|
||||
const resolved = resolveCanonicalChatContextSettingsFromConfig(
|
||||
{
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
@@ -255,7 +352,7 @@ test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped setting
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -278,7 +375,7 @@ test('resolveCanonicalChatTypesFromConfig merges global chat types with stale sc
|
||||
},
|
||||
],
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
@@ -294,7 +391,7 @@ test('resolveCanonicalChatTypesFromConfig merges global chat types with stale sc
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://test.sm-home.cloud',
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.ok(resolved);
|
||||
@@ -305,7 +402,7 @@ test('resolveCanonicalChatTypesFromConfig merges global chat types with stale sc
|
||||
test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat context settings only', () => {
|
||||
const stripped = stripChatContextSettingsFromScopedAppConfigs({
|
||||
scopedAppConfigs: {
|
||||
'https://test.sm-home.cloud': {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatContextSettings: {
|
||||
defaultContexts: [{ id: 'legacy', title: 'legacy', content: 'legacy', enabled: true }],
|
||||
@@ -321,7 +418,7 @@ test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat con
|
||||
|
||||
assert.equal(stripped.changed, true);
|
||||
assert.deepEqual(stripped.scopedConfigs, {
|
||||
'https://test.sm-home.cloud': {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
@@ -331,3 +428,104 @@ test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat con
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('stripSharedContextDataFromScopedAppConfigs removes scoped chat-type/context data and backs up non-shared origins', () => {
|
||||
const stripped = stripSharedContextDataFromScopedAppConfigs(
|
||||
{
|
||||
scopedAppConfigs: {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: 'preview-shared',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'preview-context',
|
||||
title: 'preview',
|
||||
content: 'shared',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'preview.sm-home.cloud',
|
||||
},
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
chatTypes: [
|
||||
{
|
||||
id: 'chat-type-test-temp',
|
||||
name: '임시 유형',
|
||||
description: 'test-only',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
chatContextSettings: {
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'test-context',
|
||||
title: 'test',
|
||||
content: 'legacy',
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
automation: {
|
||||
notifyOnAutomationStart: true,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'test.sm-home.cloud',
|
||||
},
|
||||
},
|
||||
},
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
|
||||
assert.equal(stripped.changed, true);
|
||||
assert.deepEqual(stripped.scopedConfigs, {
|
||||
'https://preview.sm-home.cloud': {
|
||||
config: {
|
||||
chat: {
|
||||
receiveRoomNotifications: false,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'preview.sm-home.cloud',
|
||||
},
|
||||
'https://test.sm-home.cloud': {
|
||||
config: {
|
||||
automation: {
|
||||
notifyOnAutomationStart: true,
|
||||
},
|
||||
},
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
appDomain: 'test.sm-home.cloud',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
Array.isArray(stripped.backups['https://test.sm-home.cloud']?.chatTypes),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
stripped.backups['https://test.sm-home.cloud']?.chatContextSettings?.defaultContexts[0]?.id,
|
||||
'test-context',
|
||||
);
|
||||
assert.equal(stripped.backups['https://preview.sm-home.cloud'], undefined);
|
||||
});
|
||||
|
||||
371
etc/servers/work-server/src/services/app-config-service.ts
Executable file → Normal file
371
etc/servers/work-server/src/services/app-config-service.ts
Executable file → Normal file
@@ -1,6 +1,5 @@
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
DEFAULT_CHAT_TYPES,
|
||||
PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT,
|
||||
PLAN_CHECKLIST_DEFAULT_CONTEXT_ID,
|
||||
PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE,
|
||||
@@ -10,6 +9,8 @@ export const APP_CONFIG_TABLE = 'app_configs';
|
||||
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
|
||||
const CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings';
|
||||
const SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs';
|
||||
const SCOPED_CONTEXT_CONFIG_BACKUPS_KEY = 'scopedContextConfigBackups';
|
||||
const SHARED_CHAT_CONTEXT_APP_ORIGIN = 'https://preview.sm-home.cloud';
|
||||
const DEFAULT_CHAT_APP_CONFIG = {
|
||||
maxContextMessages: 12,
|
||||
maxContextChars: 3200,
|
||||
@@ -24,6 +25,7 @@ type ChatPermissionRole = 'guest' | 'token-user';
|
||||
type ChatTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
description: string;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
@@ -33,14 +35,13 @@ type ChatTypeRecord = {
|
||||
const LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID = 'plan-checklist-execution';
|
||||
|
||||
export type ChatTypesConfigSnapshot = {
|
||||
builtInChatTypes: ChatTypeRecord[];
|
||||
customChatTypes: ChatTypeRecord[];
|
||||
chatTypes: ChatTypeRecord[];
|
||||
};
|
||||
|
||||
type ChatDefaultContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
sortOrder: number;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
@@ -66,6 +67,15 @@ export type ChatContextSettingsSnapshot = {
|
||||
roomContexts: ChatRoomContextSettings[];
|
||||
};
|
||||
|
||||
type ScopedContextConfigBackupEntry = {
|
||||
sourceOrigin: string;
|
||||
appDomain: string | null;
|
||||
updatedAt: string;
|
||||
backedUpAt: string;
|
||||
chatTypes?: ChatTypeRecord[];
|
||||
chatContextSettings?: ChatContextSettingsSnapshot;
|
||||
};
|
||||
|
||||
async function ensureAppConfigTable() {
|
||||
const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE);
|
||||
|
||||
@@ -152,6 +162,16 @@ function getScopedAppConfigEntryRecord(value: unknown) {
|
||||
return normalizeConfigRecord(value);
|
||||
}
|
||||
|
||||
function getScopedContextConfigBackupsRecord(value: unknown) {
|
||||
return normalizeConfigRecord(
|
||||
normalizeConfigRecord(value)[SCOPED_CONTEXT_CONFIG_BACKUPS_KEY],
|
||||
) as Record<string, ScopedContextConfigBackupEntry>;
|
||||
}
|
||||
|
||||
function resolveSharedChatContextAppOrigin() {
|
||||
return SHARED_CHAT_CONTEXT_APP_ORIGIN;
|
||||
}
|
||||
|
||||
function hasChatContextSettingsSnapshot(value: ChatContextSettingsSnapshot) {
|
||||
return (
|
||||
value.defaultContexts.length > 0 ||
|
||||
@@ -192,6 +212,102 @@ export function stripChatContextSettingsFromScopedAppConfigs(value: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildScopedContextConfigBackupEntry(
|
||||
origin: string,
|
||||
entry: Record<string, unknown>,
|
||||
chatTypes: ChatTypeRecord[],
|
||||
chatContextSettings: ChatContextSettingsSnapshot,
|
||||
): ScopedContextConfigBackupEntry | null {
|
||||
const normalizedOrigin = normalizeAppOrigin(origin) || origin;
|
||||
const hasChatTypes = chatTypes.length > 0;
|
||||
const hasChatContextSettings = hasChatContextSettingsSnapshot(chatContextSettings);
|
||||
|
||||
if (!normalizedOrigin || (!hasChatTypes && !hasChatContextSettings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceOrigin: normalizedOrigin,
|
||||
appDomain: normalizeAppDomain(typeof entry.appDomain === 'string' ? entry.appDomain : null) || null,
|
||||
updatedAt: normalizeText(entry.updatedAt) || new Date().toISOString(),
|
||||
backedUpAt: new Date().toISOString(),
|
||||
...(hasChatTypes ? { chatTypes } : null),
|
||||
...(hasChatContextSettings ? { chatContextSettings } : null),
|
||||
};
|
||||
}
|
||||
|
||||
export function stripSharedContextDataFromScopedAppConfigs(
|
||||
value: unknown,
|
||||
canonicalAppOrigin = SHARED_CHAT_CONTEXT_APP_ORIGIN,
|
||||
): {
|
||||
changed: boolean;
|
||||
scopedConfigs: Record<string, unknown>;
|
||||
backups: Record<string, ScopedContextConfigBackupEntry>;
|
||||
} {
|
||||
const scopedConfigs = getScopedAppConfigsRecord(value);
|
||||
const existingBackups = getScopedContextConfigBackupsRecord(value);
|
||||
const normalizedCanonicalOrigin = normalizeAppOrigin(canonicalAppOrigin);
|
||||
let stripped = false;
|
||||
let backupChanged = false;
|
||||
|
||||
const nextBackups = { ...existingBackups };
|
||||
const sanitizedScopedConfigs = Object.fromEntries(
|
||||
Object.entries(scopedConfigs).map(([origin, entry]) => {
|
||||
const normalizedEntry = getScopedAppConfigEntryRecord(entry);
|
||||
const normalizedConfig = normalizeConfigRecord(normalizedEntry.config);
|
||||
const scopedChatTypes = Array.isArray(normalizedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? sanitizeChatTypes(normalizedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||
: [];
|
||||
const scopedChatContextSettings = sanitizeChatContextSettings(
|
||||
normalizedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY],
|
||||
);
|
||||
const isCanonicalOrigin =
|
||||
normalizedCanonicalOrigin && normalizeAppOrigin(origin) === normalizedCanonicalOrigin;
|
||||
|
||||
if (!isCanonicalOrigin) {
|
||||
const backupEntry = buildScopedContextConfigBackupEntry(
|
||||
origin,
|
||||
normalizedEntry,
|
||||
scopedChatTypes,
|
||||
scopedChatContextSettings,
|
||||
);
|
||||
|
||||
if (backupEntry) {
|
||||
nextBackups[origin] = backupEntry;
|
||||
backupChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
CHAT_TYPES_CONFIG_KEY in normalizedConfig ||
|
||||
CHAT_CONTEXT_SETTINGS_CONFIG_KEY in normalizedConfig
|
||||
) {
|
||||
stripped = true;
|
||||
}
|
||||
|
||||
const {
|
||||
[CHAT_TYPES_CONFIG_KEY]: _removedChatTypes,
|
||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: _removedChatContextSettings,
|
||||
...nextConfig
|
||||
} = normalizedConfig;
|
||||
|
||||
return [
|
||||
origin,
|
||||
{
|
||||
...normalizedEntry,
|
||||
config: nextConfig,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
changed: stripped || backupChanged,
|
||||
scopedConfigs: sanitizedScopedConfigs,
|
||||
backups: nextBackups,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCanonicalChatContextSettingsFromConfig(value: unknown, appOrigin?: string | null) {
|
||||
const normalized = normalizeConfigRecord(value);
|
||||
const globalSettings = sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
@@ -221,7 +337,7 @@ export function resolveCanonicalChatTypesFromConfig(value: unknown, appOrigin?:
|
||||
return null;
|
||||
}
|
||||
|
||||
return mergeDefaultChatTypes([...globalChatTypes, ...scopedChatTypes]);
|
||||
return sanitizeChatTypes([...globalChatTypes, ...scopedChatTypes]);
|
||||
}
|
||||
|
||||
function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) {
|
||||
@@ -378,7 +494,8 @@ function normalizeDefaultContextRecord(value: unknown): ChatDefaultContextRecord
|
||||
|
||||
return {
|
||||
id: normalizeText(record.id) || `chat-default-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: title || '기본 유형',
|
||||
title: title || '공통 문맥',
|
||||
sortOrder: normalizePositiveSortOrder(record.sortOrder),
|
||||
content,
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
@@ -400,7 +517,35 @@ function sanitizeDefaultContexts(items: unknown) {
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(byId.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
return Array.from(byId.values())
|
||||
.sort((left, right) => {
|
||||
const leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null;
|
||||
const rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null;
|
||||
|
||||
if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) {
|
||||
return leftSortOrder - rightSortOrder;
|
||||
}
|
||||
|
||||
if (leftSortOrder !== null && rightSortOrder === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (leftSortOrder === null && rightSortOrder !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const titleCompare = left.title.localeCompare(right.title, 'ko-KR');
|
||||
|
||||
if (titleCompare !== 0) {
|
||||
return titleCompare;
|
||||
}
|
||||
|
||||
return compareUpdatedAt(left, right);
|
||||
})
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
sortOrder: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizeChatTypeDefaultSelections(items: unknown) {
|
||||
@@ -499,6 +644,7 @@ function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null {
|
||||
return {
|
||||
id: normalizeText(record.id) || `chat-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name,
|
||||
sortOrder: normalizePositiveSortOrder(record.sortOrder),
|
||||
description: normalizeText(record.description),
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
@@ -506,12 +652,17 @@ function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null {
|
||||
};
|
||||
}
|
||||
|
||||
function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
|
||||
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
function normalizePositiveSortOrder(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
const nextValue = Math.trunc(value);
|
||||
return nextValue > 0 ? nextValue : Number.NaN;
|
||||
}
|
||||
|
||||
function isBuiltInChatTypeId(chatTypeId: string) {
|
||||
return DEFAULT_CHAT_TYPES.some((item) => item.id === chatTypeId);
|
||||
function buildChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
|
||||
return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function isLegacyMigratedChatTypeId(chatTypeId: string) {
|
||||
@@ -554,25 +705,39 @@ function sanitizeChatTypes(items: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
return Array.from(bySemanticKey.values())
|
||||
.sort((left, right) => {
|
||||
const leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null;
|
||||
const rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null;
|
||||
|
||||
if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) {
|
||||
return leftSortOrder - rightSortOrder;
|
||||
}
|
||||
|
||||
if (leftSortOrder !== null && rightSortOrder === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (leftSortOrder === null && rightSortOrder !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
|
||||
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return compareUpdatedAt(left, right);
|
||||
})
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
sortOrder: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeDefaultChatTypes(items: unknown[]) {
|
||||
const savedItems = sanitizeChatTypes(items);
|
||||
const byId = new Map(savedItems.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_CHAT_TYPES) {
|
||||
const savedItem = byId.get(defaultItem.id);
|
||||
if (!savedItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
export function stripBuiltInChatTypes(items: unknown[]) {
|
||||
return sanitizeChatTypes(items).filter((item) => !isBuiltInChatTypeId(item.id));
|
||||
export function sanitizePersistedChatTypes(items: unknown[]) {
|
||||
return sanitizeChatTypes(items);
|
||||
}
|
||||
|
||||
function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) {
|
||||
@@ -634,6 +799,7 @@ function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
item.enabled === target.enabled &&
|
||||
item.sortOrder === target.sortOrder &&
|
||||
item.updatedAt === target.updatedAt &&
|
||||
item.permissions.length === target.permissions.length &&
|
||||
item.permissions.every((permission, permissionIndex) => permission === target.permissions[permissionIndex])
|
||||
@@ -735,15 +901,23 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot {
|
||||
export async function getAppConfigSnapshot(appOrigin?: string | null): Promise<AppConfigSnapshot> {
|
||||
const config = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
|
||||
const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin);
|
||||
const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(rawConfig);
|
||||
const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin);
|
||||
const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig(
|
||||
rawConfig,
|
||||
sharedChatContextAppOrigin,
|
||||
);
|
||||
const { scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
|
||||
rawConfig,
|
||||
sharedChatContextAppOrigin,
|
||||
);
|
||||
|
||||
return normalizeAppConfigSnapshot({
|
||||
...config,
|
||||
...(canonicalChatTypes ? { [CHAT_TYPES_CONFIG_KEY]: canonicalChatTypes } : null),
|
||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: canonicalChatContextSettings,
|
||||
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||
[SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -785,80 +959,123 @@ export async function upsertAppConfig(
|
||||
return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin);
|
||||
}
|
||||
|
||||
export async function getChatTypesConfig(appOrigin?: string | null): Promise<ChatTypesConfigSnapshot> {
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin);
|
||||
const builtInChatTypes = sanitizeChatTypes(DEFAULT_CHAT_TYPES);
|
||||
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
|
||||
const customChatTypes = stripBuiltInChatTypes(migratedChatTypeList);
|
||||
const mergedChatTypes = mergeDefaultChatTypes(customChatTypes);
|
||||
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
|
||||
canonicalChatTypes ?? [],
|
||||
);
|
||||
async function replaceAppConfig(config: Record<string, unknown>, appOrigin?: string | null) {
|
||||
await ensureAppConfigTable();
|
||||
const nextConfig = normalizeConfigRecord(config);
|
||||
const existing = await db(APP_CONFIG_TABLE).first();
|
||||
|
||||
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||
: [];
|
||||
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
if (!existing) {
|
||||
const rows = await db(APP_CONFIG_TABLE)
|
||||
.insert({
|
||||
config_json: nextConfig,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
if (!isSameChatTypeList(resolvedCustomChatTypes, customChatTypes)) {
|
||||
await upsertAppConfig({
|
||||
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
|
||||
}, appOrigin);
|
||||
return resolveAppConfigByOrigin(rows[0]?.config_json ?? nextConfig, appOrigin);
|
||||
}
|
||||
|
||||
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
|
||||
await upsertChatContextSettingsConfig(migratedSettings);
|
||||
const rows = await db(APP_CONFIG_TABLE)
|
||||
.update({
|
||||
config_json: nextConfig,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
|
||||
return resolveAppConfigByOrigin(rows[0]?.config_json ?? nextConfig, appOrigin);
|
||||
}
|
||||
|
||||
export async function getChatTypesConfig(appOrigin?: string | null): Promise<ChatTypesConfigSnapshot> {
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin);
|
||||
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
|
||||
const chatTypes = sanitizePersistedChatTypes(migratedChatTypeList);
|
||||
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, sharedChatContextAppOrigin),
|
||||
chatTypes,
|
||||
);
|
||||
const globalChatTypes = Array.isArray(rawConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]))
|
||||
: [];
|
||||
const globalSettings = sanitizeChatContextSettings(rawConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
const { changed, scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
|
||||
rawConfig,
|
||||
sharedChatContextAppOrigin,
|
||||
);
|
||||
|
||||
if (
|
||||
changed ||
|
||||
!isSameChatTypeList(globalChatTypes, chatTypes) ||
|
||||
JSON.stringify(globalSettings) !== JSON.stringify(migratedSettings)
|
||||
) {
|
||||
await replaceAppConfig({
|
||||
...rawConfig,
|
||||
[CHAT_TYPES_CONFIG_KEY]: chatTypes,
|
||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: migratedSettings,
|
||||
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||
[SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
builtInChatTypes,
|
||||
customChatTypes,
|
||||
chatTypes: mergedChatTypes,
|
||||
chatTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
|
||||
const current = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const customChatTypes = stripBuiltInChatTypes(chatTypes);
|
||||
const current = await getRawAppConfigRecord();
|
||||
const nextChatTypes = sanitizePersistedChatTypes(chatTypes);
|
||||
const { scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
|
||||
current,
|
||||
resolveSharedChatContextAppOrigin(),
|
||||
);
|
||||
const nextConfig = {
|
||||
...current,
|
||||
[CHAT_TYPES_CONFIG_KEY]: customChatTypes,
|
||||
[CHAT_TYPES_CONFIG_KEY]: nextChatTypes,
|
||||
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||
[SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups,
|
||||
};
|
||||
|
||||
await upsertAppConfig(nextConfig, appOrigin, appDomain);
|
||||
void appOrigin;
|
||||
void appDomain;
|
||||
await replaceAppConfig(nextConfig);
|
||||
return {
|
||||
builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES),
|
||||
customChatTypes,
|
||||
chatTypes: mergeDefaultChatTypes(customChatTypes),
|
||||
chatTypes: nextChatTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChatContextSettingsConfig(appOrigin?: string | null) {
|
||||
const rawConfig = await getRawAppConfigRecord();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin) ?? [];
|
||||
const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin) ?? [];
|
||||
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin),
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, sharedChatContextAppOrigin),
|
||||
canonicalChatTypes,
|
||||
);
|
||||
const migratedChatTypes = stripLegacyMigratedChatTypes(canonicalChatTypes);
|
||||
const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin));
|
||||
const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])
|
||||
const globalChatTypes = Array.isArray(rawConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]))
|
||||
: [];
|
||||
const nextCustomChatTypes = stripBuiltInChatTypes(migratedChatTypes);
|
||||
const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
const nextCustomChatTypes = sanitizePersistedChatTypes(migratedChatTypes);
|
||||
const globalSettings = sanitizeChatContextSettings(rawConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
const { changed, scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
|
||||
rawConfig,
|
||||
sharedChatContextAppOrigin,
|
||||
);
|
||||
|
||||
if (!isSameChatTypeList(resolvedCustomChatTypes, nextCustomChatTypes)) {
|
||||
await upsertAppConfig({
|
||||
if (
|
||||
changed ||
|
||||
!isSameChatTypeList(globalChatTypes, nextCustomChatTypes) ||
|
||||
JSON.stringify(globalSettings) !== JSON.stringify(migratedSettings)
|
||||
) {
|
||||
await replaceAppConfig({
|
||||
...rawConfig,
|
||||
[CHAT_TYPES_CONFIG_KEY]: nextCustomChatTypes,
|
||||
}, appOrigin);
|
||||
}
|
||||
|
||||
if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) {
|
||||
await upsertChatContextSettingsConfig(migratedSettings);
|
||||
[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: migratedSettings,
|
||||
[SCOPED_APP_CONFIGS_KEY]: scopedConfigs,
|
||||
[SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups,
|
||||
});
|
||||
}
|
||||
|
||||
return migratedSettings;
|
||||
@@ -880,6 +1097,6 @@ export async function upsertChatContextSettingsConfig(
|
||||
|
||||
void appOrigin;
|
||||
void appDomain;
|
||||
await upsertAppConfig(nextConfig);
|
||||
await replaceAppConfig(nextConfig);
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
@@ -77,11 +77,37 @@ const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/',
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/';
|
||||
const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/';
|
||||
const RESOURCE_MANAGER_ROOT_MARKER = 'resource/';
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewUrl(value: string) {
|
||||
const normalized = normalizeText(value).replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, '');
|
||||
|
||||
if (!resourcePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const relativePath = resourcePath.slice(RESOURCE_MANAGER_ROOT_MARKER.length).replace(/^\/+/, '');
|
||||
|
||||
if (!relativePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const encodedPath = relativePath
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : '';
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
@@ -113,6 +139,14 @@ function normalizeUrl(value: string) {
|
||||
return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`;
|
||||
}
|
||||
|
||||
if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) {
|
||||
const resourceManagerPreviewUrl = buildResourceManagerPreviewUrl(normalized);
|
||||
|
||||
if (resourceManagerPreviewUrl) {
|
||||
return resourceManagerPreviewUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||
buildChatConversationRequestPatchFromMessage,
|
||||
hasMeaningfulChatSourceArtifacts,
|
||||
isVisibleConversationMessage,
|
||||
mergeChatConversationRequestStatus,
|
||||
normalizeStaleRequestItem,
|
||||
selectStaleOfflineNotificationClientIds,
|
||||
resolveNextConversationContextValue,
|
||||
resolveNextConversationChatTypeId,
|
||||
shouldClearConversationJobState,
|
||||
@@ -30,9 +33,43 @@ test('resolveNextConversationChatTypeId falls back to the stored chat type when
|
||||
});
|
||||
|
||||
test('resolveNextConversationContextValue prefers the requested chat type context', () => {
|
||||
assert.equal(resolveNextConversationContextValue('old context', 'new context'), 'new context');
|
||||
assert.equal(resolveNextConversationContextValue('old context', ' '), 'old context');
|
||||
assert.equal(resolveNextConversationContextValue(null, 'new context'), 'new context');
|
||||
assert.equal(resolveNextConversationContextValue('old context', 'new context', true), 'new context');
|
||||
assert.equal(resolveNextConversationContextValue('old context', ' ', true), null);
|
||||
assert.equal(resolveNextConversationContextValue(null, 'new context', true), 'new context');
|
||||
assert.equal(resolveNextConversationContextValue('old context', undefined, false), 'old context');
|
||||
});
|
||||
|
||||
test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
|
||||
assert.deepEqual(
|
||||
selectStaleOfflineNotificationClientIds(
|
||||
[
|
||||
{
|
||||
clientId: 'client-current',
|
||||
notifyOffline: true,
|
||||
hasActivePushRegistration: false,
|
||||
},
|
||||
{
|
||||
clientId: 'client-registered',
|
||||
notifyOffline: true,
|
||||
hasActivePushRegistration: true,
|
||||
},
|
||||
{
|
||||
clientId: 'client-stale',
|
||||
notifyOffline: true,
|
||||
hasActivePushRegistration: false,
|
||||
},
|
||||
{
|
||||
clientId: 'client-disabled',
|
||||
notifyOffline: false,
|
||||
hasActivePushRegistration: false,
|
||||
},
|
||||
],
|
||||
{
|
||||
keepClientIds: ['client-current'],
|
||||
},
|
||||
),
|
||||
['client-stale'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||
@@ -73,6 +110,26 @@ test('isVisibleConversationMessage hides internal system messages and keeps acti
|
||||
);
|
||||
});
|
||||
|
||||
test('hasMeaningfulChatSourceArtifacts requires real file or diff artifacts', () => {
|
||||
assert.equal(
|
||||
hasMeaningfulChatSourceArtifacts({
|
||||
changedFiles: [],
|
||||
currentSourceFiles: [],
|
||||
diffBlocks: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasMeaningfulChatSourceArtifacts({
|
||||
changedFiles: ['src/app/main/ChatSourceChangesPage.tsx'],
|
||||
currentSourceFiles: [],
|
||||
diffBlocks: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
@@ -256,3 +313,49 @@ test('shouldClearConversationJobState keeps placeholder-only started responses w
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => {
|
||||
assert.deepEqual(
|
||||
normalizeStaleRequestItem(
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-queued',
|
||||
requesterClientId: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:30.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
{
|
||||
current_request_id: 'chat-req-running',
|
||||
current_job_status: 'started',
|
||||
current_status_updated_at: '2026-05-11T00:00:30.000Z',
|
||||
},
|
||||
),
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-queued',
|
||||
requesterClientId: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:30.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
0
etc/servers/work-server/src/services/chat-runtime-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/chat-runtime-service.ts
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
ensureChatSessionReferenceResource,
|
||||
extractDiffCodeBlocks,
|
||||
extractCodexStreamText,
|
||||
parseStructuredCodexStdoutLine,
|
||||
filterInactiveOfflineNotificationClientIds,
|
||||
fitActivityLogLines,
|
||||
isChatClientActivelyViewing,
|
||||
isAutomationRegistrationCountRequest,
|
||||
resolveResponseTimestamp,
|
||||
resolveChatContextAppOrigin,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
summarizeActivityProgressLine,
|
||||
shouldSendOfflineChatNotification,
|
||||
@@ -31,6 +35,26 @@ test('collectOfflineNotificationClientIds merges session and conversation target
|
||||
);
|
||||
});
|
||||
|
||||
test('filterInactiveOfflineNotificationClientIds excludes only actively viewing clients', () => {
|
||||
const activeSession = {
|
||||
sessionId: 'chat-room',
|
||||
clientId: 'client-a',
|
||||
socket: { readyState: 1 },
|
||||
lastSeenAt: Date.now(),
|
||||
context: {
|
||||
pageVisibilityState: 'visible' as const,
|
||||
pageFocusState: 'focused' as const,
|
||||
topMenu: 'chat',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
filterInactiveOfflineNotificationClientIds(['client-a', 'client-b', 'client-c'], [activeSession] as any),
|
||||
['client-b', 'client-c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => {
|
||||
assert.equal(
|
||||
shouldSendOfflineChatNotification({
|
||||
@@ -57,6 +81,83 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppOrigin returns normalized origin from session page url', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppOrigin({
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
} as any),
|
||||
'https://preview.sm-home.cloud',
|
||||
);
|
||||
assert.equal(resolveChatContextAppOrigin({ pageUrl: 'not-a-url' } as any), null);
|
||||
assert.equal(resolveChatContextAppOrigin(null), null);
|
||||
});
|
||||
|
||||
test('chat active-view suppression only blocks the requester client when that client app is active', () => {
|
||||
const activeSession = {
|
||||
sessionId: 'chat-room',
|
||||
clientId: 'client-a',
|
||||
socket: { readyState: 1 },
|
||||
lastSeenAt: Date.now(),
|
||||
context: {
|
||||
pageVisibilityState: 'visible' as const,
|
||||
pageFocusState: 'focused' as const,
|
||||
topMenu: 'chat',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isChatClientActivelyViewing('client-a', [activeSession] as any), true);
|
||||
assert.equal(
|
||||
isChatClientActivelyViewing('client-a', [
|
||||
{
|
||||
...activeSession,
|
||||
context: { ...activeSession.context, pageFocusState: 'blurred' },
|
||||
},
|
||||
] as any),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isChatClientActivelyViewing('client-a', [
|
||||
{
|
||||
...activeSession,
|
||||
context: { ...activeSession.context, pageVisibilityState: 'hidden' },
|
||||
},
|
||||
] as any),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isChatClientActivelyViewing('client-a', [
|
||||
{
|
||||
...activeSession,
|
||||
context: {
|
||||
...activeSession.context,
|
||||
pageUrl: 'https://external.example.com/chat/live?sessionId=chat-room',
|
||||
},
|
||||
},
|
||||
] as any),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isChatClientActivelyViewing('client-a', [
|
||||
{
|
||||
...activeSession,
|
||||
clientId: 'client-b',
|
||||
},
|
||||
] as any),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isChatClientActivelyViewing('client-a', [
|
||||
{
|
||||
...activeSession,
|
||||
clientId: 'client-b',
|
||||
},
|
||||
activeSession,
|
||||
] as any),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => {
|
||||
assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true);
|
||||
assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true);
|
||||
@@ -81,7 +182,7 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => {
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API요청',
|
||||
chatTypeDescription: 'API 요청 본문을 정리합니다.',
|
||||
},
|
||||
@@ -97,7 +198,7 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => {
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: 'API요청',
|
||||
chatTypeDescription: 'API 요청 본문을 정리합니다.',
|
||||
},
|
||||
@@ -113,7 +214,7 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => {
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청',
|
||||
},
|
||||
@@ -130,7 +231,7 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '모바일 검증',
|
||||
chatTypeDescription: '모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다.',
|
||||
},
|
||||
@@ -139,9 +240,20 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
||||
{
|
||||
recentHistoryLines: ['[user] 이전에는 비로그인 화면으로 봤어'],
|
||||
omittedHistoryCount: 2,
|
||||
sessionReferenceContent: [
|
||||
'# 채팅방 참고 리소스',
|
||||
'',
|
||||
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
||||
'',
|
||||
'## 수동 메모',
|
||||
'- 신규 방이어도 시작 전에 이 문서를 먼저 읽습니다.',
|
||||
].join('\n'),
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(prompt, /## 채팅방 참고 문서/);
|
||||
assert.match(prompt, /이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다\./);
|
||||
assert.match(prompt, /신규 방이어도 시작 전에 이 문서를 먼저 읽습니다\./);
|
||||
assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
|
||||
assert.match(prompt, /상위 필수 지시/);
|
||||
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
|
||||
@@ -156,9 +268,116 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
||||
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
|
||||
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
|
||||
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
|
||||
assert.ok(prompt.indexOf('## 채팅방 참고 문서') < prompt.indexOf('## 채팅 유형 context 필수 규칙'));
|
||||
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt uses the resolved main project root instead of an env example path', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
'AGENTS 먼저 읽고 확인해줘',
|
||||
'session-path',
|
||||
{
|
||||
repoPath: '/home/how2ice/project/ai-code-app',
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(prompt, /저장소 루트\(main_project\): \/home\/how2ice\/project\/ai-code-app/);
|
||||
assert.doesNotMatch(prompt, /저장소 루트\(main_project\): \/workspace\/main-project/);
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt keeps running when session reference content is unavailable', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
'바로 확인해줘',
|
||||
'session-no-reference',
|
||||
{
|
||||
sessionReferenceResourcePath: 'public/.codex_chat/session-no-reference/resource/source/chat-room-reference.md',
|
||||
sessionReferenceContent: '',
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(prompt, /## 채팅방 참고 문서/);
|
||||
assert.match(prompt, /현재 채팅방 참고 문서 본문을 불러오지 못했습니다\./);
|
||||
assert.match(prompt, /위에 제공된 참고 문서 경로를 우선 확인하고/);
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt keeps the chat type label provided by the client context', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
'바로 코드 수정 기준으로 다음 단계를 이어서 진행해 주세요.',
|
||||
'session-general',
|
||||
);
|
||||
|
||||
assert.match(prompt, /- label: 일반 요청/);
|
||||
assert.doesNotMatch(prompt, /- label: 코드 수정/);
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt includes room-selected default contexts as structured sections', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '합성된 설명',
|
||||
chatTypeBaseDescription: '## 기본 처리\n- 채팅 유형 원문 규칙',
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'ctx-a',
|
||||
title: '권한 관리 공통 문맥',
|
||||
content: '## 권한 규칙\n- 채팅방에서 선택된 공통 문맥도 항상 반영합니다.',
|
||||
},
|
||||
{
|
||||
id: 'ctx-b',
|
||||
title: '방 전용 공통 문맥',
|
||||
content: '- 신규 방에서도 같은 규칙으로 동작해야 합니다.',
|
||||
},
|
||||
],
|
||||
customContextTitle: '운영 메모',
|
||||
customContextContent: '- preview 기준으로 검증합니다.',
|
||||
},
|
||||
'실제 반영 상태를 확인해줘',
|
||||
'session-structured',
|
||||
);
|
||||
|
||||
assert.match(prompt, /## 채팅 유형 context 원문/);
|
||||
assert.match(prompt, /채팅 유형 원문 규칙/);
|
||||
assert.match(prompt, /## 채팅방에서 선택한 공통 문맥/);
|
||||
assert.match(prompt, /### 권한 관리 공통 문맥/);
|
||||
assert.match(prompt, /채팅방에서 선택된 공통 문맥도 항상 반영합니다\./);
|
||||
assert.match(prompt, /### 방 전용 공통 문맥/);
|
||||
assert.match(prompt, /신규 방에서도 같은 규칙으로 동작해야 합니다\./);
|
||||
assert.match(prompt, /## 채팅방 전용 Context · 운영 메모/);
|
||||
assert.match(prompt, /preview 기준으로 검증합니다\./);
|
||||
});
|
||||
|
||||
test('ensureChatSessionReferenceResource creates a minimal per-room markdown resource without chat memo accumulation', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-'));
|
||||
|
||||
@@ -171,11 +390,11 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
input: '첫 요청',
|
||||
input: '코드 수정 요청',
|
||||
recentHistoryLines: ['[user] 이전 요청'],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
@@ -186,6 +405,10 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res
|
||||
const firstContent = await readFile(absolutePath, 'utf8');
|
||||
assert.match(firstContent, /# 채팅방 참고 리소스/);
|
||||
assert.match(firstContent, /## 자동 갱신 문맥/);
|
||||
assert.match(firstContent, /- 요청 상태: 실행 중/);
|
||||
assert.match(firstContent, /- 요청 시작 시각: /);
|
||||
assert.match(firstContent, /- 채팅 유형: 일반 요청/);
|
||||
assert.doesNotMatch(firstContent, /- 요청 종료 시각: /);
|
||||
assert.doesNotMatch(firstContent, /## 수동 메모/);
|
||||
assert.doesNotMatch(firstContent, /## 최신 사용자 요청/);
|
||||
assert.doesNotMatch(firstContent, /## 최근 대화 요약/);
|
||||
@@ -199,10 +422,13 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: 'message-list',
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
requestStatus: 'completed',
|
||||
requestedAt: new Date('2026-05-14T06:00:00.000Z'),
|
||||
completedAt: new Date('2026-05-14T06:00:05.000Z'),
|
||||
input: '둘째 요청',
|
||||
recentHistoryLines: ['[user] 첫 요청', '[codex] 첫 답변'],
|
||||
omittedHistoryCount: 1,
|
||||
@@ -210,10 +436,170 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res
|
||||
|
||||
const updatedContent = await readFile(absolutePath, 'utf8');
|
||||
assert.match(updatedContent, /request-2/);
|
||||
assert.match(updatedContent, /- 요청 상태: 완료/);
|
||||
assert.match(updatedContent, /- 요청 종료 시각: /);
|
||||
assert.doesNotMatch(updatedContent, /둘째 요청/);
|
||||
assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/);
|
||||
});
|
||||
|
||||
test('ensureChatSessionReferenceResource rewrites the auto section with terminal timing metadata', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-terminal-'));
|
||||
|
||||
const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
sessionId: 'chat-room',
|
||||
requestId: 'request-started',
|
||||
context: {
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '기본처리',
|
||||
chatTypeDescription: '기본처리 설명',
|
||||
},
|
||||
requestStatus: 'started',
|
||||
requestedAt: new Date('2026-05-14T06:00:00.000Z'),
|
||||
input: '자동갱신 시작 시점 확인',
|
||||
recentHistoryLines: [],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
sessionId: 'chat-room',
|
||||
requestId: 'request-finished',
|
||||
context: {
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '기본처리',
|
||||
chatTypeDescription: '기본처리 설명',
|
||||
},
|
||||
requestStatus: 'failed',
|
||||
requestedAt: new Date('2026-05-14T06:00:00.000Z'),
|
||||
completedAt: new Date('2026-05-14T06:00:05.000Z'),
|
||||
input: '자동갱신 종료 시점 확인',
|
||||
recentHistoryLines: [],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
|
||||
const content = await readFile(absolutePath, 'utf8');
|
||||
assert.match(content, /request-finished/);
|
||||
assert.match(content, /- 요청 상태: 실패/);
|
||||
assert.match(content, /- 요청 시작 시각: /);
|
||||
assert.match(content, /- 요청 종료 시각: /);
|
||||
assert.doesNotMatch(content, /request-started/);
|
||||
});
|
||||
|
||||
test('ensureChatSessionReferenceResource updates the same request to completed status', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-same-request-'));
|
||||
const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
|
||||
const requestedAt = new Date('2026-05-14T06:00:00.000Z');
|
||||
const completedAt = new Date('2026-05-14T06:05:00.000Z');
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
sessionId: 'chat-room',
|
||||
requestId: 'request-same',
|
||||
context: {
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '기본처리',
|
||||
chatTypeDescription: '기본처리 설명',
|
||||
},
|
||||
requestStatus: 'started',
|
||||
requestedAt,
|
||||
input: '같은 요청 시작',
|
||||
recentHistoryLines: [],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
sessionId: 'chat-room',
|
||||
requestId: 'request-same',
|
||||
context: {
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '기본처리',
|
||||
chatTypeDescription: '기본처리 설명',
|
||||
},
|
||||
requestStatus: 'completed',
|
||||
requestedAt,
|
||||
completedAt,
|
||||
input: '같은 요청 완료',
|
||||
recentHistoryLines: [],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
|
||||
const content = await readFile(absolutePath, 'utf8');
|
||||
assert.match(content, /request-same/);
|
||||
assert.match(content, /- 요청 상태: 완료/);
|
||||
assert.match(content, /- 요청 시작 시각: 2026-05-14 15:00:00/);
|
||||
assert.match(content, /- 요청 종료 시각: 2026-05-14 15:05:00/);
|
||||
});
|
||||
|
||||
test('ensureChatSessionReferenceResource summarizes default contexts without copying full shared rules', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-summary-'));
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
sessionId: 'chat-room',
|
||||
requestId: 'request-summary',
|
||||
context: {
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '기본처리',
|
||||
chatTypeDescription: '합성된 설명',
|
||||
chatTypeBaseDescription: '## 기본 처리\n- Plan 체크리스트 기준',
|
||||
defaultContexts: [
|
||||
{
|
||||
id: 'ctx-resource',
|
||||
title: '개발 리소스 관리',
|
||||
content: '## 리소스 관리 등록 기준\n- 리소스 관리 상세 규칙 본문',
|
||||
},
|
||||
{
|
||||
id: 'ctx-output',
|
||||
title: '리소스 출력',
|
||||
content: '## 리소스 출력\n- resource 경로 우선 규칙',
|
||||
},
|
||||
],
|
||||
customContextTitle: '운영 메모',
|
||||
customContextContent: '- 이 방에서는 preview 기준 검증만 유지합니다.',
|
||||
},
|
||||
input: '공통 문맥 이관 이후 참고 문서 정리',
|
||||
recentHistoryLines: [],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
|
||||
const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
|
||||
const content = await readFile(absolutePath, 'utf8');
|
||||
|
||||
assert.match(content, /## 현재 채팅 유형 context 요약/);
|
||||
assert.match(content, /### 적용 중인 공통 문맥/);
|
||||
assert.match(content, /- 개발 리소스 관리/);
|
||||
assert.match(content, /- 리소스 출력/);
|
||||
assert.match(content, /### 채팅방 전용 Context · 운영 메모/);
|
||||
assert.match(content, /preview 기준 검증만 유지합니다\./);
|
||||
assert.doesNotMatch(content, /## 채팅방에서 선택한 공통 문맥/);
|
||||
assert.doesNotMatch(content, /리소스 관리 상세 규칙 본문/);
|
||||
assert.doesNotMatch(content, /resource 경로 우선 규칙/);
|
||||
});
|
||||
|
||||
test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-session-dirs-'));
|
||||
const resourceRoot = path.join(tempDir, 'public/.codex_chat/chat-room/resource');
|
||||
@@ -261,7 +647,7 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '일반 요청',
|
||||
chatTypeDescription: '일반 요청 설명',
|
||||
},
|
||||
@@ -278,16 +664,64 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho
|
||||
assert.doesNotMatch(rebuiltContent, /이전 응답 조각/);
|
||||
});
|
||||
|
||||
test('ensureChatSessionReferenceResource recreates the file when the existing reference is read-only', async () => {
|
||||
const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-readonly-'));
|
||||
const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md');
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true, mode: 0o777 });
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
[
|
||||
'# 채팅방 참고 리소스',
|
||||
'',
|
||||
'이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.',
|
||||
'',
|
||||
'<!-- codex-live:auto:start -->',
|
||||
'## 자동 갱신 문맥',
|
||||
'- requestId: stale-request',
|
||||
'- 오래된 자동 갱신 본문',
|
||||
'<!-- codex-live:auto:end -->',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
await chmod(path.dirname(absolutePath), 0o777);
|
||||
await chmod(absolutePath, 0o444);
|
||||
|
||||
await ensureChatSessionReferenceResource({
|
||||
repoPath: tempDir,
|
||||
sessionId: 'chat-room',
|
||||
requestId: 'request-readonly',
|
||||
context: {
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
chatTypeLabel: '기본처리',
|
||||
chatTypeDescription: '기본처리 설명',
|
||||
},
|
||||
input: '자동 갱신 권한 복구 확인',
|
||||
recentHistoryLines: [],
|
||||
omittedHistoryCount: 0,
|
||||
});
|
||||
|
||||
const rebuiltContent = await readFile(absolutePath, 'utf8');
|
||||
assert.match(rebuiltContent, /request-readonly/);
|
||||
assert.match(rebuiltContent, /## 자동 갱신 문맥/);
|
||||
assert.doesNotMatch(rebuiltContent, /stale-request/);
|
||||
assert.doesNotMatch(rebuiltContent, /오래된 자동 갱신 본문/);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts strips link-card markers into structured parts', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://test.sm-home.cloud/chat/live|열기]]'].join('\n')),
|
||||
extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://preview.sm-home.cloud/chat/live|열기]]'].join('\n')),
|
||||
{
|
||||
strippedText: '결과 본문',
|
||||
parts: [
|
||||
{
|
||||
type: 'link_card',
|
||||
title: '미리보기',
|
||||
url: 'https://test.sm-home.cloud/chat/live',
|
||||
url: 'https://preview.sm-home.cloud/chat/live',
|
||||
actionLabel: '열기',
|
||||
},
|
||||
],
|
||||
@@ -658,6 +1092,51 @@ test('extractChatMessageParts canonicalizes prompt preview resource urls from pu
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts canonicalizes prompt preview resource urls from resource manager registered paths', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
'[[prompt:{"title":"등록 리소스 선택","options":[{"label":"기능명세","value":"feature-spec","preview":{"type":"resource","url":"resource/Codex Live/리소스 관리/검색 및 아이콘 액션/20260513/docs/feature-spec.md"}}]}]]',
|
||||
),
|
||||
{
|
||||
strippedText: '',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '등록 리소스 선택',
|
||||
description: null,
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: [],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: '기능명세',
|
||||
value: 'feature-spec',
|
||||
description: null,
|
||||
preview: {
|
||||
type: 'resource',
|
||||
url: '/api/resource-manager/preview/Codex%20Live/%EB%A6%AC%EC%86%8C%EC%8A%A4%20%EA%B4%80%EB%A6%AC/%EA%B2%80%EC%83%89%20%EB%B0%8F%20%EC%95%84%EC%9D%B4%EC%BD%98%20%EC%95%A1%EC%85%98/20260513/docs/feature-spec.md',
|
||||
content: null,
|
||||
alt: null,
|
||||
title: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
|
||||
@@ -759,6 +1238,30 @@ test('extractCodexStreamText ignores command execution item completions', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('parseStructuredCodexStdoutLine strips nested command execution JSON from raw failure text', () => {
|
||||
assert.deepEqual(
|
||||
parseStructuredCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_13',
|
||||
type: 'command_execution',
|
||||
command: '/bin/bash -lc "sed -n \'1,220p\' /home/how2ice/.codex/config.toml"',
|
||||
aggregated_output: 'model = "gpt-5.4"',
|
||||
exit_code: 0,
|
||||
status: 'completed',
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
activityLog: '# 결과: 완료(0)\n# 출력: model = "gpt-5.4"',
|
||||
completedText: '',
|
||||
deltaText: '',
|
||||
shouldKeepRaw: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractCodexStreamText keeps completed agent messages', () => {
|
||||
assert.deepEqual(
|
||||
extractCodexStreamText({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1,9 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.SEEDED_CUSTOM_CHAT_TYPES = exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = exports.DEFAULT_CHAT_TYPES = void 0;
|
||||
exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = {
|
||||
id: 'plan-checklist-execution',
|
||||
name: 'Plan 체크리스트 실행',
|
||||
description: '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
};
|
||||
exports.SEEDED_CUSTOM_CHAT_TYPES = [exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE];
|
||||
exports.DEFAULT_CHAT_TYPES = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'md-context-managed',
|
||||
name: 'MD 기준 관리',
|
||||
description: '## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'layout-editor-execution',
|
||||
name: 'Layout editor 실행',
|
||||
description: '## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-27T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'api-request-template',
|
||||
name: 'API요청',
|
||||
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-16T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description: '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = exports.UI_IMPROVEMENT_CHAT_TYPE_ID = void 0;
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement';
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선';
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
|
||||
|
||||
@@ -1,70 +1,9 @@
|
||||
export type DefaultChatTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Array<'guest' | 'token-user'>;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
export const UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement';
|
||||
export const UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선';
|
||||
export const UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
|
||||
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT =
|
||||
'## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
|
||||
|
||||
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'md-context-managed',
|
||||
name: 'MD 기준 관리',
|
||||
description:
|
||||
'## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'chat-maximized-bottom-safe',
|
||||
name: '채팅 최대화 하단 안전영역',
|
||||
description:
|
||||
'## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'layout-editor-execution',
|
||||
name: 'Layout editor 실행',
|
||||
description:
|
||||
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-27T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'api-request-template',
|
||||
name: 'API요청',
|
||||
description:
|
||||
'## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-16T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description:
|
||||
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
'## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
|
||||
|
||||
0
etc/servers/work-server/src/services/error-log-plan-registration-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/error-log-plan-registration-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/error-log-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/error-log-service.ts
Executable file → Normal file
76
etc/servers/work-server/src/services/git-service.test.ts
Executable file → Normal file
76
etc/servers/work-server/src/services/git-service.test.ts
Executable file → Normal file
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ensureBranchExists,
|
||||
mergeBranchToRelease,
|
||||
mergeReleaseToMain,
|
||||
syncMainProjectBranchForReservedRestart,
|
||||
type GitAutomationConfig,
|
||||
} from './git-service.js';
|
||||
|
||||
@@ -148,3 +149,76 @@ test('mergeReleaseToMain keeps release to main as a normal merge commit', async
|
||||
assert.equal(mainMessage, 'merge: release -> main');
|
||||
assert.equal(parentCount.split(' ').length, 3);
|
||||
});
|
||||
|
||||
test('syncMainProjectBranchForReservedRestart commits local changes and pushes them to origin main', async () => {
|
||||
const { repoPath } = await createRepo();
|
||||
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
|
||||
process.env.PLAN_LOCAL_MAIN_MODE = 'false';
|
||||
|
||||
try {
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await writeFile(path.join(repoPath, 'note.txt'), 'hello reserved restart\n', 'utf8');
|
||||
|
||||
const result = await syncMainProjectBranchForReservedRestart(
|
||||
repoPath,
|
||||
'main',
|
||||
'chore: sync main before reserved restart',
|
||||
);
|
||||
|
||||
const head = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
const remoteHead = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
|
||||
const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']);
|
||||
|
||||
assert.equal(result.committed, true);
|
||||
assert.equal(result.commitMessage, 'chore: sync main before reserved restart');
|
||||
assert.equal(result.head, head);
|
||||
assert.equal(result.syncMode, 'remote');
|
||||
assert.equal(remoteHead, head);
|
||||
assert.equal(mainMessage, 'chore: sync main before reserved restart');
|
||||
assert.equal(noteContent, 'hello reserved restart');
|
||||
} finally {
|
||||
if (previousLocalMainMode === undefined) {
|
||||
delete process.env.PLAN_LOCAL_MAIN_MODE;
|
||||
} else {
|
||||
process.env.PLAN_LOCAL_MAIN_MODE = previousLocalMainMode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('syncMainProjectBranchForReservedRestart keeps reserved restart local when local main mode is enabled', async () => {
|
||||
const { repoPath } = await createRepo();
|
||||
const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE;
|
||||
process.env.PLAN_LOCAL_MAIN_MODE = 'true';
|
||||
|
||||
try {
|
||||
await runGit(repoPath, ['switch', 'main']);
|
||||
await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8');
|
||||
|
||||
const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||
const result = await syncMainProjectBranchForReservedRestart(
|
||||
repoPath,
|
||||
'main',
|
||||
'chore: sync main before reserved restart',
|
||||
);
|
||||
const head = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']);
|
||||
const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']);
|
||||
const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']);
|
||||
|
||||
assert.equal(result.committed, true);
|
||||
assert.equal(result.commitMessage, 'chore: sync main before reserved restart');
|
||||
assert.equal(result.head, head);
|
||||
assert.equal(result.syncMode, 'local');
|
||||
assert.equal(remoteHeadAfter, remoteHeadBefore);
|
||||
assert.notEqual(remoteHeadAfter, head);
|
||||
assert.equal(mainMessage, 'chore: sync main before reserved restart');
|
||||
assert.equal(noteContent, 'hello local reserved restart');
|
||||
} finally {
|
||||
if (previousLocalMainMode === undefined) {
|
||||
delete process.env.PLAN_LOCAL_MAIN_MODE;
|
||||
} else {
|
||||
process.env.PLAN_LOCAL_MAIN_MODE = previousLocalMainMode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
51
etc/servers/work-server/src/services/git-service.ts
Executable file → Normal file
51
etc/servers/work-server/src/services/git-service.ts
Executable file → Normal file
@@ -151,6 +151,57 @@ export async function hasWorkingTreeChanges(repoPath: string) {
|
||||
return Boolean(stdout);
|
||||
}
|
||||
|
||||
async function ensureLocalBranchFromRemote(repoPath: string, branchName: string) {
|
||||
if (await hasLocalBranch(repoPath, branchName)) {
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await hasRemoteBranch(repoPath, branchName)) {
|
||||
await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]);
|
||||
return;
|
||||
}
|
||||
|
||||
await runGit(repoPath, ['switch', '-C', branchName]);
|
||||
}
|
||||
|
||||
export async function syncMainProjectBranchForReservedRestart(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
commitMessage: string,
|
||||
) {
|
||||
const env = getEnv();
|
||||
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
|
||||
|
||||
if (useLocalMainMode) {
|
||||
await assertBranchExists(repoPath, branchName);
|
||||
await runGit(repoPath, ['switch', branchName]);
|
||||
} else {
|
||||
await runGit(repoPath, ['fetch', 'origin', branchName]);
|
||||
await ensureLocalBranchFromRemote(repoPath, branchName);
|
||||
}
|
||||
|
||||
const hadChanges = await hasWorkingTreeChanges(repoPath);
|
||||
if (hadChanges) {
|
||||
await commitAllChanges(repoPath, commitMessage);
|
||||
}
|
||||
|
||||
if (!useLocalMainMode) {
|
||||
await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]);
|
||||
await pushBranch(repoPath, branchName);
|
||||
}
|
||||
|
||||
const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']);
|
||||
|
||||
return {
|
||||
branchName,
|
||||
commitMessage: hadChanges ? commitMessage : null,
|
||||
committed: hadChanges,
|
||||
head,
|
||||
syncMode: useLocalMainMode ? 'local' as const : 'remote' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function isHotfixBranch(branchName: string) {
|
||||
return /^hotfix\//.test(branchName);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { selectNotificationMessageIdsToDelete } from './notification-message-prune.js';
|
||||
import { pruneNotificationMessageTargetClientIds } from './notification-message-service.js';
|
||||
|
||||
test('selectNotificationMessageIdsToDelete keeps only the latest notification by createdAt and id', () => {
|
||||
const ids = selectNotificationMessageIdsToDelete([
|
||||
@@ -22,3 +23,33 @@ test('selectNotificationMessageIdsToDelete respects keepLatestCount', () => {
|
||||
|
||||
assert.deepEqual(ids, [10]);
|
||||
});
|
||||
|
||||
test('pruneNotificationMessageTargetClientIds removes stale clients and preserves active targets', () => {
|
||||
const result = pruneNotificationMessageTargetClientIds(
|
||||
{
|
||||
sessionId: 'chat-room',
|
||||
targetClientIds: ['client-active', 'client-stale-1', 'client-stale-2'],
|
||||
},
|
||||
['client-stale-1', 'client-stale-2'],
|
||||
);
|
||||
|
||||
assert.deepEqual(result.targetClientIds, ['client-active']);
|
||||
assert.equal(result.hasTargets, true);
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.metadata.targetClientIds, ['client-active']);
|
||||
});
|
||||
|
||||
test('pruneNotificationMessageTargetClientIds marks rows empty when every target becomes stale', () => {
|
||||
const result = pruneNotificationMessageTargetClientIds(
|
||||
{
|
||||
sessionId: 'chat-room',
|
||||
targetClientIds: ['client-stale-1'],
|
||||
},
|
||||
['client-stale-1'],
|
||||
);
|
||||
|
||||
assert.deepEqual(result.targetClientIds, []);
|
||||
assert.equal(result.hasTargets, false);
|
||||
assert.equal(result.changed, true);
|
||||
assert.deepEqual(result.metadata.targetClientIds, []);
|
||||
});
|
||||
|
||||
204
etc/servers/work-server/src/services/notification-message-service.ts
Executable file → Normal file
204
etc/servers/work-server/src/services/notification-message-service.ts
Executable file → Normal file
@@ -40,6 +40,104 @@ export type NotificationMessageItem = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type NotificationMessageChangeEvent =
|
||||
| {
|
||||
action: 'created' | 'updated';
|
||||
item: NotificationMessageItem;
|
||||
}
|
||||
| {
|
||||
action: 'deleted';
|
||||
id: number;
|
||||
};
|
||||
|
||||
const notificationMessageChangeListeners = new Set<(event: NotificationMessageChangeEvent) => void>();
|
||||
|
||||
function emitNotificationMessageChange(event: NotificationMessageChangeEvent) {
|
||||
for (const listener of notificationMessageChangeListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeNotificationMessageChanges(listener: (event: NotificationMessageChangeEvent) => void) {
|
||||
notificationMessageChangeListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
notificationMessageChangeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTargetClientIdsFromMetadata(metadata: Record<string, unknown>) {
|
||||
const rawTargetClientIds = metadata.targetClientIds;
|
||||
|
||||
if (!Array.isArray(rawTargetClientIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawTargetClientIds
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
export function pruneNotificationMessageTargetClientIds(
|
||||
metadata: Record<string, unknown>,
|
||||
clientIds: Iterable<string | null | undefined>,
|
||||
) {
|
||||
const removeClientIds = new Set(
|
||||
Array.from(clientIds, (value) => String(value ?? '').trim()).filter(Boolean),
|
||||
);
|
||||
const currentTargetClientIds = normalizeTargetClientIdsFromMetadata(metadata);
|
||||
|
||||
if (!currentTargetClientIds.length || !removeClientIds.size) {
|
||||
return {
|
||||
changed: false,
|
||||
hasTargets: currentTargetClientIds.length > 0,
|
||||
targetClientIds: currentTargetClientIds,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const nextTargetClientIds = currentTargetClientIds.filter((clientId) => !removeClientIds.has(clientId));
|
||||
|
||||
if (nextTargetClientIds.length === currentTargetClientIds.length) {
|
||||
return {
|
||||
changed: false,
|
||||
hasTargets: true,
|
||||
targetClientIds: currentTargetClientIds,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
hasTargets: nextTargetClientIds.length > 0,
|
||||
targetClientIds: nextTargetClientIds,
|
||||
metadata: {
|
||||
...metadata,
|
||||
targetClientIds: nextTargetClientIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matchesNotificationMessageTargetClient(metadata: Record<string, unknown>, clientId: string) {
|
||||
const targetClientIds = normalizeTargetClientIdsFromMetadata(metadata);
|
||||
|
||||
if (targetClientIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedClientId = clientId.trim();
|
||||
|
||||
if (!normalizedClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return targetClientIds.includes(normalizedClientId);
|
||||
}
|
||||
|
||||
function normalizePreviewText(value: string) {
|
||||
const normalized = value
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
@@ -153,29 +251,28 @@ export async function ensureNotificationMessagesTable() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listNotificationMessages(query: z.infer<typeof notificationMessageListQuerySchema>) {
|
||||
export async function listNotificationMessages(query: z.infer<typeof notificationMessageListQuerySchema>, clientId = '') {
|
||||
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);
|
||||
.orderBy('id', 'desc');
|
||||
|
||||
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();
|
||||
const filteredItems = rows
|
||||
.map((row) => mapNotificationMessageRow(row))
|
||||
.filter((item) => matchesNotificationMessageTargetClient(item.metadata, clientId));
|
||||
const unreadCount = filteredItems.filter((item) => item.read !== true).length;
|
||||
|
||||
return {
|
||||
items: rows.map((row) => mapNotificationMessageRow(row)),
|
||||
unreadCount: Number(unreadCountResult?.count ?? 0),
|
||||
items: filteredItems.slice(0, parsedQuery.limit),
|
||||
unreadCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,7 +310,12 @@ export async function createNotificationMessage(payload: z.infer<typeof notifica
|
||||
throw new Error('저장된 알림 메시지를 다시 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
return mapNotificationMessageRow(row);
|
||||
const item = mapNotificationMessageRow(row);
|
||||
emitNotificationMessageChange({
|
||||
action: 'created',
|
||||
item,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function deleteOlderNotificationMessagesBySource(source: string, keepLatestCount = 1) {
|
||||
@@ -261,11 +363,91 @@ export async function updateNotificationMessageReadState(
|
||||
}
|
||||
|
||||
const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first();
|
||||
return row ? mapNotificationMessageRow(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = mapNotificationMessageRow(row);
|
||||
emitNotificationMessageChange({
|
||||
action: 'updated',
|
||||
item,
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function cleanupNotificationMessagesForStaleTargetClients(args: {
|
||||
sessionId: string;
|
||||
staleClientIds: Iterable<string | null | undefined>;
|
||||
}) {
|
||||
await ensureNotificationMessagesTable();
|
||||
|
||||
const sessionId = String(args.sessionId ?? '').trim();
|
||||
const staleClientIds = Array.from(args.staleClientIds, (value) => String(value ?? '').trim()).filter(Boolean);
|
||||
|
||||
if (!sessionId || staleClientIds.length === 0) {
|
||||
return [] as NotificationMessageItem[];
|
||||
}
|
||||
|
||||
const rows = await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.select('*')
|
||||
.where({
|
||||
source: 'codex-live',
|
||||
category: 'chat',
|
||||
is_read: false,
|
||||
});
|
||||
|
||||
const updatedItems: NotificationMessageItem[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const metadata =
|
||||
typeof row.metadata_json === 'object' && row.metadata_json ? (row.metadata_json as Record<string, unknown>) : {};
|
||||
|
||||
if (String(metadata.sessionId ?? '').trim() !== sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pruned = pruneNotificationMessageTargetClientIds(metadata, staleClientIds);
|
||||
|
||||
if (!pruned.changed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db(NOTIFICATION_MESSAGE_TABLE)
|
||||
.where({ id: row.id })
|
||||
.update({
|
||||
metadata_json: pruned.metadata,
|
||||
is_read: pruned.hasTargets ? false : true,
|
||||
read_at: pruned.hasTargets ? null : db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
const updatedRow = await db(NOTIFICATION_MESSAGE_TABLE).where({ id: row.id }).first();
|
||||
|
||||
if (!updatedRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = mapNotificationMessageRow(updatedRow);
|
||||
updatedItems.push(item);
|
||||
emitNotificationMessageChange({
|
||||
action: 'updated',
|
||||
item,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedItems;
|
||||
}
|
||||
|
||||
export async function deleteNotificationMessage(id: number) {
|
||||
await ensureNotificationMessagesTable();
|
||||
const deletedCount = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).del();
|
||||
|
||||
if (deletedCount > 0) {
|
||||
emitNotificationMessageChange({
|
||||
action: 'deleted',
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
return deletedCount > 0;
|
||||
}
|
||||
|
||||
0
etc/servers/work-server/src/services/notification-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/notification-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-notification-policy.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-notification-policy.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-notification-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-notification-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-policy.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-policy.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-retry-policy.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-retry-policy.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-schedule-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-schedule-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-service.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-service.test.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-service.ts
Executable file → Normal file
0
etc/servers/work-server/src/services/plan-service.ts
Executable file → Normal file
@@ -1,6 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './resource-manager-service.js';
|
||||
import {
|
||||
copyResourceManagerItem,
|
||||
createResourceManagerDirectory,
|
||||
createResourceManagerFile,
|
||||
deleteResourceManagerItem,
|
||||
getResourceManagerTree,
|
||||
listResourceManagerDirectory,
|
||||
readResourceManagerFile,
|
||||
resolveStaticContentType,
|
||||
} from './resource-manager-service.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for resource manager html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
@@ -11,3 +23,100 @@ test('resolveStaticContentType keeps markdown and text files unchanged', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.md'), 'text/markdown; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.log'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
test('resolveStaticContentType returns video content types for common video files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.mp4'), 'video/mp4');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.webm'), 'video/webm');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.mov'), 'video/quicktime');
|
||||
});
|
||||
|
||||
async function withTempRepo(callback: (repoRoot: string) => Promise<void>) {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'resource-manager-test-'));
|
||||
|
||||
try {
|
||||
await callback(repoRoot);
|
||||
} finally {
|
||||
await fs.rm(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('deleteResourceManagerItem removes nested directories recursively', async () => {
|
||||
await withTempRepo(async (repoRoot) => {
|
||||
await createResourceManagerDirectory(repoRoot, '', 'docs');
|
||||
await createResourceManagerFile(repoRoot, 'docs', 'note.md', '# hello');
|
||||
|
||||
await deleteResourceManagerItem(repoRoot, 'docs');
|
||||
|
||||
const directory = await listResourceManagerDirectory(repoRoot, '');
|
||||
assert.deepEqual(directory.items, []);
|
||||
});
|
||||
});
|
||||
|
||||
test('deleteResourceManagerItem keeps the root protected', async () => {
|
||||
await withTempRepo(async (repoRoot) => {
|
||||
await assert.rejects(
|
||||
() => deleteResourceManagerItem(repoRoot, ''),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message === '루트 폴더는 삭제할 수 없습니다.' &&
|
||||
'statusCode' in error &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('resource manager errors expose friendly missing-item messages', async () => {
|
||||
await withTempRepo(async (repoRoot) => {
|
||||
await assert.rejects(
|
||||
() => readResourceManagerFile(repoRoot, 'missing.md'),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message === '항목을 찾을 수 없습니다.' &&
|
||||
'statusCode' in error &&
|
||||
error.statusCode === 404,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('copyResourceManagerItem rejects same-path copy requests with a clear message', async () => {
|
||||
await withTempRepo(async (repoRoot) => {
|
||||
await createResourceManagerFile(repoRoot, '', 'dup.md', 'a');
|
||||
|
||||
await assert.rejects(
|
||||
() => copyResourceManagerItem(repoRoot, 'dup.md', '', 'dup.md'),
|
||||
(error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message === '같은 위치로는 복사하거나 이동할 수 없습니다.' &&
|
||||
'statusCode' in error &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('directory modifiedAt reflects the latest nested descendant change', async () => {
|
||||
await withTempRepo(async (repoRoot) => {
|
||||
await createResourceManagerDirectory(repoRoot, '', 'docs');
|
||||
await createResourceManagerDirectory(repoRoot, 'docs', 'nested');
|
||||
await createResourceManagerFile(repoRoot, 'docs/nested', 'latest.md', 'new');
|
||||
|
||||
const nestedFilePath = path.join(repoRoot, 'resource', 'docs', 'nested', 'latest.md');
|
||||
const nestedDirectoryPath = path.join(repoRoot, 'resource', 'docs', 'nested');
|
||||
const docsDirectoryPath = path.join(repoRoot, 'resource', 'docs');
|
||||
const latestModifiedAt = new Date('2026-05-13T14:15:16.000Z');
|
||||
const staleModifiedAt = new Date('2026-05-10T01:02:03.000Z');
|
||||
|
||||
await fs.utimes(nestedFilePath, latestModifiedAt, latestModifiedAt);
|
||||
await fs.utimes(nestedDirectoryPath, staleModifiedAt, staleModifiedAt);
|
||||
await fs.utimes(docsDirectoryPath, staleModifiedAt, staleModifiedAt);
|
||||
|
||||
const directory = await listResourceManagerDirectory(repoRoot, '');
|
||||
const docsEntry = directory.items.find((item) => item.path === 'docs');
|
||||
assert.ok(docsEntry);
|
||||
assert.equal(docsEntry.modifiedAt, latestModifiedAt.toISOString());
|
||||
|
||||
const tree = await getResourceManagerTree(repoRoot);
|
||||
const docsNode = tree.tree.children?.find((item) => item.path === 'docs');
|
||||
assert.ok(docsNode);
|
||||
assert.equal(docsNode.modifiedAt, latestModifiedAt.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,16 @@ export type ResourceManagerFileDetail = {
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
class ResourceManagerError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
constructor(message: string, statusCode = 400) {
|
||||
super(message);
|
||||
this.name = 'ResourceManagerError';
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
const RESOURCE_MANAGER_ROOT_DIR = 'resource';
|
||||
const RESOURCE_MANAGER_ROOT_LABEL = 'resource';
|
||||
|
||||
@@ -123,6 +133,16 @@ export function resolveStaticContentType(filePath: string) {
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
case '.mov':
|
||||
return 'video/quicktime';
|
||||
case '.m4v':
|
||||
return 'video/x-m4v';
|
||||
case '.ogv':
|
||||
return 'video/ogg';
|
||||
case '.pdf':
|
||||
return 'application/pdf';
|
||||
default:
|
||||
@@ -139,7 +159,7 @@ function sanitizeEntryName(name: string) {
|
||||
const trimmed = name.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' ');
|
||||
|
||||
if (!trimmed || trimmed === '.' || trimmed === '..') {
|
||||
throw new Error('이름이 올바르지 않습니다.');
|
||||
throw new ResourceManagerError('이름이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
@@ -159,7 +179,7 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) {
|
||||
}
|
||||
|
||||
if (normalized.startsWith('../') || normalized === '..' || normalized.includes('/../')) {
|
||||
throw new Error('허용되지 않은 경로입니다.');
|
||||
throw new ResourceManagerError('허용되지 않은 경로입니다.');
|
||||
}
|
||||
|
||||
return normalized.replace(/^\/+/, '');
|
||||
@@ -193,7 +213,7 @@ function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: st
|
||||
const absolutePath = path.resolve(rootPath, normalizedRelativePath);
|
||||
|
||||
if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) {
|
||||
throw new Error('허용되지 않은 경로입니다.');
|
||||
throw new ResourceManagerError('허용되지 않은 경로입니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -203,6 +223,48 @@ function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: st
|
||||
};
|
||||
}
|
||||
|
||||
function toResourceManagerError(error: unknown) {
|
||||
if (error instanceof ResourceManagerError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = String((error as { code?: string }).code ?? '');
|
||||
|
||||
switch (code) {
|
||||
case 'ENOENT':
|
||||
return new ResourceManagerError('항목을 찾을 수 없습니다.', 404);
|
||||
case 'EEXIST':
|
||||
return new ResourceManagerError('같은 이름의 항목이 이미 존재합니다.', 409);
|
||||
case 'ENOTDIR':
|
||||
return new ResourceManagerError('디렉터리가 아닙니다.', 400);
|
||||
case 'EISDIR':
|
||||
return new ResourceManagerError('파일이 아닙니다.', 400);
|
||||
case 'ENOTEMPTY':
|
||||
return new ResourceManagerError('비어 있지 않은 폴더입니다.', 409);
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
return new ResourceManagerError('항목에 접근할 권한이 없습니다.', 403);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new ResourceManagerError(error.message || '리소스 작업에 실패했습니다.', 500);
|
||||
}
|
||||
|
||||
return new ResourceManagerError('리소스 작업에 실패했습니다.', 500);
|
||||
}
|
||||
|
||||
async function withResourceManagerError<T>(callback: () => Promise<T>) {
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
throw toResourceManagerError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewUrl(relativePath: string) {
|
||||
const encodedPath = normalizeRelativeTarget(relativePath)
|
||||
.split('/')
|
||||
@@ -213,6 +275,48 @@ function buildPreviewUrl(relativePath: string) {
|
||||
return `/api/resource-manager/preview/${encodedPath}`;
|
||||
}
|
||||
|
||||
function resolveLatestModifiedAt(currentModifiedAt: string, childModifiedAts: string[]) {
|
||||
let latestTime = Date.parse(currentModifiedAt);
|
||||
let latestModifiedAt = currentModifiedAt;
|
||||
|
||||
for (const childModifiedAt of childModifiedAts) {
|
||||
const childTime = Date.parse(childModifiedAt);
|
||||
|
||||
if (!Number.isFinite(childTime)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(latestTime) || childTime > latestTime) {
|
||||
latestTime = childTime;
|
||||
latestModifiedAt = childModifiedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return latestModifiedAt;
|
||||
}
|
||||
|
||||
async function resolveDirectoryLatestModifiedAt(absolutePath: string, stats?: Awaited<ReturnType<typeof fs.stat>>) {
|
||||
const currentStats = stats ?? (await fs.stat(absolutePath));
|
||||
let latestModifiedAt = currentStats.mtime.toISOString();
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryStats = await fs.stat(entryAbsolutePath);
|
||||
const entryModifiedAt = entry.isDirectory()
|
||||
? await resolveDirectoryLatestModifiedAt(entryAbsolutePath, entryStats)
|
||||
: entryStats.mtime.toISOString();
|
||||
|
||||
latestModifiedAt = resolveLatestModifiedAt(latestModifiedAt, [entryModifiedAt]);
|
||||
}
|
||||
|
||||
return latestModifiedAt;
|
||||
}
|
||||
|
||||
async function buildTreeNode(absolutePath: string, relativePath: string): Promise<ResourceManagerTreeNode> {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
const type: ResourceManagerEntryType = stats.isDirectory() ? 'directory' : 'file';
|
||||
@@ -250,117 +354,138 @@ async function buildTreeNode(absolutePath: string, relativePath: string): Promis
|
||||
);
|
||||
|
||||
node.children = children;
|
||||
node.modifiedAt = resolveLatestModifiedAt(
|
||||
node.modifiedAt,
|
||||
children.map((child) => child.modifiedAt),
|
||||
);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export async function ensureResourceManagerRoot(repoRootPath: string) {
|
||||
const rootPath = resolveResourceManagerRoot(repoRootPath);
|
||||
await fs.mkdir(rootPath, { recursive: true });
|
||||
return withResourceManagerError(async () => {
|
||||
const rootPath = resolveResourceManagerRoot(repoRootPath);
|
||||
await fs.mkdir(rootPath, { recursive: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResourceManagerTree(repoRootPath: string) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
|
||||
return {
|
||||
label: RESOURCE_MANAGER_ROOT_LABEL,
|
||||
rootPath: RESOURCE_MANAGER_ROOT_DIR,
|
||||
tree: await buildTreeNode(resolveResourceManagerRoot(repoRootPath), ''),
|
||||
} satisfies ResourceManagerTreeRoot;
|
||||
return {
|
||||
label: RESOURCE_MANAGER_ROOT_LABEL,
|
||||
rootPath: RESOURCE_MANAGER_ROOT_DIR,
|
||||
tree: await buildTreeNode(resolveResourceManagerRoot(repoRootPath), ''),
|
||||
} satisfies ResourceManagerTreeRoot;
|
||||
});
|
||||
}
|
||||
|
||||
export async function listResourceManagerDirectory(repoRootPath: string, directoryPath = '') {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, directoryPath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, directoryPath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error('디렉터리가 아닙니다.');
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
throw new ResourceManagerError('디렉터리가 아닙니다.');
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
const items: ResourceManagerDirectoryEntry[] = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith('.'))
|
||||
.sort((left, right) => {
|
||||
if (left.isDirectory() && !right.isDirectory()) {
|
||||
return -1;
|
||||
}
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
const items: ResourceManagerDirectoryEntry[] = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith('.'))
|
||||
.sort((left, right) => {
|
||||
if (left.isDirectory() && !right.isDirectory()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!left.isDirectory() && right.isDirectory()) {
|
||||
return 1;
|
||||
}
|
||||
if (!left.isDirectory() && right.isDirectory()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, 'ko');
|
||||
})
|
||||
.map(async (entry) => {
|
||||
const entryRelativePath = path.posix.join(relativePath, entry.name);
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryStats = await fs.stat(entryAbsolutePath);
|
||||
const type: ResourceManagerEntryType = entry.isDirectory() ? 'directory' : 'file';
|
||||
return left.name.localeCompare(right.name, 'ko');
|
||||
})
|
||||
.map(async (entry) => {
|
||||
const entryRelativePath = path.posix.join(relativePath, entry.name);
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryStats = await fs.stat(entryAbsolutePath);
|
||||
const type: ResourceManagerEntryType = entry.isDirectory() ? 'directory' : 'file';
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entryRelativePath,
|
||||
type,
|
||||
extension: type === 'file' ? path.extname(entry.name).toLowerCase() || null : null,
|
||||
size: type === 'file' ? entryStats.size : null,
|
||||
modifiedAt: entryStats.mtime.toISOString(),
|
||||
previewUrl: type === 'file' ? buildPreviewUrl(entryRelativePath) : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
name: entry.name,
|
||||
path: entryRelativePath,
|
||||
type,
|
||||
extension: type === 'file' ? path.extname(entry.name).toLowerCase() || null : null,
|
||||
size: type === 'file' ? entryStats.size : null,
|
||||
modifiedAt:
|
||||
type === 'directory'
|
||||
? await resolveDirectoryLatestModifiedAt(entryAbsolutePath, entryStats)
|
||||
: entryStats.mtime.toISOString(),
|
||||
previewUrl: type === 'file' ? buildPreviewUrl(entryRelativePath) : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
path: relativePath,
|
||||
items,
|
||||
};
|
||||
return {
|
||||
path: relativePath,
|
||||
items,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function readResourceManagerFile(repoRootPath: string, filePath: string) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, filePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, filePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('파일이 아닙니다.');
|
||||
}
|
||||
if (!stats.isFile()) {
|
||||
throw new ResourceManagerError('파일이 아닙니다.');
|
||||
}
|
||||
|
||||
const textEditable = isTextEditable(absolutePath);
|
||||
const textEditable = isTextEditable(absolutePath);
|
||||
|
||||
return {
|
||||
name: path.basename(relativePath),
|
||||
path: relativePath,
|
||||
extension: path.extname(absolutePath).toLowerCase() || null,
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
mimeType: resolveStaticContentType(absolutePath),
|
||||
previewUrl: buildPreviewUrl(relativePath),
|
||||
isTextEditable: textEditable,
|
||||
content: textEditable ? await fs.readFile(absolutePath, 'utf8') : null,
|
||||
} satisfies ResourceManagerFileDetail;
|
||||
return {
|
||||
name: path.basename(relativePath),
|
||||
path: relativePath,
|
||||
extension: path.extname(absolutePath).toLowerCase() || null,
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
mimeType: resolveStaticContentType(absolutePath),
|
||||
previewUrl: buildPreviewUrl(relativePath),
|
||||
isTextEditable: textEditable,
|
||||
content: textEditable ? await fs.readFile(absolutePath, 'utf8') : null,
|
||||
} satisfies ResourceManagerFileDetail;
|
||||
});
|
||||
}
|
||||
|
||||
export async function createResourceManagerDirectory(repoRootPath: string, parentPath: string, name: string) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const safeName = sanitizeEntryName(name);
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName));
|
||||
await fs.mkdir(absolutePath, { recursive: false });
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const safeName = sanitizeEntryName(name);
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName));
|
||||
await fs.mkdir(absolutePath, { recursive: false });
|
||||
});
|
||||
}
|
||||
|
||||
export async function createResourceManagerFile(repoRootPath: string, parentPath: string, name: string, content = '') {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const safeName = sanitizeEntryName(name);
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName));
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, 'utf8');
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const safeName = sanitizeEntryName(name);
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName));
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, content, 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveResourceManagerFile(repoRootPath: string, filePath: string, content: string) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, filePath);
|
||||
await fs.writeFile(absolutePath, content, 'utf8');
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, filePath);
|
||||
await fs.writeFile(absolutePath, content, 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadResourceManagerFile(
|
||||
@@ -369,25 +494,27 @@ export async function uploadResourceManagerFile(
|
||||
fileName: string,
|
||||
contentBase64: string,
|
||||
) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const safeFileName = sanitizeEntryName(fileName);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeFileName));
|
||||
const buffer = Buffer.from(contentBase64, 'base64');
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const safeFileName = sanitizeEntryName(fileName);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeFileName));
|
||||
const buffer = Buffer.from(contentBase64, 'base64');
|
||||
|
||||
if (!buffer.byteLength) {
|
||||
throw new Error('업로드할 파일 내용을 찾지 못했습니다.');
|
||||
}
|
||||
if (!buffer.byteLength) {
|
||||
throw new ResourceManagerError('업로드할 파일 내용을 찾지 못했습니다.');
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, buffer);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, buffer);
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
name: safeFileName,
|
||||
path: relativePath,
|
||||
previewUrl: buildPreviewUrl(relativePath),
|
||||
size: buffer.byteLength,
|
||||
};
|
||||
return {
|
||||
id: randomUUID(),
|
||||
name: safeFileName,
|
||||
path: relativePath,
|
||||
previewUrl: buildPreviewUrl(relativePath),
|
||||
size: buffer.byteLength,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveCopyMoveTarget(
|
||||
@@ -401,6 +528,10 @@ async function resolveCopyMoveTarget(
|
||||
const resolvedName = sanitizeEntryName(nextName?.trim() || path.basename(sourceTarget.relativePath));
|
||||
const targetTarget = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(targetDirectoryPath, resolvedName));
|
||||
|
||||
if (sourceTarget.relativePath === targetTarget.relativePath) {
|
||||
throw new ResourceManagerError('같은 위치로는 복사하거나 이동할 수 없습니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAbsolutePath: sourceTarget.absolutePath,
|
||||
sourceRelativePath: sourceTarget.relativePath,
|
||||
@@ -416,14 +547,20 @@ export async function copyResourceManagerItem(
|
||||
targetDirectoryPath: string,
|
||||
nextName?: string | null,
|
||||
) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName);
|
||||
await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true });
|
||||
await fs.cp(target.sourceAbsolutePath, target.targetAbsolutePath, { recursive: target.sourceStats.isDirectory(), force: false });
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName);
|
||||
await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true });
|
||||
await fs.cp(target.sourceAbsolutePath, target.targetAbsolutePath, {
|
||||
recursive: target.sourceStats.isDirectory(),
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
});
|
||||
|
||||
return {
|
||||
path: target.targetRelativePath,
|
||||
};
|
||||
return {
|
||||
path: target.targetRelativePath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveResourceManagerItem(
|
||||
@@ -432,42 +569,48 @@ export async function moveResourceManagerItem(
|
||||
targetDirectoryPath: string,
|
||||
nextName?: string | null,
|
||||
) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName);
|
||||
await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true });
|
||||
await fs.rename(target.sourceAbsolutePath, target.targetAbsolutePath);
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName);
|
||||
await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true });
|
||||
await fs.rename(target.sourceAbsolutePath, target.targetAbsolutePath);
|
||||
|
||||
return {
|
||||
path: target.targetRelativePath,
|
||||
};
|
||||
return {
|
||||
path: target.targetRelativePath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteResourceManagerItem(repoRootPath: string, targetPath: string) {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
|
||||
return withResourceManagerError(async () => {
|
||||
await ensureResourceManagerRoot(repoRootPath);
|
||||
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
|
||||
|
||||
if (!relativePath) {
|
||||
throw new Error('루트 폴더는 삭제할 수 없습니다.');
|
||||
}
|
||||
if (!relativePath) {
|
||||
throw new ResourceManagerError('루트 폴더는 삭제할 수 없습니다.');
|
||||
}
|
||||
|
||||
await fs.rm(absolutePath, { recursive: true, force: false });
|
||||
await fs.rm(absolutePath, { recursive: true, force: false });
|
||||
});
|
||||
}
|
||||
|
||||
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
|
||||
return withResourceManagerError(async () => {
|
||||
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
throw new Error('리소스를 찾을 수 없습니다.');
|
||||
}
|
||||
if (!existsSync(absolutePath)) {
|
||||
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
const stats = await fs.stat(absolutePath);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
throw new Error('파일만 미리보기할 수 있습니다.');
|
||||
}
|
||||
if (!stats.isFile()) {
|
||||
throw new ResourceManagerError('파일만 미리보기할 수 있습니다.');
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(absolutePath),
|
||||
contentType: resolveStaticContentType(absolutePath),
|
||||
};
|
||||
return {
|
||||
stream: createReadStream(absolutePath),
|
||||
contentType: resolveStaticContentType(absolutePath),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8');
|
||||
|
||||
assert.match(testScript, /command -v docker >/);
|
||||
assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/);
|
||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
|
||||
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/);
|
||||
@@ -103,6 +107,15 @@ test('prod restart script pulls the configured remote main branch before restart
|
||||
assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/);
|
||||
});
|
||||
|
||||
test('test restart script pulls the configured remote main branch before restart', () => {
|
||||
const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url);
|
||||
const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8');
|
||||
|
||||
assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
assert.match(testScript, /git switch "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
});
|
||||
|
||||
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 {
|
||||
|
||||
2
etc/servers/work-server/src/services/server-command-service.ts
Executable file → Normal file
2
etc/servers/work-server/src/services/server-command-service.ts
Executable file → Normal file
@@ -596,6 +596,8 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_TEST_SERVICE,
|
||||
SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-app-1',
|
||||
SERVER_COMMAND_TEST_GIT_REMOTE: 'origin',
|
||||
SERVER_COMMAND_TEST_GIT_BRANCH: env.PLAN_MAIN_BRANCH,
|
||||
},
|
||||
restartStrategy: 'wait',
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type ServerCommandKey,
|
||||
} from './server-command-service.js';
|
||||
import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js';
|
||||
import { syncMainProjectBranchForReservedRestart } from './git-service.js';
|
||||
|
||||
const SERVER_RESTART_RESERVATION_TABLE = 'server_restart_reservations';
|
||||
const SERVER_RESTART_RESERVATION_ROW_ID = 1;
|
||||
@@ -26,9 +27,16 @@ const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservati
|
||||
const RESERVED_RESTART_AUTO_FIX_SESSION_ID = 'server-restart-reservation';
|
||||
const RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS = 600;
|
||||
const RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS = 180;
|
||||
const RESERVED_RESTART_MAIN_COMMIT_MESSAGE = 'chore: sync main before reserved restart';
|
||||
|
||||
type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'recovering' | 'completed' | 'cancelled' | 'failed';
|
||||
type RestartReservationTarget = 'all' | 'test' | 'work-server';
|
||||
type RestartReservationExecutionPhase =
|
||||
| 'idle'
|
||||
| 'commit-main-worktree'
|
||||
| 'restart-test'
|
||||
| 'restart-work-server'
|
||||
| 'verify-runtime';
|
||||
|
||||
type RestartReservationWorkloadSummary = {
|
||||
codexRunningCount: number;
|
||||
@@ -81,6 +89,7 @@ type RestartReservationRow = {
|
||||
app_origin: string | null;
|
||||
auto_execute_at: string | null;
|
||||
auto_execute_delay_seconds: number | null;
|
||||
execution_phase: RestartReservationExecutionPhase | string | null;
|
||||
updated_at: string | null;
|
||||
auto_fix_json: RestartReservationAutoFix | string | null;
|
||||
};
|
||||
@@ -104,11 +113,21 @@ export type ServerRestartReservationSnapshot = {
|
||||
appOrigin: string | null;
|
||||
autoExecuteAt: string | null;
|
||||
autoExecuteDelaySeconds: number;
|
||||
executionPhase: RestartReservationExecutionPhase;
|
||||
updatedAt: string | null;
|
||||
workItems: RestartReservationWorkItem[];
|
||||
autoFix: RestartReservationAutoFix;
|
||||
};
|
||||
|
||||
function normalizeExecutionPhase(value: unknown): RestartReservationExecutionPhase {
|
||||
return value === 'commit-main-worktree'
|
||||
|| value === 'restart-test'
|
||||
|| value === 'restart-work-server'
|
||||
|| value === 'verify-runtime'
|
||||
? value
|
||||
: 'idle';
|
||||
}
|
||||
|
||||
function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary {
|
||||
return {
|
||||
codexRunningCount: 0,
|
||||
@@ -255,6 +274,29 @@ function parseAutoFixState(rawValue: RestartReservationRow['auto_fix_json']): Re
|
||||
};
|
||||
}
|
||||
|
||||
async function syncReservedRestartMainProject(logger: FastifyBaseLogger) {
|
||||
const mainProjectRoot = resolveMainProjectRoot();
|
||||
const result = await syncMainProjectBranchForReservedRestart(
|
||||
mainProjectRoot,
|
||||
env.PLAN_MAIN_BRANCH,
|
||||
RESERVED_RESTART_MAIN_COMMIT_MESSAGE,
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
branchName: result.branchName,
|
||||
committed: result.committed,
|
||||
commitMessage: result.commitMessage,
|
||||
head: result.head,
|
||||
syncMode: result.syncMode,
|
||||
repoPath: mainProjectRoot,
|
||||
},
|
||||
'Reserved restart synced main project branch',
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mapReservationRow(
|
||||
row: RestartReservationRow | null | undefined,
|
||||
options?: {
|
||||
@@ -306,6 +348,7 @@ function mapReservationRow(
|
||||
appOrigin: row?.app_origin ?? null,
|
||||
autoExecuteAt: row?.auto_execute_at ?? null,
|
||||
autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)),
|
||||
executionPhase: normalizeExecutionPhase(row?.execution_phase),
|
||||
updatedAt: row?.updated_at ?? null,
|
||||
workItems: options?.workItems ?? [],
|
||||
autoFix,
|
||||
@@ -335,6 +378,7 @@ async function ensureServerRestartReservationTable() {
|
||||
table.string('app_origin', 255).nullable();
|
||||
table.timestamp('auto_execute_at', { useTz: true }).nullable();
|
||||
table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10);
|
||||
table.string('execution_phase', 40).notNullable().defaultTo('idle');
|
||||
table.jsonb('auto_fix_json').notNullable().defaultTo('{}');
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
@@ -344,6 +388,7 @@ async function ensureServerRestartReservationTable() {
|
||||
['app_origin', (table) => table.string('app_origin', 255).nullable()],
|
||||
['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()],
|
||||
['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)],
|
||||
['execution_phase', (table) => table.string('execution_phase', 40).notNullable().defaultTo('idle')],
|
||||
['auto_fix_json', (table) => table.jsonb('auto_fix_json').notNullable().defaultTo('{}')],
|
||||
];
|
||||
|
||||
@@ -366,6 +411,7 @@ async function ensureServerRestartReservationTable() {
|
||||
status: 'idle',
|
||||
workload_summary_json: getDefaultWorkloadSummary(),
|
||||
active_client_count: 0,
|
||||
execution_phase: 'idle',
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
@@ -857,6 +903,7 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'verify-runtime',
|
||||
last_checked_at: db.fn.now(),
|
||||
waiting_reason: `${waitingTargets.join(' / ')} 새 런타임과 정상 기동을 확인하는 중입니다.`,
|
||||
last_error: null,
|
||||
@@ -873,6 +920,7 @@ async function finalizeReservedRestart(row: RestartReservationRow) {
|
||||
last_error: null,
|
||||
last_checked_at: db.fn.now(),
|
||||
auto_execute_at: null,
|
||||
execution_phase: 'idle',
|
||||
});
|
||||
|
||||
return mapReservationRow(nextRow);
|
||||
@@ -886,6 +934,7 @@ async function restartReservedTargetWithRecovery(
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: targetKey === 'test' ? 'restart-test' : 'restart-work-server',
|
||||
waiting_reason: startMessage,
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
@@ -917,6 +966,7 @@ async function restartReservedTargetWithRecovery(
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: targetKey === 'test' ? 'restart-test' : 'restart-work-server',
|
||||
waiting_reason: `${targetKey.toUpperCase()} 빌드 오류를 수정해 재기동을 다시 시도합니다.`,
|
||||
last_checked_at: db.fn.now(),
|
||||
last_error: null,
|
||||
@@ -937,6 +987,7 @@ async function finalizeSingleServerRestart(targetKey: 'test' | 'work-server') {
|
||||
last_error: null,
|
||||
last_checked_at: db.fn.now(),
|
||||
auto_execute_at: null,
|
||||
execution_phase: 'idle',
|
||||
});
|
||||
|
||||
return mapReservationRow(nextRow);
|
||||
@@ -967,6 +1018,7 @@ export async function requestImmediateRestartRecovery(
|
||||
auto_execute_at: null,
|
||||
auto_execute_delay_seconds: 10,
|
||||
last_checked_at: db.fn.now(),
|
||||
execution_phase: targetKey === 'test' ? 'restart-test' : 'restart-work-server',
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
@@ -992,6 +1044,7 @@ export async function requestImmediateRestartRecovery(
|
||||
waiting_reason: null,
|
||||
last_error: message,
|
||||
last_checked_at: db.fn.now(),
|
||||
execution_phase: 'idle',
|
||||
}).catch(() => undefined);
|
||||
} finally {
|
||||
immediateRecoveryPromise = null;
|
||||
@@ -1008,7 +1061,8 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
status: 'executing',
|
||||
started_at: row.started_at ?? db.fn.now(),
|
||||
last_checked_at: db.fn.now(),
|
||||
waiting_reason: null,
|
||||
execution_phase: 'commit-main-worktree',
|
||||
waiting_reason: 'main 작업트리 커밋 단계를 확인한 뒤 예약된 재기동을 이어갑니다.',
|
||||
active_client_count: activeClients.length,
|
||||
last_error: null,
|
||||
});
|
||||
@@ -1043,17 +1097,38 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes
|
||||
'Executing reserved restart',
|
||||
);
|
||||
|
||||
const syncResult = await syncReservedRestartMainProject(logger);
|
||||
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'restart-test',
|
||||
waiting_reason: syncResult.committed
|
||||
? 'main 변경을 정리한 뒤 TEST 서버 재기동을 시작합니다.'
|
||||
: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.');
|
||||
await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS);
|
||||
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'restart-work-server',
|
||||
waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.',
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
|
||||
await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.');
|
||||
|
||||
await updateReservationRow({
|
||||
enabled: true,
|
||||
status: 'executing',
|
||||
execution_phase: 'verify-runtime',
|
||||
waiting_reason: 'TEST / WORK 서버 새 런타임과 정상 기동을 확인하는 중입니다.',
|
||||
last_checked_at: db.fn.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerRestartReservation() {
|
||||
@@ -1094,6 +1169,7 @@ export async function scheduleServerRestartReservation(options?: {
|
||||
app_origin: options?.appOrigin?.trim() || null,
|
||||
auto_execute_at: null,
|
||||
auto_execute_delay_seconds: autoExecuteDelaySeconds,
|
||||
execution_phase: 'idle',
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
@@ -1110,6 +1186,7 @@ export async function cancelServerRestartReservation() {
|
||||
active_client_count: 0,
|
||||
last_error: null,
|
||||
auto_execute_at: null,
|
||||
execution_phase: 'idle',
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
@@ -1138,9 +1215,10 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
|
||||
status: 'executing',
|
||||
started_at: db.fn.now(),
|
||||
last_checked_at: db.fn.now(),
|
||||
waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.',
|
||||
waiting_reason: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.',
|
||||
last_error: null,
|
||||
auto_execute_at: null,
|
||||
execution_phase: 'commit-main-worktree',
|
||||
auto_fix_json: getDefaultAutoFixState(),
|
||||
});
|
||||
|
||||
@@ -1156,6 +1234,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger)
|
||||
status: 'failed',
|
||||
last_error: message,
|
||||
waiting_reason: null,
|
||||
execution_phase: 'idle',
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
|
||||
@@ -1239,6 +1318,7 @@ export class ServerRestartReservationWorker {
|
||||
? row.auto_execute_at
|
||||
: autoExecuteAt,
|
||||
auto_execute_delay_seconds: autoExecuteDelaySeconds,
|
||||
execution_phase: 'idle',
|
||||
});
|
||||
|
||||
if (waitingReason) {
|
||||
@@ -1252,6 +1332,7 @@ export class ServerRestartReservationWorker {
|
||||
status: 'failed',
|
||||
last_error: message,
|
||||
waiting_reason: null,
|
||||
execution_phase: 'idle',
|
||||
}).catch(() => undefined);
|
||||
} finally {
|
||||
this.running = false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user