feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

View File

@@ -8,17 +8,4 @@ COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
cd "$REPO_ROOT"
if docker inspect work-server >/dev/null 2>&1; then
RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false')
SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '')
case "$SUPERVISOR_CMD" in
*work-server-supervisor*)
if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then
echo "work-server reload requested"
exit 0
fi
;;
esac
fi
exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server
exec docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate --no-deps work-server

View File

@@ -103,6 +103,7 @@ npm run server-command:runner
- `DELETE /api/plan/items/:id`
- `POST /api/notifications/setup`
- `GET /api/notifications/tokens`
- `GET /api/notifications/subscriptions/web`
- `PUT /api/notifications/tokens/ios`
- `DELETE /api/notifications/tokens/ios`
- `POST /api/notifications/send-test`
@@ -112,3 +113,12 @@ npm run server-command:runner
- 프론트에서 알림 `On``PUT /api/notifications/tokens/ios`로 APNs 토큰을 등록합니다.
- 프론트에서 알림 `Off` 시 또는 토큰이 폐기되면 `DELETE /api/notifications/tokens/ios`로 토큰을 제거합니다.
- Plan worker가 브랜치 준비, 자동 작업 완료/실패, release 반영 완료/실패, main 반영 완료/실패 시 등록된 iOS 토큰으로 APNs 알림을 전송합니다.
## 웹푸쉬 호출 메모
- `POST /api/notifications/send``title`, `body`, `data`, `threadId` 외에 `targetClientIds`도 받을 수 있습니다.
- `targetClientIds`를 넣으면 `web_push_subscriptions.device_id` 또는 PWA iOS 토큰의 `device_id`가 일치하는 클라이언트에게만 알림을 보냅니다.
- 웹푸시 구독과 PWA iOS 토큰 등록 시 서버는 `appOrigin`, `appDomain`도 함께 저장합니다.
- `POST /api/notifications/send``targetAppOrigins`, `targetAppDomains`를 넣으면 해당 앱 도메인/오리진으로 등록된 구독에만 발송할 수 있습니다.
- `GET /api/notifications/subscriptions/web`로 현재 저장된 웹푸시 구독의 `deviceId`, `appOrigin`, `appDomain`, `enabled` 상태를 확인할 수 있습니다.
- 같은 알림을 교체하려면 DB 삭제 대신 `data.notificationKey` 또는 `threadId`를 고정값으로 보내세요. 서비스워커가 이 값을 브라우저 알림 `tag`로 사용해 이전 알림을 대체합니다.

View File

@@ -12,6 +12,7 @@ import { registerNotificationRoutes } from './routes/notification.js';
import { registerPlanRoutes } from './routes/plan.js';
import { registerServerCommandRoutes } from './routes/server-command.js';
import { registerSchemaRoutes } from './routes/schema.js';
import { registerStockAlertRoutes } from './routes/stock-alert.js';
import { registerTextMemoRoutes } from './routes/text-memo.js';
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
import { shouldPersistNotFoundErrorLog } from './not-found.js';
@@ -34,6 +35,7 @@ export function createApp() {
app.register(registerSchemaRoutes);
app.register(registerDdlRoutes);
app.register(registerCrudRoutes);
app.register(registerStockAlertRoutes);
app.register(registerErrorLogRoutes);
app.register(registerNotificationRoutes);
app.register(registerPlanRoutes);

View File

@@ -7,6 +7,10 @@ import {
upsertAppConfig,
upsertChatTypesConfig,
} from '../services/app-config-service.js';
import {
getAutomationContextsConfig,
upsertAutomationContextsConfig,
} from '../services/automation-context-config-service.js';
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
export async function registerAppConfigRoutes(app: FastifyInstance) {
@@ -37,6 +41,15 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
};
});
app.get('/api/automation-contexts', async () => {
const automationContexts = await getAutomationContextsConfig();
return {
ok: true,
automationContexts,
};
});
app.put('/api/chat-types', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
@@ -95,6 +108,35 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
}
});
app.put('/api/automation-contexts', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch {
payload = {};
}
}
const parsed = z.object({
automationContexts: z.array(z.unknown()),
}).parse(payload ?? {});
const savedAutomationContexts = await upsertAutomationContextsConfig(parsed.automationContexts);
return {
ok: true,
automationContexts: savedAutomationContexts,
};
} catch (error) {
return reply.code(409).send({
message: error instanceof Error ? error.message : '자동화 Context 저장에 실패했습니다.',
});
}
});
app.put('/api/app-config', async (request, reply) => {
try {
let payload: unknown = request.body ?? {};

View File

@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
listIosNotificationTokens,
listWebPushSubscriptions,
getAutomationNotificationPreference,
getWebPushConfig,
registerIosNotificationToken,
@@ -51,6 +52,10 @@ export async function registerNotificationRoutes(app: FastifyInstance) {
items: await listIosNotificationTokens(),
}));
app.get('/api/notifications/subscriptions/web', async () => ({
items: await listWebPushSubscriptions(),
}));
app.get('/api/notifications/webpush/config', async () => getWebPushConfig());
app.get('/api/notifications/messages', async (request) => {

View File

@@ -164,11 +164,22 @@ export async function registerPlanRoutes(app: FastifyInstance) {
const payload = createPlanScheduledTaskSchema.parse(request.body ?? {});
const row = await createPlanScheduledTask(payload);
const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null;
const ignoreScheduleDueForImmediateRegistration = !payload.repeatWindowStartTime;
const immediateRegistration =
payload.executionMode === 'managed-service' && payload.recreateManagedServiceOnNextSave
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
forceManagedServiceGeneration: true,
})
: payload.enabled && payload.immediateRunEnabled
? await registerPlanScheduledTaskNow(Number(row.id), new Date(), {
ignoreScheduleDue: ignoreScheduleDueForImmediateRegistration,
})
: null;
const latestRow = await getPlanScheduledTaskById(Number(row.id));
return {
ok: true,
item: mapPlanScheduledTaskRow(row),
item: mapPlanScheduledTaskRow(latestRow ?? row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
};
@@ -210,16 +221,34 @@ export async function registerPlanRoutes(app: FastifyInstance) {
});
}
const shouldTriggerImmediateRegistration =
row &&
Boolean(row.enabled ?? true) &&
Boolean(row.immediate_run_enabled ?? true) &&
payload.enabled !== false;
const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null;
const shouldTriggerImmediateRegistration = (
row
&& String(row.execution_mode ?? '') === 'managed-service'
&& payload.recreateManagedServiceOnNextSave === true
) || (
row
&& Boolean(row.enabled ?? true)
&& Boolean(row.immediate_run_enabled ?? true)
&& payload.enabled !== false
);
const effectiveRepeatWindowStartTime =
payload.repeatWindowStartTime !== undefined
? payload.repeatWindowStartTime
: (typeof row?.repeat_window_start_time === 'string' ? row.repeat_window_start_time : null);
const immediateRegistration = shouldTriggerImmediateRegistration
? await registerPlanScheduledTaskNow(id, new Date(), {
ignoreScheduleDue:
payload.recreateManagedServiceOnNextSave === true
? false
: !effectiveRepeatWindowStartTime,
forceManagedServiceGeneration: payload.recreateManagedServiceOnNextSave === true,
})
: null;
const latestRow = await getPlanScheduledTaskById(Number(id));
return {
ok: true,
item: mapPlanScheduledTaskRow(row),
item: mapPlanScheduledTaskRow(latestRow ?? row),
registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null,
registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [],
};

View File

@@ -0,0 +1,146 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
STOCK_ALERT_TYPE_OPTIONS,
createStockAlert,
deleteStockAlert,
listStockAlerts,
sendCurrentPriceStockAlertWebPush,
searchStockAlertCandidates,
saveStockAlerts,
updateStockAlert,
updateStockAlertLayoutFeatureDescription,
} from '../services/stock-alert-service.js';
const filterTypeSchema = z.enum(STOCK_ALERT_TYPE_OPTIONS.map((option) => option.value) as [string, ...string[]]).default('all');
const stockAlertMutationSchema = z
.object({
id: z.string().trim().optional(),
stockCode: z.string().trim().optional(),
stockName: z.string().trim().optional(),
alertType: z.string().trim().optional(),
alertTypes: z.array(z.string().trim()).optional(),
})
.transform((value) => ({
id: value.id,
stockCode: value.stockCode,
stockName: value.stockName,
alertTypes: value.alertTypes?.length
? value.alertTypes
: value.alertType?.trim()
? [value.alertType.trim()]
: [],
}));
const stockAlertMutationBodySchema = z
.object({
stockCode: z.string().trim().optional(),
stockName: z.string().trim().optional(),
alertType: z.string().trim().optional(),
alertTypes: z.array(z.string().trim()).optional(),
})
.transform((value) => ({
stockCode: value.stockCode,
stockName: value.stockName,
alertTypes: value.alertTypes?.length
? value.alertTypes
: value.alertType?.trim()
? [value.alertType.trim()]
: [],
}));
export async function registerStockAlertRoutes(app: FastifyInstance) {
app.get('/api/stock-alerts/search', async (request) => {
const query = z
.object({
query: z.string().trim().min(1),
limit: z.coerce.number().int().min(1).max(50).optional(),
})
.parse(request.query ?? {});
const items = await searchStockAlertCandidates(query.query, query.limit ?? 20);
return {
ok: true,
items,
};
});
app.get('/api/stock-alerts', async (request) => {
const query = z
.object({
alertType: filterTypeSchema.optional(),
})
.parse(request.query ?? {});
const alertType = (query.alertType ?? 'all') as 'all' | 'price' | 'top3';
await updateStockAlertLayoutFeatureDescription().catch(() => false);
const items = await listStockAlerts(alertType);
return {
ok: true,
items,
};
});
app.post('/api/stock-alerts/notify-current-price', async () => {
const result = await sendCurrentPriceStockAlertWebPush();
return {
ok: result.ok,
skipped: Boolean(result.web?.skipped),
title: result.title,
body: result.body,
itemCount: result.itemCount,
lines: result.lines,
ios: result.ios,
web: result.web,
};
});
app.post('/api/stock-alerts', async (request) => {
const payload = stockAlertMutationSchema.parse(request.body ?? {});
const item = await createStockAlert(payload);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
item,
};
});
app.patch('/api/stock-alerts/:id', async (request) => {
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
const payload = stockAlertMutationBodySchema.parse(request.body ?? {});
const item = await updateStockAlert(params.id, payload);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
item,
};
});
app.put('/api/stock-alerts/batch', async (request) => {
const payload = z.object({ items: z.array(stockAlertMutationSchema).default([]) }).parse(request.body ?? {});
const items = await saveStockAlerts(payload.items);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
items,
};
});
app.delete('/api/stock-alerts/:id', async (request) => {
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
await deleteStockAlert(params.id);
await updateStockAlertLayoutFeatureDescription().catch(() => false);
return {
ok: true,
id: params.id,
};
});
}

View File

