chore: exclude local resource artifacts from main sync

This commit is contained in:
2026-05-15 10:16:45 +09:00
parent 442879313f
commit d38d022872
504 changed files with 17074 additions and 3642 deletions

2
.gitignore vendored
View File

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

0
docs/README.md Executable file → Normal file
View File

0
docs/components/check-combo.md Executable file → Normal file
View File

0
docs/components/codex-diff-previewer.md Executable file → Normal file
View File

0
docs/components/evidence-attachment-strip-ui.md Executable file → Normal file
View File

0
docs/components/input.md Executable file → Normal file
View File

0
docs/components/popup.md Executable file → Normal file
View File

0
docs/components/previewer-ui.md Executable file → Normal file
View File

0
docs/components/process-flow-ui.md Executable file → Normal file
View File

0
docs/components/search-command.md Executable file → Normal file
View File

0
docs/components/select.md Executable file → Normal file
View File

0
docs/components/status-badge.md Executable file → Normal file
View File

0
docs/components/stepper.md Executable file → Normal file
View File

0
docs/components/window-ui.md Executable file → Normal file
View File

0
docs/features/plan-automation.md Executable file → Normal file
View File

0
docs/features/plan-board-review.md Executable file → Normal file
View File

0
docs/features/plan-schedule.md Executable file → Normal file
View File

0
docs/features/plan-usage.md Executable file → Normal file
View File

0
docs/features/search-layer.md Executable file → Normal file
View File

0
docs/templates/feature-template.md vendored Executable file → Normal file
View File

0
docs/templates/worklog-template.md vendored Executable file → Normal file
View File

0
docs/worklogs/2026-03-30.md Executable file → Normal file
View File

0
docs/worklogs/2026-03-31.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-01.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-02.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-03.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-04.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-05.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-06.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-07.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-08.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-09.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-10.md Executable file → Normal file
View File

0
docs/worklogs/2026-04-11.md Executable file → Normal file
View File

View File

@@ -0,0 +1,34 @@
# 2026-05-13 작업일지
## 오늘 작업
- 화면 캡처 추가 예정
## 스크린샷
![feature-chat-live](../assets/worklogs/2026-05-13/feature-chat-live.png)
## 소스
### 파일 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
View File

View File

11
etc/commands/server-command/restart-test.sh Executable file → Normal file
View 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

View File

0
etc/commands/server-command/restart-work-server.sh Executable file → Normal file
View File

0
etc/db/work-db/README.md Executable file → Normal file
View File

0
etc/db/work-db/docker-compose.yml Executable file → Normal file
View File

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

View File

0
etc/servers/work-server/scripts/write-build-info.mjs Executable file → Normal file
View File

0
etc/servers/work-server/src/app.ts Executable file → Normal file
View File

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

View File

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

0
etc/servers/work-server/src/json-body.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/lib/identifier.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/not-found.test.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/not-found.ts Executable file → Normal file
View File

3
etc/servers/work-server/src/routes/app-config.ts Executable file → Normal file
View 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
View File

39
etc/servers/work-server/src/routes/chat.ts Executable file → Normal file
View 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,
});

View File

0
etc/servers/work-server/src/routes/crud.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/routes/ddl.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/routes/error-log.ts Executable file → Normal file
View File

0
etc/servers/work-server/src/routes/health.ts Executable file → Normal file
View File

56
etc/servers/work-server/src/routes/notification.ts Executable file → Normal file
View 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
View File

0
etc/servers/work-server/src/routes/schema.ts Executable file → Normal file
View File

40
etc/servers/work-server/src/routes/server-command.ts Executable file → Normal file
View 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
View File

0
etc/servers/work-server/src/server.ts Executable file → Normal file
View File

View 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'); });
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;
}
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 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 sanitizeChatTypes(Array.from(byId.values()));
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,
}];
}
});
});

View File

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

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

View File

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

View File

@@ -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

View File

View 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

View File

@@ -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 기본 문맥으로 섞지 않습니다.';

View File

@@ -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 기본 문맥으로 섞지 않습니다.';

View File

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

View File

@@ -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, []);
});

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

View File

View File

View File

View File

View File

View File

View File

0
etc/servers/work-server/src/services/plan-service.ts Executable file → Normal file
View File

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

View File

