diff --git a/.gitignore b/.gitignore index 5f6dfe0..9871cb5 100755 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ playwright-report/ test-results/ .cache/ tmp/ +.tmp-*/ node_modules.root-owned-backup/ .env diff --git a/docs/README.md b/docs/README.md index 7130756..abc47b6 100755 --- a/docs/README.md +++ b/docs/README.md @@ -100,6 +100,9 @@ src/components - 위치: `src/features/layout` - 대상: 현재 프로젝트 화면에서만 사용하는 레이아웃 - 예시: 컴포넌트 샘플 목록, 위젯 샘플 목록, Docs markdown preview, Plan 게시판 +- `Layout Editor`의 기능 명세는 위젯 기본 스펙 정의가 아니라, 현재 레이아웃에 배치된 컴포넌트의 화면 역할과 상호작용을 기술하는 데이터로 취급 +- 따라서 컴포넌트 간 이벤트/상태 연결과 API 연결 흐름도 `Layout Editor` 기능 명세에서 구현 가능한 범위로 본다 +- 따라서 샘플 메타나 위젯 기본 기능을 `Layout Editor` 기능 명세에 자동 주입하지 않음 프로젝트 종속 기능 규칙: @@ -135,6 +138,13 @@ src/components - alpha 버전 배포는 `npm publish --tag alpha` - Nexus 인증은 `~/.npmrc`의 `username / _password(base64) / email` 방식으로 확인 +## 8-1. 웹푸쉬 작업 메모 + +- 동일한 웹푸쉬를 새 알림으로 교체하려면 DB에서 이전 알림을 지우지 말고 `POST /api/notifications/send` 호출 시 `data.notificationKey` 또는 `threadId`를 고정값으로 보냅니다. +- 서비스워커는 같은 `notificationKey`를 `tag`로 사용하므로 같은 브라우저의 이전 알림이 자동으로 대체됩니다. +- 특정 브라우저 클라이언트에만 보내려면 같은 API payload에 `targetClientIds: ['클라이언트ID']`를 넣습니다. +- 대상 클라이언트 ID가 필요하면 `web_push_subscriptions.device_id`를 조회하고, raw SQL 대신 `/api/crud/web_push_subscriptions/select` 같은 기존 CRUD API를 우선 사용합니다. + ## 9. etc 운영 기준 - 부가 서버/DB/타언어 프로젝트는 `etc` 아래에서 분리 관리 diff --git a/docs/features/plan-schedule.md b/docs/features/plan-schedule.md index 43b1b6d..676cffb 100755 --- a/docs/features/plan-schedule.md +++ b/docs/features/plan-schedule.md @@ -22,16 +22,22 @@ ## 입력 항목 - `workId`: 반복 등록할 작업 ID + - 스케줄이 실제 자동화 접수로 Plan을 만들 때 이 값을 베이스 ID로 사용합니다. + - 생성되는 Plan 작업 ID는 `workId-1`부터 `workId-999` 범위의 suffix를 붙여 유니크하게 관리합니다. - `note`: 매번 생성될 요청 메모 - `automationType`: 자동화 유형 - - `plan`: Markdown 스타일 Plan 문서 등록/접수 - - `auto_worker`: 실제 자동 작업 실행 - - `command_execution`, `non_source_work`: 기존 분류 유지 + - 자동화 유형 관리를 통해 등록된 항목을 그대로 선택합니다. + - 스케줄 실행 시 선택한 자동화 유형 ID를 유지한 채 자동화 작업메모를 등록하고 즉시 접수합니다. - `releaseTarget`: 반영 대상 브랜치 - `jangsingProcessingRequired`: 기능동작확인 필요 여부 - `autoDeployToMain`: main 자동 반영 대상 여부 - `enabled`: 스케줄 사용 여부 - `immediateRunEnabled`: 생성 직후 바로 등록 허용 여부 +- `refreshContextSnapshotOnNextRun`: 다음 자동 실행 1회에 한해 프로젝트 구조/관련 소스를 다시 읽고 `.auto_codex/schedule//` 아래 Markdown 문서를 재정리할지 여부 +- `executionMode`: `codex` 또는 `managed-service` + - `managed-service`를 선택하면 스케줄 PK가 포함된 `.auto_codex/schedule//managed-service/...` 경로에 서비스 패키지 번들을 생성해 구분합니다. + - 스케줄 삭제 시 해당 디렉터리도 함께 삭제합니다. +- `recreateManagedServiceOnNextSave`: 관리형 서비스 패키지를 저장 시 다시 생성할지 여부 ## 스케줄 모드 @@ -75,6 +81,8 @@ - 자주 반복되는 운영 작업은 고정 `workId`로 등록 - 사람이 검토해야 하는 작업은 `autoDeployToMain`을 끄고 release 검수 단계에서 확인 - 단순 알림성/반복성 작업은 `immediateRunEnabled`를 켜서 누락 없이 시작 +- 기존 스케줄 참조 문서를 다시 만들고 싶으면 `refreshContextSnapshotOnNextRun`을 켠 뒤 저장하면 다음 실행 1회 후 자동 해제 +- 외부 프로그램으로 확장할 예정인 작업은 `managed-service`로 분리해 두면 스케줄 PK 기준 서비스 키와 패키지 경로를 고정할 수 있음 - 짧은 주기 스케줄은 10분 이상으로 유지해 중복 생성 위험을 낮춤 ## API 경로 메모 diff --git a/etc/commands/server-command/restart-work-server.sh b/etc/commands/server-command/restart-work-server.sh index 03878fe..7179c89 100755 --- a/etc/commands/server-command/restart-work-server.sh +++ b/etc/commands/server-command/restart-work-server.sh @@ -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 diff --git a/etc/servers/work-server/README.md b/etc/servers/work-server/README.md index ddedea6..18aa931 100644 --- a/etc/servers/work-server/README.md +++ b/etc/servers/work-server/README.md @@ -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`로 사용해 이전 알림을 대체합니다. diff --git a/etc/servers/work-server/src/app.ts b/etc/servers/work-server/src/app.ts index 2ba02f6..8832f15 100755 --- a/etc/servers/work-server/src/app.ts +++ b/etc/servers/work-server/src/app.ts @@ -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); diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts index f0e76dc..bca22b2 100755 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -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 ?? {}; diff --git a/etc/servers/work-server/src/routes/notification.ts b/etc/servers/work-server/src/routes/notification.ts index f5fc304..13eaf60 100755 --- a/etc/servers/work-server/src/routes/notification.ts +++ b/etc/servers/work-server/src/routes/notification.ts @@ -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) => { diff --git a/etc/servers/work-server/src/routes/plan.ts b/etc/servers/work-server/src/routes/plan.ts index 53b09bf..c2725b6 100755 --- a/etc/servers/work-server/src/routes/plan.ts +++ b/etc/servers/work-server/src/routes/plan.ts @@ -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 ?? [], }; diff --git a/etc/servers/work-server/src/routes/stock-alert.ts b/etc/servers/work-server/src/routes/stock-alert.ts new file mode 100644 index 0000000..06a188e --- /dev/null +++ b/etc/servers/work-server/src/routes/stock-alert.ts @@ -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, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/text-memo.ts b/etc/servers/work-server/src/routes/text-memo.ts index 2c338c9..0cf099a 100644 --- a/etc/servers/work-server/src/routes/text-memo.ts +++ b/etc/servers/work-server/src/routes/text-memo.ts @@ -8,6 +8,7 @@ import { textMemoNoteCreateSchema, textMemoNoteImportSchema, textMemoNoteUpdateSchema, + updateTextMemoLayoutFeatureDescription, updateTextMemoNote, } from '../services/text-memo-service.js'; @@ -17,6 +18,7 @@ function resolveClientId(headers: Record) { 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, }; diff --git a/etc/servers/work-server/src/services/app-config-service.test.ts b/etc/servers/work-server/src/services/app-config-service.test.ts index dc6d20e..3f3504a 100644 --- a/etc/servers/work-server/src/services/app-config-service.test.ts +++ b/etc/servers/work-server/src/services/app-config-service.test.ts @@ -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')); }); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index 61d0700..6b1785f 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -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); } } diff --git a/etc/servers/work-server/src/services/automation-context-config-service.test.ts b/etc/servers/work-server/src/services/automation-context-config-service.test.ts new file mode 100644 index 0000000..180c9b8 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-context-config-service.test.ts @@ -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'], + ); +}); diff --git a/etc/servers/work-server/src/services/automation-context-config-service.ts b/etc/servers/work-server/src/services/automation-context-config-service.ts new file mode 100644 index 0000000..7c0faa3 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-context-config-service.ts @@ -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 | 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[] | null | undefined) { + const byId = new Map(); + const bySemanticKey = new Map(); + + (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) { + 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[] = [...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).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)) + .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[]) : []), + ); + + 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)); +} diff --git a/etc/servers/work-server/src/services/automation-context-service.ts b/etc/servers/work-server/src/services/automation-context-service.ts new file mode 100644 index 0000000..dd5f567 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-context-service.ts @@ -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> | 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 | 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`, + }; +} diff --git a/etc/servers/work-server/src/services/automation-type-config-service.test.ts b/etc/servers/work-server/src/services/automation-type-config-service.test.ts new file mode 100644 index 0000000..940c4b9 --- /dev/null +++ b/etc/servers/work-server/src/services/automation-type-config-service.test.ts @@ -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'); +}); diff --git a/etc/servers/work-server/src/services/automation-type-config-service.ts b/etc/servers/work-server/src/services/automation-type-config-service.ts index 2e394f9..b41e4be 100644 --- a/etc/servers/work-server/src/services/automation-type-config-service.ts +++ b/etc/servers/work-server/src/services/automation-type-config-service.ts @@ -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 | 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[] | null | undefined) { + const byId = new Map(); + const bySemanticKey = new Map(); + + (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 | null { const name = normalizeText(record.name); @@ -205,13 +281,13 @@ function normalizeAutomationType(record: Partial): 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[] | 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; @@ -273,11 +428,27 @@ function normalizeConfigRecord(value: unknown) { return value as Record; } +function parseContextsFromRow(row: Record) { + 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) { 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) { 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 | 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)); } diff --git a/etc/servers/work-server/src/services/board-service.test.ts b/etc/servers/work-server/src/services/board-service.test.ts index 5b7fe80..f8b1167 100644 --- a/etc/servers/work-server/src/services/board-service.test.ts +++ b/etc/servers/work-server/src/services/board-service.test.ts @@ -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')); +}); diff --git a/etc/servers/work-server/src/services/board-service.ts b/etc/servers/work-server/src/services/board-service.ts index fa13ecd..153f84a 100755 --- a/etc/servers/work-server/src/services/board-service.ts +++ b/etc/servers/work-server/src/services/board-service.ts @@ -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['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): 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['attachments'] = [], - automationType?: Pick | null, + automationType?: Pick | 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, + 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 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)\]]+)\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(); + 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; + 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 []; +} diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index fd05372..0965dbe 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -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()).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): 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'); diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 446cfde..347003b 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -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}`); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index e981ade..61cf053 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -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), }; diff --git a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts index cdc304d..0daddf2 100755 --- a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts +++ b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts @@ -439,6 +439,7 @@ export async function registerErrorLogBoardPosts(args?: { content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd), attachments: [], automationType: 'none', + automationContextIds: [], }); createdPosts.push({ diff --git a/etc/servers/work-server/src/services/managed-schedule-service.test.ts b/etc/servers/work-server/src/services/managed-schedule-service.test.ts new file mode 100644 index 0000000..a8aaa70 --- /dev/null +++ b/etc/servers/work-server/src/services/managed-schedule-service.test.ts @@ -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'); +}); diff --git a/etc/servers/work-server/src/services/managed-schedule-service.ts b/etc/servers/work-server/src/services/managed-schedule-service.ts new file mode 100644 index 0000000..794900b --- /dev/null +++ b/etc/servers/work-server/src/services/managed-schedule-service.ts @@ -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; +} + +function trimmedRelativeDirectory(relativeDirectory: string) { + return String(relativeDirectory ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); +} diff --git a/etc/servers/work-server/src/services/notification-service.test.ts b/etc/servers/work-server/src/services/notification-service.test.ts new file mode 100644 index 0000000..16fd673 --- /dev/null +++ b/etc/servers/work-server/src/services/notification-service.test.ts @@ -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, + }); +}); diff --git a/etc/servers/work-server/src/services/notification-service.ts b/etc/servers/work-server/src/services/notification-service.ts index 6d074a0..addf952 100755 --- a/etc/servers/work-server/src/services/notification-service.ts +++ b/etc/servers/work-server/src/services/notification-service.ts @@ -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; @@ -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) { 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, ) { 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(); diff --git a/etc/servers/work-server/src/services/plan-notification-service.ts b/etc/servers/work-server/src/services/plan-notification-service.ts index dbddba4..7f78421 100755 --- a/etc/servers/work-server/src/services/plan-notification-service.ts +++ b/etc/servers/work-server/src/services/plan-notification-service.ts @@ -110,5 +110,7 @@ export async function notifyPlanEvent( body, threadId: `plan-${planId}`, data: buildPlanNotificationData(planId, String(item.workId), eventType), + }, { + disableWebPush: Boolean(item.suppressWebPush), }); } diff --git a/etc/servers/work-server/src/services/plan-schedule-service.test.ts b/etc/servers/work-server/src/services/plan-schedule-service.test.ts new file mode 100644 index 0000000..f5d7399 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-schedule-service.test.ts @@ -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); +}); diff --git a/etc/servers/work-server/src/services/plan-schedule-service.ts b/etc/servers/work-server/src/services/plan-schedule-service.ts index 6139063..c5c407b 100755 --- a/etc/servers/work-server/src/services/plan-schedule-service.ts +++ b/etc/servers/work-server/src/services/plan-schedule-service.ts @@ -1,39 +1,85 @@ import { z } from 'zod'; import { db } from '../db/client.js'; import { resolveAutomationType, resolveStoredAutomationTypeId } from './automation-type-config-service.js'; +import { createBoardPost, receiveBoardPostAutomation } from './board-service.js'; +import { + buildManagedScheduleServiceMetadata, + hasManagedScheduleServicePackage, + prepareManagedScheduleServiceDirectory, + removeManagedScheduleServiceArtifacts, + runManagedScheduleService, +} from './managed-schedule-service.js'; +import { + ensureSchedulePromptSnapshot, + parseAutomationContextIds, + stringifyAutomationContextIds, +} from './automation-context-service.js'; import { createCompletedPlanExecutionLogItem, + createPlanSourceWorkHistory, createPlanActionHistory, - createPlanItem, ensurePlanTable, - normalizePlanAutomationType, planAutomationTypeSchema, + PLAN_TABLE, } from './plan-service.js'; import { getKstNowParts } from './worklog-automation-utils.js'; -import { registerErrorLogBoardPosts } from './error-log-plan-registration-service.js'; export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks'; const scheduleModes = ['interval', 'daily'] as const; -const repeatIntervalUnits = ['minute', 'hour', 'day', 'week', 'month'] as const; +const repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month'] as const; +const scheduleExecutionModes = ['codex', 'managed-service'] as const; const DEFAULT_DAILY_RUN_TIME = '09:00'; +const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; +const MAX_REPEAT_INTERVAL_SECONDS = 31_536_000; +const DEFAULT_REPEAT_INTERVAL_SECONDS = 60 * 60; export const createPlanScheduledTaskSchema = z.object({ workId: z.string().trim().optional().default('반복작업'), note: z.string().default(''), automationType: z.preprocess((value) => value, planAutomationTypeSchema.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), enabled: z.boolean().default(true), immediateRunEnabled: z.boolean().default(true), + refreshContextSnapshotOnNextRun: z.boolean().default(false), + executionMode: z.enum(scheduleExecutionModes).default('codex'), + recreateManagedServiceOnNextSave: z.boolean().default(false), scheduleMode: z.enum(scheduleModes).default('interval'), - repeatIntervalValue: z.coerce.number().int().min(1).max(525600).default(60), + repeatIntervalValue: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).default(60), repeatIntervalUnit: z.enum(repeatIntervalUnits).default('minute'), repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), + repeatIntervalSeconds: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), dailyRunTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME), + repeatWindowStartTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), + repeatWindowEndTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional().default(null), }); -export const updatePlanScheduledTaskSchema = createPlanScheduledTaskSchema.partial(); +export const updatePlanScheduledTaskSchema = z.object({ + workId: z.string().trim().optional(), + note: z.string().optional(), + automationType: z.preprocess((value) => value, planAutomationTypeSchema.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(), + enabled: z.boolean().optional(), + immediateRunEnabled: z.boolean().optional(), + refreshContextSnapshotOnNextRun: z.boolean().optional(), + executionMode: z.enum(scheduleExecutionModes).optional(), + recreateManagedServiceOnNextSave: z.boolean().optional(), + scheduleMode: z.enum(scheduleModes).optional(), + repeatIntervalValue: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), + repeatIntervalUnit: z.enum(repeatIntervalUnits).optional(), + repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), + repeatIntervalSeconds: z.coerce.number().int().min(1).max(MAX_REPEAT_INTERVAL_SECONDS).optional(), + dailyRunTime: z.string().regex(TIME_OF_DAY_PATTERN).optional(), + repeatWindowStartTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), + repeatWindowEndTime: z.string().regex(TIME_OF_DAY_PATTERN).nullable().optional(), +}); function normalizeScheduledWorkId(value?: string | null) { const workId = String(value ?? '').trim(); @@ -63,7 +109,17 @@ function normalizeRepeatIntervalValue(value: unknown) { return 60; } - return Math.min(525600, Math.max(1, Math.round(numericValue))); + return Math.min(MAX_REPEAT_INTERVAL_SECONDS, Math.max(1, Math.round(numericValue))); +} + +function normalizeRepeatIntervalSeconds(value: unknown) { + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + return DEFAULT_REPEAT_INTERVAL_SECONDS; + } + + return Math.min(MAX_REPEAT_INTERVAL_SECONDS, Math.max(1, Math.round(numericValue))); } function normalizeRepeatIntervalUnit(value: unknown): (typeof repeatIntervalUnits)[number] { @@ -79,32 +135,86 @@ function normalizeScheduleMode(value: unknown): (typeof scheduleModes)[number] { } function normalizeDailyRunTime(value: unknown) { - return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) + return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : DEFAULT_DAILY_RUN_TIME; } +function normalizeOptionalTimeOfDay(value: unknown) { + return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null; +} + +function toMinutesOfDay(value: string) { + const [hours, minutes] = value.split(':').map((part) => Number(part)); + return hours * 60 + minutes; +} + +function buildKstDateTime(dateKey: string, timeOfDay: string) { + return new Date(`${dateKey}T${timeOfDay}:00+09:00`); +} + +function shiftKstDateKey(dateKey: string, offsetDays: number) { + const baseDate = buildKstDateTime(dateKey, '00:00'); + + if (Number.isNaN(baseDate.getTime())) { + return dateKey; + } + + baseDate.setUTCDate(baseDate.getUTCDate() + offsetDays); + return getKstDateKey(baseDate) ?? dateKey; +} + +function normalizeScheduleExecutionMode(value: unknown): (typeof scheduleExecutionModes)[number] { + return scheduleExecutionModes.includes(value as (typeof scheduleExecutionModes)[number]) + ? (value as (typeof scheduleExecutionModes)[number]) + : 'codex'; +} + function toRepeatIntervalMinutes(value: unknown, unit: unknown) { + return Math.max(1, Math.ceil(toRepeatIntervalSeconds(value, unit) / 60)); +} + +function toRepeatIntervalSeconds(value: unknown, unit: unknown) { const repeatIntervalValue = normalizeRepeatIntervalValue(value); const repeatIntervalUnit = normalizeRepeatIntervalUnit(unit); + if (repeatIntervalUnit === 'second') { + return repeatIntervalValue; + } + if (repeatIntervalUnit === 'day') { - return repeatIntervalValue * 24 * 60; + return repeatIntervalValue * 24 * 60 * 60; } if (repeatIntervalUnit === 'week') { - return repeatIntervalValue * 7 * 24 * 60; + return repeatIntervalValue * 7 * 24 * 60 * 60; } if (repeatIntervalUnit === 'month') { - return repeatIntervalValue * 30 * 24 * 60; + return repeatIntervalValue * 30 * 24 * 60 * 60; } if (repeatIntervalUnit === 'hour') { - return repeatIntervalValue * 60; + return repeatIntervalValue * 60 * 60; } - return repeatIntervalValue; + return repeatIntervalValue * 60; +} + +function resolveStoredRepeatIntervalValue(row: Record) { + return normalizeRepeatIntervalValue(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60); +} + +function resolveStoredRepeatIntervalUnit(row: Record) { + return normalizeRepeatIntervalUnit(row.repeat_interval_unit); +} + +function resolveStoredRepeatIntervalSeconds(row: Record) { + return toRepeatIntervalSeconds(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row)); +} + +function resolveStoredRepeatIntervalMinutes(row: Record) { + return toRepeatIntervalMinutes(resolveStoredRepeatIntervalValue(row), resolveStoredRepeatIntervalUnit(row)); } function normalizeBoolean(value: unknown, fallback: boolean) { @@ -123,16 +233,59 @@ function normalizeBoolean(value: unknown, fallback: boolean) { return fallback; } -function formatScheduleWorkId(workId: string, date: Date) { - const timestamp = [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, '0'), - String(date.getDate()).padStart(2, '0'), - String(date.getHours()).padStart(2, '0'), - String(date.getMinutes()).padStart(2, '0'), - ].join(''); +function buildManagedServiceFailureSummary(result: { + title?: string; + skipped?: boolean; + itemCount?: number; + ios?: { ok?: boolean; skipped?: boolean; reason?: string; sentCount?: number; failedCount?: number }; + web?: { ok?: boolean; skipped?: boolean; reason?: string; sentCount?: number; failedCount?: number }; +}) { + const summaryParts = [ + result.title ? `title=${result.title}` : null, + `itemCount=${Number(result.itemCount ?? 0)}`, + `skipped=${result.skipped ? 'true' : 'false'}`, + `webOk=${result.web?.ok ? 'true' : 'false'}`, + `webSkipped=${result.web?.skipped ? 'true' : 'false'}`, + `webSent=${Number(result.web?.sentCount ?? 0)}`, + `webFailed=${Number(result.web?.failedCount ?? 0)}`, + result.web?.reason ? `webReason=${result.web.reason}` : null, + `iosOk=${result.ios?.ok ? 'true' : 'false'}`, + `iosSkipped=${result.ios?.skipped ? 'true' : 'false'}`, + result.ios?.reason ? `iosReason=${result.ios.reason}` : null, + ].filter((value): value is string => Boolean(value)); - return `${normalizeScheduledWorkId(workId)}-${timestamp}`; + return summaryParts.join(', '); +} + +export function buildScheduledBoardPostTitle(row: Record) { + const workId = normalizeScheduledWorkId(String(row.work_id ?? '반복작업')); + const note = String(row.note ?? '') + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + const title = note || workId; + + return title.length > 200 ? `${title.slice(0, 197).trimEnd()}...` : title; +} + +export function buildScheduledPlanWorkIdBase(row: Record) { + const workId = normalizeScheduledWorkId(String(row.work_id ?? '반복작업')); + + if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { + return workId; + } + + const scheduleId = Number(row.id); + + if (!Number.isInteger(scheduleId) || scheduleId <= 0) { + return workId; + } + + return normalizeScheduledWorkId(`schedule-${scheduleId}-${workId}`); +} + +export function shouldCreatePlanForScheduleExecution(row: Record) { + return normalizeScheduleExecutionMode(row.execution_mode) === 'managed-service'; } function getKstDateKey(value: unknown) { @@ -158,6 +311,32 @@ function isDailyScheduleDue(row: Record, now: Date) { function isIntervalScheduleDue(row: Record, now: Date) { const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null; + const repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); + + if (!lastRegisteredAt || Number.isNaN(lastRegisteredAt.getTime())) { + const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); + + if (startTime) { + const nowParts = getKstNowParts(now); + const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); + const startMinutesOfDay = toMinutesOfDay(startTime); + const endMinutesOfDay = endTime ? toMinutesOfDay(endTime) : null; + const anchorDateKey = + endMinutesOfDay !== null && startMinutesOfDay > endMinutesOfDay && nowParts.minutesOfDay <= endMinutesOfDay + ? shiftKstDateKey(nowParts.dateKey, -1) + : nowParts.dateKey; + const startAt = buildKstDateTime(anchorDateKey, startTime); + + if (!Number.isNaN(startAt.getTime())) { + if (normalizeBoolean(row.immediate_run_enabled, true)) { + return now.getTime() >= startAt.getTime(); + } + + return now.getTime() >= startAt.getTime() + repeatIntervalSeconds * 1000; + } + } + } + const intervalBaseAt = lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime()) ? lastRegisteredAt : new Date(String(row.created_at ?? now.toISOString())); @@ -166,46 +345,336 @@ function isIntervalScheduleDue(row: Record, now: Date) { return true; } - const repeatIntervalMinutes = normalizeRepeatIntervalMinutes( - row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit), - ); - return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalMinutes * 60 * 1000; + return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalSeconds * 1000; +} + +function isWithinRepeatWindow(row: Record, now: Date) { + const startTime = normalizeOptionalTimeOfDay(row.repeat_window_start_time); + const endTime = normalizeOptionalTimeOfDay(row.repeat_window_end_time); + + if (!startTime && !endTime) { + return true; + } + + const nowMinutesOfDay = getKstNowParts(now).minutesOfDay; + const startMinutesOfDay = startTime ? toMinutesOfDay(startTime) : null; + const endMinutesOfDay = endTime ? toMinutesOfDay(endTime) : null; + + if (startMinutesOfDay !== null && endMinutesOfDay !== null) { + if (startMinutesOfDay <= endMinutesOfDay) { + return nowMinutesOfDay >= startMinutesOfDay && nowMinutesOfDay <= endMinutesOfDay; + } + + return nowMinutesOfDay >= startMinutesOfDay || nowMinutesOfDay <= endMinutesOfDay; + } + + if (startMinutesOfDay !== null) { + return nowMinutesOfDay >= startMinutesOfDay; + } + + return nowMinutesOfDay <= (endMinutesOfDay ?? nowMinutesOfDay); } function isScheduleDue(row: Record, now: Date) { + const scheduleMode = normalizeScheduleMode(row.schedule_mode); + + if (scheduleMode === 'interval' && !isWithinRepeatWindow(row, now)) { + return false; + } + if (!row.last_registered_at && normalizeBoolean(row.immediate_run_enabled, true)) { return true; } - return normalizeScheduleMode(row.schedule_mode) === 'daily' - ? isDailyScheduleDue(row, now) - : isIntervalScheduleDue(row, now); + return scheduleMode === 'daily' ? isDailyScheduleDue(row, now) : isIntervalScheduleDue(row, now); +} + +export function isPlanScheduledTaskDue(row: Record, now = new Date()) { + return isScheduleDue(row, now); } export function mapPlanScheduledTaskRow(row: Record) { + const repeatIntervalValue = resolveStoredRepeatIntervalValue(row); + const repeatIntervalUnit = resolveStoredRepeatIntervalUnit(row); + return { id: row.id, workId: row.work_id, note: row.note, automationType: resolveStoredAutomationTypeId(row), + automationContextIds: parseAutomationContextIds(row.automation_context_ids_json), releaseTarget: row.release_target, jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), + suppressWebPush: Boolean(row.suppress_web_push ?? false), enabled: Boolean(row.enabled ?? true), immediateRunEnabled: normalizeBoolean(row.immediate_run_enabled, true), + refreshContextSnapshotOnNextRun: normalizeBoolean(row.context_snapshot_refresh_requested, false), + executionMode: normalizeScheduleExecutionMode(row.execution_mode), + managedServiceKey: typeof row.managed_service_key === 'string' ? row.managed_service_key : null, + managedServicePackageName: typeof row.managed_service_package_name === 'string' ? row.managed_service_package_name : null, + managedServiceDirectory: typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null, + managedServiceManifestPath: typeof row.managed_service_manifest_path === 'string' ? row.managed_service_manifest_path : null, + managedServiceGeneratedAt: row.managed_service_generated_at ?? null, + managedServiceGenerationPlanItemId: + row.managed_service_generation_plan_item_id === null || row.managed_service_generation_plan_item_id === undefined + ? null + : Number(row.managed_service_generation_plan_item_id), + managedServiceGenerationBoardPostId: + row.managed_service_generation_board_post_id === null || row.managed_service_generation_board_post_id === undefined + ? null + : Number(row.managed_service_generation_board_post_id), + recreateManagedServiceOnNextSave: normalizeBoolean(row.managed_service_recreate_requested, false), scheduleMode: normalizeScheduleMode(row.schedule_mode), - repeatIntervalValue: Number(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60), - repeatIntervalUnit: normalizeRepeatIntervalUnit(row.repeat_interval_unit), - repeatIntervalMinutes: normalizeRepeatIntervalMinutes( - row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value ?? 60, row.repeat_interval_unit), - ), + repeatIntervalValue, + repeatIntervalUnit, + repeatIntervalSeconds: resolveStoredRepeatIntervalSeconds(row), + repeatIntervalMinutes: resolveStoredRepeatIntervalMinutes(row), dailyRunTime: normalizeDailyRunTime(row.daily_run_time), + repeatWindowStartTime: normalizeOptionalTimeOfDay(row.repeat_window_start_time), + repeatWindowEndTime: normalizeOptionalTimeOfDay(row.repeat_window_end_time), lastRegisteredAt: row.last_registered_at, createdAt: row.created_at, updatedAt: row.updated_at, }; } +async function removeManagedServicePackage(scheduleId: number) { + await removeManagedScheduleServiceArtifacts(scheduleId); +} + +async function ensureManagedServicePackage(options: { + scheduleId: number; + workId: string; + note: string; + releaseTarget: string; + automationType: string; +}) { + const metadata = buildManagedScheduleServiceMetadata(options.scheduleId, options.workId); + await prepareManagedScheduleServiceDirectory(options.scheduleId); + return metadata; +} + +function buildScheduledManagedServicePlanWorkIdBase(row: Record) { + return buildScheduledPlanWorkIdBase(row); +} + +function isManagedServiceGenerationPlanPending(row: Record | null | undefined) { + const status = String(row?.status ?? '').trim(); + const workerStatus = String(row?.worker_status ?? '').trim(); + + if (!status) { + return false; + } + + if (['완료', '릴리즈완료', '작업완료'].includes(status)) { + return false; + } + + if (['작업취소', '브랜치실패', '자동작업실패', 'release반영실패', 'main반영실패'].includes(workerStatus)) { + return false; + } + + return true; +} + +function buildManagedServiceGenerationPlanNote(options: { + row: Record; + scheduleSnapshot: { + requestPath: string; + contextPath: string; + manifestPath: string; + }; + reason: 'requested' | 'missing'; +}) { + const scheduleId = Number(options.row.id); + const metadata = buildManagedScheduleServiceMetadata(scheduleId, String(options.row.work_id ?? '반복작업')); + const requestedReason = options.reason === 'requested' ? '사용자가 패키지 재처리를 요청함' : '실행 대상 서비스 패키지가 누락됨'; + + return [ + '## 스케줄 서비스 패키지 생성 지시', + `- 대상 스케줄 PK: ${scheduleId}`, + `- 작업 ID base: ${buildScheduledManagedServicePlanWorkIdBase(options.row)}`, + `- 생성 사유: ${requestedReason}`, + `- 패키지 루트: ${metadata.relativeDirectory}`, + `- 서비스 키: ${metadata.serviceKey}`, + `- 패키지명: ${metadata.packageName}`, + '', + '반드시 아래 파일을 위 패키지 루트에 직접 생성하거나 갱신하세요.', + `- ${metadata.readmePath}`, + `- ${metadata.sourcePath}`, + `- ${metadata.runtimePath}`, + `- ${metadata.manifestPath}`, + '', + '생성 규칙:', + '- service.ts 와 service.mjs 는 둘 다 유지합니다.', + '- service.mjs 는 스케줄 실행 시 직접 import 되어 `run(runtime)` 를 호출합니다.', + '- README.md 에 서비스 목적, 실행 방식, 의존 경로, 검증 방법을 적습니다.', + '- service-manifest.json 에 schedulePk, workId, serviceKey, packageName, relativeDirectory, sourcePath, runtimePath, readmePath, createdAt 를 기록합니다.', + '- managed-service 같은 하위 임시 디렉터리를 다시 만들지 말고, 위 루트 경로만 사용합니다.', + '- 실제 서비스 로직 요구사항은 아래 request/context 문서를 읽고 구현하세요. 본 자동화 메모 안에 원문 요구사항을 다시 복사하지 마세요.', + '- generic placeholder 같은 빈 구현으로 남기지 마세요.', + '', + '참고 문서:', + `- 요청 정리: ${options.scheduleSnapshot.requestPath}`, + `- 컨텍스트 정리: ${options.scheduleSnapshot.contextPath}`, + `- 스냅샷 manifest: ${options.scheduleSnapshot.manifestPath}`, + '', + '검증 기준:', + '- 생성 직후 위 네 파일이 모두 존재해야 합니다.', + '- service.mjs 가 현재 저장소 코드 기준으로 바로 실행 가능한 상태여야 합니다.', + ] + .filter(Boolean) + .join('\n') + .trim(); +} + +async function queueManagedServiceGenerationPlan(options: { + row: Record; + scheduleSnapshot: { + requestPath: string; + contextPath: string; + manifestPath: string; + }; + automationContextIds: string[]; + reason: 'requested' | 'missing'; +}) { + const boardPost = await createBoardPost({ + title: `[스케줄 서비스 생성] ${buildScheduledBoardPostTitle(options.row)}`, + content: buildManagedServiceGenerationPlanNote({ + row: options.row, + scheduleSnapshot: options.scheduleSnapshot, + reason: options.reason, + }), + attachments: [], + automationType: String(options.row.automation_type_id ?? options.row.automation_type ?? 'none'), + automationContextIds: options.automationContextIds, + }); + const automationReceipt = await receiveBoardPostAutomation(Number(boardPost.id), { + planWorkIdBase: buildScheduledManagedServicePlanWorkIdBase(options.row), + planWorkIdSuffixLabel: 'service', + suppressWebPush: Boolean(options.row.suppress_web_push ?? false), + }); + + if (!automationReceipt?.planItemId) { + throw new Error(`Plan 스케줄 #${options.row.id} 서비스 패키지 자동 접수에 실패했습니다.`); + } + + const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: options.row.id }) + .update({ + managed_service_generation_board_post_id: Number(boardPost.id), + managed_service_generation_plan_item_id: Number(automationReceipt.planItemId), + managed_service_recreate_requested: false, + updated_at: db.fn.now(), + }) + .returning('*'); + const createdPlan = await db(PLAN_TABLE).where({ id: automationReceipt.planItemId }).first(); + + return { + row: updatedRows[0] ?? options.row, + createdPlan: createdPlan ?? null, + createdBoardPost: boardPost, + }; +} + +async function ensureManagedServiceExecutionReady(options: { + row: Record; + scheduleSnapshot: { + requestPath: string; + contextPath: string; + manifestPath: string; + }; + automationContextIds: string[]; +}) { + const { row, scheduleSnapshot, automationContextIds } = options; + + if (normalizeScheduleExecutionMode(row.execution_mode) !== 'managed-service') { + return { + row, + ready: true, + generationTriggered: false, + reason: null as 'requested' | 'missing' | null, + createdPlan: null as Record | null, + createdBoardPosts: [] as Array>, + }; + } + + const recreateRequested = normalizeBoolean(row.managed_service_recreate_requested, false); + const packageExists = await hasManagedScheduleServicePackage( + typeof row.managed_service_directory === 'string' ? row.managed_service_directory : null, + ); + + if (packageExists && !recreateRequested) { + return { + row, + ready: true, + generationTriggered: false, + reason: null as 'requested' | 'missing' | null, + createdPlan: null as Record | null, + createdBoardPosts: [] as Array>, + }; + } + + const existingPlanId = Number(row.managed_service_generation_plan_item_id ?? 0); + const existingPlan = existingPlanId > 0 ? await db(PLAN_TABLE).where({ id: existingPlanId }).first() : null; + + if (packageExists && existingPlan && !recreateRequested) { + const updatedRows = await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + managed_service_recreate_requested: false, + managed_service_generated_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + return { + row: updatedRows[0] ?? row, + ready: true, + generationTriggered: false, + reason: null as 'requested' | 'missing' | null, + createdPlan: null as Record | null, + createdBoardPosts: [] as Array>, + }; + } + + if (existingPlan && isManagedServiceGenerationPlanPending(existingPlan)) { + return { + row, + ready: false, + generationTriggered: false, + reason: recreateRequested ? ('requested' as const) : ('missing' as const), + createdPlan: existingPlan, + createdBoardPosts: [], + }; + } + + await removeManagedServicePackage(Number(row.id)); + await ensureManagedServicePackage({ + scheduleId: Number(row.id), + workId: String(row.work_id ?? '반복작업'), + note: String(row.note ?? ''), + releaseTarget: String(row.release_target ?? 'release'), + automationType: String(row.automation_type_id ?? row.automation_type ?? 'none'), + }); + const queued = await queueManagedServiceGenerationPlan({ + row, + scheduleSnapshot, + automationContextIds, + reason: recreateRequested ? 'requested' : 'missing', + }); + + return { + row: queued.row, + ready: false, + generationTriggered: true, + reason: recreateRequested ? ('requested' as const) : ('missing' as const), + createdPlan: queued.createdPlan, + createdBoardPosts: [queued.createdBoardPost], + }; +} + async function ensurePlanScheduledTaskColumn( columnName: string, addColumn: (table: any) => void, @@ -231,17 +700,33 @@ export async function ensurePlanScheduledTaskTable() { table.text('note').notNullable().defaultTo(''); table.string('automation_type', 40).notNullable().defaultTo('none'); table.string('automation_type_id', 120).nullable(); + table.text('automation_context_ids_json').notNullable().defaultTo('[]'); table.string('release_target', 120).notNullable().defaultTo('release'); table.boolean('jangsing_processing_required').notNullable().defaultTo(true); table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + table.boolean('suppress_web_push').notNullable().defaultTo(false); table.boolean('enabled').notNullable().defaultTo(true); table.boolean('immediate_run_enabled').notNullable().defaultTo(true); + table.boolean('context_snapshot_refresh_requested').notNullable().defaultTo(false); + table.string('execution_mode', 40).notNullable().defaultTo('codex'); + table.string('managed_service_key', 160).nullable(); + table.string('managed_service_package_name', 200).nullable(); + table.text('managed_service_directory').nullable(); + table.text('managed_service_manifest_path').nullable(); + table.timestamp('managed_service_generated_at', { useTz: true }).nullable(); + table.integer('managed_service_generation_plan_item_id').nullable(); + table.integer('managed_service_generation_board_post_id').nullable(); + table.boolean('managed_service_recreate_requested').notNullable().defaultTo(false); table.string('schedule_mode', 20).notNullable().defaultTo('interval'); table.integer('repeat_interval_value').notNullable().defaultTo(60); table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); + table.string('repeat_window_start_time', 5).nullable(); + table.string('repeat_window_end_time', 5).nullable(); table.timestamp('last_registered_at', { useTz: true }).nullable(); + table.timestamp('context_snapshot_generated_at', { useTz: true }).nullable(); table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); @@ -257,18 +742,54 @@ export async function ensurePlanScheduledTaskTable() { await ensurePlanScheduledTaskColumn('automation_type_id', (table) => { table.string('automation_type_id', 120).nullable(); }); + await ensurePlanScheduledTaskColumn('automation_context_ids_json', (table) => { + table.text('automation_context_ids_json').notNullable().defaultTo('[]'); + }); await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => { table.boolean('jangsing_processing_required').notNullable().defaultTo(true); }); await ensurePlanScheduledTaskColumn('auto_deploy_to_main', (table) => { table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); }); + await ensurePlanScheduledTaskColumn('suppress_web_push', (table) => { + table.boolean('suppress_web_push').notNullable().defaultTo(false); + }); await ensurePlanScheduledTaskColumn('enabled', (table) => { table.boolean('enabled').notNullable().defaultTo(true); }); await ensurePlanScheduledTaskColumn('immediate_run_enabled', (table) => { table.boolean('immediate_run_enabled').notNullable().defaultTo(true); }); + await ensurePlanScheduledTaskColumn('context_snapshot_refresh_requested', (table) => { + table.boolean('context_snapshot_refresh_requested').notNullable().defaultTo(false); + }); + await ensurePlanScheduledTaskColumn('execution_mode', (table) => { + table.string('execution_mode', 40).notNullable().defaultTo('codex'); + }); + await ensurePlanScheduledTaskColumn('managed_service_key', (table) => { + table.string('managed_service_key', 160).nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_package_name', (table) => { + table.string('managed_service_package_name', 200).nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_directory', (table) => { + table.text('managed_service_directory').nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_manifest_path', (table) => { + table.text('managed_service_manifest_path').nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_generated_at', (table) => { + table.timestamp('managed_service_generated_at', { useTz: true }).nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_generation_plan_item_id', (table) => { + table.integer('managed_service_generation_plan_item_id').nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_generation_board_post_id', (table) => { + table.integer('managed_service_generation_board_post_id').nullable(); + }); + await ensurePlanScheduledTaskColumn('managed_service_recreate_requested', (table) => { + table.boolean('managed_service_recreate_requested').notNullable().defaultTo(false); + }); await ensurePlanScheduledTaskColumn('schedule_mode', (table) => { table.string('schedule_mode', 20).notNullable().defaultTo('interval'); }); @@ -281,12 +802,24 @@ export async function ensurePlanScheduledTaskTable() { await ensurePlanScheduledTaskColumn('repeat_interval_minutes', (table) => { table.integer('repeat_interval_minutes').notNullable().defaultTo(60); }); + await ensurePlanScheduledTaskColumn('repeat_interval_seconds', (table) => { + table.integer('repeat_interval_seconds').notNullable().defaultTo(DEFAULT_REPEAT_INTERVAL_SECONDS); + }); await ensurePlanScheduledTaskColumn('daily_run_time', (table) => { table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); }); + await ensurePlanScheduledTaskColumn('repeat_window_start_time', (table) => { + table.string('repeat_window_start_time', 5).nullable(); + }); + await ensurePlanScheduledTaskColumn('repeat_window_end_time', (table) => { + table.string('repeat_window_end_time', 5).nullable(); + }); await ensurePlanScheduledTaskColumn('last_registered_at', (table) => { table.timestamp('last_registered_at', { useTz: true }).nullable(); }); + await ensurePlanScheduledTaskColumn('context_snapshot_generated_at', (table) => { + table.timestamp('context_snapshot_generated_at', { useTz: true }).nullable(); + }); await ensurePlanScheduledTaskColumn('created_at', (table) => { table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); @@ -311,6 +844,42 @@ export async function ensurePlanScheduledTaskTable() { .update({ repeat_interval_value: db.raw('repeat_interval_minutes'), }); + await db(PLAN_SCHEDULED_TASK_TABLE) + .whereNull('repeat_interval_seconds') + .update({ + repeat_interval_seconds: db.raw('repeat_interval_minutes * 60'), + }); + await db(PLAN_SCHEDULED_TASK_TABLE) + .whereNull('suppress_web_push') + .update({ + suppress_web_push: false, + }); + + const existingRows = await db(PLAN_SCHEDULED_TASK_TABLE).select( + 'id', + 'repeat_interval_value', + 'repeat_interval_unit', + 'repeat_interval_minutes', + 'repeat_interval_seconds', + ); + + for (const row of existingRows) { + const repeatIntervalSeconds = resolveStoredRepeatIntervalSeconds(row); + const repeatIntervalMinutes = resolveStoredRepeatIntervalMinutes(row); + const currentSeconds = normalizeRepeatIntervalSeconds(row.repeat_interval_seconds ?? repeatIntervalSeconds); + const currentMinutes = normalizeRepeatIntervalMinutes(row.repeat_interval_minutes ?? repeatIntervalMinutes); + + if (currentSeconds === repeatIntervalSeconds && currentMinutes === repeatIntervalMinutes) { + continue; + } + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + repeat_interval_seconds: repeatIntervalSeconds, + repeat_interval_minutes: repeatIntervalMinutes, + }); + } } export async function listPlanScheduledTasks() { @@ -331,6 +900,9 @@ export async function createPlanScheduledTask(payload: z.infer) { @@ -370,9 +975,15 @@ export async function updatePlanScheduledTask(id: number, payload: z.infer, now: Date) { - if (normalizePlanAutomationType(row.automation_type) === 'plan') { - const rangeEnd = now; - const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null; - const rangeStart = - lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime()) - ? lastRegisteredAt - : new Date(now.getTime() - 24 * 60 * 60 * 1000); - const registration = await registerErrorLogBoardPosts({ - rangeStart, - rangeEnd, + const executionMode = normalizeScheduleExecutionMode(row.execution_mode); + const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json); + const shouldRefreshSnapshot = + !row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false); + const scheduleSnapshot = shouldRefreshSnapshot + ? await ensureSchedulePromptSnapshot({ + scheduleId: Number(row.id), + workId: buildScheduledPlanWorkIdBase(row), + note: String(row.note ?? ''), + forceRefresh: true, + }) + : { + directory: `.auto_codex/schedule/${row.id}`, + requestPath: `.auto_codex/schedule/${row.id}/request.md`, + contextPath: `.auto_codex/schedule/${row.id}/context.md`, + manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`, + }; + const managedServiceReady = await ensureManagedServiceExecutionReady({ + row, + scheduleSnapshot, + automationContextIds, + }); + const effectiveRow = managedServiceReady.row; + const scheduleNote = [ + String(effectiveRow.note ?? '').trim(), + '', + '## 스케줄 전용 참조 문서', + `- ${scheduleSnapshot.requestPath}`, + `- ${scheduleSnapshot.contextPath}`, + '', + '위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.', + executionMode === 'managed-service' + ? [ + '', + '## 스케줄 관리 서비스', + `- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`, + `- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`)}`, + managedServiceReady.ready + ? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.' + : `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`, + managedServiceReady.reason + ? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}` + : null, + '- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.', + ].join('\n') + : null, + ] + .filter((value): value is string => Boolean(value)) + .join('\n') + .trim(); + + if (executionMode === 'managed-service') { + if (!managedServiceReady.ready) { + return { + createdPlan: managedServiceReady.createdPlan, + createdBoardPosts: managedServiceReady.createdBoardPosts, + }; + } + + const managedServiceDirectory = String( + effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`, + ); + const managedServiceResult = await runManagedScheduleService(managedServiceDirectory); + if (!managedServiceResult.ok) { + throw new Error( + `스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`, + ); + } + + const createdPlan = await createCompletedPlanExecutionLogItem({ + workId: buildScheduledPlanWorkIdBase(effectiveRow), + note: scheduleNote, + automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), + automationContextIds, + releaseTarget: String(effectiveRow.release_target ?? 'release'), + jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true), + autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true), + suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false), + repeatRequestEnabled: false, + repeatIntervalMinutes: 60, }); - const executionLogNoteLines = [ - `Plan 스케줄 #${row.id} 실행 이력입니다.`, - `조회 구간: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`, - `신규 게시글 등록: ${registration.createdPosts.length}건`, - `중복 제외: ${registration.skippedPosts.length}건`, + const managedServiceChangedFiles = [ + `${managedServiceDirectory}/README.md`, + `${managedServiceDirectory}/service.ts`, + `${managedServiceDirectory}/service.mjs`, + `${managedServiceDirectory}/service-manifest.json`, ]; - if (registration.createdPosts.length > 0) { - executionLogNoteLines.push(''); - executionLogNoteLines.push('등록된 게시글:'); - executionLogNoteLines.push( - ...registration.createdPosts.map((post) => `- 게시글 #${post.postId} ${post.workId} (${post.count}건)`), - ); - } - - if (registration.skippedPosts.length > 0) { - executionLogNoteLines.push(''); - executionLogNoteLines.push('제외된 항목:'); - executionLogNoteLines.push( - ...registration.skippedPosts.map((post) => `- ${post.workId}: ${post.reason}`), - ); - } - - const executionLog = await createCompletedPlanExecutionLogItem({ - workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now), - note: executionLogNoteLines.join('\n'), - automationType: String(row.automation_type_id ?? row.automation_type ?? 'plan'), - releaseTarget: String(row.release_target ?? 'release'), - jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), - autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), - repeatRequestEnabled: false, - repeatIntervalMinutes: normalizeRepeatIntervalMinutes( - row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit), - ), + await createPlanSourceWorkHistory(Number(createdPlan.id), { + summary: [ + `스케줄 서비스 실행: schedule #${effectiveRow.id}`, + `서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`, + `결과: ${ + managedServiceResult.skipped + ? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})` + : `${managedServiceResult.itemCount}건 전송 시도` + }`, + ].join('\n'), + branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'), + commitHash: null, + changedFiles: managedServiceChangedFiles, + commandLog: [ + `schedule-managed-service run scheduleId=${String(effectiveRow.id)}`, + `servicePath=${managedServiceDirectory}/service.mjs`, + `itemCount=${managedServiceResult.itemCount}`, + `webSent=${managedServiceResult.web.sentCount}`, + `webFailed=${managedServiceResult.web.failedCount}`, + `skipped=${managedServiceResult.skipped ? 'true' : 'false'}`, + `reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`, + ].join('\n'), + diffText: null, + sourceFiles: [], }); - await createPlanActionHistory( - Number(executionLog.id), - '스케줄등록', - `Plan 스케줄 #${row.id} 실행 이력을 저장했습니다.`, + Number(createdPlan.id), + '스케줄서비스실행', + `Plan 스케줄 #${effectiveRow.id} 전용 서비스 파일을 직접 실행했습니다.`, ); - await createPlanActionHistory( - Number(executionLog.id), - '완료처리', - registration.createdPosts.length > 0 - ? `Plan 게시판 글 ${registration.createdPosts.length}건을 등록했습니다.` - : '등록할 신규 Plan 게시판 글이 없었습니다.', - ); - await db(PLAN_SCHEDULED_TASK_TABLE) - .where({ id: row.id }) + .where({ id: effectiveRow.id }) .update({ last_registered_at: now, + context_snapshot_generated_at: now, + context_snapshot_refresh_requested: false, + managed_service_generated_at: db.fn.now(), updated_at: db.fn.now(), }); return { - createdPlan: executionLog, - createdBoardPosts: registration.createdPosts, + createdPlan, + createdBoardPosts: [], }; } - const repeatIntervalMinutes = normalizeRepeatIntervalMinutes( - row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit), - ); - const createdPlan = await createPlanItem({ - workId: formatScheduleWorkId(String(row.work_id ?? '반복작업'), now), - note: String(row.note ?? ''), - automationType: String(row.automation_type_id ?? row.automation_type ?? 'none'), - releaseTarget: String(row.release_target ?? 'release'), - jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), - autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), - repeatRequestEnabled: false, - repeatIntervalMinutes, + const boardPost = await createBoardPost({ + title: buildScheduledBoardPostTitle(effectiveRow), + content: scheduleNote, + attachments: [], + automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), + automationContextIds, }); await db(PLAN_SCHEDULED_TASK_TABLE) - .where({ id: row.id }) + .where({ id: effectiveRow.id }) .update({ last_registered_at: now, + context_snapshot_generated_at: now, + context_snapshot_refresh_requested: false, updated_at: db.fn.now(), }); - await createPlanActionHistory( - Number(createdPlan.id), - '스케줄등록', - `Plan 스케줄 #${row.id} 반복 작업에서 등록했습니다.`, - ); - return { - createdPlan, - createdBoardPosts: [], + createdPlan: null, + createdBoardPosts: [boardPost], }; } -export async function registerPlanScheduledTaskNow(id: number, now = new Date()) { +export async function registerPlanScheduledTaskNow( + id: number, + now = new Date(), + options?: { + ignoreScheduleDue?: boolean; + forceManagedServiceGeneration?: boolean; + }, +) { await ensurePlanTable(); await ensurePlanScheduledTaskTable(); - const row = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id, enabled: true }).first(); + const row = options?.forceManagedServiceGeneration + ? await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first() + : await db(PLAN_SCHEDULED_TASK_TABLE).where({ id, enabled: true }).first(); - if (!row || !isScheduleDue(row, now)) { + if ( + !row + || (!options?.forceManagedServiceGeneration && !options?.ignoreScheduleDue && !isScheduleDue(row, now)) + ) { return null; } diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts index b7ec80b..a891d62 100755 --- a/etc/servers/work-server/src/services/plan-service.ts +++ b/etc/servers/work-server/src/services/plan-service.ts @@ -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) 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 { + 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'), '\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, '\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 }); + } +}); diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts index 05d312e..71ed6f0 100755 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -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 { try { + if (isExcludedAppSourcePath(rootPath, targetPath)) { + return null; + } + const targetStat = await stat(targetPath); if (targetStat.isFile()) { diff --git a/etc/servers/work-server/src/services/stock-alert-service.test.ts b/etc/servers/work-server/src/services/stock-alert-service.test.ts new file mode 100644 index 0000000..24a1673 --- /dev/null +++ b/etc/servers/work-server/src/services/stock-alert-service.test.ts @@ -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'); +}); diff --git a/etc/servers/work-server/src/services/stock-alert-service.ts b/etc/servers/work-server/src/services/stock-alert-service.ts new file mode 100644 index 0000000..a1590f2 --- /dev/null +++ b/etc/servers/work-server/src/services/stock-alert-service.ts @@ -0,0 +1,1427 @@ +import { sendNotifications } from './notification-service.js'; +import { db } from '../db/client.js'; + +export const STOCK_ALERT_TABLE = 'stock_alerts'; +export const STOCK_ALERT_LAYOUT_NAME = 'stock알림'; + +export const STOCK_ALERT_TYPE_OPTIONS = [ + { value: 'all', label: '전체' }, + { value: 'price', label: '현재가' }, + { value: 'top3', label: '등락폭이 큰 상위3종목' }, +] as const; + +export type StockAlertFilterType = (typeof STOCK_ALERT_TYPE_OPTIONS)[number]['value']; +export type StockAlertType = Exclude; + +export type StockAlertRow = { + id: string; + stock_code: string; + stock_name: string; + alert_type: StockAlertType; + created_at: string; + updated_at: string; +}; + +export type StockAlertQuote = { + currentPrice: number | null; + changeRate: number | null; + quotedAt: string | null; + stockName: string | null; +}; + +export type StockAlertItem = { + id: string; + stockCode: string; + stockName: string; + alertTypes: StockAlertType[]; + alertTypeLabels: string[]; + currentPrice: number | null; + changeRate: number | null; + quotedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +type YahooQuoteRow = { + symbol?: string; + regularMarketPrice?: number; + preMarketPrice?: number; + postMarketPrice?: number; + regularMarketChangePercent?: number; + preMarketChangePercent?: number; + postMarketChangePercent?: number; + regularMarketTime?: number; + preMarketTime?: number; + postMarketTime?: number; + shortName?: string; + longName?: string; +}; + +type YahooSearchQuote = { + symbol?: string; + shortname?: string; + longname?: string; + exchange?: string; + exchDisp?: string; + typeDisp?: string; +}; + +type NaverRealtimeRow = { + cd?: string; + nm?: string; + nv?: number; + cr?: number; + rf?: string; + ms?: string; + tyn?: string; + pcv?: number; + nxtOverMarketPriceInfo?: { + tradingSessionType?: string; + overMarketStatus?: string; + overPrice?: string; + fluctuationsRatio?: string; + compareToPreviousClosePrice?: string; + compareToPreviousPrice?: { + code?: string; + name?: string; + text?: string; + }; + localTradedAt?: string; + }; +}; + +type NaverCompareToPreviousPrice = { + code?: string; + name?: string; + text?: string; +}; + +export type StockAlertSearchItem = { + stockCode: string; + stockName: string; + market: string; +}; + +type KrxListedStock = { + stockCode: string; + stockName: string; + market: string; +}; + +const STOCK_ALERT_LABEL_MAP = new Map( + STOCK_ALERT_TYPE_OPTIONS.map((option) => [option.value, option.label]), +); + +const STOCK_ALERT_VALUE_SET = new Set(['price', 'top3']); +const KRX_CORP_LIST_URL = 'https://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13'; +const KRX_CORP_LIST_CACHE_TTL_MS = 1000 * 60 * 60 * 12; +const STOCK_ALERT_NOTIFICATION_TITLE = '현재 주가'; +const STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN = 'test.sm-home.cloud'; +const STOCK_ALERT_NOTIFICATION_SCOPE = 'schedule-2-stock-alert'; +const STOCK_ALERT_NOTIFICATION_TARGET_URL = `https://${STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN}/?topMenu=play&playMenu=layout`; +const KOREA_TIMEZONE = 'Asia/Seoul'; +const KOREA_PREOPEN_RESET_HOUR = 5; +const KOREA_REGULAR_OPEN_HOUR = 9; + +let cachedKrxListedStocks: { + expiresAt: number; + items: KrxListedStock[]; +} | null = null; + +function normalizeTimestamp(value: Date | string | undefined) { + if (!value) { + return new Date().toISOString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString(); +} + +function normalizeAlertType(value: string): StockAlertType { + const normalized = value.trim().toLowerCase(); + + if (!STOCK_ALERT_VALUE_SET.has(normalized as StockAlertType)) { + throw new Error('알림유형은 현재가 또는 등락폭이 큰 상위3종목만 저장할 수 있습니다.'); + } + + return normalized as StockAlertType; +} + +function normalizeStockCode(value: string) { + const digits = value.replace(/\D+/g, ''); + return digits.length === 6 ? digits : ''; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function parseLooseNumber(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value !== 'string') { + return null; + } + + const normalized = value.replace(/[^0-9.-]+/g, ''); + + if (!normalized) { + return null; + } + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function applySign(value: number | null, sign: -1 | 1 | null) { + if (value === null || sign === null || value === 0) { + return value; + } + + return Math.abs(value) * sign; +} + +function resolveNaverDirectionSign(compareToPreviousPrice: NaverCompareToPreviousPrice | undefined) { + const direction = compareToPreviousPrice?.name?.trim().toUpperCase() || compareToPreviousPrice?.code?.trim(); + + if (direction === 'FALLING' || direction === '4' || direction === '5') { + return -1; + } + + if (direction === 'RISING' || direction === '2') { + return 1; + } + + return null; +} + +function resolveSignedNaverChangeRate( + rate: number | null, + compareToPreviousClosePrice: unknown, + compareToPreviousPrice: NaverCompareToPreviousPrice | undefined, +) { + const signedChangeAmount = parseLooseNumber(compareToPreviousClosePrice); + + if (signedChangeAmount !== null && signedChangeAmount !== 0) { + return applySign(rate, signedChangeAmount < 0 ? -1 : 1); + } + + return applySign(rate, resolveNaverDirectionSign(compareToPreviousPrice)); +} + +function resolveCapturedTimestampMs(value: number | string | null | undefined) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function extractKoreaHour(timestampMs: number) { + const formatted = new Intl.DateTimeFormat('en-GB', { + timeZone: KOREA_TIMEZONE, + hour: '2-digit', + hour12: false, + }).format(new Date(timestampMs)); + const hour = Number(formatted); + return Number.isFinite(hour) ? hour : null; +} + +function isKoreaMorningResetWindow(timestampMs: number | null) { + if (!Number.isFinite(timestampMs)) { + return false; + } + + const koreaHour = extractKoreaHour(timestampMs as number); + return koreaHour !== null && koreaHour >= KOREA_PREOPEN_RESET_HOUR && koreaHour < KOREA_REGULAR_OPEN_HOUR; +} + +function getAlertTypeLabel(value: StockAlertType) { + return STOCK_ALERT_LABEL_MAP.get(value) ?? value; +} + +function buildStockSymbols(stockCode: string) { + const normalizedCode = normalizeStockCode(stockCode); + + if (!normalizedCode) { + return []; + } + + return [`${normalizedCode}.KS`, `${normalizedCode}.KQ`]; +} + +function extractStockCodeFromSymbol(symbol: string) { + const match = symbol.trim().match(/^(\d{6})\.(?:KS|KQ)$/i); + return match ? match[1] : ''; +} + +function resolveMarketLabel(quote: YahooSearchQuote) { + const symbol = quote.symbol?.trim().toUpperCase() ?? ''; + + if (symbol.endsWith('.KS')) { + return 'KOSPI'; + } + + if (symbol.endsWith('.KQ')) { + return 'KOSDAQ'; + } + + const exchange = quote.exchDisp?.trim() || quote.exchange?.trim() || quote.typeDisp?.trim(); + + if (!exchange) { + return '기타'; + } + + if (/KOSDAQ/i.test(exchange)) { + return 'KOSDAQ'; + } + + if (/KOSPI|KOSE/i.test(exchange)) { + return 'KOSPI'; + } + + return exchange; +} + +async function ensureStockAlertTable() { + const exists = await db.schema.hasTable(STOCK_ALERT_TABLE); + + if (exists) { + return; + } + + await db.schema.createTable(STOCK_ALERT_TABLE, (table) => { + table.text('id').primary(); + table.text('stock_code').notNullable(); + table.text('stock_name').notNullable(); + table.text('alert_type').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable(); + table.timestamp('updated_at', { useTz: true }).notNullable(); + }); +} + +async function fetchJson(url: URL, init?: RequestInit) { + const response = await fetch(url, { + ...init, + headers: { + accept: 'application/json', + 'user-agent': 'ai-code-app/stock-alert', + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + throw new Error(`외부 시세 응답을 불러오지 못했습니다. (${response.status})`); + } + + return response.json() as Promise; +} + +async function fetchText(url: string, init?: RequestInit) { + const response = await fetch(url, { + ...init, + headers: { + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'user-agent': 'ai-code-app/stock-alert', + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + throw new Error(`외부 종목 검색 응답을 불러오지 못했습니다. (${response.status})`); + } + + return response.arrayBuffer(); +} + +function decodeEucKr(value: ArrayBuffer) { + return new TextDecoder('euc-kr').decode(value); +} + +function decodeHtmlEntities(value: string) { + return value + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/'/g, "'") + .replace(/"/gi, '"'); +} + +function stripHtmlTags(value: string) { + return decodeHtmlEntities(value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); +} + +function normalizeSearchKeyword(value: string) { + return value.trim().replace(/\s+/g, '').toLowerCase(); +} + +function normalizeMarketLabel(value: string) { + const trimmedValue = value.trim(); + + if (/코스닥/i.test(trimmedValue)) { + return 'KOSDAQ'; + } + + if (/유가증권|코스피|유가/i.test(trimmedValue)) { + return 'KOSPI'; + } + + return trimmedValue || '기타'; +} + +function parseKrxListedStocks(html: string) { + const rowMatches = html.match(//gi) ?? []; + const items: KrxListedStock[] = []; + + rowMatches.forEach((rowHtml) => { + const cellMatches = rowHtml.match(//gi) ?? []; + + if (cellMatches.length < 3) { + return; + } + + const stockName = stripHtmlTags(cellMatches[0] ?? ''); + const market = normalizeMarketLabel(stripHtmlTags(cellMatches[1] ?? '')); + const stockCode = normalizeStockCode(stripHtmlTags(cellMatches[2] ?? '')); + + if (!stockCode || !stockName) { + return; + } + + items.push({ + stockCode, + stockName, + market, + }); + }); + + return items; +} + +async function findKrxListedStockByCode(stockCode: string) { + const normalizedCode = normalizeStockCode(stockCode); + + if (!normalizedCode) { + return null; + } + + const items = await fetchKrxListedStocks(); + return items.find((item) => item.stockCode === normalizedCode) ?? null; +} + +async function fetchKrxListedStocks() { + if (cachedKrxListedStocks && cachedKrxListedStocks.expiresAt > Date.now()) { + return cachedKrxListedStocks.items; + } + + const buffer = await fetchText(KRX_CORP_LIST_URL); + const decodedHtml = decodeEucKr(buffer); + const items = parseKrxListedStocks(decodedHtml); + + cachedKrxListedStocks = { + expiresAt: Date.now() + KRX_CORP_LIST_CACHE_TTL_MS, + items, + }; + + return items; +} + +async function searchKrxListedStocks(query: string, limit = 20) { + const normalizedKeyword = normalizeSearchKeyword(query); + + if (!normalizedKeyword) { + return []; + } + + const items = await fetchKrxListedStocks(); + + const matchedItems = items.filter((item) => { + const normalizedCode = item.stockCode.toLowerCase(); + const normalizedName = normalizeSearchKeyword(item.stockName); + return normalizedCode.includes(normalizedKeyword) || normalizedName.includes(normalizedKeyword); + }); + + matchedItems.sort((left, right) => { + const trimmedQuery = query.trim(); + const leftExactCode = left.stockCode === trimmedQuery ? 1 : 0; + const rightExactCode = right.stockCode === trimmedQuery ? 1 : 0; + + if (leftExactCode !== rightExactCode) { + return rightExactCode - leftExactCode; + } + + const leftExactName = normalizeSearchKeyword(left.stockName) === normalizedKeyword ? 1 : 0; + const rightExactName = normalizeSearchKeyword(right.stockName) === normalizedKeyword ? 1 : 0; + + if (leftExactName !== rightExactName) { + return rightExactName - leftExactName; + } + + const leftStartsWith = normalizeSearchKeyword(left.stockName).startsWith(normalizedKeyword) ? 1 : 0; + const rightStartsWith = normalizeSearchKeyword(right.stockName).startsWith(normalizedKeyword) ? 1 : 0; + + if (leftStartsWith !== rightStartsWith) { + return rightStartsWith - leftStartsWith; + } + + const leftLengthGap = Math.abs(left.stockName.length - trimmedQuery.length); + const rightLengthGap = Math.abs(right.stockName.length - trimmedQuery.length); + + if (leftLengthGap !== rightLengthGap) { + return leftLengthGap - rightLengthGap; + } + + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }); + + return matchedItems.slice(0, Math.max(1, Math.min(50, limit))); +} + +async function resolveStockIdentity(input: { stockCode?: string; stockName?: string }) { + const codeFromInput = normalizeStockCode(input.stockCode ?? ''); + const trimmedName = input.stockName?.trim() ?? ''; + + if (codeFromInput) { + const krxMatch = await findKrxListedStockByCode(codeFromInput); + + if (krxMatch) { + return { + stockCode: codeFromInput, + stockName: krxMatch.stockName, + }; + } + + if (trimmedName) { + return { + stockCode: codeFromInput, + stockName: trimmedName, + }; + } + + const quotes = await fetchQuotesByCodes([codeFromInput]); + const quote = quotes.get(codeFromInput); + + if (quote) { + return { + stockCode: codeFromInput, + stockName: quote.stockName ?? codeFromInput, + }; + } + + return { + stockCode: codeFromInput, + stockName: codeFromInput, + }; + } + + if (!trimmedName) { + throw new Error('종목명을 입력해 주세요.'); + } + + const krxMatches = await searchKrxListedStocks(trimmedName, 10); + const exactMatch = + krxMatches.find((item) => normalizeSearchKeyword(item.stockName) === normalizeSearchKeyword(trimmedName)) ?? krxMatches[0] ?? null; + + if (exactMatch) { + return { + stockCode: exactMatch.stockCode, + stockName: exactMatch.stockName, + }; + } + + const searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); + searchUrl.searchParams.set('q', trimmedName); + searchUrl.searchParams.set('quotesCount', '10'); + searchUrl.searchParams.set('newsCount', '0'); + searchUrl.searchParams.set('lang', 'ko-KR'); + searchUrl.searchParams.set('region', 'KR'); + + const payload = await fetchJson<{ quotes?: YahooSearchQuote[] }>(searchUrl); + const matchedQuote = + payload.quotes?.find((quote) => typeof quote.symbol === 'string' && extractStockCodeFromSymbol(quote.symbol).length === 6) ?? + null; + + if (!matchedQuote?.symbol) { + throw new Error(`종목명 "${trimmedName}"에 해당하는 종목코드를 찾지 못했습니다.`); + } + + const resolvedCode = extractStockCodeFromSymbol(matchedQuote.symbol); + + if (!resolvedCode) { + throw new Error(`종목명 "${trimmedName}"에 해당하는 종목코드를 찾지 못했습니다.`); + } + + return { + stockCode: resolvedCode, + stockName: matchedQuote.shortname?.trim() || matchedQuote.longname?.trim() || trimmedName, + }; +} + +async function searchYahooStocks(query: string, quotesCount = 20) { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return []; + } + + const searchUrl = new URL('https://query2.finance.yahoo.com/v1/finance/search'); + searchUrl.searchParams.set('q', trimmedQuery); + searchUrl.searchParams.set('quotesCount', String(Math.max(1, Math.min(50, quotesCount)))); + searchUrl.searchParams.set('newsCount', '0'); + searchUrl.searchParams.set('lang', 'ko-KR'); + searchUrl.searchParams.set('region', 'KR'); + + try { + const payload = await fetchJson<{ quotes?: YahooSearchQuote[] }>(searchUrl); + return payload.quotes ?? []; + } catch (error) { + // Yahoo search rejects some non-code Korean queries with HTTP 400. + if (/[^\x00-\x7F]/.test(trimmedQuery)) { + return []; + } + + throw error; + } +} + +export async function searchStockAlertCandidates(query: string, limit = 20) { + const normalizedLimit = Math.max(1, Math.min(50, limit)); + const [krxItems, quotes] = await Promise.all([ + searchKrxListedStocks(query, normalizedLimit), + searchYahooStocks(query, normalizedLimit * 2), + ]); + const seenCodes = new Set(); + const items: StockAlertSearchItem[] = [...krxItems]; + const krxByCode = new Map(krxItems.map((item) => [item.stockCode, item])); + + krxItems.forEach((item) => { + seenCodes.add(item.stockCode); + }); + + quotes.forEach((quote) => { + if (!quote.symbol) { + return; + } + + const stockCode = extractStockCodeFromSymbol(quote.symbol); + + if (!stockCode || seenCodes.has(stockCode)) { + return; + } + + const stockName = quote.shortname?.trim() || quote.longname?.trim() || stockCode; + const krxMatch = krxByCode.get(stockCode); + seenCodes.add(stockCode); + items.push({ + stockCode, + stockName: krxMatch?.stockName ?? stockName, + market: krxMatch?.market ?? resolveMarketLabel(quote), + }); + }); + + return items.slice(0, normalizedLimit); +} + +async function ensureNoDuplicateStockCode(stockCode: string, currentId?: string) { + const normalizedCode = normalizeStockCode(stockCode); + + if (!normalizedCode) { + throw new Error('종목코드를 확인할 수 없습니다.'); + } + + const existing = (await db(STOCK_ALERT_TABLE) + .select('id') + .where({ stock_code: normalizedCode }) + .modify((query) => { + if (currentId?.trim()) { + query.whereNot('id', currentId.trim()); + } + }) + .first()) as Pick | undefined; + + if (existing?.id) { + throw new Error('이미 추가된 종목입니다.'); + } +} + +export function resolveLatestQuoteFromMeta(meta: { + regularMarketPrice?: number; + regularMarketTime?: number; + regularMarketChangePercent?: number; + chartPreviousClose?: number; + previousClose?: number; + preMarketPrice?: number; + preMarketTime?: number; + preMarketChangePercent?: number; + postMarketPrice?: number; + postMarketTime?: number; + postMarketChangePercent?: number; + marketState?: string; + shortName?: string; + longName?: string; +}) { + const marketState = String(meta.marketState ?? '') + .trim() + .toUpperCase(); + const shouldPreferPremarket = ['PRE', 'PREPRE'].includes(marketState); + const shouldPreferPostmarket = ['POST', 'POSTPOST', 'CLOSED'].includes(marketState); + const preferredCandidate = shouldPreferPremarket + ? { + price: meta.preMarketPrice, + changeRate: meta.preMarketChangePercent, + time: meta.preMarketTime, + } + : shouldPreferPostmarket + ? { + price: meta.postMarketPrice, + changeRate: meta.postMarketChangePercent, + time: meta.postMarketTime, + } + : { + price: meta.regularMarketPrice, + changeRate: meta.regularMarketChangePercent, + time: meta.regularMarketTime, + }; + const quoteCandidates: Array<{ price?: number; changeRate?: number; time?: number }> = [ + { + price: meta.regularMarketPrice, + changeRate: meta.regularMarketChangePercent, + time: meta.regularMarketTime, + }, + { + price: meta.preMarketPrice, + changeRate: meta.preMarketChangePercent, + time: meta.preMarketTime, + }, + { + price: meta.postMarketPrice, + changeRate: meta.postMarketChangePercent, + time: meta.postMarketTime, + }, + ]; + const latestCandidate = quoteCandidates + .flatMap((item) => + isFiniteNumber(item.price) && isFiniteNumber(item.time) + ? [ + { + price: item.price, + changeRate: item.changeRate, + time: item.time, + }, + ] + : [], + ) + .sort((left, right) => right.time - left.time)[0] ?? null; + const resolvedCandidate = + isFiniteNumber(preferredCandidate.price) && isFiniteNumber(preferredCandidate.time) ? preferredCandidate : latestCandidate; + const currentPrice = isFiniteNumber(resolvedCandidate?.price) + ? resolvedCandidate.price + : isFiniteNumber(meta.regularMarketPrice) + ? meta.regularMarketPrice + : null; + const quotedAt = isFiniteNumber(resolvedCandidate?.time) + ? new Date(resolvedCandidate.time * 1000).toISOString() + : isFiniteNumber(meta.regularMarketTime) + ? new Date(meta.regularMarketTime * 1000).toISOString() + : null; + const previousClose = + typeof meta.chartPreviousClose === 'number' + ? meta.chartPreviousClose + : typeof meta.previousClose === 'number' + ? meta.previousClose + : null; + const changeRate = isFiniteNumber(resolvedCandidate?.changeRate) + ? resolvedCandidate.changeRate + : currentPrice !== null && previousClose !== null && previousClose !== 0 + ? ((currentPrice - previousClose) / previousClose) * 100 + : null; + + return { + currentPrice, + changeRate, + quotedAt, + stockName: meta.shortName?.trim() || meta.longName?.trim() || null, + } satisfies StockAlertQuote; +} + +export function resolveLatestQuoteFromNaverRealtime(data: NaverRealtimeRow, capturedAt: number | string | null | undefined) { + const overMarketInfo = data.nxtOverMarketPriceInfo; + const overPrice = parseLooseNumber(overMarketInfo?.overPrice); + const overChangeRate = resolveSignedNaverChangeRate( + parseLooseNumber(overMarketInfo?.fluctuationsRatio), + overMarketInfo?.compareToPreviousClosePrice, + overMarketInfo?.compareToPreviousPrice, + ); + const overQuotedAt = overMarketInfo?.localTradedAt?.trim() || null; + const hasExtendedSessionQuote = overPrice !== null && overQuotedAt; + const baseQuotedAt = + typeof capturedAt === 'number' && Number.isFinite(capturedAt) + ? new Date(capturedAt).toISOString() + : typeof capturedAt === 'string' && capturedAt.trim() + ? new Date(capturedAt).toISOString() + : null; + const capturedTimestampMs = resolveCapturedTimestampMs(capturedAt); + const basePrice = isFiniteNumber(data.nv) ? data.nv : null; + const baseChangeRate = applySign( + isFiniteNumber(data.cr) ? data.cr : null, + resolveNaverDirectionSign(data.rf?.trim() ? { code: data.rf } : undefined), + ); + const previousClosePrice = isFiniteNumber(data.pcv) ? data.pcv : null; + const shouldResetToPreviousClose = + !hasExtendedSessionQuote && + isKoreaMorningResetWindow(capturedTimestampMs) && + previousClosePrice !== null; + + return { + currentPrice: hasExtendedSessionQuote ? overPrice : shouldResetToPreviousClose ? previousClosePrice : basePrice, + changeRate: hasExtendedSessionQuote ? overChangeRate : shouldResetToPreviousClose ? 0 : baseChangeRate, + quotedAt: hasExtendedSessionQuote ? overQuotedAt : baseQuotedAt, + stockName: data.nm?.trim() || null, + } satisfies StockAlertQuote; +} + +function choosePreferredQuote(primary: StockAlertQuote | null, fallback: StockAlertQuote | null) { + if (primary?.currentPrice === null && fallback?.currentPrice === null) { + return primary ?? fallback ?? null; + } + + if (!primary) { + return fallback; + } + + if (!fallback) { + return primary; + } + + const primaryQuotedAt = primary.quotedAt ? Date.parse(primary.quotedAt) : Number.NaN; + const fallbackQuotedAt = fallback.quotedAt ? Date.parse(fallback.quotedAt) : Number.NaN; + + if (Number.isFinite(primaryQuotedAt) && Number.isFinite(fallbackQuotedAt) && fallbackQuotedAt > primaryQuotedAt) { + return { + ...fallback, + stockName: fallback.stockName ?? primary.stockName, + } satisfies StockAlertQuote; + } + + return { + ...primary, + stockName: primary.stockName ?? fallback.stockName, + currentPrice: primary.currentPrice ?? fallback.currentPrice, + changeRate: primary.changeRate ?? fallback.changeRate, + quotedAt: primary.quotedAt ?? fallback.quotedAt, + } satisfies StockAlertQuote; +} + +async function fetchNaverRealtimeQuoteByCode(stockCode: string) { + const quoteUrl = new URL('https://polling.finance.naver.com/api/realtime'); + quoteUrl.searchParams.set('query', `SERVICE_ITEM:${stockCode}`); + + const payload = await fetchJson<{ + result?: { + time?: number; + areas?: Array<{ + name?: string; + datas?: NaverRealtimeRow[]; + }>; + }; + }>(quoteUrl, { + headers: { + accept: '*/*', + }, + }); + const data = payload.result?.areas?.find((area) => area.name === 'SERVICE_ITEM')?.datas?.[0]; + + if (!data) { + return null; + } + + return resolveLatestQuoteFromNaverRealtime(data, payload.result?.time); +} + +async function fetchQuoteBySymbol(symbol: string) { + const quoteUrl = new URL(`https://query1.finance.yahoo.com/v8/finance/chart/${symbol}`); + quoteUrl.searchParams.set('range', '1d'); + quoteUrl.searchParams.set('interval', '1m'); + quoteUrl.searchParams.set('includePrePost', 'true'); + quoteUrl.searchParams.set('lang', 'ko-KR'); + quoteUrl.searchParams.set('region', 'KR'); + + const payload = await fetchJson<{ + chart?: { + result?: Array<{ + meta?: { + regularMarketPrice?: number; + regularMarketTime?: number; + regularMarketChangePercent?: number; + previousClose?: number; + preMarketPrice?: number; + preMarketTime?: number; + preMarketChangePercent?: number; + postMarketPrice?: number; + postMarketTime?: number; + postMarketChangePercent?: number; + marketState?: string; + shortName?: string; + longName?: string; + chartPreviousClose?: number; + }; + }>; + }; + }>(quoteUrl); + const meta = payload.chart?.result?.[0]?.meta; + + if (!meta) { + return null; + } + + return resolveLatestQuoteFromMeta(meta); +} + +export async function fetchQuotesByCodes(stockCodes: string[]) { + const normalizedCodes = Array.from( + new Set( + stockCodes + .map((value) => normalizeStockCode(value)) + .filter(Boolean), + ), + ); + const quoteMap = new Map(); + + if (!normalizedCodes.length) { + return quoteMap; + } + + await Promise.all( + normalizedCodes.map(async (stockCode) => { + let preferredQuote: StockAlertQuote | null = null; + + try { + preferredQuote = await fetchNaverRealtimeQuoteByCode(stockCode); + } catch { + // Ignore realtime provider failures and fall back to the next source. + } + + if (preferredQuote && preferredQuote.currentPrice !== null) { + quoteMap.set(stockCode, preferredQuote); + return; + } + + const symbols = buildStockSymbols(stockCode); + + for (const symbol of symbols) { + try { + const yahooQuote = await fetchQuoteBySymbol(symbol); + preferredQuote = choosePreferredQuote(preferredQuote, yahooQuote); + + if (preferredQuote && (preferredQuote.currentPrice !== null || preferredQuote.stockName)) { + quoteMap.set(stockCode, preferredQuote); + return; + } + } catch { + // Ignore per-symbol failures and try the next market suffix. + } + } + }), + ); + + return quoteMap; +} + +export async function listStockAlerts(filterType: StockAlertFilterType = 'all') { + await ensureStockAlertTable(); + + let rows: StockAlertRow[] = []; + + if (filterType === 'all') { + rows = (await db(STOCK_ALERT_TABLE).select('*').orderBy('updated_at', 'desc')) as StockAlertRow[]; + } else { + const matchedCodes = ( + (await db(STOCK_ALERT_TABLE) + .select('stock_code') + .where({ alert_type: normalizeAlertType(filterType) }) + .groupBy('stock_code')) as Array> + ).map((row) => row.stock_code); + + if (!matchedCodes.length) { + return []; + } + + rows = (await db(STOCK_ALERT_TABLE) + .select('*') + .whereIn('stock_code', matchedCodes) + .orderBy('updated_at', 'desc')) as StockAlertRow[]; + } + + const quotes = await fetchQuotesByCodes(rows.map((row) => row.stock_code)); + const krxItems = await fetchKrxListedStocks(); + const krxByCode = new Map(krxItems.map((item) => [item.stockCode, item])); + const groupedItems = new Map(); + + rows.forEach((row) => { + const alertType = normalizeAlertType(row.alert_type); + const quote = quotes.get(row.stock_code); + const krxMatch = krxByCode.get(row.stock_code); + const existing = groupedItems.get(row.stock_code); + + if (existing) { + if (!existing.alertTypes.includes(alertType)) { + existing.alertTypes.push(alertType); + existing.alertTypeLabels.push(getAlertTypeLabel(alertType)); + } + + const updatedAtTime = Date.parse(normalizeTimestamp(row.updated_at)); + const existingUpdatedAtTime = Date.parse(existing.updatedAt); + + if (Number.isFinite(updatedAtTime) && (!Number.isFinite(existingUpdatedAtTime) || updatedAtTime > existingUpdatedAtTime)) { + existing.updatedAt = normalizeTimestamp(row.updated_at); + } + + return; + } + + groupedItems.set(row.stock_code, { + id: row.stock_code, + stockCode: row.stock_code, + stockName: row.stock_name || krxMatch?.stockName || quote?.stockName || row.stock_code, + alertTypes: [alertType], + alertTypeLabels: [getAlertTypeLabel(alertType)], + currentPrice: quote?.currentPrice ?? null, + changeRate: quote?.changeRate ?? null, + quotedAt: quote?.quotedAt ?? null, + createdAt: normalizeTimestamp(row.created_at), + updatedAt: normalizeTimestamp(row.updated_at), + }); + }); + + return Array.from(groupedItems.values()).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)); +} + +export async function createStockAlert(input: { + stockCode?: string; + stockName?: string; + alertTypes: string[]; +}) { + await ensureStockAlertTable(); + + const identity = await resolveStockIdentity(input); + const now = new Date().toISOString(); + const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value)))); + + if (!alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + + await ensureNoDuplicateStockCode(identity.stockCode); + + await db(STOCK_ALERT_TABLE).insert( + alertTypes.map((alertType) => ({ + id: `stock-alert-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + stock_code: identity.stockCode, + stock_name: identity.stockName, + alert_type: alertType, + created_at: now, + updated_at: now, + })), + ); + + const [created] = await listStockAlerts('all').then((rows) => rows.filter((row) => row.stockCode === identity.stockCode)); + + if (!created) { + throw new Error('저장된 종목 알림을 다시 불러오지 못했습니다.'); + } + + return created; +} + +export async function updateStockAlert( + id: string, + input: { + stockCode?: string; + stockName?: string; + alertTypes: string[]; + }, +) { + await ensureStockAlertTable(); + + const currentRows = (await db(STOCK_ALERT_TABLE) + .select('*') + .where((query) => { + query.where({ stock_code: normalizeStockCode(id) || '__never__' }).orWhere({ id }); + })) as StockAlertRow[]; + + if (!currentRows.length) { + throw new Error('수정할 종목 알림을 찾을 수 없습니다.'); + } + + const existing = currentRows[0]; + const identity = await resolveStockIdentity({ + stockCode: input.stockCode ?? existing?.stock_code, + stockName: input.stockName ?? existing?.stock_name, + }); + const updatedAt = new Date().toISOString(); + const alertTypes = Array.from(new Set(input.alertTypes.map((value) => normalizeAlertType(value)))); + + if (!alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + + if (identity.stockCode !== existing.stock_code) { + await ensureNoDuplicateStockCode(identity.stockCode); + } + + await db.transaction(async (trx) => { + await trx(STOCK_ALERT_TABLE).where({ stock_code: existing.stock_code }).delete(); + await trx(STOCK_ALERT_TABLE).insert( + alertTypes.map((alertType) => ({ + id: `stock-alert-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + stock_code: identity.stockCode, + stock_name: identity.stockName, + alert_type: alertType, + created_at: currentRows.find((row) => row.alert_type === alertType)?.created_at ?? updatedAt, + updated_at: updatedAt, + })), + ); + }); + + const [updated] = await listStockAlerts('all').then((rows) => rows.filter((row) => row.stockCode === identity.stockCode)); + + if (!updated) { + throw new Error('수정된 종목 알림을 다시 불러오지 못했습니다.'); + } + + return updated; +} + +export async function deleteStockAlert(id: string) { + await ensureStockAlertTable(); + const normalizedCode = normalizeStockCode(id); + const count = normalizedCode + ? await db(STOCK_ALERT_TABLE).where({ stock_code: normalizedCode }).delete() + : await db(STOCK_ALERT_TABLE).where({ id }).delete(); + + if (!count) { + throw new Error('삭제할 종목 알림을 찾을 수 없습니다.'); + } +} + +export async function saveStockAlerts( + items: Array<{ + id?: string; + stockCode?: string; + stockName?: string; + alertTypes: string[]; + }>, +) { + await ensureStockAlertTable(); + + const seenCodes = new Set(); + + for (const item of items) { + const normalizedCode = normalizeStockCode(item.stockCode ?? ''); + + if (!normalizedCode) { + continue; + } + + if (seenCodes.has(normalizedCode)) { + throw new Error('동일한 종목코드를 중복 저장할 수 없습니다.'); + } + + if (!item.alertTypes.length) { + throw new Error('알림유형을 하나 이상 선택해 주세요.'); + } + + seenCodes.add(normalizedCode); + } + + const savedItems: StockAlertItem[] = []; + + for (const item of items) { + const trimmedId = item.id?.trim(); + const savedItem = trimmedId + ? await updateStockAlert(trimmedId, item) + : await createStockAlert(item); + savedItems.push(savedItem); + } + + return savedItems; +} + +function formatStockAlertPrice(value: number | null) { + if (!isFiniteNumber(value)) { + return '-'; + } + + return `${Math.round(value).toLocaleString('ko-KR')}₩`; +} + +function formatStockAlertChangeRate(value: number | null) { + if (!isFiniteNumber(value)) { + return '(변동률 확인불가)'; + } + + if (value > 0) { + return `(+${value.toFixed(2)}% ▲)`; + } + + if (value < 0) { + return `(${value.toFixed(2)}% ▼)`; + } + + return '(0.00% -)'; +} + +function canBuildCurrentPriceStockAlertLine(item: StockAlertItem) { + return item.alertTypes.includes('price') && isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate); +} + +function canBuildChangeThresholdStockAlertLine(item: StockAlertItem) { + return isFiniteNumber(item.currentPrice) && isFiniteNumber(item.changeRate); +} + +export function buildCurrentPriceStockAlertLines(items: StockAlertItem[]) { + return items + .filter(canBuildCurrentPriceStockAlertLine) + .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`); +} + +export function buildChangeRateThresholdStockAlertLines(items: StockAlertItem[], thresholdPercent: number) { + return items + .filter((item) => canBuildChangeThresholdStockAlertLine(item) && Math.abs(item.changeRate ?? 0) >= thresholdPercent) + .sort((left, right) => { + const changeRateGap = Math.abs((right.changeRate ?? 0)) - Math.abs((left.changeRate ?? 0)); + + if (changeRateGap !== 0) { + return changeRateGap; + } + + return left.stockName.localeCompare(right.stockName, 'ko-KR'); + }) + .map((item) => `${item.stockName} ${formatStockAlertPrice(item.currentPrice)} ${formatStockAlertChangeRate(item.changeRate)}`); +} + +function createSkippedNotificationResult(reason: string) { + const skippedWebResult = { + ok: true, + skipped: true, + reason, + sentCount: 0, + failedCount: 0, + invalidEndpoints: [], + }; + const skippedIosResult = { + ok: true, + skipped: true, + reason, + sentCount: 0, + failedCount: 0, + invalidTokens: [], + }; + + return { + ios: skippedIosResult, + web: skippedWebResult, + }; +} + +export function buildStockAlertNotificationIdentity(options: { + scheduleId: number; + serviceKey: string; + mode: 'price' | 'change-threshold'; +}) { + const modeKey = options.mode === 'price' ? 'current-price' : 'change-threshold'; + const legacyNotificationKey = `${options.serviceKey}:current-price`; + const legacyModeNotificationKey = `${options.serviceKey}:${modeKey}`; + + return { + threadId: `schedule-stock-alert:${options.scheduleId}`, + notificationKey: `schedule-stock-alert:${options.scheduleId}`, + notificationScope: `schedule-stock-alert:${options.scheduleId}`, + notificationAliases: [...new Set([ + options.serviceKey, + legacyNotificationKey, + legacyModeNotificationKey, + options.scheduleId === 2 ? STOCK_ALERT_NOTIFICATION_SCOPE : '', + options.scheduleId === 2 ? `${STOCK_ALERT_NOTIFICATION_SCOPE}:current-price` : '', + ].filter(Boolean))], + }; +} + +export async function sendManagedStockAlertWebPush(options: { + scheduleId: number; + serviceKey: string; + title: string; + mode: 'price' | 'change-threshold'; + thresholdPercent?: number; +}) { + const items = await listStockAlerts(options.mode === 'price' ? 'price' : 'all'); + const lines = options.mode === 'price' + ? buildCurrentPriceStockAlertLines(items) + : buildChangeRateThresholdStockAlertLines(items, Math.max(0, Number(options.thresholdPercent ?? 5))); + const hasRegisteredTargets = options.mode === 'price' + ? items.some((item) => item.alertTypes.includes('price')) + : items.length > 0; + const skippedReason = options.mode === 'price' + ? hasRegisteredTargets + ? '현재가 시세를 확인할 수 있는 종목이 없습니다.' + : '현재가로 등록된 종목이 없습니다.' + : hasRegisteredTargets + ? `${Math.max(0, Number(options.thresholdPercent ?? 5))}% 이상 변동 종목이 없습니다.` + : '등록된 종목이 없습니다.'; + const skippedResult = createSkippedNotificationResult(skippedReason); + + if (!lines.length) { + return { + ok: true, + skipped: true, + reason: skippedReason, + title: options.title, + body: '', + itemCount: 0, + lines: [], + ios: skippedResult.ios, + web: skippedResult.web, + }; + } + + const body = lines.join('\n'); + const notificationIdentity = buildStockAlertNotificationIdentity(options); + const result = await sendNotifications( + { + title: options.title, + body, + threadId: notificationIdentity.threadId, + targetAppDomains: [STOCK_ALERT_NOTIFICATION_TARGET_DOMAIN], + data: { + category: 'stock-alert', + eventType: options.mode === 'price' ? 'stock-alert-current-price' : 'stock-alert-change-threshold', + notificationKey: notificationIdentity.notificationKey, + notificationScope: notificationIdentity.notificationScope, + notificationAliases: JSON.stringify(notificationIdentity.notificationAliases), + replaceExistingScope: 'true', + source: options.serviceKey, + targetUrl: STOCK_ALERT_NOTIFICATION_TARGET_URL, + }, + }, + { + disableIos: true, + }, + ); + + return { + ...result, + title: options.title, + body, + itemCount: lines.length, + lines, + }; +} + +export async function sendCurrentPriceStockAlertWebPush() { + return sendManagedStockAlertWebPush({ + scheduleId: 2, + serviceKey: STOCK_ALERT_NOTIFICATION_SCOPE, + title: STOCK_ALERT_NOTIFICATION_TITLE, + mode: 'price', + }); +} + +export async function updateStockAlertLayoutFeatureDescription() { + await ensureStockAlertTable(); + + const layoutRecord = await db('play_layouts').select('id', 'tree').where({ name: STOCK_ALERT_LAYOUT_NAME }).first(); + + if (!layoutRecord || typeof layoutRecord !== 'object') { + return false; + } + + const tree = layoutRecord.tree as + | { + root?: unknown; + interactions?: Array<{ + id?: string; + title?: string; + description?: string; + implementationNotes?: string; + }>; + interactionMode?: string; + } + | null; + + if (!tree || !Array.isArray(tree.interactions)) { + return false; + } + + let changed = false; + const nextInteractions = tree.interactions.map((interaction) => { + const title = interaction.title?.trim(); + + if (title === '그리드 기본정의') { + const nextNotes = + 'GET /api/stock-alerts?alertType=... 로 조회하고 PUT /api/stock-alerts/batch, DELETE /api/stock-alerts/:id 로 저장/삭제합니다. 알림유형은 그리드 행에서 멀티선택 드롭다운으로 편집되며 한 종목에 멀티선택으로 저장됩니다. 현재가·등락률·기준일시는 네이버 실시간 시세를 우선 사용하고, 필요 시 Yahoo Finance를 보조로 사용하며 비정규장 가격도 반영합니다.'; + + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return { + ...interaction, + implementationNotes: nextNotes, + }; + } + } + + if (title === '얼림유형 검색') { + const nextNotes = + 'Primary Pane Select Input 값은 all, price, top3 코드로 유지하고 Secondary Pane 그리드 조회 파라미터 alertType 으로 바로 전달합니다.'; + + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return { + ...interaction, + implementationNotes: nextNotes, + }; + } + } + + if (title === '행추가 기능') { + const nextNotes = + 'GET /api/stock-alerts/search?query=... 로 종목명/종목코드를 조회하고, 선택한 종목코드·종목명·시장구분을 그리드 신규 행으로 매핑합니다. 동일 종목코드는 중복 추가를 막고, 선택 후 해당 행으로 포커스를 이동합니다.'; + + if (interaction.implementationNotes !== nextNotes) { + changed = true; + return { + ...interaction, + implementationNotes: nextNotes, + }; + } + } + + return interaction; + }); + + if (!changed) { + return false; + } + + await db('play_layouts') + .where({ id: layoutRecord.id as string }) + .update({ + tree: { + ...tree, + interactions: nextInteractions, + }, + }); + + return true; +} diff --git a/etc/servers/work-server/src/services/text-memo-service.ts b/etc/servers/work-server/src/services/text-memo-service.ts index b923cac..f17a027 100644 --- a/etc/servers/work-server/src/services/text-memo-service.ts +++ b/etc/servers/work-server/src/services/text-memo-service.ts @@ -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>; + 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; +} diff --git a/etc/servers/work-server/src/workers/plan-worker.test.ts b/etc/servers/work-server/src/workers/plan-worker.test.ts new file mode 100644 index 0000000..76cc92f --- /dev/null +++ b/etc/servers/work-server/src/workers/plan-worker.test.ts @@ -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'); +}); diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts index 9f82d39..6b796db 100755 --- a/etc/servers/work-server/src/workers/plan-worker.ts +++ b/etc/servers/work-server/src/workers/plan-worker.ts @@ -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, diff --git a/package-lock.json b/package-lock.json index 1f00128..46e6440 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0-alpha.0", "dependencies": { "@ant-design/icons": "^6.0.1", + "ag-grid-community": "^35.2.1", + "ag-grid-react": "^35.2.1", "antd": "^5.27.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -2595,6 +2597,35 @@ "node": ">=0.4.0" } }, + "node_modules/ag-charts-types": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-13.2.1.tgz", + "integrity": "sha512-r7veb3QqJtIKlXmeUsLR4/oDPwmHxFI2tmbZra/203mdaz3uwQUrrgYNg628nrK+7L2YxXnwGc6L05tWjLLjNQ==", + "license": "MIT" + }, + "node_modules/ag-grid-community": { + "version": "35.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-35.2.1.tgz", + "integrity": "sha512-ycmGI+1EbUT7i3eg/Kgi1owwnkdHXRufo10Xm6cfSsVPM3TMpvlbLgi28KIPt9DGHZWHq9fOBn7nxMNdv1Yaow==", + "license": "MIT", + "dependencies": { + "ag-charts-types": "13.2.1" + } + }, + "node_modules/ag-grid-react": { + "version": "35.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-35.2.1.tgz", + "integrity": "sha512-UzdU15R6fyGJB+lBKEC458xacGoZged3Ra6Plqa7LvrJ/Mg0tWn1NH01UnuKyGEKPWMEAGvdXruOtOUywsPElA==", + "license": "MIT", + "dependencies": { + "ag-grid-community": "35.2.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -4502,7 +4533,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsesc": { @@ -4862,6 +4892,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4951,6 +4993,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5183,6 +5234,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/package.json b/package.json index b0e6767..d263e35 100755 --- a/package.json +++ b/package.json @@ -50,6 +50,8 @@ }, "dependencies": { "@ant-design/icons": "^6.0.1", + "ag-grid-community": "^35.2.1", + "ag-grid-react": "^35.2.1", "antd": "^5.27.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/public/apple-touch-icon.svg b/public/apple-touch-icon.svg deleted file mode 100755 index b7e1aa8..0000000 --- a/public/apple-touch-icon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs index 4ad3839..791a418 100755 --- a/scripts/run-plan-codex-once.mjs +++ b/scripts/run-plan-codex-once.mjs @@ -26,11 +26,29 @@ const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024; const ERROR_SUMMARY_MAX_LENGTH = 500; const SOURCE_SNAPSHOT_TEXT_PATTERN = /\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i; +const NAVER_STOCK_ITEM_URL = 'https://finance.naver.com/item/main.naver?code='; +const DEFAULT_STOCK_ALERT_TITLE = '현재 주가'; +const STOCK_ALERT_COMPANIES = [ + { + code: '005930', + labels: ['삼성전자'], + pattern: /삼성전자/i, + }, + { + code: '000660', + labels: ['SK하이닉스', '하이닉스'], + pattern: /(?:sk\s*)?하이닉스|hynix/i, + }, +]; +const STOCK_ALERT_REGISTERED_CURRENT_PRICE_PATTERN = + /stock알림|현재가로 등록된 종목|등록된 알림유형이 현재가|현재가 알림.*등록된 종목/i; 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 CODEX_EXEC_MAX_ATTEMPTS = 2; const CODEX_EXEC_RETRY_DELAY_MS = 3000; const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6; @@ -47,6 +65,7 @@ const CODEX_HOME_RUNTIME_PATHS = [ 'models_cache.json', 'version.json', ]; +const ZERO_TOKEN_USAGE_TEXT = 'total 0 input 0 output 0 cached 0 reasoning 0'; function parseCodexTokenUsage(output) { const text = stripAnsi(String(output ?? '')); @@ -99,6 +118,11 @@ function parseCodexTokenUsage(output) { .trim(); } +function buildTokenUsageLine(tokenUsage) { + const normalized = String(tokenUsage ?? '').trim(); + return `토큰 사용량: ${normalized || ZERO_TOKEN_USAGE_TEXT}`; +} + function reportProgress(message) { const text = String(message ?? '').replace(/\s+/g, ' ').trim(); @@ -506,7 +530,8 @@ function summarizeFailureOutput(output, fallback) { .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) ?? @@ -577,6 +602,18 @@ function summarizeRequest(note, limit = PROMPT_REQUEST_SUMMARY_LIMIT) { return `${text.slice(0, Math.max(0, limit - 1)).trim()}…`; } +function truncateInlineText(value, maxLength = 400) { + const normalized = String(value ?? '') + .replace(/\s+/g, ' ') + .trim(); + + if (!normalized) { + return ''; + } + + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + function escapeTemplateValue(value) { return encodeURIComponent(String(value ?? '').trim()); } @@ -636,7 +673,7 @@ function formatRecentActionHistories(histories) { .slice(0, 5) .map( (history, index) => - `${index + 1}. [${history.actionType}] ${history.createdAt}\n${String(history.note ?? '').trim()}`, + `${index + 1}. [${history.actionType}] ${history.createdAt}\n${truncateInlineText(history.note, 600)}`, ) .join('\n\n'); } @@ -649,12 +686,297 @@ function formatRecentIssueHistories(histories) { return histories .slice(0, 5) .map((history, index) => { - const actionNote = history.actionNote ? `\n조치이력:\n${String(history.actionNote).trim()}` : ''; - return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`; + const actionNote = history.actionNote ? `\n조치이력:\n${truncateInlineText(history.actionNote, 500)}` : ''; + return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${truncateInlineText(history.message, 500)}${actionNote}`; }) .join('\n\n'); } +function isStockAlertRequest(note) { + const text = extractPrimaryRequestText(note); + const isExplicitCompanyRequest = /주가/.test(text) && STOCK_ALERT_COMPANIES.some((item) => item.pattern.test(text)); + return isExplicitCompanyRequest || isRegisteredCurrentPriceStockAlertRequest(text); +} + +function isRegisteredCurrentPriceStockAlertRequest(noteOrText) { + const text = extractPrimaryRequestText(noteOrText); + return STOCK_ALERT_REGISTERED_CURRENT_PRICE_PATTERN.test(text); +} + +function extractPrimaryRequestText(note) { + const text = String(note ?? ''); + const requestBodyMatch = text.match(/## 요청 본문\s*([\s\S]*?)(?:\n##\s+|\s*$)/); + return (requestBodyMatch?.[1] ?? text).trim(); +} + +function extractTargetAppDomains(note) { + const text = extractPrimaryRequestText(note); + const matches = text.match(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi) ?? []; + + return [...new Set(matches.map((value) => value.trim().toLowerCase()).filter(Boolean))]; +} + +function resolveRequestedStockCompanies(note) { + const text = extractPrimaryRequestText(note); + return STOCK_ALERT_COMPANIES.filter((item) => item.pattern.test(text)); +} + +function stripHtmlTags(value) { + return String(value ?? '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function parseQuotedNumber(value) { + const normalized = String(value ?? '').replace(/[^\d.-]/g, ''); + return normalized ? Number(normalized) : NaN; +} + +async function fetchNaverStockPage(code) { + const response = await fetch(`${NAVER_STOCK_ITEM_URL}${code}`, { + headers: { + 'user-agent': 'Mozilla/5.0 (compatible; ai-code-app/1.0; +https://test.sm-home.cloud/)', + accept: 'text/html,application/xhtml+xml', + 'accept-language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', + }, + }); + + if (!response.ok) { + throw new Error(`주가 페이지를 불러오지 못했습니다. code=${code}, status=${response.status}`); + } + + return response.text(); +} + +function parseNaverStockQuote(html, code) { + const infoMatch = html.match( + new RegExp( + `
\\s*종목명\\s*([^<]+?)\\s*
[\\s\\S]*?
\\s*종목코드\\s*${code}[^<]*
[\\s\\S]*?
\\s*현재가\\s*([\\d,]+)\\s*전일대비\\s*(상승|하락|보합)\\s*([\\d,]+)?[\\s\\S]*?([\\d.]+)\\s*퍼센트`, + 'i', + ), + ); + const timeMatch = html.match(/\s*([^<]+?)\s*([^<]+)<\/span>\s*<\/em>/i); + + if (!infoMatch) { + throw new Error(`주가 페이지 파싱에 실패했습니다. code=${code}`); + } + + const name = stripHtmlTags(infoMatch[1]); + const priceText = String(infoMatch[2] ?? '').trim(); + const direction = String(infoMatch[3] ?? '').trim(); + const changeText = String(infoMatch[4] ?? '0').trim() || '0'; + const percentText = String(infoMatch[5] ?? '0').trim() || '0'; + const price = parseQuotedNumber(priceText); + const change = parseQuotedNumber(changeText); + const changePercent = Number(percentText); + + if (!Number.isFinite(price) || !Number.isFinite(change) || !Number.isFinite(changePercent)) { + throw new Error(`주가 숫자 파싱에 실패했습니다. code=${code}`); + } + + return { + code, + name, + price, + priceText, + direction, + change, + changeText, + changePercent, + changePercentText: Number(changePercent).toFixed(2), + quotedAt: timeMatch ? `${stripHtmlTags(timeMatch[1])} ${stripHtmlTags(timeMatch[2])}`.trim() : '', + }; +} + +function formatStockAlertPercent(quote) { + if (quote.direction === '상승') { + return `(+${quote.changePercentText}% ▲)`; + } + + if (quote.direction === '하락') { + return `(-${quote.changePercentText}% ▼)`; + } + + return `(0.00% -)`; +} + +function buildStockAlertBodyLines(quotes) { + return quotes.map((quote) => `${quote.name} ${quote.priceText}₩ ${formatStockAlertPercent(quote)}`); +} + +async function processStockAlertPlan(item) { + if (isRegisteredCurrentPriceStockAlertRequest(item.note)) { + return processRegisteredCurrentPriceStockAlertPlan(item); + } + + const requestedCompanies = resolveRequestedStockCompanies(item.note); + + if (requestedCompanies.length === 0) { + throw new Error('주가 요청에서 대상 종목을 찾지 못했습니다.'); + } + + const targetAppDomains = extractTargetAppDomains(item.note); + + if (targetAppDomains.length === 0) { + throw new Error('주가 알림 요청에서 대상 앱 도메인을 찾지 못했습니다.'); + } + + reportProgress(`주가 알림 요청을 감지해 ${requestedCompanies.length}개 종목 시세를 조회하는 중입니다.`); + const quotes = await Promise.all( + requestedCompanies.map(async (company) => parseNaverStockQuote(await fetchNaverStockPage(company.code), company.code)), + ); + const bodyLines = buildStockAlertBodyLines(quotes); + const notificationKey = [ + 'stock-alert', + targetAppDomains.join(','), + quotes.map((quote) => quote.code).join(','), + ].join(':'); + const sendResult = await request('/notifications/send', { + method: 'POST', + body: JSON.stringify({ + title: DEFAULT_STOCK_ALERT_TITLE, + body: bodyLines.join('\n'), + threadId: `stock-alert:${targetAppDomains.join(',')}`, + targetAppDomains, + data: { + category: 'stock-alert', + eventType: 'stock-alert', + notificationKey, + source: 'finance.naver.com', + symbols: quotes.map((quote) => quote.code).join(','), + }, + }), + }); + + const webSentCount = Number(sendResult?.web?.sentCount ?? 0); + const webFailedCount = Number(sendResult?.web?.failedCount ?? 0); + + if (webSentCount <= 0) { + throw new Error( + `대상 도메인(${targetAppDomains.join(', ')})에 보낼 Web Push 구독이 없거나 발송에 실패했습니다. failed=${webFailedCount}`, + ); + } + + const summary = [ + `주가 알림 전송 완료: ${targetAppDomains.join(', ')}`, + ...bodyLines, + ].join('\n'); + const tokenUsageLine = buildTokenUsageLine(null); + + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary, + branchName: item.assignedBranch || item.releaseTarget || 'main', + commitHash: null, + changedFiles: [], + commandLog: [ + ...requestedCompanies.map((company) => `fetch ${NAVER_STOCK_ITEM_URL}${company.code}`), + tokenUsageLine, + `POST /api/notifications/send targetAppDomains=${targetAppDomains.join(',')}`, + ].join('\n'), + diffText: null, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '자동완료메모', + actionNote: [ + summary, + tokenUsageLine, + quotes.some((quote) => quote.quotedAt) ? `시세 기준: ${quotes.map((quote) => quote.quotedAt).filter(Boolean).join(' / ')}` : null, + `notificationKey: ${notificationKey}`, + `web sent=${webSentCount}, failed=${webFailedCount}`, + ] + .filter(Boolean) + .join('\n'), + }), + }); + + await request(`/plan/items/${item.id}/actions/complete`, { + method: 'POST', + body: JSON.stringify({ + note: `주가 알림을 ${targetAppDomains.join(', ')} 대상으로 전송했습니다.`, + }), + }); + + return { + id: item.id, + outcome: 'stock-alert-complete', + targetAppDomains, + quotes, + sendResult, + }; +} + +async function processRegisteredCurrentPriceStockAlertPlan(item) { + reportProgress('주가 알림 요청을 감지해 stock알림 현재가 등록 종목 기준으로 웹푸시를 전송하는 중입니다.'); + const sendResult = await request('/stock-alerts/notify-current-price', { + method: 'POST', + }); + const lines = Array.isArray(sendResult?.lines) + ? sendResult.lines.map((value) => String(value ?? '').trim()).filter(Boolean) + : []; + const itemCount = Number(sendResult?.itemCount ?? lines.length ?? 0); + const summary = [ + sendResult?.skipped ? '주가 알림 전송 건너뜀' : '주가 알림 전송 완료', + `대상 도메인: test.sm-home.cloud`, + lines.length > 0 ? lines.join('\n') : String(sendResult?.web?.reason ?? sendResult?.ios?.reason ?? sendResult?.reason ?? '').trim(), + ] + .filter(Boolean) + .join('\n'); + const tokenUsageLine = buildTokenUsageLine(null); + + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary, + branchName: item.assignedBranch || item.releaseTarget || 'main', + commitHash: null, + changedFiles: [], + commandLog: [tokenUsageLine, 'POST /api/stock-alerts/notify-current-price'].filter(Boolean).join('\n'), + diffText: null, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '자동완료메모', + actionNote: [ + summary, + tokenUsageLine, + `itemCount=${Number.isFinite(itemCount) ? itemCount : lines.length}`, + `web sent=${Number(sendResult?.web?.sentCount ?? 0)}, failed=${Number(sendResult?.web?.failedCount ?? 0)}`, + ] + .filter(Boolean) + .join('\n'), + }), + }); + + await request(`/plan/items/${item.id}/actions/complete`, { + method: 'POST', + body: JSON.stringify({ + note: sendResult?.skipped + ? '현재가 알림 대상이 없어 웹푸시를 건너뛰었습니다.' + : 'stock알림 현재가 등록 종목 대상으로 웹푸시를 전송했습니다.', + }), + }); + + return { + id: item.id, + outcome: sendResult?.skipped ? 'noop-complete' : 'stock-alert-complete', + targetAppDomains: ['test.sm-home.cloud'], + itemCount, + lines, + sendResult, + }; +} + async function runCommand(command, args) { if (command === 'git') { await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], { @@ -1296,6 +1618,23 @@ async function processErrorLogReviewPlan(item) { } const summary = summaryLines.join('\n'); + const tokenUsageLine = buildTokenUsageLine(null); + + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary, + branchName: item.assignedBranch || item.releaseTarget || 'main', + commitHash: null, + changedFiles: [], + commandLog: [ + 'POST /api/plan/registrations/error-logs', + tokenUsageLine, + 'ERROR_LOG_REVIEW: 저장소 파일 변경 없음', + ].join('\n'), + diffText: null, + }), + }); await request(`/plan/items/${item.id}/actions/note`, { method: 'POST', @@ -1303,6 +1642,7 @@ async function processErrorLogReviewPlan(item) { actionType: '에러로그점검', actionNote: [ summary, + tokenUsageLine, '', 'Plan 게시판 등록 전용 요청으로 처리했고, 저장소 소스 수정 이력은 남기지 않았습니다.', ].join('\n'), @@ -1330,12 +1670,16 @@ async function processPlan(item) { return processErrorLogReviewPlan(item); } + if (isStockAlertRequest(item.note)) { + return processStockAlertPlan(item); + } + const baselineChangedFiles = localMainMode ? await listChangedFiles() : []; const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles)); const codexResult = await runCodexForPlan(item); const result = codexResult.message; const tokenUsage = codexResult.tokenUsage; - const tokenUsageLine = tokenUsage ? `토큰 사용량: ${tokenUsage}` : null; + const tokenUsageLine = buildTokenUsageLine(tokenUsage); const summary = result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '요청 검토를 완료했습니다.'; const boardPost = parseBoardPostResult(result); diff --git a/src/app/main/AutomationContextManagementPage.tsx b/src/app/main/AutomationContextManagementPage.tsx new file mode 100644 index 0000000..e68ad77 --- /dev/null +++ b/src/app/main/AutomationContextManagementPage.tsx @@ -0,0 +1,284 @@ +import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { MarkdownPreviewContent } from '../../components/markdownPreview'; +import { + deleteAutomationContext, + type AutomationContextRecord, + upsertAutomationContext, + useAutomationContextRegistry, +} from './automationContextAccess'; +import { useTokenAccess } from './tokenAccess'; +import './ChatTypeManagementPage.css'; + +const { Text, Title } = Typography; + +type AutomationContextFormValue = { + id?: string; + title: string; + content: string; + enabled: boolean; + defaultSelected: boolean; +}; + +const EMPTY_FORM_VALUE: AutomationContextFormValue = { + title: '', + content: '', + enabled: true, + defaultSelected: true, +}; + +function toFormValue(context: AutomationContextRecord | null): AutomationContextFormValue { + if (!context) { + return EMPTY_FORM_VALUE; + } + + return { + id: context.id, + title: context.title, + content: context.content, + enabled: context.enabled, + defaultSelected: context.defaultSelected, + }; +} + +export function AutomationContextManagementPage() { + const { hasAccess } = useTokenAccess(); + const { automationContexts, setAutomationContexts, isLoading, errorMessage } = useAutomationContextRegistry(); + const [selectedAutomationContextId, setSelectedAutomationContextId] = useState(automationContexts[0]?.id ?? null); + const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); + const [isCreating, setIsCreating] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveErrorMessage, setSaveErrorMessage] = useState(''); + const [form] = Form.useForm(); + + const selectedAutomationContext = useMemo( + () => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null, + [automationContexts, selectedAutomationContextId], + ); + + useEffect(() => { + if (selectedAutomationContextId && automationContexts.some((item) => item.id === selectedAutomationContextId)) { + return; + } + + setSelectedAutomationContextId(automationContexts[0]?.id ?? null); + }, [automationContexts, selectedAutomationContextId]); + + useEffect(() => { + if (detailMode !== 'detail') { + return; + } + + form.resetFields(); + form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext)); + }, [detailMode, form, isCreating, selectedAutomationContext]); + + const openCreateForm = () => { + setIsCreating(true); + setSelectedAutomationContextId(null); + setDetailMode('detail'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + }; + + const openDetail = (automationContextId: string) => { + setIsCreating(false); + setSelectedAutomationContextId(automationContextId); + setDetailMode('detail'); + }; + + const closeDetail = () => { + setIsCreating(false); + setDetailMode('list'); + }; + + const handleDelete = async () => { + if (!selectedAutomationContext) { + return; + } + + if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) { + return; + } + + const nextAutomationContexts = deleteAutomationContext(automationContexts, selectedAutomationContext.id); + setIsSaving(true); + setSaveErrorMessage(''); + + try { + const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts); + setSelectedAutomationContextId(savedAutomationContexts[0]?.id ?? null); + setIsCreating(false); + setDetailMode('list'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + } catch (error) { + setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 삭제에 실패했습니다.'); + } finally { + setIsSaving(false); + } + }; + + if (!hasAccess) { + return ( + + + + ); + } + + return ( +
+ {detailMode === 'list' ? ( + } onClick={openCreateForm}> + 신규 Context + + } + > +
+ {errorMessage ? : null} + {saveErrorMessage ? : null} +
+ 등록 Context + {isLoading ? '불러오는 중' : `${automationContexts.length}건`} +
+ {automationContexts.length > 0 ? ( + ( + openDetail(item.id)} + actions={[ +
+
+ ) : ( + +
+ ); +} diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index d1422ed..4ac3551 100755 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -124,6 +124,24 @@ export function ChatTypeManagementPage() { }; }, []); + useEffect(() => { + if (typeof document === 'undefined' || !isMobileViewport) { + return; + } + + const { body, documentElement } = document; + const previousBodyOverflow = body.style.overflow; + const previousHtmlOverflow = documentElement.style.overflow; + + body.style.overflow = 'hidden'; + documentElement.style.overflow = 'hidden'; + + return () => { + body.style.overflow = previousBodyOverflow; + documentElement.style.overflow = previousHtmlOverflow; + }; + }, [isMobileViewport]); + const openCreateForm = () => { setIsCreating(true); setSelectedChatTypeId(null); @@ -151,7 +169,7 @@ export function ChatTypeManagementPage() { return; } - if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) { + if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) { return; } @@ -197,13 +215,13 @@ export function ChatTypeManagementPage() { /> {!isCreating && selectedChatType ? ( - + + + + + + ); +} diff --git a/src/app/main/mainChatPanel/ChatPreviewBody.tsx b/src/app/main/mainChatPanel/ChatPreviewBody.tsx index 1ccfeb2..fdcf46a 100755 --- a/src/app/main/mainChatPanel/ChatPreviewBody.tsx +++ b/src/app/main/mainChatPanel/ChatPreviewBody.tsx @@ -9,7 +9,7 @@ import { PictureOutlined, VideoCameraOutlined, } from '@ant-design/icons'; -import { Alert, Button, Empty, Space, Spin, Typography } from 'antd'; +import { Alert, Button, Empty, Space, Spin, Typography, message } from 'antd'; import { InlineImage } from '../../../components/common/InlineImage'; import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent'; import { CodexDiffBlock } from '../../../components/previewer'; @@ -326,6 +326,13 @@ export function ChatPreviewBody({ return