@@ -8,6 +8,7 @@ import {
textMemoNoteCreateSchema,
textMemoNoteImportSchema,
textMemoNoteUpdateSchema,
updateTextMemoLayoutFeatureDescription,
updateTextMemoNote,
} from '../services/text-memo-service.js';
@@ -17,6 +18,7 @@ function resolveClientId(headers: Record<string, unknown>) {
export async function registerTextMemoRoutes(app: FastifyInstance) {
app.get('/api/text-memo/notes', async (request) => {
await updateTextMemoLayoutFeatureDescription().catch(() => false);
const items = await listTextMemoNotes(resolveClientId(request.headers));
return {
ok: true,
@@ -27,6 +29,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
app.post('/api/text-memo/notes', async (request) => {
const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
const item = await createTextMemoNote(resolveClientId(request.headers), payload);
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return {
ok: true,
@@ -37,6 +40,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
app.post('/api/text-memo/notes/import', async (request) => {
const payload = textMemoNoteImportSchema.parse(request.body ?? {});
const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return {
ok: true,
@@ -55,6 +59,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
});
}
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return {
ok: true,
item,
@@ -71,6 +77,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
});
}
await updateTextMemoLayoutFeatureDescription().catch(() => false);
return {
ok: true,
};

View File

@@ -21,10 +21,29 @@ test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () =
assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']);
});
test('mergeDefaultChatTypes preserves saved edits for layout editor execution', () => {
const merged = mergeDefaultChatTypes([
{
id: 'layout-editor-execution',
name: 'Layout editor 실행',
description: '호출 가능한 API 요청만 처리합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-27T00:00:00.000Z',
},
]);
const layoutEditorExecution = merged.find((item) => item.id === 'layout-editor-execution');
assert.ok(layoutEditorExecution);
assert.equal(layoutEditorExecution.description, '호출 가능한 API 요청만 처리합니다.');
});
test('mergeDefaultChatTypes still appends missing built-in chat types', () => {
const merged = mergeDefaultChatTypes([]);
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'));
});

View File

@@ -2,6 +2,9 @@ import { db } from '../db/client.js';
export const APP_CONFIG_TABLE = 'app_configs';
const CHAT_TYPES_CONFIG_KEY = 'chatTypes';
const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
const LAYOUT_EDITOR_CHAT_TYPE_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 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
const DEFAULT_CHAT_APP_CONFIG = {
maxContextMessages: 12,
maxContextChars: 3200,
@@ -31,6 +34,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
name: 'Layout editor 실행',
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-28T23:55:00.000Z',
},
{
id: 'api-request-template',
name: 'API요청',
@@ -203,7 +214,8 @@ export function mergeDefaultChatTypes(items: unknown[]) {
const byId = new Map(savedItems.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_CHAT_TYPES) {
if (!byId.has(defaultItem.id)) {
const savedItem = byId.get(defaultItem.id);
if (!savedItem) {
byId.set(defaultItem.id, defaultItem);
}
}

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_AUTOMATION_CONTEXTS,
resolveAutomationContexts,
sanitizeAutomationContexts,
} from './automation-context-config-service.js';
test('default automation contexts include base handling context', () => {
assert.ok(DEFAULT_AUTOMATION_CONTEXTS.some((item) => item.id === 'none-default'));
});
test('sanitizeAutomationContexts falls back to defaults', () => {
const items = sanitizeAutomationContexts([]);
assert.ok(items.some((item) => item.id === 'auto-worker-default'));
});
test('resolveAutomationContexts returns only explicitly selected contexts', () => {
const contexts = resolveAutomationContexts(
[
{
id: 'ctx-1',
title: 'A',
content: 'A',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'ctx-2',
title: 'B',
content: 'B',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
['ctx-2'],
);
assert.deepEqual(
contexts.map((item) => item.id),
['ctx-2'],
);
});

View File

@@ -0,0 +1,360 @@
import { db } from '../db/client.js';
const AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
export type AutomationContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
];
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeEnabled(value: unknown) {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
return false;
}
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
return true;
}
}
return value !== false;
}
function buildContextTitleKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const rawId = normalizeText(record.id);
const normalizedId =
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
title: title || 'Context',
content,
enabled: normalizeEnabled(record.enabled),
defaultSelected: normalizeEnabled(record.defaultSelected),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationContextRecord>();
const bySemanticKey = new Map<string, AutomationContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = buildContextTitleKey(item.title);
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
}
async function ensureAutomationContextsTable() {
const hasTable = await db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE);
if (!hasTable) {
await db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
table.string('id').primary();
table.string('title').notNullable();
table.text('content').notNullable().defaultTo('');
table.boolean('enabled').notNullable().defaultTo(true);
table.boolean('default_selected').notNullable().defaultTo(false);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['title', (table) => table.string('title').notNullable().defaultTo('')],
['content', (table) => table.text('content').notNullable().defaultTo('')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['default_selected', (table) => table.boolean('default_selected').notNullable().defaultTo(false)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
createColumn(table);
});
}
}
}
function parseContextsFromLegacyValue(value: unknown) {
if (typeof value !== 'string') {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toAutomationContextRecord(row: Record<string, unknown>) {
return normalizeAutomationContext({
id: typeof row.id === 'string' ? row.id : undefined,
title: typeof row.title === 'string' ? row.title : undefined,
content: typeof row.content === 'string' ? row.content : undefined,
enabled: normalizeEnabled(row.enabled),
defaultSelected: normalizeEnabled(row.default_selected),
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
});
}
async function replaceAutomationContextsInTable(items: AutomationContextRecord[]) {
await ensureAutomationContextsTable();
const nextItems = sanitizeAutomationContexts(items);
await db.transaction(async (trx) => {
await trx(AUTOMATION_CONTEXTS_TABLE).del();
await trx(AUTOMATION_CONTEXTS_TABLE).insert(
nextItems.map((item) => ({
id: item.id,
title: item.title,
content: item.content,
enabled: item.enabled,
default_selected: item.defaultSelected,
updated_at: item.updatedAt,
})),
);
});
return nextItems;
}
async function seedAutomationContextsFromLegacySources() {
const seededItems: Partial<AutomationContextRecord>[] = [...DEFAULT_AUTOMATION_CONTEXTS];
const hasAutomationTypesTable = await db.schema.hasTable('automation_types');
if (hasAutomationTypesTable) {
const rows = await db('automation_types').select('contexts_json');
for (const row of rows) {
seededItems.push(...parseContextsFromLegacyValue((row as Record<string, unknown>).contexts_json));
}
}
return replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems));
}
function isSameAutomationContextList(left: AutomationContextRecord[], right: AutomationContextRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.title === target.title &&
item.content === target.content &&
item.enabled === target.enabled &&
item.defaultSelected === target.defaultSelected &&
item.updatedAt === target.updatedAt
);
});
}
function mergeDefaultAutomationContexts(items: AutomationContextRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_CONTEXTS) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
title: defaultItem.title,
content: existingItem.content || defaultItem.content,
});
}
return sanitizeAutomationContexts(Array.from(byId.values()));
}
async function readAutomationContextsFromTable() {
await ensureAutomationContextsTable();
const rows = await db(AUTOMATION_CONTEXTS_TABLE)
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
.orderBy('title', 'asc');
const savedItems = rows
.map((row) => toAutomationContextRecord(row as Record<string, unknown>))
.filter((item): item is AutomationContextRecord => Boolean(item));
return sanitizeAutomationContexts(savedItems);
}
export async function getAutomationContextsConfig() {
const savedContexts = await readAutomationContextsFromTable();
if (savedContexts.length === 0 || savedContexts === DEFAULT_AUTOMATION_CONTEXTS) {
return seedAutomationContextsFromLegacySources();
}
const mergedContexts = mergeDefaultAutomationContexts(savedContexts);
if (!isSameAutomationContextList(savedContexts, mergedContexts)) {
await replaceAutomationContextsInTable(mergedContexts);
}
return mergedContexts;
}
export async function upsertAutomationContextsConfig(items: unknown[]) {
const nextContexts = mergeDefaultAutomationContexts(
sanitizeAutomationContexts(Array.isArray(items) ? (items as Partial<AutomationContextRecord>[]) : []),
);
return replaceAutomationContextsInTable(nextContexts);
}
export function normalizeAutomationContextSelection(value: unknown) {
const rawValues = Array.isArray(value)
? value
: typeof value === 'string'
? value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
}
export function resolveAutomationContexts(
contexts: AutomationContextRecord[] | null | undefined,
selectedContextIds?: unknown,
) {
const normalizedContexts = sanitizeAutomationContexts(contexts);
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
return [];
}
if (requestedIds.length === 0) {
return normalizedContexts.filter((item) => item.enabled && item.defaultSelected);
}
const requestedIdSet = new Set(requestedIds);
return normalizedContexts.filter((item) => requestedIdSet.has(item.id));
}

View File

@@ -0,0 +1,187 @@
import path from 'node:path';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { getEnv } from '../config/env.js';
import {
type AutomationContextRecord,
getAutomationContextsConfig,
normalizeAutomationContextSelection,
resolveAutomationContexts,
} from './automation-context-config-service.js';
import type { AutomationTypeRecord } from './automation-type-config-service.js';
export function stringifyAutomationContextIds(value: unknown) {
return JSON.stringify(normalizeAutomationContextSelection(value));
}
export function parseAutomationContextIds(value: unknown) {
if (Array.isArray(value)) {
return normalizeAutomationContextSelection(value);
}
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
if (!trimmed) {
return [];
}
try {
const parsed = JSON.parse(trimmed);
return normalizeAutomationContextSelection(parsed);
} catch {
return normalizeAutomationContextSelection(trimmed);
}
}
export function buildAutomationContextMarkdown(
contexts: Awaited<ReturnType<typeof getAutomationContextsConfig>> | null | undefined,
selectedContextIds?: unknown,
) {
const resolvedContexts = resolveAutomationContexts(contexts, selectedContextIds);
if (resolvedContexts.length === 0) {
return '선택된 자동화 Context 없음';
}
return resolvedContexts
.map((item) => [`### ${item.title}`, item.content.trim() || '(내용 없음)'].join('\n'))
.join('\n\n');
}
export async function buildAutomationNoteSections(options: {
title?: string;
sourceLabel: string;
requestContent: string;
attachments?: string[];
automationType?: Pick<AutomationTypeRecord, 'name'> | null;
availableContexts?: AutomationContextRecord[];
selectedContextIds?: unknown;
extraSections?: string[];
}) {
const availableContexts = options.availableContexts ?? (await getAutomationContextsConfig());
const lines = [
'# 자동화 작업메모',
'',
options.title?.trim() ? `- 게시판 제목: ${options.title.trim()}` : null,
`- 메모 출처: ${options.sourceLabel}`,
options.automationType?.name?.trim() ? `- 선택 자동화 유형: ${options.automationType.name.trim()}` : null,
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
'',
'## 자동화 Context',
buildAutomationContextMarkdown(availableContexts, options.selectedContextIds),
'',
...(options.extraSections ?? []),
'## 요청 본문',
options.requestContent.trim(),
...(options.attachments?.length ? ['', '## 첨부 파일', ...options.attachments] : []),
];
return lines.filter((line): line is string => line !== null && line !== undefined).join('\n');
}
function extractRequestedPaths(note: string) {
const matches = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g) ?? [];
return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))];
}
async function tryReadFile(filePath: string) {
try {
return await readFile(filePath, 'utf8');
} catch {
return null;
}
}
function limitText(value: string, maxChars = 12000) {
const normalized = value.trim();
return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars).trimEnd()}\n\n...`;
}
function getScheduleRepoRoot() {
const env = getEnv();
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
}
export async function ensureSchedulePromptSnapshot(options: {
scheduleId: number;
workId: string;
note: string;
forceRefresh?: boolean;
}) {
const repoRoot = getScheduleRepoRoot();
const scheduleDir = path.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId));
await mkdir(scheduleDir, { recursive: true });
const requestPath = path.join(scheduleDir, 'request.md');
const contextPath = path.join(scheduleDir, 'context.md');
const manifestPath = path.join(scheduleDir, 'manifest.json');
const requestedPaths = extractRequestedPaths(options.note);
const candidatePaths = [
'AGENTS.md',
'docs/README.md',
...requestedPaths.filter((item) => !item.startsWith('http://') && !item.startsWith('https://')),
];
const uniqueRelativePaths = [...new Set(candidatePaths)];
const references: string[] = [];
for (const relativePath of uniqueRelativePaths) {
const absolutePath = path.resolve(repoRoot, relativePath);
const content = await tryReadFile(absolutePath);
if (!content) {
continue;
}
references.push(`## ${relativePath}\n\n\`\`\`\n${limitText(content)}\n\`\`\``);
}
const requestMarkdown = [
'# 스케줄 요청 원문',
'',
`- 스케줄 ID: ${options.scheduleId}`,
`- 작업 ID: ${options.workId}`,
'',
'## 원본 메모',
options.note.trim() || '(비어 있음)',
].join('\n');
const contextMarkdown = [
'# 스케줄 전용 참조',
'',
'- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.',
'- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.',
'',
...(references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']),
].join('\n\n');
await writeFile(requestPath, `${requestMarkdown}\n`, 'utf8');
await writeFile(contextPath, `${contextMarkdown}\n`, 'utf8');
await writeFile(
manifestPath,
JSON.stringify(
{
scheduleId: options.scheduleId,
workId: options.workId,
refreshedAt: new Date().toISOString(),
forceRefresh: Boolean(options.forceRefresh),
sourcePaths: uniqueRelativePaths,
},
null,
2,
),
'utf8',
);
const relativeDir = path.relative(repoRoot, scheduleDir).replace(/\\/g, '/');
return {
directory: relativeDir,
requestPath: `${relativeDir}/request.md`,
contextPath: `${relativeDir}/context.md`,
manifestPath: `${relativeDir}/manifest.json`,
};
}

View File

@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
DEFAULT_AUTOMATION_TYPES,
resolveStoredAutomationTypeId,
sanitizeAutomationTypes,
} from './automation-type-config-service.js';
test('default automation types include general inquiry', () => {
const generalInquiry = DEFAULT_AUTOMATION_TYPES.find((item) => item.id === 'general-inquiry');
assert.ok(generalInquiry);
assert.equal(generalInquiry.name, '일반 문의');
assert.equal(generalInquiry.behaviorType, 'command_execution');
});
test('sanitizeAutomationTypes falls back to general inquiry in defaults', () => {
const items = sanitizeAutomationTypes([]);
assert.ok(items.some((item) => item.id === 'general-inquiry'));
});
test('resolveStoredAutomationTypeId remaps legacy stock-alert id to general inquiry', () => {
assert.equal(resolveStoredAutomationTypeId({ automation_type_id: 'stock-alert' }), 'general-inquiry');
});