@@ -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,17 +354,24 @@ 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) {
return withResourceManagerError(async () => {
const rootPath = resolveResourceManagerRoot(repoRootPath);
await fs.mkdir(rootPath, { recursive: true });
});
}
export async function getResourceManagerTree(repoRootPath: string) {
return withResourceManagerError(async () => {
await ensureResourceManagerRoot(repoRootPath);
return {
@@ -268,15 +379,17 @@ export async function getResourceManagerTree(repoRootPath: string) {
rootPath: RESOURCE_MANAGER_ROOT_DIR,
tree: await buildTreeNode(resolveResourceManagerRoot(repoRootPath), ''),
} satisfies ResourceManagerTreeRoot;
});
}
export async function listResourceManagerDirectory(repoRootPath: string, directoryPath = '') {
return withResourceManagerError(async () => {
await ensureResourceManagerRoot(repoRootPath);
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, directoryPath);
const stats = await fs.stat(absolutePath);
if (!stats.isDirectory()) {
throw new Error('디렉터리가 아닙니다.');
throw new ResourceManagerError('디렉터리가 아닙니다.');
}
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
@@ -306,7 +419,10 @@ export async function listResourceManagerDirectory(repoRootPath: string, directo
type,
extension: type === 'file' ? path.extname(entry.name).toLowerCase() || null : null,
size: type === 'file' ? entryStats.size : null,
modifiedAt: entryStats.mtime.toISOString(),
modifiedAt:
type === 'directory'
? await resolveDirectoryLatestModifiedAt(entryAbsolutePath, entryStats)
: entryStats.mtime.toISOString(),
previewUrl: type === 'file' ? buildPreviewUrl(entryRelativePath) : null,
};
}),
@@ -316,15 +432,17 @@ export async function listResourceManagerDirectory(repoRootPath: string, directo
path: relativePath,
items,
};
});
}
export async function readResourceManagerFile(repoRootPath: string, filePath: string) {
return withResourceManagerError(async () => {
await ensureResourceManagerRoot(repoRootPath);
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, filePath);
const stats = await fs.stat(absolutePath);
if (!stats.isFile()) {
throw new Error('파일이 아닙니다.');
throw new ResourceManagerError('파일이 아닙니다.');
}
const textEditable = isTextEditable(absolutePath);
@@ -340,27 +458,34 @@ export async function readResourceManagerFile(repoRootPath: string, filePath: st
isTextEditable: textEditable,
content: textEditable ? await fs.readFile(absolutePath, 'utf8') : null,
} satisfies ResourceManagerFileDetail;
});
}
export async function createResourceManagerDirectory(repoRootPath: string, parentPath: string, name: string) {
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 = '') {
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) {
return withResourceManagerError(async () => {
await ensureResourceManagerRoot(repoRootPath);
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, filePath);
await fs.writeFile(absolutePath, content, 'utf8');
});
}
export async function uploadResourceManagerFile(
@@ -369,13 +494,14 @@ export async function uploadResourceManagerFile(
fileName: string,
contentBase64: string,
) {
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('업로드할 파일 내용을 찾지 못했습니다.');
throw new ResourceManagerError('업로드할 파일 내용을 찾지 못했습니다.');
}
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
@@ -388,6 +514,7 @@ export async function uploadResourceManagerFile(
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,
) {
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 });
await fs.cp(target.sourceAbsolutePath, target.targetAbsolutePath, {
recursive: target.sourceStats.isDirectory(),
force: false,
errorOnExist: true,
});
return {
path: target.targetRelativePath,
};
});
}
export async function moveResourceManagerItem(
@@ -432,6 +569,7 @@ export async function moveResourceManagerItem(
targetDirectoryPath: string,
nextName?: string | null,
) {
return withResourceManagerError(async () => {
await ensureResourceManagerRoot(repoRootPath);
const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName);
await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true });
@@ -440,34 +578,39 @@ export async function moveResourceManagerItem(
return {
path: target.targetRelativePath,
};
});
}
export async function deleteResourceManagerItem(repoRootPath: string, targetPath: string) {
return withResourceManagerError(async () => {
await ensureResourceManagerRoot(repoRootPath);
const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
if (!relativePath) {
throw new Error('루트 폴더는 삭제할 수 없습니다.');
throw new ResourceManagerError('루트 폴더는 삭제할 수 없습니다.');
}
await fs.rm(absolutePath, { recursive: true, force: false });
});
}
export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) {
return withResourceManagerError(async () => {
const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath);
if (!existsSync(absolutePath)) {
throw new Error('리소스를 찾을 수 없습니다.');
throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404);
}
const stats = await fs.stat(absolutePath);
if (!stats.isFile()) {
throw new Error('파일만 미리보기할 수 있습니다.');
throw new ResourceManagerError('파일만 미리보기할 수 있습니다.');
}
return {
stream: createReadStream(absolutePath),
contentType: resolveStaticContentType(absolutePath),
};
});
}

View File

@@ -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 {

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

View File

@@ -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