View File

@@ -14,21 +14,59 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export type AutomationTypeRecord = {
id: string;
name: string;
description: string;
contexts: AutomationTypeContextRecord[];
behaviorType: AutomationBehaviorType;
enabled: boolean;
updatedAt: string;
};
export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'general-inquiry',
name: '일반 문의',
description: '일반 문의/확인 요청으로 처리합니다.',
contexts: [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
description: '기본 자동화 처리용 유형입니다.',
contexts: [
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -37,6 +75,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'plan',
name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
contexts: [
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'plan',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -45,6 +93,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'command_execution',
name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
contexts: [
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -53,6 +111,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'non_source_work',
name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
contexts: [
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'non_source_work',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -60,55 +128,24 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'auto_worker',
name: 'autoWorker',
description:
'자동화 작업메모로 처리합니다.\n\n## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
description: '자동화 작업메모로 처리합니다.',
contexts: [
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'auto_worker',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
];
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: existingItem.description || defaultItem.description,
behaviorType: defaultItem.behaviorType,
});
}
return sanitizeAutomationTypes(Array.from(byId.values()));
}
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.name === target.name &&
item.description === target.description &&
item.behaviorType === target.behaviorType &&
item.enabled === target.enabled &&
item.updatedAt === target.updatedAt
);
});
}
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -137,38 +174,14 @@ function normalizeEnabled(value: unknown) {
return value !== false;
}
async function ensureAutomationTypesTable() {
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
function normalizeLegacyAutomationTypeId(value: unknown) {
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
if (!hasTable) {
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
table.string('id').primary();
table.string('name').notNullable();
table.text('description').notNullable().defaultTo('');
table.string('behavior_type').notNullable();
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
if (normalizedValue === 'stock-alert') {
return 'general-inquiry';
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['name', (table) => table.string('name').notNullable().defaultTo('')],
['description', (table) => table.text('description').notNullable().defaultTo('')],
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
createColumn(table);
});
}
}
return normalizedValue;
}
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
@@ -196,6 +209,69 @@ function buildNameKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function buildContextTitleKey(value: string) {
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
}
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const rawId = normalizeText(record.id);
const normalizedId =
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
title: title || 'Context',
content,
enabled: normalizeEnabled(record.enabled),
defaultSelected: normalizeEnabled(record.defaultSelected),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationTypeContextRecord>();
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = buildContextTitleKey(item.title);
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
}
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
const name = normalizeText(record.name);
@@ -205,13 +281,13 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
const rawId = normalizeText(record.id);
const normalizedId =
rawId ||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
rawId || `automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: normalizedId,
name,
description: normalizeText(record.description),
contexts: sanitizeAutomationContexts(record.contexts),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: normalizeEnabled(record.enabled),
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
@@ -265,6 +341,85 @@ export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] |
return dedupeAutomationTypes(normalized);
}
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
const byId = new Map(items.map((item) => [item.id, item] as const));
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
const existingItem = byId.get(defaultItem.id);
if (!existingItem) {
byId.set(defaultItem.id, defaultItem);
continue;
}
byId.set(defaultItem.id, {
...existingItem,
name: defaultItem.name,
description: existingItem.description || defaultItem.description,
contexts: sanitizeAutomationContexts(existingItem.contexts?.length ? existingItem.contexts : defaultItem.contexts),
behaviorType: defaultItem.behaviorType,
});
}
return sanitizeAutomationTypes(Array.from(byId.values()));
}
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
if (left.length !== right.length) {
return false;
}
return left.every((item, index) => {
const target = right[index];
return (
target &&
item.id === target.id &&
item.name === target.name &&
item.description === target.description &&
JSON.stringify(item.contexts) === JSON.stringify(target.contexts) &&
item.behaviorType === target.behaviorType &&
item.enabled === target.enabled &&
item.updatedAt === target.updatedAt
);
});
}
async function ensureAutomationTypesTable() {
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
if (!hasTable) {
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
table.string('id').primary();
table.string('name').notNullable();
table.text('description').notNullable().defaultTo('');
table.text('contexts_json').notNullable().defaultTo('[]');
table.string('behavior_type').notNullable();
table.boolean('enabled').notNullable().defaultTo(true);
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
});
return;
}
const requiredColumns: Array<[string, (table: any) => void]> = [
['name', (table) => table.string('name').notNullable().defaultTo('')],
['description', (table) => table.text('description').notNullable().defaultTo('')],
['contexts_json', (table) => table.text('contexts_json').notNullable().defaultTo('[]')],
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
];
for (const [columnName, createColumn] of requiredColumns) {
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
if (!hasColumn) {
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
createColumn(table);
});
}
}
}
function normalizeConfigRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {} as Record<string, unknown>;
@@ -273,11 +428,27 @@ function normalizeConfigRecord(value: unknown) {
return value as Record<string, unknown>;
}
function parseContextsFromRow(row: Record<string, unknown>) {
const rawValue = row.contexts_json;
if (typeof rawValue !== 'string') {
return [];
}
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toAutomationTypeRecord(row: Record<string, unknown>) {
return normalizeAutomationType({
id: typeof row.id === 'string' ? row.id : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
description: typeof row.description === 'string' ? row.description : undefined,
contexts: parseContextsFromRow(row),
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
enabled: normalizeEnabled(row.enabled),
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
@@ -303,7 +474,7 @@ async function readAutomationTypesFromTable() {
await ensureAutomationTypesTable();
const rows = await db(AUTOMATION_TYPES_TABLE)
.select('id', 'name', 'description', 'behavior_type', 'enabled', 'updated_at')
.select('id', 'name', 'description', 'contexts_json', 'behavior_type', 'enabled', 'updated_at')
.orderBy('name', 'asc');
const savedItems = rows
@@ -326,6 +497,7 @@ async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
id: item.id,
name: item.name,
description: item.description,
contexts_json: JSON.stringify(item.contexts ?? []),
behavior_type: item.behaviorType,
enabled: item.enabled,
updated_at: item.updatedAt,
@@ -359,7 +531,7 @@ export async function upsertAutomationTypesConfig(items: unknown[]) {
}
export async function resolveAutomationType(input: unknown) {
const requestedId = normalizeLegacyAutomationBehaviorType(input);
const requestedId = normalizeLegacyAutomationTypeId(input);
const automationTypes = await getAutomationTypesConfig();
const matched = automationTypes.find((item) => item.id === requestedId);
@@ -383,8 +555,40 @@ export function resolveStoredAutomationTypeId(row: Record<string, unknown>) {
const automationTypeId = normalizeText(row.automation_type_id);
if (automationTypeId) {
return normalizeLegacyAutomationBehaviorType(automationTypeId);
return normalizeLegacyAutomationTypeId(automationTypeId);
}
return normalizeLegacyAutomationBehaviorType(row.automation_type) || 'none';
return normalizeLegacyAutomationTypeId(row.automation_type) || 'none';
}
export function normalizeAutomationContextSelection(value: unknown) {
const rawValues = Array.isArray(value)
? value
: typeof value === 'string'
? value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
: [];
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
}
export function resolveAutomationTypeContexts(
automationType: Pick<AutomationTypeRecord, 'contexts'> | null | undefined,
selectedContextIds?: unknown,
) {
const contexts = sanitizeAutomationContexts(automationType?.contexts);
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
return [];
}
if (requestedIds.length === 0) {
return contexts.filter((item) => item.enabled && item.defaultSelected);
}
const requestedIdSet = new Set(requestedIds);
return contexts.filter((item) => requestedIdSet.has(item.id));
}

View File

@@ -1,17 +1,31 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-service.js';
import {
BoardPostAutomationLockedError,
buildBoardPostPlanNote,
resolveSequencedPlanWorkId,
} from './board-service.js';
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
test('buildBoardPostPlanNote formats automation work memo with clear sections', async () => {
assert.equal(
buildBoardPostPlanNote(
await buildBoardPostPlanNote(
' 알림 개선 ',
'본문 첫 줄\n본문 둘째 줄\n',
[],
{
name: '자동화 메모',
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
},
['ctx-1'],
[
{
id: 'ctx-1',
title: '처리 기준',
content: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
),
[
'# 자동화 작업메모',
@@ -19,10 +33,10 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
'- 게시판 제목: 알림 개선',
'- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 자동화 메모',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조니다.',
'',
'## 자동화 유형 context',
'## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
'## 자동화 Context',
'### 처리 기준\n## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
'',
'## 요청 본문',
'본문 첫 줄\n본문 둘째 줄',
@@ -30,16 +44,17 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
);
});
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
test('buildBoardPostPlanNote keeps context section even when selected context is empty', async () => {
assert.equal(
buildBoardPostPlanNote(
await buildBoardPostPlanNote(
'작업',
'본문',
[],
{
name: '빈 context 유형',
description: ' ',
},
[],
[],
),
[
'# 자동화 작업메모',
@@ -47,10 +62,10 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
'- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수',
'- 선택 자동화 유형: 빈 context 유형',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조니다.',
'',
'## 자동화 유형 context',
'선택된 자동화 유형 context 없음',
'## 자동화 Context',
'선택된 자동화 Context 없음',
'',
'## 요청 본문',
'본문',
@@ -58,9 +73,9 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
);
});
test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
test('buildBoardPostPlanNote appends attachment lines when files exist', async () => {
assert.equal(
buildBoardPostPlanNote(
await buildBoardPostPlanNote(
'작업',
'본문',
[
@@ -74,16 +89,18 @@ test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
},
],
null,
[],
[],
),
[
'# 자동화 작업메모',
'',
'- 게시판 제목: 작업',
'- 메모 출처: board_posts 자동화 접수',
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조니다.',
'',
'## 자동화 유형 context',
'선택된 자동화 유형 context 없음',
'## 자동화 Context',
'선택된 자동화 Context 없음',
'',
'## 요청 본문',
'본문',
@@ -98,3 +115,28 @@ test('BoardPostAutomationLockedError keeps user-facing message by action', () =>
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
});
test('resolveSequencedPlanWorkId uses -1 suffix first and finds the next available suffix', () => {
assert.equal(resolveSequencedPlanWorkId('정기-점검', []), '정기-점검-1');
assert.equal(resolveSequencedPlanWorkId('정기-점검', ['정기-점검-1', '정기-점검-2', '다른작업-1']), '정기-점검-3');
});
test('resolveSequencedPlanWorkId supports custom suffix labels such as service-{seq}', () => {
assert.equal(resolveSequencedPlanWorkId('schedule-10-반복-정리', [], 'service'), 'schedule-10-반복-정리-service-1');
assert.equal(
resolveSequencedPlanWorkId(
'schedule-10-반복-정리',
['schedule-10-반복-정리-service-1', 'schedule-10-반복-정리-service-2'],
'service',
),
'schedule-10-반복-정리-service-3',
);
});
test('resolveSequencedPlanWorkId truncates the base to preserve the suffix within 120 chars', () => {
const longBase = 'a'.repeat(120);
const workId = resolveSequencedPlanWorkId(longBase, []);
assert.equal(workId.length, 120);
assert.ok(workId.endsWith('-1'));
});

View File

@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
import {
ensurePlanTable,
normalizePlanAutomationType,
normalizePlanWorkId,
PLAN_TABLE,
planAutomationTypeSchema,
} from './plan-service.js';
@@ -11,6 +12,12 @@ import {
resolveStoredAutomationTypeId,
type AutomationTypeRecord,
} from './automation-type-config-service.js';
import {
buildAutomationNoteSections,
parseAutomationContextIds,
stringifyAutomationContextIds,
} from './automation-context-service.js';
import type { AutomationContextRecord } from './automation-context-config-service.js';
export const BOARD_POSTS_TABLE = 'board_posts';
@@ -28,6 +35,7 @@ export const boardPostPayloadSchema = z.object({
}),
).max(20).default([]),
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
});
export type BoardPostItem = {
@@ -39,6 +47,7 @@ export type BoardPostItem = {
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
automationPlanItemId: number | null;
automationReceivedAt: string | null;
automationContextIds: string[];
createdAt: string;
updatedAt: string;
};
@@ -54,6 +63,9 @@ export class BoardPostAutomationLockedError extends Error {
}
}
const MAX_SEQUENCED_PLAN_WORK_ID = 999;
const PLAN_WORK_ID_MAX_LENGTH = 120;
function createPreview(content: string) {
const normalized = content
.replace(/```[\s\S]*?```/g, ' ')
@@ -91,6 +103,7 @@ function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined
? null
: String(row.automation_received_at),
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
createdAt: String(row.created_at ?? ''),
updatedAt: String(row.updated_at ?? ''),
};
@@ -121,34 +134,52 @@ function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloa
return ['## 첨부 파일', ...lines];
}
export function buildBoardPostPlanNote(
export async function buildBoardPostPlanNote(
title: string,
content: string,
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null,
automationType?: Pick<AutomationTypeRecord, 'name'> | null,
automationContextIds?: string[],
availableContexts?: AutomationContextRecord[],
) {
const normalizedTitle = title.trim();
const normalizedContent = content.trim();
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
const normalizedAutomationContext = String(automationType?.description ?? '').trim();
return buildAutomationNoteSections({
title: title.trim(),
sourceLabel: 'board_posts 자동화 접수',
requestContent: content.trim(),
attachments: attachments.length ? buildBoardAttachmentSection(attachments).slice(1) : [],
automationType,
availableContexts,
selectedContextIds: automationContextIds,
});
}
return [
'# 자동화 작업메모',
'',
`- 게시판 제목: ${normalizedTitle}`,
'- 메모 출처: board_posts 자동화 접수',
normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null,
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
'',
'## 자동화 유형 context',
normalizedAutomationContext || '선택된 자동화 유형 context 없음',
'',
'## 요청 본문',
normalizedContent,
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
]
.filter((line): line is string => line !== null)
.join('\n');
function buildSequencedPlanWorkId(baseWorkId: string, sequence: number, suffixLabel?: string | null) {
const normalizedBaseWorkId = normalizePlanWorkId(baseWorkId);
const normalizedSuffixLabel = String(suffixLabel ?? '').trim().replace(/^-+|-+$/g, '');
const suffix = normalizedSuffixLabel ? `-${normalizedSuffixLabel}-${sequence}` : `-${sequence}`;
const maxBaseLength = Math.max(1, PLAN_WORK_ID_MAX_LENGTH - suffix.length);
const trimmedBaseWorkId = normalizedBaseWorkId.slice(0, maxBaseLength).trimEnd() || '작업ID';
return `${trimmedBaseWorkId}${suffix}`;
}
export function resolveSequencedPlanWorkId(
baseWorkId: string,
existingWorkIds: Iterable<string>,
suffixLabel?: string | null,
) {
const existingWorkIdSet = new Set(
Array.from(existingWorkIds, (value) => String(value ?? '').trim()).filter(Boolean),
);
for (let sequence = 1; sequence <= MAX_SEQUENCED_PLAN_WORK_ID; sequence += 1) {
const candidate = buildSequencedPlanWorkId(baseWorkId, sequence, suffixLabel);
if (!existingWorkIdSet.has(candidate)) {
return candidate;
}
}
throw new Error(`자동화 접수 ID suffix를 더 이상 생성할 수 없습니다. (${MAX_SEQUENCED_PLAN_WORK_ID}개 초과)`);
}
function resolveInsertedId(result: unknown): number | null {
@@ -223,6 +254,7 @@ export async function ensureBoardPostsTable() {
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
['automation_context_ids_json', (table) => table.text('automation_context_ids_json').notNullable().defaultTo('[]')],
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -282,6 +314,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
created_at: db.fn.now(),
updated_at: db.fn.now(),
});
@@ -302,7 +335,14 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
return mapBoardPostRow(row);
}
export async function receiveBoardPostAutomation(id: number) {
export async function receiveBoardPostAutomation(
id: number,
options?: {
planWorkIdBase?: string | null;
planWorkIdSuffixLabel?: string | null;
suppressWebPush?: boolean;
},
) {
await ensureBoardPostsTable();
await ensurePlanTable();
@@ -327,17 +367,26 @@ export async function receiveBoardPostAutomation(id: number) {
const title = String(currentRow.title ?? '').trim();
const content = String(currentRow.content ?? '').trim();
const attachments = mapBoardPostRow(currentRow).attachments;
const workId = `board-post-${id}`;
const automationContextIds = mapBoardPostRow(currentRow).automationContextIds;
const workId = options?.planWorkIdBase?.trim()
? resolveSequencedPlanWorkId(
options.planWorkIdBase,
(await trx(PLAN_TABLE).select('work_id')).map((row) => String(row.work_id ?? '')),
options.planWorkIdSuffixLabel,
)
: `board-post-${id}`;
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
const insertQuery = trx(PLAN_TABLE).insert({
work_id: workId,
note: buildBoardPostPlanNote(title, content, attachments, automationType),
note: await buildBoardPostPlanNote(title, content, attachments, automationType, automationContextIds),
automation_type: normalizePlanAutomationType(currentRow.automation_type),
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
automation_context_ids_json: stringifyAutomationContextIds(automationContextIds),
status: '등록',
release_target: 'release',
jangsing_processing_required: true,
auto_deploy_to_main: false,
suppress_web_push: options?.suppressWebPush ?? false,
worker_status: '대기',
last_error: null,
updated_at: trx.fn.now(),
@@ -398,6 +447,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
attachments_json: JSON.stringify(parsedPayload.attachments),
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
updated_at: trx.fn.now(),
});

View File

@@ -0,0 +1,216 @@
export type ChatMessagePart =
| {
type: 'link_card';
title: string;
url: string;
actionLabel?: string | null;
};
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
return '';
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return false;
}
if (/^https?:\/\//i.test(normalized)) {
return !hasKnownFileExtension(normalized);
}
return !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
try {
const parsed = new URL(url, 'https://local.invalid');
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return lastSegment || parsed.hostname || normalizeText(url);
} catch {
return normalizeText(url);
}
}
function normalizeStandaloneTitle(value: string) {
return value
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
.replace(/[`'"]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
if (candidate) {
return candidate;
}
}
return buildFallbackLinkTitle(url);
}
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const segments = rawBody
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length < 2) {
return null;
}
const [rawTitle, rawUrl, rawActionLabel] = segments;
const title = normalizeText(rawTitle);
const url = normalizeUrl(rawUrl);
const actionLabel = normalizeText(rawActionLabel) || null;
if (!title || !url) {
return null;
}
return {
type: 'link_card',
title,
url,
actionLabel,
};
}
export function extractChatMessageParts(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
const parts: ChatMessagePart[] = [];
const seenLinkKeys = new Set<string>();
const pushPart = (nextPart: ChatMessagePart | null) => {
if (!nextPart) {
return false;
}
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
if (seenLinkKeys.has(dedupeKey)) {
return true;
}
seenLinkKeys.add(dedupeKey);
parts.push(nextPart);
return true;
};
for (const line of lines) {
const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) {
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
if (markdownLinkMatch) {
const [, rawTitle, rawUrl] = markdownLinkMatch;
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
continue;
}
}
}
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
if (standaloneUrlMatch) {
const rawUrl = standaloneUrlMatch[1] ?? '';
if (isStructuredLinkCardCandidate(rawUrl)) {
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
continue;
}
}
}
keptLines.push(line);
continue;
}
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
keptLines.push(line);
}
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
parts,
};
}
export function stringifyChatMessageParts(parts: ChatMessagePart[] | null | undefined) {
return JSON.stringify(Array.isArray(parts) ? parts : []);
}
export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
if (Array.isArray(value)) {
return value
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const record = item as Record<string, unknown>;
if (record.type !== 'link_card') {
return null;
}
const title = normalizeText(record.title);
const url = normalizeUrl(String(record.url ?? ''));
const actionLabel = normalizeText(record.actionLabel) || null;
if (!title || !url) {
return null;
}
return {
type: 'link_card' as const,
title,
url,
actionLabel,
};
})
.filter(Boolean) as ChatMessagePart[];
}
if (typeof value === 'string') {
try {
return parseChatMessageParts(JSON.parse(value));
} catch {
return [];
}
}
return [];
}

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
import { db } from '../db/client.js';
import { chatRuntimeService } from './chat-runtime-service.js';
import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
export const CHAT_CONVERSATION_TABLE = 'chat_conversations';
export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages';
@@ -28,6 +29,7 @@ const conversationMessagePayloadSchema = z.object({
text: z.string().max(200000),
timestamp: z.string().trim().max(40),
clientRequestId: z.string().trim().max(120).nullable().optional(),
parts: z.array(z.custom<ChatMessagePart>()).optional(),
});
export type ChatConversationItem = {
@@ -57,6 +59,7 @@ export type StoredChatMessage = {
text: string;
timestamp: string;
clientRequestId?: string | null;
parts?: ChatMessagePart[];
};
export type ChatConversationRequestStatus =
@@ -199,6 +202,7 @@ function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
text: String(row.text ?? ''),
timestamp: String(row.display_timestamp ?? ''),
clientRequestId: row.client_request_id == null ? null : String(row.client_request_id),
parts: parseChatMessageParts(row.parts_json),
};
}
@@ -800,6 +804,7 @@ export async function ensureChatConversationTables() {
table.bigInteger('message_id').notNullable();
table.string('author', 20).notNullable();
table.text('text').notNullable();
table.text('parts_json').notNullable().defaultTo('[]');
table.string('display_timestamp', 40).notNullable().defaultTo('');
table.string('client_request_id', 120).nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
@@ -812,6 +817,7 @@ export async function ensureChatConversationTables() {
['message_id', (table) => table.bigInteger('message_id').notNullable()],
['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')],
['text', (table) => table.text('text').notNullable().defaultTo('')],
['parts_json', (table) => table.text('parts_json').notNullable().defaultTo('[]')],
['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')],
['client_request_id', (table) => table.string('client_request_id', 120).nullable()],
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
@@ -1608,6 +1614,7 @@ export async function appendChatConversationMessage(
message_id: message.messageId,
author: message.author,
text: message.text,
parts_json: stringifyChatMessageParts(message.parts),
display_timestamp: message.timestamp,
client_request_id: resolvedClientRequestId,
created_at: db.fn.now(),
@@ -1616,6 +1623,7 @@ export async function appendChatConversationMessage(
.merge({
author: message.author,
text: message.text,
parts_json: stringifyChatMessageParts(message.parts),
display_timestamp: message.timestamp,
client_request_id: resolvedClientRequestId,
});
@@ -1971,7 +1979,7 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
.orderBy('request_id', 'asc');
const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
.where({ session_id: currentSessionId })
.select('id', 'message_id', 'author', 'text', 'client_request_id', 'created_at')
.select('id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'created_at')
.orderBy('created_at', 'asc')
.orderBy('message_id', 'asc')
.orderBy('id', 'asc');

View File

@@ -18,6 +18,7 @@ import {
shouldUseTemplateMacroReply,
validateAgenticCodexRuntime,
} from './chat-service.js';
import { extractChatMessageParts } from './chat-message-parts.js';
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
assert.deepEqual(
@@ -116,9 +117,67 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
assert.match(prompt, /### 반드시 지킬 context 원문/);
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)'));
});
test('extractChatMessageParts strips link-card markers into structured parts', () => {
assert.deepEqual(
extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://test.sm-home.cloud/chat/live|열기]]'].join('\n')),
{
strippedText: '결과 본문',
parts: [
{
type: 'link_card',
title: '미리보기',
url: 'https://test.sm-home.cloud/chat/live',
actionLabel: '열기',
},
],
},
);
});
test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => {
assert.deepEqual(
extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')),
{
strippedText: '결과 본문',
parts: [
{
type: 'link_card',
title: '판매글 열기',
url: 'https://www.daangn.com/kr/buy-sell/mac-studio',
actionLabel: null,
},
],
},
);
});
test('extractChatMessageParts promotes standalone urls with the previous line as the card title', () => {
assert.deepEqual(
extractChatMessageParts(
[
'수원 인근 공개 항목',
'- 동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원',
'https://www.daangn.com/kr/buy-sell/mac-studio-m2-max-12core-64gb-512g',
].join('\n'),
),
{
strippedText: ['수원 인근 공개 항목', '- 동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원'].join('\n'),
parts: [
{
type: 'link_card',
title: '동천동 : Mac Studio M2 Max 12core 64GB 512G 225만원',
url: 'https://www.daangn.com/kr/buy-sell/mac-studio-m2-max-12core-64gb-512g',
actionLabel: null,
},
],
},
);
});
test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => {
const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`);

View File

@@ -25,6 +25,7 @@ import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot
import { hasErrorLogViewAccessToken } from './error-log-service.js';
import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js';
import { createNotificationMessage } from './notification-message-service.js';
import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
import {
findLatestPlanItem,
findPlanItemByPreviewUrl,
@@ -47,6 +48,7 @@ type ChatMessage = {
text: string;
timestamp: string;
clientRequestId?: string | null;
parts?: ChatMessagePart[];
};
type ChatContext = {
@@ -392,13 +394,14 @@ function createChatMessageId() {
return Date.now() * 1_000 + chatMessageSequence;
}
function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null): ChatMessage {
function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null, parts?: ChatMessagePart[]): ChatMessage {
return {
id: createChatMessageId(),
author,
text,
timestamp: formatTime(new Date()),
clientRequestId: clientRequestId?.trim() || null,
parts: Array.isArray(parts) ? parts : [],
};
}
@@ -1511,6 +1514,7 @@ export function buildAgenticCodexPrompt(
'- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.',
'- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.',
'- 첨부파일 경로, 세션 리소스 경로, preview URL은 본문에 장황하게 나열하지 말고 꼭 필요한 설명만 남기세요.',
'- 링크를 본문과 분리된 결과 컴포넌트로 보여줘야 하면 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.',
'- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.',
'- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.',
'- 한국어로 간결하게 답하세요.',
@@ -2405,6 +2409,7 @@ export class ChatService {
text: message.text,
timestamp: message.timestamp,
clientRequestId: message.clientRequestId ?? null,
parts: message.parts ?? [],
},
),
);
@@ -3402,7 +3407,13 @@ export class ChatService {
const finalCodexReplyMessage = {
...codexReplyMessage,
text: reply,
...(() => {
const extracted = extractChatMessageParts(reply);
return {
text: extracted.strippedText,
parts: extracted.parts,
};
})(),
timestamp: resolveResponseTimestamp(request.requestedAtMs),
};
@@ -3455,9 +3466,11 @@ export class ChatService {
error instanceof ChatRuntimeExecutionError ? error.responseText : '';
if (failureResponseText) {
const extractedFailureReply = extractChatMessageParts(failureResponseText);
const failedCodexReplyMessage = {
...codexReplyMessage,
text: failureResponseText,
text: extractedFailureReply.strippedText,
parts: extractedFailureReply.parts,
timestamp: resolveResponseTimestamp(request.requestedAtMs),
};

View File

@@ -439,6 +439,7 @@ export async function registerErrorLogBoardPosts(args?: {
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
attachments: [],
automationType: 'none',
automationContextIds: [],
});
createdPosts.push({

View File

@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildManagedScheduleServiceMetadata } from './managed-schedule-service.js';
test('buildManagedScheduleServiceMetadata keeps schedule service artifacts in the strict schedule root', () => {
const metadata = buildManagedScheduleServiceMetadata(10, '반복-정리');
assert.equal(metadata.serviceKey, 'schedule-10-service');
assert.equal(metadata.packageName, 'service');
assert.equal(metadata.relativeDirectory, '.auto_codex/schedule/10');
assert.equal(metadata.readmePath, '.auto_codex/schedule/10/README.md');
assert.equal(metadata.sourcePath, '.auto_codex/schedule/10/service.ts');
assert.equal(metadata.runtimePath, '.auto_codex/schedule/10/service.mjs');
assert.equal(metadata.manifestPath, '.auto_codex/schedule/10/service-manifest.json');
});
test('buildManagedScheduleServiceMetadata appends the service suffix once for work ids', () => {
const metadata = buildManagedScheduleServiceMetadata(2, 'stock-alert-high-service');
assert.equal(metadata.serviceKey, 'schedule-2-stock-alert-high-service');
assert.equal(metadata.packageName, 'stock-alert-high-service');
});

View File

@@ -0,0 +1,168 @@
import path from 'node:path';
import { access, mkdir, rm } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { getEnv } from '../config/env.js';
import { sendManagedStockAlertWebPush } from './stock-alert-service.js';
export type ManagedScheduleServiceResult = {
ok: boolean;
skipped: boolean;
title: string;
body: string;
itemCount: number;
lines: string[];
ios: {
ok: boolean;
skipped: boolean;
reason?: string;
sentCount: number;
failedCount: number;
};
web: {
ok: boolean;
skipped: boolean;
reason?: string;
sentCount: number;
failedCount: number;
};
};
export type ManagedScheduleServiceMetadata = {
scheduleId: number;
serviceKey: string;
packageName: string;
relativeDirectory: string;
manifestPath: string;
readmePath: string;
sourcePath: string;
runtimePath: string;
};
function sanitizeManagedServiceToken(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
function appendScheduleSuffixOnce(value: string, suffix: string) {
const normalizedValue = value.trim();
const normalizedSuffix = suffix.trim().toLowerCase();
if (!normalizedValue || !normalizedSuffix) {
return normalizedValue;
}
return normalizedValue.toLowerCase().endsWith(`-${normalizedSuffix}`)
? normalizedValue
: `${normalizedValue}-${suffix.trim()}`;
}
function getScheduleRepoRoot() {
const env = getEnv();
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
}
export function buildManagedScheduleServiceMetadata(scheduleId: number, workId: string): ManagedScheduleServiceMetadata {
const workToken = sanitizeManagedServiceToken(appendScheduleSuffixOnce(workId, 'service')) || 'task-service';
const serviceKey = `schedule-${scheduleId}-${workToken}`;
const relativeDirectory = `.auto_codex/schedule/${scheduleId}`;
const packageName = workToken || `schedule-${scheduleId}-service`;
return {
scheduleId,
serviceKey,
packageName,
relativeDirectory,
manifestPath: `${relativeDirectory}/service-manifest.json`,
readmePath: `${relativeDirectory}/README.md`,
sourcePath: `${relativeDirectory}/service.ts`,
runtimePath: `${relativeDirectory}/service.mjs`,
};
}
export async function prepareManagedScheduleServiceDirectory(scheduleId: number) {
const repoRoot = getScheduleRepoRoot();
const absoluteDirectory = path.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId));
await mkdir(absoluteDirectory, { recursive: true });
await rm(path.join(absoluteDirectory, 'managed-service'), { recursive: true, force: true });
}
export async function removeManagedScheduleServiceArtifacts(scheduleId: number) {
const repoRoot = getScheduleRepoRoot();
const scheduleDirectory = path.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId));
await rm(path.join(scheduleDirectory, 'managed-service'), { recursive: true, force: true });
await rm(path.join(scheduleDirectory, 'README.md'), { force: true });
await rm(path.join(scheduleDirectory, 'service-manifest.json'), { force: true });
await rm(path.join(scheduleDirectory, 'service.ts'), { force: true });
await rm(path.join(scheduleDirectory, 'service.mjs'), { force: true });
}
export async function hasManagedScheduleServicePackage(relativeDirectory: string | null | undefined) {
const trimmedDirectory = String(relativeDirectory ?? '').trim();
if (!trimmedDirectory) {
return false;
}
const repoRoot = getScheduleRepoRoot();
const runtimePath = path.join(repoRoot, trimmedDirectory, 'service.mjs');
const manifestPath = path.join(repoRoot, trimmedDirectory, 'service-manifest.json');
try {
await Promise.all([access(runtimePath), access(manifestPath)]);
return true;
} catch {
return false;
}
}
export async function runManagedScheduleService(relativeDirectory: string) {
const repoRoot = getScheduleRepoRoot();
const runtimePath = path.join(repoRoot, relativeDirectory, 'service.mjs');
const importedModule = await import(`${pathToFileURL(runtimePath).href}?t=${Date.now()}`);
const run =
typeof importedModule.run === 'function'
? importedModule.run
: typeof importedModule.default?.run === 'function'
? importedModule.default.run
: null;
if (!run) {
throw new Error(`스케줄 서비스 실행 함수를 찾을 수 없습니다: ${relativeDirectory}/service.mjs`);
}
return run({
scheduleRoot: path.join(repoRoot, trimmedRelativeDirectory(relativeDirectory)),
scheduleDirectory: trimmedRelativeDirectory(relativeDirectory),
repoRoot,
now: new Date().toISOString(),
runCurrentPriceStockAlertService(definition: { scheduleId: number; serviceKey: string; title: string }) {
return sendManagedStockAlertWebPush({
scheduleId: definition.scheduleId,
serviceKey: definition.serviceKey,
title: definition.title,
mode: 'price',
});
},
runChangeRateThresholdStockAlertService(
definition: { scheduleId: number; serviceKey: string; title: string; thresholdPercent: number },
) {
return sendManagedStockAlertWebPush({
scheduleId: definition.scheduleId,
serviceKey: definition.serviceKey,
title: definition.title,
mode: 'change-threshold',
thresholdPercent: definition.thresholdPercent,
});
},
}) as Promise<ManagedScheduleServiceResult>;
}
function trimmedRelativeDirectory(relativeDirectory: string) {
return String(relativeDirectory ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}

View File

@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveNotificationAggregateResult } from './notification-service.js';
test('resolveNotificationAggregateResult marks managed-service web failures as failed when iOS is disabled', () => {
const result = resolveNotificationAggregateResult(
{
ios: { ok: true, skipped: true },
web: { ok: false, skipped: false },
},
{
disableIos: true,
},
);
assert.deepEqual(result, {
ok: false,
skipped: false,
});
});
test('resolveNotificationAggregateResult treats fully skipped enabled channels as skipped success', () => {
const result = resolveNotificationAggregateResult(
{
ios: { ok: true, skipped: true },
web: { ok: true, skipped: true },
},
{
disableIos: true,
},
);
assert.deepEqual(result, {
ok: true,
skipped: true,
});
});

View File

@@ -32,6 +32,8 @@ export const registerAutomationNotificationPreferenceSchema = z.object({
export const registerIosTokenSchema = z.object({
token: z.string().trim().min(1),
deviceId: z.string().trim().min(1).max(200).optional(),
appOrigin: z.string().trim().url().max(500).optional(),
appDomain: z.string().trim().min(1).max(255).optional(),
enabled: z.boolean().default(true),
});
@@ -50,6 +52,8 @@ export const registerWebPushSubscriptionSchema = z.object({
}),
deviceId: z.string().trim().min(1).max(200).optional(),
userAgent: z.string().trim().max(500).optional(),
appOrigin: z.string().trim().url().max(500).optional(),
appDomain: z.string().trim().min(1).max(255).optional(),
enabled: z.boolean().default(true),
});
@@ -62,6 +66,9 @@ export const sendIosNotificationSchema = z.object({
body: z.string().trim().min(1),
data: z.record(z.string(), z.string()).default({}),
threadId: z.string().trim().min(1).optional(),
targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(),
targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(),
});
type IosNotificationPayload = z.infer<typeof sendIosNotificationSchema>;
@@ -73,6 +80,84 @@ type NotificationPreferenceTarget = {
id: string;
};
function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
}
function normalizeTargetAppOrigins(targetAppOrigins: string[] | undefined) {
return [...new Set((targetAppOrigins ?? []).map((value) => normalizeAppOrigin(value)).filter(Boolean))];
}
function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
}
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
if (targetClientIds.length === 0) {
return true;
}
return Boolean(deviceId) && targetClientIds.includes(deviceId);
}
function normalizeAppOrigin(value: unknown) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
try {
const url = new URL(normalized);
return url.origin;
} catch {
return '';
}
}
function normalizeAppDomain(value: unknown) {
return String(value ?? '').trim().toLowerCase();
}
function resolveAppDomainFromOrigin(origin: string) {
if (!origin) {
return '';
}
try {
return new URL(origin).hostname.trim().toLowerCase();
} catch {
return '';
}
}
function isAllowedAppTarget(
item: {
appOrigin?: string;
appDomain?: string;
},
targetAppOrigins: string[],
targetAppDomains: string[],
) {
if (targetAppOrigins.length === 0 && targetAppDomains.length === 0) {
return true;
}
if (targetAppOrigins.length > 0) {
if (!item.appOrigin || !targetAppOrigins.includes(item.appOrigin)) {
return false;
}
}
if (targetAppDomains.length > 0) {
if (!item.appDomain || !targetAppDomains.includes(item.appDomain)) {
return false;
}
}
return true;
}
type WebPushFailureDetail = {
endpoint: string;
statusCode?: number;
@@ -244,6 +329,8 @@ async function ensureNotificationTokenTable() {
table.string('platform', 20).notNullable().defaultTo('ios');
table.string('device_token', 255).notNullable().unique();
table.string('device_id', 200).nullable();
table.string('app_origin', 500).nullable();
table.string('app_domain', 255).nullable();
table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
@@ -257,6 +344,8 @@ async function ensureNotificationTokenTable() {
['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')],
['device_token', (table) => table.string('device_token', 255).notNullable()],
['device_id', (table) => table.string('device_id', 200).nullable()],
['app_origin', (table) => table.string('app_origin', 500).nullable()],
['app_domain', (table) => table.string('app_domain', 255).nullable()],
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
[
'last_registered_at',
@@ -287,6 +376,8 @@ async function ensureWebPushSubscriptionTable() {
table.jsonb('subscription_json').notNullable();
table.string('device_id', 200).nullable();
table.text('user_agent').nullable();
table.string('app_origin', 500).nullable();
table.string('app_domain', 255).nullable();
table.boolean('is_enabled').notNullable().defaultTo(true);
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
@@ -301,6 +392,8 @@ async function ensureWebPushSubscriptionTable() {
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
['device_id', (table) => table.string('device_id', 200).nullable()],
['user_agent', (table) => table.text('user_agent').nullable()],
['app_origin', (table) => table.string('app_origin', 500).nullable()],
['app_domain', (table) => table.string('app_domain', 255).nullable()],
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
[
'last_registered_at',
@@ -389,6 +482,8 @@ export async function listIosNotificationTokens() {
platform: row.platform,
token: row.device_token,
deviceId: row.device_id,
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
enabled: row.is_enabled,
lastRegisteredAt: row.last_registered_at,
createdAt: row.created_at,
@@ -396,8 +491,29 @@ export async function listIosNotificationTokens() {
}));
}
export async function listWebPushSubscriptions() {
await ensureWebPushSubscriptionTable();
const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE).orderBy('updated_at', 'desc');
return rows.map((row) => ({
id: row.id,
endpoint: String(row.endpoint ?? ''),
deviceId: row.device_id ? String(row.device_id) : '',
userAgent: row.user_agent ? String(row.user_agent) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
enabled: Boolean(row.is_enabled),
lastRegisteredAt: row.last_registered_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
export async function registerIosNotificationToken(payload: z.infer<typeof registerIosTokenSchema>) {
await ensureNotificationTokenTable();
const appOrigin = normalizeAppOrigin(payload.appOrigin);
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
if (!payload.enabled) {
await unregisterIosNotificationToken(payload.token);
@@ -414,6 +530,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
platform: 'ios',
device_token: payload.token,
device_id: payload.deviceId ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true,
last_registered_at: db.fn.now(),
updated_at: db.fn.now(),
@@ -422,6 +540,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
.merge({
platform: 'ios',
device_id: payload.deviceId ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true,
last_registered_at: db.fn.now(),
updated_at: db.fn.now(),
@@ -519,6 +639,8 @@ export async function registerWebPushSubscription(
payload: z.infer<typeof registerWebPushSubscriptionSchema>,
) {
await ensureWebPushSubscriptionTable();
const appOrigin = normalizeAppOrigin(payload.appOrigin);
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
if (!payload.enabled) {
await unregisterWebPushSubscription(payload.subscription.endpoint);
@@ -536,6 +658,8 @@ export async function registerWebPushSubscription(
subscription_json: payload.subscription,
device_id: payload.deviceId ?? null,
user_agent: payload.userAgent ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true,
last_registered_at: db.fn.now(),
updated_at: db.fn.now(),
@@ -545,6 +669,8 @@ export async function registerWebPushSubscription(
subscription_json: payload.subscription,
device_id: payload.deviceId ?? null,
user_agent: payload.userAgent ?? null,
app_origin: appOrigin || null,
app_domain: appDomain || null,
is_enabled: true,
last_registered_at: db.fn.now(),
updated_at: db.fn.now(),
@@ -583,11 +709,13 @@ async function getEnabledIosTokens() {
platform: 'ios',
is_enabled: true,
})
.select('device_token', 'device_id');
.select('device_token', 'device_id', 'app_origin', 'app_domain');
return rows.map((row) => ({
token: String(row.device_token),
deviceId: row.device_id ? String(row.device_id) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
}));
}
@@ -598,12 +726,14 @@ async function getEnabledWebPushSubscriptions() {
.where({
is_enabled: true,
})
.select('endpoint', 'subscription_json', 'device_id');
.select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
return rows.map((row) => ({
endpoint: String(row.endpoint),
subscription: row.subscription_json as WebPushSubscriptionPayload,
deviceId: row.device_id ? String(row.device_id) : '',
appOrigin: row.app_origin ? String(row.app_origin) : '',
appDomain: row.app_domain ? String(row.app_domain) : '',
}));
}
@@ -704,6 +834,9 @@ async function isNotificationRecipientAllowed(
export async function sendIosNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const provider = await getProvider();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!provider || !env.APNS_BUNDLE_ID) {
return {
@@ -720,6 +853,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
await Promise.all(
tokenRows.map(async (row) => ({
token: row.token,
deviceId: row.deviceId,
appOrigin: row.appOrigin,
appDomain: row.appDomain,
allowed: await isNotificationRecipientAllowed(
[
{ kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) },
@@ -731,7 +867,12 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
})),
)
)
.filter((row) => row.allowed)
.filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
)
.map((row) => row.token);
if (!tokens.length) {
@@ -778,6 +919,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
async function sendWebPushNotifications(payload: IosNotificationPayload) {
const env = getEnv();
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
if (!ensureWebPushConfigured(env)) {
return {
ok: false,
@@ -801,7 +945,12 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
),
})),
)
).filter((row) => row.allowed);
).filter(
(row) =>
row.allowed &&
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
);
if (!subscriptions.length) {
return {
@@ -880,19 +1029,73 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
};
}
export async function sendNotifications(payload: IosNotificationPayload) {
export async function sendNotifications(
payload: IosNotificationPayload,
options?: {
disableIos?: boolean;
disableWebPush?: boolean;
},
) {
const [ios, web] = await Promise.all([
sendIosNotifications(payload),
sendWebPushNotifications(payload),
options?.disableIos
? Promise.resolve({
ok: true,
skipped: true,
reason: '요청 설정에 따라 iOS 알림 발송을 건너뛰었습니다.',
sentCount: 0,
failedCount: 0,
invalidTokens: [],
})
: sendIosNotifications(payload),
options?.disableWebPush
? Promise.resolve({
ok: true,
skipped: true,
reason: '요청 설정에 따라 Web Push 발송을 건너뛰었습니다.',
sentCount: 0,
failedCount: 0,
invalidEndpoints: [],
})
: sendWebPushNotifications(payload),
]);
const aggregate = resolveNotificationAggregateResult(
{
ios,
web,
},
options,
);
return {
ok: ios.ok || web.ok,
ok: aggregate.ok,
skipped: aggregate.skipped,
ios,
web,
};
}
export function resolveNotificationAggregateResult(
result: {
ios: { ok: boolean; skipped: boolean };
web: { ok: boolean; skipped: boolean };
},
options?: {
disableIos?: boolean;
disableWebPush?: boolean;
},
) {
const enabledResults = [
options?.disableIos ? null : result.ios,
options?.disableWebPush ? null : result.web,
].filter((entry): entry is { ok: boolean; skipped: boolean } => Boolean(entry));
return {
ok: enabledResults.length === 0 ? true : enabledResults.every((entry) => entry.ok),
skipped: enabledResults.length === 0 ? true : enabledResults.every((entry) => entry.skipped),
};
}
export async function shutdownNotificationProvider() {
const provider = await getProvider();
provider?.shutdown();

View File

@@ -110,5 +110,7 @@ export async function notifyPlanEvent(
body,
threadId: `plan-${planId}`,
data: buildPlanNotificationData(planId, String(item.workId), eventType),
}, {
disableWebPush: Boolean(item.suppressWebPush),
});
}

View File

@@ -0,0 +1,247 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildScheduledBoardPostTitle,
buildScheduledPlanWorkIdBase,
isPlanScheduledTaskDue,
mapPlanScheduledTaskRow,
shouldCreatePlanForScheduleExecution,
updatePlanScheduledTaskSchema,
} from './plan-schedule-service.js';
test('buildScheduledBoardPostTitle prefers the first memo line when present', () => {
assert.equal(
buildScheduledBoardPostTitle({
work_id: '반복작업',
note: ' 첫 줄 제목 \n둘째 줄 설명',
}),
'첫 줄 제목',
);
});
test('buildScheduledBoardPostTitle falls back to normalized work id when memo is empty', () => {
assert.equal(
buildScheduledBoardPostTitle({
work_id: ' 작업 ID ',
note: ' \n ',
}),
'반복작업',
);
});
test('buildScheduledPlanWorkIdBase uses the schedule work id for automation registration', () => {
assert.equal(
buildScheduledPlanWorkIdBase({
work_id: ' 반복-정리 ',
}),
'반복-정리',
);
});
test('buildScheduledPlanWorkIdBase keeps managed-service runs on the schedule work id with schedule pk prefix', () => {
assert.equal(
buildScheduledPlanWorkIdBase({
id: 10,
work_id: ' 반복-정리 ',
execution_mode: 'managed-service',
}),
'schedule-10-반복-정리',
);
assert.equal(
buildScheduledPlanWorkIdBase({
id: 10,
work_id: '반복-정리-service',
execution_mode: 'managed-service',
}),
'schedule-10-반복-정리-service',
);
});
test('buildScheduledPlanWorkIdBase falls back when managed-service schedule id is missing', () => {
assert.equal(
buildScheduledPlanWorkIdBase({
work_id: '반복-정리-service',
execution_mode: 'managed-service',
}),
'반복-정리-service',
);
});
test('shouldCreatePlanForScheduleExecution only returns true for managed-service schedules', () => {
assert.equal(
shouldCreatePlanForScheduleExecution({
execution_mode: 'codex',
}),
false,
);
assert.equal(
shouldCreatePlanForScheduleExecution({
execution_mode: 'managed-service',
}),
true,
);
});
test('updatePlanScheduledTaskSchema keeps partial updates partial', () => {
assert.deepEqual(updatePlanScheduledTaskSchema.parse({ recreateManagedServiceOnNextSave: true }), {
recreateManagedServiceOnNextSave: true,
});
});
test('updatePlanScheduledTaskSchema accepts second-based interval updates', () => {
assert.deepEqual(
updatePlanScheduledTaskSchema.parse({
repeatIntervalValue: 10,
repeatIntervalUnit: 'second',
repeatIntervalSeconds: 10,
}),
{
repeatIntervalValue: 10,
repeatIntervalUnit: 'second',
repeatIntervalSeconds: 10,
},
);
});
test('interval schedule with start time waits until start time when immediate run is enabled', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: true,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T08:59:00+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: true,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T09:00:00+09:00'),
),
true,
);
});
test('interval schedule with start time waits one interval after the start when immediate run is disabled', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T09:59:00+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_minutes: 60,
repeat_window_start_time: '09:00',
created_at: '2026-04-30T07:00:00+09:00',
},
new Date('2026-04-30T10:00:00+09:00'),
),
true,
);
});
test('interval schedule supports second-based due calculation', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'second',
repeat_interval_value: 10,
repeat_interval_seconds: 10,
created_at: '2026-04-30T09:00:00+09:00',
},
new Date('2026-04-30T09:00:09+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'second',
repeat_interval_value: 10,
repeat_interval_seconds: 10,
created_at: '2026-04-30T09:00:00+09:00',
},
new Date('2026-04-30T09:00:10+09:00'),
),
true,
);
});
test('interval schedule uses repeat interval value and unit when stored seconds are stale', () => {
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'minute',
repeat_interval_value: 10,
repeat_interval_seconds: 3600,
created_at: '2026-04-30T09:50:00+09:00',
},
new Date('2026-04-30T09:59:59+09:00'),
),
false,
);
assert.equal(
isPlanScheduledTaskDue(
{
schedule_mode: 'interval',
immediate_run_enabled: false,
repeat_interval_unit: 'minute',
repeat_interval_value: 10,
repeat_interval_seconds: 3600,
created_at: '2026-04-30T09:50:00+09:00',
},
new Date('2026-04-30T10:00:00+09:00'),
),
true,
);
});
test('mapPlanScheduledTaskRow normalizes stale stored repeat interval seconds', () => {
const mapped = mapPlanScheduledTaskRow({
id: 2,
work_id: 'stock-alert',
schedule_mode: 'interval',
repeat_interval_unit: 'minute',
repeat_interval_value: 10,
repeat_interval_seconds: 3600,
repeat_interval_minutes: 60,
});
assert.equal(mapped.repeatIntervalValue, 10);
assert.equal(mapped.repeatIntervalUnit, 'minute');
assert.equal(mapped.repeatIntervalSeconds, 600);
assert.equal(mapped.repeatIntervalMinutes, 10);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
import { z } from 'zod';
import { getEnv } from '../config/env.js';
import { db } from '../db/client.js';
import {
parseAutomationContextIds,
stringifyAutomationContextIds,
} from './automation-context-service.js';
import {
normalizeLegacyAutomationBehaviorType,
resolveAutomationType,
@@ -78,9 +82,11 @@ export const createPlanSchema = z.object({
workId: z.string().trim().optional().default('작업ID'),
note: z.string().default(''),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')),
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
releaseTarget: z.string().trim().min(1).default('release'),
jangsingProcessingRequired: z.boolean().default(true),
autoDeployToMain: z.boolean().default(true),
suppressWebPush: z.boolean().default(false),
repeatRequestEnabled: z.boolean().default(false),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60),
});
@@ -89,9 +95,11 @@ export const updatePlanSchema = z.object({
workId: z.string().trim().optional(),
note: z.string().optional(),
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()),
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional(),
releaseTarget: z.string().trim().min(1).optional(),
jangsingProcessingRequired: z.boolean().optional(),
autoDeployToMain: z.boolean().optional(),
suppressWebPush: z.boolean().optional(),
repeatRequestEnabled: z.boolean().optional(),
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(),
});
@@ -168,7 +176,7 @@ function sanitizeBranchToken(value: string) {
.slice(0, 48);
}
function normalizePlanWorkId(value?: string | null) {
export function normalizePlanWorkId(value?: string | null) {
const workId = String(value ?? '').trim();
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
@@ -273,6 +281,7 @@ export function mapPlanRow(
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
automationBehaviorType: normalizePlanAutomationType(row.automation_type),
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
releaseReviewNote: options?.releaseReviewNote ?? '',
noteMasked: Boolean(options?.noteMasked),
status: row.status,
@@ -281,6 +290,7 @@ export function mapPlanRow(
? row.jangsing_processing_required
: row.normal_processing_level === '상',
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
suppressWebPush: Boolean(row.suppress_web_push ?? false),
repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false),
repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60),
assignedBranch: row.assigned_branch,
@@ -819,9 +829,15 @@ async function syncPlanColumns() {
await ensureColumn('automation_type_id', (table) => {
table.string('automation_type_id', 120).nullable();
});
await ensureColumn('automation_context_ids_json', (table) => {
table.text('automation_context_ids_json').notNullable().defaultTo('[]');
});
await ensureColumn('auto_deploy_to_main', (table) => {
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
});
await ensureColumn('suppress_web_push', (table) => {
table.boolean('suppress_web_push').notNullable().defaultTo(false);
});
await ensureColumn('repeat_request_enabled', (table) => {
table.boolean('repeat_request_enabled').notNullable().defaultTo(false);
});
@@ -1024,6 +1040,7 @@ export async function ensurePlanTable() {
table.string('status', 40).notNullable().defaultTo('등록');
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
table.boolean('suppress_web_push').notNullable().defaultTo(false);
table.string('assigned_branch', 200).nullable();
table.string('release_target', 120).notNullable().defaultTo('release');
table.string('worker_status', 80).nullable();
@@ -1056,6 +1073,11 @@ export async function ensurePlanTable() {
.update({
jangsing_processing_required: true,
});
await db(PLAN_TABLE)
.whereNull('suppress_web_push')
.update({
suppress_web_push: false,
});
await db(PLAN_TABLE)
.whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[])
.whereNull('completed_at')
@@ -1098,9 +1120,11 @@ export async function createPlanItem(payload: z.infer<typeof createPlanSchema>)
status: '등록',
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
suppress_web_push: payload.suppressWebPush,
repeat_request_enabled: payload.repeatRequestEnabled,
repeat_interval_minutes: payload.repeatIntervalMinutes,
worker_status: '대기',
@@ -1124,9 +1148,11 @@ export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeo
status: '완료',
automation_type: automationType.behaviorType,
automation_type_id: automationType.id,
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
release_target: payload.releaseTarget,
jangsing_processing_required: payload.jangsingProcessingRequired,
auto_deploy_to_main: payload.autoDeployToMain,
suppress_web_push: payload.suppressWebPush,
repeat_request_enabled: payload.repeatRequestEnabled,
repeat_interval_minutes: payload.repeatIntervalMinutes,
worker_status: '자동완료',
@@ -1150,7 +1176,9 @@ export async function upsertAutoPlanItem(args: {
releaseTarget: string;
jangsingProcessingRequired: boolean;
autoDeployToMain: boolean;
suppressWebPush?: boolean;
automationType?: PlanAutomationType;
automationContextIds?: string[];
requeue: boolean;
}) {
await ensurePlanTable();
@@ -1168,9 +1196,11 @@ export async function upsertAutoPlanItem(args: {
workId,
note: args.note,
automationType: automationType.id,
automationContextIds: args.automationContextIds ?? [],
releaseTarget: args.releaseTarget,
jangsingProcessingRequired: args.jangsingProcessingRequired,
autoDeployToMain: args.autoDeployToMain,
suppressWebPush: args.suppressWebPush ?? false,
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
}),
@@ -1182,7 +1212,9 @@ export async function upsertAutoPlanItem(args: {
const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type);
const nextJangsingProcessingRequired = args.jangsingProcessingRequired;
const nextAutoDeployToMain = args.autoDeployToMain;
const nextSuppressWebPush = args.suppressWebPush ?? Boolean(existingRow.suppress_web_push ?? false);
const nextNote = args.note;
const nextAutomationContextIds = args.automationContextIds ?? parseAutomationContextIds(existingRow.automation_context_ids_json);
const currentJangsingProcessingRequired =
typeof existingRow.jangsing_processing_required === 'boolean'
? existingRow.jangsing_processing_required
@@ -1192,6 +1224,8 @@ export async function upsertAutoPlanItem(args: {
String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
(existingRow.release_target ?? 'release') !== nextReleaseTarget ||
Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain ||
Boolean(existingRow.suppress_web_push ?? false) !== nextSuppressWebPush ||
JSON.stringify(parseAutomationContextIds(existingRow.automation_context_ids_json)) !== JSON.stringify(nextAutomationContextIds) ||
Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired;
if (!args.requeue) {
@@ -1208,9 +1242,11 @@ export async function upsertAutoPlanItem(args: {
note: nextNote,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
suppress_web_push: nextSuppressWebPush,
updated_at: db.fn.now(),
})
.returning('*');
@@ -1230,9 +1266,11 @@ export async function upsertAutoPlanItem(args: {
assigned_branch: null,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
suppress_web_push: nextSuppressWebPush,
worker_status: '대기',
last_error: null,
locked_by: null,
@@ -1278,17 +1316,22 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
? currentRow.jangsing_processing_required
: currentRow.normal_processing_level === '상');
const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true;
const nextSuppressWebPush = payload.suppressWebPush ?? currentRow.suppress_web_push ?? false;
const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false;
const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60;
const nextNote = payload.note ?? currentRow.note;
const nextAutomationContextIds =
payload.automationContextIds ?? parseAutomationContextIds(currentRow.automation_context_ids_json);
const isOnlyJangsingUpdate =
nextWorkId === currentRow.work_id &&
nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
nextReleaseTarget === (currentRow.release_target ?? 'release') &&
nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
nextSuppressWebPush === (currentRow.suppress_web_push ?? false) &&
nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) &&
JSON.stringify(parseAutomationContextIds(currentRow.automation_context_ids_json)) === JSON.stringify(nextAutomationContextIds) &&
nextNote === currentRow.note;
if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) {
@@ -1307,9 +1350,11 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
status: currentRow.status,
automation_type: nextAutomationType.behaviorType,
automation_type_id: nextAutomationType.id,
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
release_target: nextReleaseTarget,
jangsing_processing_required: nextJangsingProcessingRequired,
auto_deploy_to_main: nextAutoDeployToMain,
suppress_web_push: nextSuppressWebPush,
repeat_request_enabled: nextRepeatRequestEnabled,
repeat_interval_minutes: nextRepeatIntervalMinutes,
worker_status: currentRow.worker_status,

View File

@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { writeFile } from 'node:fs/promises';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { env } from '../config/env.js';
import {
buildHealthCheckUrls,
@@ -88,6 +90,7 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
workServerScript,
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
);
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
});
@@ -271,3 +274,65 @@ test('listServerCommands marks command-runner online when localhost fallback res
assert.equal(runnerCommand.httpStatus, 200);
assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/);
});
test('listServerCommands ignores public codex chat resources when checking app source updates', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-app-source-scan-'));
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
const buildTargetPath = '/tmp/ai-code-test-app-dist/index.html';
const previousBuildContents = fs.existsSync(buildTargetPath) ? await fs.promises.readFile(buildTargetPath) : null;
const previousBuildStat = fs.existsSync(buildTargetPath) ? await fs.promises.stat(buildTargetPath) : null;
try {
const staleDate = new Date('2026-04-19T00:00:00.000Z');
await mkdir(path.join(tempRoot, 'src'), { recursive: true });
await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
await writeFile(path.join(tempRoot, 'tsconfig.app.json'), '{}\n', 'utf8');
await writeFile(path.join(tempRoot, 'vite.config.ts'), 'export default {};\n', 'utf8');
await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
await Promise.all([
fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'tsconfig.app.json'), staleDate, staleDate),
fs.promises.utimes(path.join(tempRoot, 'vite.config.ts'), staleDate, staleDate),
]);
await mkdir(path.dirname(buildTargetPath), { recursive: true });
await writeFile(buildTargetPath, '<!doctype html>\n', 'utf8');
const buildDate = new Date('2026-04-20T00:00:00.000Z');
await fs.promises.utimes(buildTargetPath, buildDate, buildDate);
const resourceDate = new Date('2026-04-28T00:00:00.000Z');
await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
const commands = await listServerCommands();
const testCommand = commands.find((item) => item.key === 'test');
assert.ok(testCommand);
assert.equal(testCommand.buildRequired, false);
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
} finally {
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
if (previousBuildContents) {
await writeFile(buildTargetPath, previousBuildContents, 'utf8');
if (previousBuildStat) {
await fs.promises.utimes(buildTargetPath, previousBuildStat.atime, previousBuildStat.mtime);
}
} else {
await rm(buildTargetPath, { force: true });
}
await rm(tempRoot, { recursive: true, force: true });
}
});

View File

@@ -134,6 +134,7 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
'/tmp/ai-code-test-app-dist/assets',
] as const;
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
const allowLocal = options?.allowLocal ?? false;
@@ -191,8 +192,17 @@ type SourceChangeInfo = {
path: string;
};
function isExcludedAppSourcePath(rootPath: string, targetPath: string) {
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
return APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
}
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
try {
if (isExcludedAppSourcePath(rootPath, targetPath)) {
return null;
}
const targetStat = await stat(targetPath);
if (targetStat.isFile()) {

View File

@@ -0,0 +1,382 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildChangeRateThresholdStockAlertLines,
buildCurrentPriceStockAlertLines,
buildStockAlertNotificationIdentity,
resolveLatestQuoteFromMeta,
resolveLatestQuoteFromNaverRealtime,
type StockAlertItem,
} from './stock-alert-service.js';
test('resolveLatestQuoteFromMeta prefers post-market quote outside regular session', () => {
const quote = resolveLatestQuoteFromMeta({
regularMarketPrice: 210000,
regularMarketTime: 1714377600,
regularMarketChangePercent: 1.23,
previousClose: 207450,
postMarketPrice: 211500,
postMarketTime: 1714381200,
postMarketChangePercent: 1.95,
marketState: 'POST',
shortName: '삼성전자',
});
assert.equal(quote.currentPrice, 211500);
assert.equal(quote.changeRate, 1.95);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2024-04-29T09:00:00.000Z');
});
test('buildCurrentPriceStockAlertLines formats current-price stock alert lines', () => {
const items: StockAlertItem[] = [
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 210000,
changeRate: 1.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '000660',
stockCode: '000660',
stockName: 'SK하이닉스',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 198000,
changeRate: -2.34,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildCurrentPriceStockAlertLines(items), [
'삼성전자 210,000₩ (+1.23% ▲)',
'SK하이닉스 198,000₩ (-2.34% ▼)',
]);
});
test('buildCurrentPriceStockAlertLines skips rows without resolved current-price quote', () => {
const items: StockAlertItem[] = [
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 210000,
changeRate: 1.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '035420',
stockCode: '035420',
stockName: 'NAVER',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildCurrentPriceStockAlertLines(items), ['삼성전자 210,000₩ (+1.23% ▲)']);
});
test('buildCurrentPriceStockAlertLines excludes stocks not registered for current-price alerts', () => {
const items: StockAlertItem[] = [
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price', 'top3'],
alertTypeLabels: ['현재가', '등락폭이 큰 상위3종목'],
currentPrice: 210000,
changeRate: 1.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '035420',
stockCode: '035420',
stockName: 'NAVER',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 230000,
changeRate: 3.45,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildCurrentPriceStockAlertLines(items), ['삼성전자 210,000₩ (+1.23% ▲)']);
});
test('buildChangeRateThresholdStockAlertLines includes every registered stock that crosses the threshold and sorts by absolute rate', () => {
const items: StockAlertItem[] = [
{
id: '290550',
stockCode: '290550',
stockName: '디케이티',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 26500,
changeRate: 11.11,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '005930',
stockCode: '005930',
stockName: '삼성전자',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: 210000,
changeRate: 6.1,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '066570',
stockCode: '066570',
stockName: 'LG전자',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 137400,
changeRate: -1.86,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
{
id: '035420',
stockCode: '035420',
stockName: 'NAVER',
alertTypes: ['top3'],
alertTypeLabels: ['등락폭이 큰 상위3종목'],
currentPrice: 230000,
changeRate: -7.23,
quotedAt: '2026-04-29T09:00:00.000Z',
createdAt: '2026-04-29T09:00:00.000Z',
updatedAt: '2026-04-29T09:00:00.000Z',
},
];
assert.deepEqual(buildChangeRateThresholdStockAlertLines(items, 5), [
'디케이티 26,500₩ (+11.11% ▲)',
'NAVER 230,000₩ (-7.23% ▼)',
'삼성전자 210,000₩ (+6.10% ▲)',
]);
});
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {
assert.deepEqual(
buildStockAlertNotificationIdentity({
scheduleId: 2,
serviceKey: 'schedule-2-stock-alert-service',
mode: 'price',
}),
{
threadId: 'schedule-stock-alert:2',
notificationKey: 'schedule-stock-alert:2',
notificationScope: 'schedule-stock-alert:2',
notificationAliases: [
'schedule-2-stock-alert-service',
'schedule-2-stock-alert-service:current-price',
'schedule-2-stock-alert',
'schedule-2-stock-alert:current-price',
],
},
);
assert.deepEqual(
buildStockAlertNotificationIdentity({
scheduleId: 10,
serviceKey: 'schedule-10-stock-high-service',
mode: 'change-threshold',
}),
{
threadId: 'schedule-stock-alert:10',
notificationKey: 'schedule-stock-alert:10',
notificationScope: 'schedule-stock-alert:10',
notificationAliases: [
'schedule-10-stock-high-service',
'schedule-10-stock-high-service:current-price',
'schedule-10-stock-high-service:change-threshold',
],
},
);
});
test('resolveLatestQuoteFromNaverRealtime prefers extended-hours quote when available', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
nxtOverMarketPriceInfo: {
tradingSessionType: 'AFTER_MARKET',
overMarketStatus: 'OPEN',
overPrice: '225,500',
fluctuationsRatio: '1.58',
localTradedAt: '2026-04-29T19:05:12.465411+09:00',
},
},
1777457112465,
);
assert.equal(quote.currentPrice, 225500);
assert.equal(quote.changeRate, 1.58);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-29T19:05:12.465411+09:00');
});
test('resolveLatestQuoteFromNaverRealtime keeps after-hours quote even after session close', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '066570',
nm: 'LG전자',
nv: 135800,
cr: 3,
nxtOverMarketPriceInfo: {
tradingSessionType: 'AFTER_MARKET',
overMarketStatus: 'CLOSE',
overPrice: '137,400',
compareToPreviousClosePrice: '-2,600',
fluctuationsRatio: '-1.86',
localTradedAt: '2026-04-29T20:00:00.000000+09:00',
},
},
1777461821319,
);
assert.equal(quote.currentPrice, 137400);
assert.equal(quote.changeRate, -1.86);
assert.equal(quote.stockName, 'LG전자');
assert.equal(quote.quotedAt, '2026-04-29T20:00:00.000000+09:00');
});
test('resolveLatestQuoteFromNaverRealtime resets domestic quote to previous close from 5AM before premarket starts', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
ms: 'CLOSE',
pcv: 222000,
},
'2026-04-30T05:30:00+09:00',
);
assert.equal(quote.currentPrice, 222000);
assert.equal(quote.changeRate, 0);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-29T20:30:00.000Z');
});
test('resolveLatestQuoteFromNaverRealtime keeps before-market quote when available in the morning reset window', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
ms: 'CLOSE',
pcv: 222000,
nxtOverMarketPriceInfo: {
tradingSessionType: 'BEFORE_MARKET',
overMarketStatus: 'OPEN',
overPrice: '223,500',
fluctuationsRatio: '0.68',
compareToPreviousClosePrice: '1,500',
localTradedAt: '2026-04-30T08:10:00+09:00',
},
},
'2026-04-30T08:10:05+09:00',
);
assert.equal(quote.currentPrice, 223500);
assert.equal(quote.changeRate, 0.68);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-30T08:10:00+09:00');
});
test('resolveLatestQuoteFromNaverRealtime restores negative sign when ratio arrives unsigned', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '066570',
nm: 'LG전자',
nv: 135800,
cr: 3,
nxtOverMarketPriceInfo: {
tradingSessionType: 'AFTER_MARKET',
overMarketStatus: 'CLOSE',
overPrice: '137,400',
compareToPreviousClosePrice: '-2,600',
fluctuationsRatio: '1.86',
compareToPreviousPrice: {
code: '5',
name: 'FALLING',
},
localTradedAt: '2026-04-29T20:00:00.000000+09:00',
},
},
1777461821319,
);
assert.equal(quote.currentPrice, 137400);
assert.equal(quote.changeRate, -1.86);
assert.equal(quote.stockName, 'LG전자');
});
test('resolveLatestQuoteFromNaverRealtime falls back to regular quote when extended-hours quote is unavailable', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '005930',
nm: '삼성전자',
nv: 226000,
cr: 1.8,
},
1777457112465,
);
assert.equal(quote.currentPrice, 226000);
assert.equal(quote.changeRate, 1.8);
assert.equal(quote.stockName, '삼성전자');
assert.equal(quote.quotedAt, '2026-04-29T10:05:12.465Z');
});
test('resolveLatestQuoteFromNaverRealtime restores negative sign for regular-session quotes from rf direction', () => {
const quote = resolveLatestQuoteFromNaverRealtime(
{
cd: '024950',
nm: '삼천리자전거',
nv: 5130,
cr: 2.47,
rf: '5',
pcv: 5260,
},
'2026-04-30T14:13:05+09:00',
);
assert.equal(quote.currentPrice, 5130);
assert.equal(quote.changeRate, -2.47);
assert.equal(quote.stockName, '삼천리자전거');
assert.equal(quote.quotedAt, '2026-04-30T05:13:05.000Z');
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { z } from 'zod';
import { db } from '../db/client.js';
const TEXT_MEMO_TABLE = 'text_memo_notes';
const MEMO_LAYOUT_NAME = '메모';
const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200;
const CLIENT_ID_MAX_LENGTH = 120;
@@ -284,3 +285,103 @@ export async function importTextMemoNotes(
return listTextMemoNotes(clientId);
}
export async function updateTextMemoLayoutFeatureDescription() {
const layoutRecord = await db('play_layouts').select('id', 'tree').where({ name: MEMO_LAYOUT_NAME }).first();
if (!layoutRecord || typeof layoutRecord !== 'object') {
return false;
}
const tree = layoutRecord.tree as
| {
root?: unknown;
interactions?: Array<Record<string, unknown>>;
interactionMode?: string;
}
| null;
if (!tree || typeof tree !== 'object') {
return false;
}
const nextInteractions = [
{
id: 'memo-sync-first-line',
sourceLeafId: 'layout-node-2',
targetLeafId: 'layout-node-1',
sourceComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
targetComponent: {
label: 'Base Input · Base',
optionId: 'component:input:input-base',
keywords: ['input', 'base'],
},
title: '메모 첫 줄 연결',
description: '선택한 메모와 작성 중 본문의 첫 줄을 Primary Pane Base Input 값으로 동기화합니다.',
implementationNotes:
'메모 선택, 본문 입력, 저장 완료 시 첫 줄을 추출해 Base Input에 반영합니다. 빈 본문이면 빈 문자열을 유지합니다.',
},
{
id: 'memo-title-commit',
sourceLeafId: 'layout-node-1',
targetLeafId: 'layout-node-2',
sourceComponent: {
label: 'Base Input · Base',
optionId: 'component:input:input-base',
keywords: ['input', 'base'],
},
targetComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
title: '제목 확정 반영',
description: 'Primary Pane Base Input에서 Enter 또는 blur로 확정한 값을 메모 첫 줄로 반영합니다.',
implementationNotes:
'InputUI 확정 이벤트를 사용해 현재 편집 중 본문의 첫 줄만 교체하고 나머지 줄바꿈/본문은 유지합니다.',
},
{
id: 'memo-crud-api',
sourceLeafId: 'layout-node-2',
targetLeafId: 'layout-node-2',
sourceComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
targetComponent: {
label: 'Text Memo Widget',
optionId: 'widget:text-memo-widget:text-memo-widget',
keywords: ['text-memo-widget', 'memo'],
},
title: '메모 저장/조회 API',
description: '메모 목록 조회와 저장, 수정, 삭제를 text-memo API로 처리합니다.',
implementationNotes:
'GET /api/text-memo/notes, POST /api/text-memo/notes, PUT /api/text-memo/notes/:noteId, DELETE /api/text-memo/notes/:noteId 를 사용합니다. clientId 헤더 기준으로 사용자별 최근 12개 메모를 유지합니다.',
},
];
const previousInteractions = Array.isArray(tree.interactions) ? JSON.stringify(tree.interactions) : '';
const nextInteractionsJson = JSON.stringify(nextInteractions);
const nextInteractionMode = 'scoped-v2';
if (previousInteractions === nextInteractionsJson && tree.interactionMode === nextInteractionMode) {
return false;
}
await db('play_layouts')
.where({ id: layoutRecord.id as string })
.update({
tree: {
...tree,
interactions: nextInteractions,
interactionMode: nextInteractionMode,
},
});
return true;
}

View File

@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { summarizeFailureOutput } from './plan-worker.js';
test('summarizeFailureOutput ignores structured zero-failure diff lines', () => {
const output = [
'```diff',
'+ failedCount: 0,',
'+ webFailed=0',
'+ iosFailed=0,',
'```',
'actual failure: service file missing',
].join('\n');
assert.equal(summarizeFailureOutput(output, 'fallback'), 'actual failure: service file missing');
});
test('summarizeFailureOutput falls back when output only contains zero-failure noise', () => {
const output = [
'```diff',
'+ failedCount: 0,',
'+ webFailed=0',
'+ failedCount: number;',
'```',
].join('\n');
assert.equal(summarizeFailureOutput(output, 'fallback failure'), 'fallback failure');
});
test('summarizeFailureOutput ignores managed service success stub lines', () => {
const output = [
"return Promise.resolve({ ok: true, skipped: false, title: definition.title, body: '', itemCount: 0, lines: [], ios: { ok: true, skipped: true, sentCount: 0, failedCount: 0 }, web: { ok: true, skipped: true, sentCount: 0, failedCount: 0 }, definition });",
].join('\n');
assert.equal(summarizeFailureOutput(output, 'fallback success'), 'fallback success');
});

View File

@@ -21,6 +21,7 @@ import {
claimNextPlanForMerge,
claimNextPlanForMainMerge,
formatPlanNotificationLabel,
getPlanItemById,
isPlanLockedByWorker,
mapPlanRow,
markPlanAsCompleted,
@@ -50,9 +51,11 @@ const REPEATED_PROGRESS_NOTIFICATION_MS = 180_000;
const STARTED_REQUEST_SUMMARY_LIMIT = 72;
const ERROR_SUMMARY_MAX_LENGTH = 500;
const ERROR_SUMMARY_LINE_PATTERN =
/(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
/(ERROR:|\bfailed\b|\bfailure\b|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i;
const ERROR_SUMMARY_NOISE_PATTERN =
/^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
/^(exec|codex|tokens used|succeeded in \d+ms:?|```|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i;
const ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN =
/^(?:(?:[+\-]\s*)?(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=)\s*(?:\[\s*\]|0)\s*[,;]?|[+\-]\s*(?:"(?:failed|failedCount|iosFailed|webFailed)"|'(?:failed|failedCount|iosFailed|webFailed)'|(?:failed|failedCount|iosFailed|webFailed))\s*(?::|=).+|return\s+Promise\.resolve\(\{\s*ok:\s*true,\s*skipped:\s*(?:true|false),[\s\S]*failedCount:\s*0[\s\S]*\}\);?)$/i;
const PLAN_CODEX_RUNNER_MAX_ATTEMPTS = 2;
const PLAN_CODEX_RUNNER_RETRY_DELAY_MS = 5000;
const PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN =
@@ -133,12 +136,13 @@ function normalizeErrorSummaryLine(line: string) {
.trim();
}
function summarizeFailureOutput(output: string, fallback: string) {
export function summarizeFailureOutput(output: string, fallback: string) {
const normalizedLines = output
.split('\n')
.map((line) => normalizeErrorSummaryLine(line))
.filter(Boolean)
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line));
.filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line))
.filter((line) => !ERROR_SUMMARY_STRUCTURED_NOISE_PATTERN.test(line));
const bestLine =
normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ??
@@ -288,11 +292,16 @@ export class PlanWorker {
return;
}
const item = await getPlanItemById(planId);
const disableWebPush = Boolean(item?.suppressWebPush);
const result = await sendNotifications({
title,
body,
threadId: `plan-${planId}`,
data: buildPlanNotificationData(planId, workId, eventType),
}, {
disableWebPush,
});
this.logger.info(
@@ -300,6 +309,7 @@ export class PlanWorker {
planId,
workId,
eventType,
disableWebPush,
ios: {
skipped: result.ios.skipped,
sentCount: result.ios.sentCount,