feat: expand live chat and work server tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`로 사용해 이전 알림을 대체합니다.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
|
||||
146
etc/servers/work-server/src/routes/stock-alert.ts
Normal file
146
etc/servers/work-server/src/routes/stock-alert.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
STOCK_ALERT_TYPE_OPTIONS,
|
||||
createStockAlert,
|
||||
deleteStockAlert,
|
||||
listStockAlerts,
|
||||
sendCurrentPriceStockAlertWebPush,
|
||||
searchStockAlertCandidates,
|
||||
saveStockAlerts,
|
||||
updateStockAlert,
|
||||
updateStockAlertLayoutFeatureDescription,
|
||||
} from '../services/stock-alert-service.js';
|
||||
|
||||
const filterTypeSchema = z.enum(STOCK_ALERT_TYPE_OPTIONS.map((option) => option.value) as [string, ...string[]]).default('all');
|
||||
|
||||
const stockAlertMutationSchema = z
|
||||
.object({
|
||||
id: z.string().trim().optional(),
|
||||
stockCode: z.string().trim().optional(),
|
||||
stockName: z.string().trim().optional(),
|
||||
alertType: z.string().trim().optional(),
|
||||
alertTypes: z.array(z.string().trim()).optional(),
|
||||
})
|
||||
.transform((value) => ({
|
||||
id: value.id,
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
alertTypes: value.alertTypes?.length
|
||||
? value.alertTypes
|
||||
: value.alertType?.trim()
|
||||
? [value.alertType.trim()]
|
||||
: [],
|
||||
}));
|
||||
|
||||
const stockAlertMutationBodySchema = z
|
||||
.object({
|
||||
stockCode: z.string().trim().optional(),
|
||||
stockName: z.string().trim().optional(),
|
||||
alertType: z.string().trim().optional(),
|
||||
alertTypes: z.array(z.string().trim()).optional(),
|
||||
})
|
||||
.transform((value) => ({
|
||||
stockCode: value.stockCode,
|
||||
stockName: value.stockName,
|
||||
alertTypes: value.alertTypes?.length
|
||||
? value.alertTypes
|
||||
: value.alertType?.trim()
|
||||
? [value.alertType.trim()]
|
||||
: [],
|
||||
}));
|
||||
|
||||
export async function registerStockAlertRoutes(app: FastifyInstance) {
|
||||
app.get('/api/stock-alerts/search', async (request) => {
|
||||
const query = z
|
||||
.object({
|
||||
query: z.string().trim().min(1),
|
||||
limit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
})
|
||||
.parse(request.query ?? {});
|
||||
|
||||
const items = await searchStockAlertCandidates(query.query, query.limit ?? 20);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/stock-alerts', async (request) => {
|
||||
const query = z
|
||||
.object({
|
||||
alertType: filterTypeSchema.optional(),
|
||||
})
|
||||
.parse(request.query ?? {});
|
||||
const alertType = (query.alertType ?? 'all') as 'all' | 'price' | 'top3';
|
||||
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
const items = await listStockAlerts(alertType);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/stock-alerts/notify-current-price', async () => {
|
||||
const result = await sendCurrentPriceStockAlertWebPush();
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
skipped: Boolean(result.web?.skipped),
|
||||
title: result.title,
|
||||
body: result.body,
|
||||
itemCount: result.itemCount,
|
||||
lines: result.lines,
|
||||
ios: result.ios,
|
||||
web: result.web,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/stock-alerts', async (request) => {
|
||||
const payload = stockAlertMutationSchema.parse(request.body ?? {});
|
||||
const item = await createStockAlert(payload);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.patch('/api/stock-alerts/:id', async (request) => {
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
const payload = stockAlertMutationBodySchema.parse(request.body ?? {});
|
||||
const item = await updateStockAlert(params.id, payload);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/stock-alerts/batch', async (request) => {
|
||||
const payload = z.object({ items: z.array(stockAlertMutationSchema).default([]) }).parse(request.body ?? {});
|
||||
const items = await saveStockAlerts(payload.items);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/stock-alerts/:id', async (request) => {
|
||||
const params = z.object({ id: z.string().trim().min(1) }).parse(request.params ?? {});
|
||||
await deleteStockAlert(params.id);
|
||||
await updateStockAlertLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: params.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
textMemoNoteCreateSchema,
|
||||
textMemoNoteImportSchema,
|
||||
textMemoNoteUpdateSchema,
|
||||
updateTextMemoLayoutFeatureDescription,
|
||||
updateTextMemoNote,
|
||||
} from '../services/text-memo-service.js';
|
||||
|
||||
@@ -17,6 +18,7 @@ function resolveClientId(headers: Record<string, unknown>) {
|
||||
|
||||
export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.get('/api/text-memo/notes', async (request) => {
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
const items = await listTextMemoNotes(resolveClientId(request.headers));
|
||||
return {
|
||||
ok: true,
|
||||
@@ -27,6 +29,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.post('/api/text-memo/notes', async (request) => {
|
||||
const payload = textMemoNoteCreateSchema.parse(request.body ?? {});
|
||||
const item = await createTextMemoNote(resolveClientId(request.headers), payload);
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -37,6 +40,7 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
app.post('/api/text-memo/notes/import', async (request) => {
|
||||
const payload = textMemoNoteImportSchema.parse(request.body ?? {});
|
||||
const items = await importTextMemoNotes(resolveClientId(request.headers), payload);
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -55,6 +59,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
item,
|
||||
@@ -71,6 +77,8 @@ export async function registerTextMemoRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
await updateTextMemoLayoutFeatureDescription().catch(() => false);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const AUTOMATION_CONTEXTS_TABLE = 'automation_contexts';
|
||||
|
||||
export type AutomationContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
defaultSelected: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: false,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content:
|
||||
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeEnabled(value: unknown) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalizedValue = value.trim().toLowerCase();
|
||||
|
||||
if (['false', '0', 'n', 'no', 'off'].includes(normalizedValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (['true', '1', 'y', 'yes', 'on'].includes(normalizedValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
function buildContextTitleKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
|
||||
const title = normalizeText(record.title);
|
||||
const content = normalizeText(record.content);
|
||||
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, AutomationContextRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationContextRecord>();
|
||||
|
||||
(items ?? [])
|
||||
.map((item) => normalizeAutomationContext(item))
|
||||
.filter((item): item is AutomationContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = buildContextTitleKey(item.title);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
|
||||
}
|
||||
|
||||
async function ensureAutomationContextsTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_CONTEXTS_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('title').notNullable();
|
||||
table.text('content').notNullable().defaultTo('');
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.boolean('default_selected').notNullable().defaultTo(false);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['title', (table) => table.string('title').notNullable().defaultTo('')],
|
||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['default_selected', (table) => table.boolean('default_selected').notNullable().defaultTo(false)],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(AUTOMATION_CONTEXTS_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(AUTOMATION_CONTEXTS_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseContextsFromLegacyValue(value: unknown) {
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toAutomationContextRecord(row: Record<string, unknown>) {
|
||||
return normalizeAutomationContext({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
title: typeof row.title === 'string' ? row.title : undefined,
|
||||
content: typeof row.content === 'string' ? row.content : undefined,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
defaultSelected: normalizeEnabled(row.default_selected),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceAutomationContextsInTable(items: AutomationContextRecord[]) {
|
||||
await ensureAutomationContextsTable();
|
||||
|
||||
const nextItems = sanitizeAutomationContexts(items);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(AUTOMATION_CONTEXTS_TABLE).del();
|
||||
await trx(AUTOMATION_CONTEXTS_TABLE).insert(
|
||||
nextItems.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
enabled: item.enabled,
|
||||
default_selected: item.defaultSelected,
|
||||
updated_at: item.updatedAt,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
async function seedAutomationContextsFromLegacySources() {
|
||||
const seededItems: Partial<AutomationContextRecord>[] = [...DEFAULT_AUTOMATION_CONTEXTS];
|
||||
|
||||
const hasAutomationTypesTable = await db.schema.hasTable('automation_types');
|
||||
if (hasAutomationTypesTable) {
|
||||
const rows = await db('automation_types').select('contexts_json');
|
||||
for (const row of rows) {
|
||||
seededItems.push(...parseContextsFromLegacyValue((row as Record<string, unknown>).contexts_json));
|
||||
}
|
||||
}
|
||||
|
||||
return replaceAutomationContextsInTable(sanitizeAutomationContexts(seededItems));
|
||||
}
|
||||
|
||||
function isSameAutomationContextList(left: AutomationContextRecord[], right: AutomationContextRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.title === target.title &&
|
||||
item.content === target.content &&
|
||||
item.enabled === target.enabled &&
|
||||
item.defaultSelected === target.defaultSelected &&
|
||||
item.updatedAt === target.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDefaultAutomationContexts(items: AutomationContextRecord[]) {
|
||||
const byId = new Map(items.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_AUTOMATION_CONTEXTS) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
title: defaultItem.title,
|
||||
content: existingItem.content || defaultItem.content,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeAutomationContexts(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
async function readAutomationContextsFromTable() {
|
||||
await ensureAutomationContextsTable();
|
||||
|
||||
const rows = await db(AUTOMATION_CONTEXTS_TABLE)
|
||||
.select('id', 'title', 'content', 'enabled', 'default_selected', 'updated_at')
|
||||
.orderBy('title', 'asc');
|
||||
|
||||
const savedItems = rows
|
||||
.map((row) => toAutomationContextRecord(row as Record<string, unknown>))
|
||||
.filter((item): item is AutomationContextRecord => Boolean(item));
|
||||
|
||||
return sanitizeAutomationContexts(savedItems);
|
||||
}
|
||||
|
||||
export async function getAutomationContextsConfig() {
|
||||
const savedContexts = await readAutomationContextsFromTable();
|
||||
|
||||
if (savedContexts.length === 0 || savedContexts === DEFAULT_AUTOMATION_CONTEXTS) {
|
||||
return seedAutomationContextsFromLegacySources();
|
||||
}
|
||||
|
||||
const mergedContexts = mergeDefaultAutomationContexts(savedContexts);
|
||||
if (!isSameAutomationContextList(savedContexts, mergedContexts)) {
|
||||
await replaceAutomationContextsInTable(mergedContexts);
|
||||
}
|
||||
|
||||
return mergedContexts;
|
||||
}
|
||||
|
||||
export async function upsertAutomationContextsConfig(items: unknown[]) {
|
||||
const nextContexts = mergeDefaultAutomationContexts(
|
||||
sanitizeAutomationContexts(Array.isArray(items) ? (items as Partial<AutomationContextRecord>[]) : []),
|
||||
);
|
||||
|
||||
return replaceAutomationContextsInTable(nextContexts);
|
||||
}
|
||||
|
||||
export function normalizeAutomationContextSelection(value: unknown) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function resolveAutomationContexts(
|
||||
contexts: AutomationContextRecord[] | null | undefined,
|
||||
selectedContextIds?: unknown,
|
||||
) {
|
||||
const normalizedContexts = sanitizeAutomationContexts(contexts);
|
||||
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (requestedIds.length === 0) {
|
||||
return normalizedContexts.filter((item) => item.enabled && item.defaultSelected);
|
||||
}
|
||||
|
||||
const requestedIdSet = new Set(requestedIds);
|
||||
return normalizedContexts.filter((item) => requestedIdSet.has(item.id));
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import path from 'node:path';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import {
|
||||
type AutomationContextRecord,
|
||||
getAutomationContextsConfig,
|
||||
normalizeAutomationContextSelection,
|
||||
resolveAutomationContexts,
|
||||
} from './automation-context-config-service.js';
|
||||
import type { AutomationTypeRecord } from './automation-type-config-service.js';
|
||||
|
||||
export function stringifyAutomationContextIds(value: unknown) {
|
||||
return JSON.stringify(normalizeAutomationContextSelection(value));
|
||||
}
|
||||
|
||||
export function parseAutomationContextIds(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return normalizeAutomationContextSelection(value);
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return normalizeAutomationContextSelection(parsed);
|
||||
} catch {
|
||||
return normalizeAutomationContextSelection(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAutomationContextMarkdown(
|
||||
contexts: Awaited<ReturnType<typeof getAutomationContextsConfig>> | null | undefined,
|
||||
selectedContextIds?: unknown,
|
||||
) {
|
||||
const resolvedContexts = resolveAutomationContexts(contexts, selectedContextIds);
|
||||
|
||||
if (resolvedContexts.length === 0) {
|
||||
return '선택된 자동화 Context 없음';
|
||||
}
|
||||
|
||||
return resolvedContexts
|
||||
.map((item) => [`### ${item.title}`, item.content.trim() || '(내용 없음)'].join('\n'))
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export async function buildAutomationNoteSections(options: {
|
||||
title?: string;
|
||||
sourceLabel: string;
|
||||
requestContent: string;
|
||||
attachments?: string[];
|
||||
automationType?: Pick<AutomationTypeRecord, 'name'> | null;
|
||||
availableContexts?: AutomationContextRecord[];
|
||||
selectedContextIds?: unknown;
|
||||
extraSections?: string[];
|
||||
}) {
|
||||
const availableContexts = options.availableContexts ?? (await getAutomationContextsConfig());
|
||||
const lines = [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
options.title?.trim() ? `- 게시판 제목: ${options.title.trim()}` : null,
|
||||
`- 메모 출처: ${options.sourceLabel}`,
|
||||
options.automationType?.name?.trim() ? `- 선택 자동화 유형: ${options.automationType.name.trim()}` : null,
|
||||
'- 자동화 처리 원칙: 아래에서 선택된 자동화 Context만 우선 참조합니다.',
|
||||
'',
|
||||
'## 자동화 Context',
|
||||
buildAutomationContextMarkdown(availableContexts, options.selectedContextIds),
|
||||
'',
|
||||
...(options.extraSections ?? []),
|
||||
'## 요청 본문',
|
||||
options.requestContent.trim(),
|
||||
...(options.attachments?.length ? ['', '## 첨부 파일', ...options.attachments] : []),
|
||||
];
|
||||
|
||||
return lines.filter((line): line is string => line !== null && line !== undefined).join('\n');
|
||||
}
|
||||
|
||||
function extractRequestedPaths(note: string) {
|
||||
const matches = note.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g) ?? [];
|
||||
return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))];
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath: string) {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function limitText(value: string, maxChars = 12000) {
|
||||
const normalized = value.trim();
|
||||
return normalized.length <= maxChars ? normalized : `${normalized.slice(0, maxChars).trimEnd()}\n\n...`;
|
||||
}
|
||||
|
||||
function getScheduleRepoRoot() {
|
||||
const env = getEnv();
|
||||
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
|
||||
}
|
||||
|
||||
export async function ensureSchedulePromptSnapshot(options: {
|
||||
scheduleId: number;
|
||||
workId: string;
|
||||
note: string;
|
||||
forceRefresh?: boolean;
|
||||
}) {
|
||||
const repoRoot = getScheduleRepoRoot();
|
||||
const scheduleDir = path.join(repoRoot, '.auto_codex', 'schedule', String(options.scheduleId));
|
||||
await mkdir(scheduleDir, { recursive: true });
|
||||
|
||||
const requestPath = path.join(scheduleDir, 'request.md');
|
||||
const contextPath = path.join(scheduleDir, 'context.md');
|
||||
const manifestPath = path.join(scheduleDir, 'manifest.json');
|
||||
|
||||
const requestedPaths = extractRequestedPaths(options.note);
|
||||
const candidatePaths = [
|
||||
'AGENTS.md',
|
||||
'docs/README.md',
|
||||
...requestedPaths.filter((item) => !item.startsWith('http://') && !item.startsWith('https://')),
|
||||
];
|
||||
const uniqueRelativePaths = [...new Set(candidatePaths)];
|
||||
|
||||
const references: string[] = [];
|
||||
|
||||
for (const relativePath of uniqueRelativePaths) {
|
||||
const absolutePath = path.resolve(repoRoot, relativePath);
|
||||
const content = await tryReadFile(absolutePath);
|
||||
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
references.push(`## ${relativePath}\n\n\`\`\`\n${limitText(content)}\n\`\`\``);
|
||||
}
|
||||
|
||||
const requestMarkdown = [
|
||||
'# 스케줄 요청 원문',
|
||||
'',
|
||||
`- 스케줄 ID: ${options.scheduleId}`,
|
||||
`- 작업 ID: ${options.workId}`,
|
||||
'',
|
||||
'## 원본 메모',
|
||||
options.note.trim() || '(비어 있음)',
|
||||
].join('\n');
|
||||
|
||||
const contextMarkdown = [
|
||||
'# 스케줄 전용 참조',
|
||||
'',
|
||||
'- 최초 활성화 시점에 읽은 요청/문서/소스 일부를 이 디렉터리 아래로 정리했습니다.',
|
||||
'- 이후 자동화 실행은 우선 이 디렉터리의 Markdown 문서를 참조하고, 원본 소스 재탐색은 꼭 필요할 때만 제한적으로 수행합니다.',
|
||||
'',
|
||||
...(references.length > 0 ? references : ['## 참조 문서', '별도로 추출된 문서가 없습니다. request.md를 우선 참조합니다.']),
|
||||
].join('\n\n');
|
||||
|
||||
await writeFile(requestPath, `${requestMarkdown}\n`, 'utf8');
|
||||
await writeFile(contextPath, `${contextMarkdown}\n`, 'utf8');
|
||||
await writeFile(
|
||||
manifestPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
scheduleId: options.scheduleId,
|
||||
workId: options.workId,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
forceRefresh: Boolean(options.forceRefresh),
|
||||
sourcePaths: uniqueRelativePaths,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const relativeDir = path.relative(repoRoot, scheduleDir).replace(/\\/g, '/');
|
||||
return {
|
||||
directory: relativeDir,
|
||||
requestPath: `${relativeDir}/request.md`,
|
||||
contextPath: `${relativeDir}/context.md`,
|
||||
manifestPath: `${relativeDir}/manifest.json`,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -14,21 +14,59 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
|
||||
|
||||
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
|
||||
|
||||
export type AutomationTypeContextRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
defaultSelected: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AutomationTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
contexts: AutomationTypeContextRecord[];
|
||||
behaviorType: AutomationBehaviorType;
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description: '일반 문의/확인 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'general-inquiry-default',
|
||||
title: '기본 확인',
|
||||
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
description: '기본 자동화 처리용 유형입니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'none-default',
|
||||
title: '기본 처리',
|
||||
content:
|
||||
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -37,6 +75,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'plan',
|
||||
name: '작업 요청 등록',
|
||||
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'plan-default',
|
||||
title: '문서형 처리',
|
||||
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'plan',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -45,6 +93,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'command_execution',
|
||||
name: 'Command 실행',
|
||||
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'command-execution-default',
|
||||
title: '명령 실행',
|
||||
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'command_execution',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -53,6 +111,16 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'non_source_work',
|
||||
name: '비 소스작업',
|
||||
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'non-source-work-default',
|
||||
title: '비소스 작업',
|
||||
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'non_source_work',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -60,55 +128,24 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
{
|
||||
id: 'auto_worker',
|
||||
name: 'autoWorker',
|
||||
description:
|
||||
'자동화 작업메모로 처리합니다.\n\n## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
description: '자동화 작업메모로 처리합니다.',
|
||||
contexts: [
|
||||
{
|
||||
id: 'auto-worker-default',
|
||||
title: '자동화 기본 규칙',
|
||||
content:
|
||||
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
|
||||
enabled: true,
|
||||
defaultSelected: true,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
behaviorType: 'auto_worker',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
const byId = new Map(items.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
name: defaultItem.name,
|
||||
description: existingItem.description || defaultItem.description,
|
||||
behaviorType: defaultItem.behaviorType,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeAutomationTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
item.behaviorType === target.behaviorType &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -137,38 +174,14 @@ function normalizeEnabled(value: unknown) {
|
||||
return value !== false;
|
||||
}
|
||||
|
||||
async function ensureAutomationTypesTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
|
||||
function normalizeLegacyAutomationTypeId(value: unknown) {
|
||||
const normalizedValue = normalizeLegacyAutomationBehaviorType(value);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.string('behavior_type').notNullable();
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
if (normalizedValue === 'stock-alert') {
|
||||
return 'general-inquiry';
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['name', (table) => table.string('name').notNullable().defaultTo('')],
|
||||
['description', (table) => table.text('description').notNullable().defaultTo('')],
|
||||
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
|
||||
@@ -196,6 +209,69 @@ function buildNameKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function buildContextTitleKey(value: string) {
|
||||
return value.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
|
||||
const title = normalizeText(record.title);
|
||||
const content = normalizeText(record.content);
|
||||
|
||||
if (!title && !content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId || `automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
title: title || 'Context',
|
||||
content,
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
defaultSelected: normalizeEnabled(record.defaultSelected),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, AutomationTypeContextRecord>();
|
||||
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
|
||||
|
||||
(items ?? [])
|
||||
.map((item) => normalizeAutomationContext(item))
|
||||
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
|
||||
.forEach((item) => {
|
||||
const currentById = byId.get(item.id);
|
||||
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
});
|
||||
|
||||
for (const item of byId.values()) {
|
||||
const semanticKey = buildContextTitleKey(item.title);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current || compareContextUpdatedAt(current, item) <= 0) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
|
||||
}
|
||||
|
||||
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
@@ -205,13 +281,13 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
|
||||
|
||||
const rawId = normalizeText(record.id);
|
||||
const normalizedId =
|
||||
rawId ||
|
||||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
rawId || `automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id: normalizedId,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
contexts: sanitizeAutomationContexts(record.contexts),
|
||||
behaviorType: normalizeBehaviorType(record.behaviorType),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
@@ -265,6 +341,85 @@ export function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[] |
|
||||
return dedupeAutomationTypes(normalized);
|
||||
}
|
||||
|
||||
function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
const byId = new Map(items.map((item) => [item.id, item] as const));
|
||||
|
||||
for (const defaultItem of DEFAULT_AUTOMATION_TYPES) {
|
||||
const existingItem = byId.get(defaultItem.id);
|
||||
|
||||
if (!existingItem) {
|
||||
byId.set(defaultItem.id, defaultItem);
|
||||
continue;
|
||||
}
|
||||
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
name: defaultItem.name,
|
||||
description: existingItem.description || defaultItem.description,
|
||||
contexts: sanitizeAutomationContexts(existingItem.contexts?.length ? existingItem.contexts : defaultItem.contexts),
|
||||
behaviorType: defaultItem.behaviorType,
|
||||
});
|
||||
}
|
||||
|
||||
return sanitizeAutomationTypes(Array.from(byId.values()));
|
||||
}
|
||||
|
||||
function isSameAutomationTypeList(left: AutomationTypeRecord[], right: AutomationTypeRecord[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((item, index) => {
|
||||
const target = right[index];
|
||||
return (
|
||||
target &&
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
JSON.stringify(item.contexts) === JSON.stringify(target.contexts) &&
|
||||
item.behaviorType === target.behaviorType &&
|
||||
item.enabled === target.enabled &&
|
||||
item.updatedAt === target.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureAutomationTypesTable() {
|
||||
const hasTable = await db.schema.hasTable(AUTOMATION_TYPES_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.text('contexts_json').notNullable().defaultTo('[]');
|
||||
table.string('behavior_type').notNullable();
|
||||
table.boolean('enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredColumns: Array<[string, (table: any) => void]> = [
|
||||
['name', (table) => table.string('name').notNullable().defaultTo('')],
|
||||
['description', (table) => table.text('description').notNullable().defaultTo('')],
|
||||
['contexts_json', (table) => table.text('contexts_json').notNullable().defaultTo('[]')],
|
||||
['behavior_type', (table) => table.string('behavior_type').notNullable().defaultTo('none')],
|
||||
['enabled', (table) => table.boolean('enabled').notNullable().defaultTo(true)],
|
||||
['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
];
|
||||
|
||||
for (const [columnName, createColumn] of requiredColumns) {
|
||||
const hasColumn = await db.schema.hasColumn(AUTOMATION_TYPES_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(AUTOMATION_TYPES_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfigRecord(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {} as Record<string, unknown>;
|
||||
@@ -273,11 +428,27 @@ function normalizeConfigRecord(value: unknown) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseContextsFromRow(row: Record<string, unknown>) {
|
||||
const rawValue = row.contexts_json;
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toAutomationTypeRecord(row: Record<string, unknown>) {
|
||||
return normalizeAutomationType({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
contexts: parseContextsFromRow(row),
|
||||
behaviorType: normalizeLegacyAutomationBehaviorType(row.behavior_type) as AutomationBehaviorType,
|
||||
enabled: normalizeEnabled(row.enabled),
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
@@ -303,7 +474,7 @@ async function readAutomationTypesFromTable() {
|
||||
await ensureAutomationTypesTable();
|
||||
|
||||
const rows = await db(AUTOMATION_TYPES_TABLE)
|
||||
.select('id', 'name', 'description', 'behavior_type', 'enabled', 'updated_at')
|
||||
.select('id', 'name', 'description', 'contexts_json', 'behavior_type', 'enabled', 'updated_at')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const savedItems = rows
|
||||
@@ -326,6 +497,7 @@ async function replaceAutomationTypesInTable(items: AutomationTypeRecord[]) {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
contexts_json: JSON.stringify(item.contexts ?? []),
|
||||
behavior_type: item.behaviorType,
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
@@ -359,7 +531,7 @@ export async function upsertAutomationTypesConfig(items: unknown[]) {
|
||||
}
|
||||
|
||||
export async function resolveAutomationType(input: unknown) {
|
||||
const requestedId = normalizeLegacyAutomationBehaviorType(input);
|
||||
const requestedId = normalizeLegacyAutomationTypeId(input);
|
||||
const automationTypes = await getAutomationTypesConfig();
|
||||
const matched = automationTypes.find((item) => item.id === requestedId);
|
||||
|
||||
@@ -383,8 +555,40 @@ export function resolveStoredAutomationTypeId(row: Record<string, unknown>) {
|
||||
const automationTypeId = normalizeText(row.automation_type_id);
|
||||
|
||||
if (automationTypeId) {
|
||||
return normalizeLegacyAutomationBehaviorType(automationTypeId);
|
||||
return normalizeLegacyAutomationTypeId(automationTypeId);
|
||||
}
|
||||
|
||||
return normalizeLegacyAutomationBehaviorType(row.automation_type) || 'none';
|
||||
return normalizeLegacyAutomationTypeId(row.automation_type) || 'none';
|
||||
}
|
||||
|
||||
export function normalizeAutomationContextSelection(value: unknown) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return [...new Set(rawValues.map((item) => normalizeText(String(item))).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function resolveAutomationTypeContexts(
|
||||
automationType: Pick<AutomationTypeRecord, 'contexts'> | null | undefined,
|
||||
selectedContextIds?: unknown,
|
||||
) {
|
||||
const contexts = sanitizeAutomationContexts(automationType?.contexts);
|
||||
const requestedIds = normalizeAutomationContextSelection(selectedContextIds);
|
||||
|
||||
if (requestedIds.length === 0 && Array.isArray(selectedContextIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (requestedIds.length === 0) {
|
||||
return contexts.filter((item) => item.enabled && item.defaultSelected);
|
||||
}
|
||||
|
||||
const requestedIdSet = new Set(requestedIds);
|
||||
return contexts.filter((item) => requestedIdSet.has(item.id));
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db/client.js';
|
||||
import {
|
||||
ensurePlanTable,
|
||||
normalizePlanAutomationType,
|
||||
normalizePlanWorkId,
|
||||
PLAN_TABLE,
|
||||
planAutomationTypeSchema,
|
||||
} from './plan-service.js';
|
||||
@@ -11,6 +12,12 @@ import {
|
||||
resolveStoredAutomationTypeId,
|
||||
type AutomationTypeRecord,
|
||||
} from './automation-type-config-service.js';
|
||||
import {
|
||||
buildAutomationNoteSections,
|
||||
parseAutomationContextIds,
|
||||
stringifyAutomationContextIds,
|
||||
} from './automation-context-service.js';
|
||||
import type { AutomationContextRecord } from './automation-context-config-service.js';
|
||||
|
||||
export const BOARD_POSTS_TABLE = 'board_posts';
|
||||
|
||||
@@ -28,6 +35,7 @@ export const boardPostPayloadSchema = z.object({
|
||||
}),
|
||||
).max(20).default([]),
|
||||
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
||||
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
|
||||
});
|
||||
|
||||
export type BoardPostItem = {
|
||||
@@ -39,6 +47,7 @@ export type BoardPostItem = {
|
||||
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
automationContextIds: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -54,6 +63,9 @@ export class BoardPostAutomationLockedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_SEQUENCED_PLAN_WORK_ID = 999;
|
||||
const PLAN_WORK_ID_MAX_LENGTH = 120;
|
||||
|
||||
function createPreview(content: string) {
|
||||
const normalized = content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
@@ -91,6 +103,7 @@ function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
||||
automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined
|
||||
? null
|
||||
: String(row.automation_received_at),
|
||||
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
@@ -121,34 +134,52 @@ function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloa
|
||||
return ['## 첨부 파일', ...lines];
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(
|
||||
export async function buildBoardPostPlanNote(
|
||||
title: string,
|
||||
content: string,
|
||||
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
|
||||
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null,
|
||||
automationType?: Pick<AutomationTypeRecord, 'name'> | null,
|
||||
automationContextIds?: string[],
|
||||
availableContexts?: AutomationContextRecord[],
|
||||
) {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedContent = content.trim();
|
||||
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
|
||||
const normalizedAutomationContext = String(automationType?.description ?? '').trim();
|
||||
return buildAutomationNoteSections({
|
||||
title: title.trim(),
|
||||
sourceLabel: 'board_posts 자동화 접수',
|
||||
requestContent: content.trim(),
|
||||
attachments: attachments.length ? buildBoardAttachmentSection(attachments).slice(1) : [],
|
||||
automationType,
|
||||
availableContexts,
|
||||
selectedContextIds: automationContextIds,
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
`- 게시판 제목: ${normalizedTitle}`,
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
normalizedAutomationTypeName ? `- 선택 자동화 유형: ${normalizedAutomationTypeName}` : null,
|
||||
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
|
||||
'',
|
||||
'## 자동화 유형 context',
|
||||
normalizedAutomationContext || '선택된 자동화 유형 context 없음',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
function buildSequencedPlanWorkId(baseWorkId: string, sequence: number, suffixLabel?: string | null) {
|
||||
const normalizedBaseWorkId = normalizePlanWorkId(baseWorkId);
|
||||
const normalizedSuffixLabel = String(suffixLabel ?? '').trim().replace(/^-+|-+$/g, '');
|
||||
const suffix = normalizedSuffixLabel ? `-${normalizedSuffixLabel}-${sequence}` : `-${sequence}`;
|
||||
const maxBaseLength = Math.max(1, PLAN_WORK_ID_MAX_LENGTH - suffix.length);
|
||||
const trimmedBaseWorkId = normalizedBaseWorkId.slice(0, maxBaseLength).trimEnd() || '작업ID';
|
||||
return `${trimmedBaseWorkId}${suffix}`;
|
||||
}
|
||||
|
||||
export function resolveSequencedPlanWorkId(
|
||||
baseWorkId: string,
|
||||
existingWorkIds: Iterable<string>,
|
||||
suffixLabel?: string | null,
|
||||
) {
|
||||
const existingWorkIdSet = new Set(
|
||||
Array.from(existingWorkIds, (value) => String(value ?? '').trim()).filter(Boolean),
|
||||
);
|
||||
|
||||
for (let sequence = 1; sequence <= MAX_SEQUENCED_PLAN_WORK_ID; sequence += 1) {
|
||||
const candidate = buildSequencedPlanWorkId(baseWorkId, sequence, suffixLabel);
|
||||
|
||||
if (!existingWorkIdSet.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`자동화 접수 ID suffix를 더 이상 생성할 수 없습니다. (${MAX_SEQUENCED_PLAN_WORK_ID}개 초과)`);
|
||||
}
|
||||
|
||||
function resolveInsertedId(result: unknown): number | null {
|
||||
@@ -223,6 +254,7 @@ export async function ensureBoardPostsTable() {
|
||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
|
||||
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
|
||||
['automation_context_ids_json', (table) => table.text('automation_context_ids_json').notNullable().defaultTo('[]')],
|
||||
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
||||
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
@@ -282,6 +314,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
|
||||
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
|
||||
created_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
@@ -302,7 +335,14 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
|
||||
return mapBoardPostRow(row);
|
||||
}
|
||||
|
||||
export async function receiveBoardPostAutomation(id: number) {
|
||||
export async function receiveBoardPostAutomation(
|
||||
id: number,
|
||||
options?: {
|
||||
planWorkIdBase?: string | null;
|
||||
planWorkIdSuffixLabel?: string | null;
|
||||
suppressWebPush?: boolean;
|
||||
},
|
||||
) {
|
||||
await ensureBoardPostsTable();
|
||||
await ensurePlanTable();
|
||||
|
||||
@@ -327,17 +367,26 @@ export async function receiveBoardPostAutomation(id: number) {
|
||||
const title = String(currentRow.title ?? '').trim();
|
||||
const content = String(currentRow.content ?? '').trim();
|
||||
const attachments = mapBoardPostRow(currentRow).attachments;
|
||||
const workId = `board-post-${id}`;
|
||||
const automationContextIds = mapBoardPostRow(currentRow).automationContextIds;
|
||||
const workId = options?.planWorkIdBase?.trim()
|
||||
? resolveSequencedPlanWorkId(
|
||||
options.planWorkIdBase,
|
||||
(await trx(PLAN_TABLE).select('work_id')).map((row) => String(row.work_id ?? '')),
|
||||
options.planWorkIdSuffixLabel,
|
||||
)
|
||||
: `board-post-${id}`;
|
||||
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
|
||||
const insertQuery = trx(PLAN_TABLE).insert({
|
||||
work_id: workId,
|
||||
note: buildBoardPostPlanNote(title, content, attachments, automationType),
|
||||
note: await buildBoardPostPlanNote(title, content, attachments, automationType, automationContextIds),
|
||||
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
||||
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(automationContextIds),
|
||||
status: '등록',
|
||||
release_target: 'release',
|
||||
jangsing_processing_required: true,
|
||||
auto_deploy_to_main: false,
|
||||
suppress_web_push: options?.suppressWebPush ?? false,
|
||||
worker_status: '대기',
|
||||
last_error: null,
|
||||
updated_at: trx.fn.now(),
|
||||
@@ -398,6 +447,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
|
||||
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(parsedPayload.automationContextIds),
|
||||
updated_at: trx.fn.now(),
|
||||
});
|
||||
|
||||
|
||||
216
etc/servers/work-server/src/services/chat-message-parts.ts
Normal file
216
etc/servers/work-server/src/services/chat-message-parts.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export type ChatMessagePart =
|
||||
| {
|
||||
type: 'link_card';
|
||||
title: string;
|
||||
url: string;
|
||||
actionLabel?: string | null;
|
||||
};
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
|
||||
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasKnownFileExtension(url: string) {
|
||||
const pathname = url.split('?')[0] ?? '';
|
||||
return /\.[a-z0-9]{1,8}$/i.test(pathname);
|
||||
}
|
||||
|
||||
function isStructuredLinkCardCandidate(url: string) {
|
||||
const normalized = normalizeUrl(url);
|
||||
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(normalized)) {
|
||||
return !hasKnownFileExtension(normalized);
|
||||
}
|
||||
|
||||
return !hasKnownFileExtension(normalized);
|
||||
}
|
||||
|
||||
function buildFallbackLinkTitle(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url, 'https://local.invalid');
|
||||
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
||||
return lastSegment || parsed.hostname || normalizeText(url);
|
||||
} catch {
|
||||
return normalizeText(url);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStandaloneTitle(value: string) {
|
||||
return value
|
||||
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
|
||||
.replace(/[`'"]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
|
||||
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
|
||||
|
||||
if (candidate) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return buildFallbackLinkTitle(url);
|
||||
}
|
||||
|
||||
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
|
||||
const segments = rawBody
|
||||
.split('|')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rawTitle, rawUrl, rawActionLabel] = segments;
|
||||
const title = normalizeText(rawTitle);
|
||||
const url = normalizeUrl(rawUrl);
|
||||
const actionLabel = normalizeText(rawActionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card',
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractChatMessageParts(text: string) {
|
||||
const lines = String(text ?? '').split('\n');
|
||||
const keptLines: string[] = [];
|
||||
const parts: ChatMessagePart[] = [];
|
||||
const seenLinkKeys = new Set<string>();
|
||||
const pushPart = (nextPart: ChatMessagePart | null) => {
|
||||
if (!nextPart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
|
||||
|
||||
if (seenLinkKeys.has(dedupeKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
seenLinkKeys.add(dedupeKey);
|
||||
parts.push(nextPart);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const matched = line.match(LINK_CARD_LINE_PATTERN);
|
||||
|
||||
if (!matched) {
|
||||
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
|
||||
if (markdownLinkMatch) {
|
||||
const [, rawTitle, rawUrl] = markdownLinkMatch;
|
||||
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
|
||||
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
|
||||
if (standaloneUrlMatch) {
|
||||
const rawUrl = standaloneUrlMatch[1] ?? '';
|
||||
if (isStructuredLinkCardCandidate(rawUrl)) {
|
||||
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
|
||||
keptLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||
parts,
|
||||
};
|
||||
}
|
||||
|
||||
export function stringifyChatMessageParts(parts: ChatMessagePart[] | null | undefined) {
|
||||
return JSON.stringify(Array.isArray(parts) ? parts : []);
|
||||
}
|
||||
|
||||
export function parseChatMessageParts(value: unknown): ChatMessagePart[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
if (record.type !== 'link_card') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = normalizeText(record.title);
|
||||
const url = normalizeUrl(String(record.url ?? ''));
|
||||
const actionLabel = normalizeText(record.actionLabel) || null;
|
||||
|
||||
if (!title || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'link_card' as const,
|
||||
title,
|
||||
url,
|
||||
actionLabel,
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as ChatMessagePart[];
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return parseChatMessageParts(JSON.parse(value));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
import { chatRuntimeService } from './chat-runtime-service.js';
|
||||
import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js';
|
||||
|
||||
export const CHAT_CONVERSATION_TABLE = 'chat_conversations';
|
||||
export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages';
|
||||
@@ -28,6 +29,7 @@ const conversationMessagePayloadSchema = z.object({
|
||||
text: z.string().max(200000),
|
||||
timestamp: z.string().trim().max(40),
|
||||
clientRequestId: z.string().trim().max(120).nullable().optional(),
|
||||
parts: z.array(z.custom<ChatMessagePart>()).optional(),
|
||||
});
|
||||
|
||||
export type ChatConversationItem = {
|
||||
@@ -57,6 +59,7 @@ export type StoredChatMessage = {
|
||||
text: string;
|
||||
timestamp: string;
|
||||
clientRequestId?: string | null;
|
||||
parts?: ChatMessagePart[];
|
||||
};
|
||||
|
||||
export type ChatConversationRequestStatus =
|
||||
@@ -199,6 +202,7 @@ function mapMessageRow(row: Record<string, unknown>): StoredChatMessage {
|
||||
text: String(row.text ?? ''),
|
||||
timestamp: String(row.display_timestamp ?? ''),
|
||||
clientRequestId: row.client_request_id == null ? null : String(row.client_request_id),
|
||||
parts: parseChatMessageParts(row.parts_json),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -800,6 +804,7 @@ export async function ensureChatConversationTables() {
|
||||
table.bigInteger('message_id').notNullable();
|
||||
table.string('author', 20).notNullable();
|
||||
table.text('text').notNullable();
|
||||
table.text('parts_json').notNullable().defaultTo('[]');
|
||||
table.string('display_timestamp', 40).notNullable().defaultTo('');
|
||||
table.string('client_request_id', 120).nullable();
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
@@ -812,6 +817,7 @@ export async function ensureChatConversationTables() {
|
||||
['message_id', (table) => table.bigInteger('message_id').notNullable()],
|
||||
['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')],
|
||||
['text', (table) => table.text('text').notNullable().defaultTo('')],
|
||||
['parts_json', (table) => table.text('parts_json').notNullable().defaultTo('[]')],
|
||||
['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')],
|
||||
['client_request_id', (table) => table.string('client_request_id', 120).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
@@ -1608,6 +1614,7 @@ export async function appendChatConversationMessage(
|
||||
message_id: message.messageId,
|
||||
author: message.author,
|
||||
text: message.text,
|
||||
parts_json: stringifyChatMessageParts(message.parts),
|
||||
display_timestamp: message.timestamp,
|
||||
client_request_id: resolvedClientRequestId,
|
||||
created_at: db.fn.now(),
|
||||
@@ -1616,6 +1623,7 @@ export async function appendChatConversationMessage(
|
||||
.merge({
|
||||
author: message.author,
|
||||
text: message.text,
|
||||
parts_json: stringifyChatMessageParts(message.parts),
|
||||
display_timestamp: message.timestamp,
|
||||
client_request_id: resolvedClientRequestId,
|
||||
});
|
||||
@@ -1971,7 +1979,7 @@ export async function repairChatConversationRequestLinks(sessionId?: string | nu
|
||||
.orderBy('request_id', 'asc');
|
||||
const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE)
|
||||
.where({ session_id: currentSessionId })
|
||||
.select('id', 'message_id', 'author', 'text', 'client_request_id', 'created_at')
|
||||
.select('id', 'message_id', 'author', 'text', 'parts_json', 'client_request_id', 'created_at')
|
||||
.orderBy('created_at', 'asc')
|
||||
.orderBy('message_id', 'asc')
|
||||
.orderBy('id', 'asc');
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -439,6 +439,7 @@ export async function registerErrorLogBoardPosts(args?: {
|
||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||
attachments: [],
|
||||
automationType: 'none',
|
||||
automationContextIds: [],
|
||||
});
|
||||
|
||||
createdPosts.push({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
168
etc/servers/work-server/src/services/managed-schedule-service.ts
Normal file
168
etc/servers/work-server/src/services/managed-schedule-service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import path from 'node:path';
|
||||
import { access, mkdir, rm } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { sendManagedStockAlertWebPush } from './stock-alert-service.js';
|
||||
|
||||
export type ManagedScheduleServiceResult = {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
itemCount: number;
|
||||
lines: string[];
|
||||
ios: {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
web: {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ManagedScheduleServiceMetadata = {
|
||||
scheduleId: number;
|
||||
serviceKey: string;
|
||||
packageName: string;
|
||||
relativeDirectory: string;
|
||||
manifestPath: string;
|
||||
readmePath: string;
|
||||
sourcePath: string;
|
||||
runtimePath: string;
|
||||
};
|
||||
|
||||
function sanitizeManagedServiceToken(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
function appendScheduleSuffixOnce(value: string, suffix: string) {
|
||||
const normalizedValue = value.trim();
|
||||
const normalizedSuffix = suffix.trim().toLowerCase();
|
||||
|
||||
if (!normalizedValue || !normalizedSuffix) {
|
||||
return normalizedValue;
|
||||
}
|
||||
|
||||
return normalizedValue.toLowerCase().endsWith(`-${normalizedSuffix}`)
|
||||
? normalizedValue
|
||||
: `${normalizedValue}-${suffix.trim()}`;
|
||||
}
|
||||
|
||||
function getScheduleRepoRoot() {
|
||||
const env = getEnv();
|
||||
return env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || process.cwd();
|
||||
}
|
||||
|
||||
export function buildManagedScheduleServiceMetadata(scheduleId: number, workId: string): ManagedScheduleServiceMetadata {
|
||||
const workToken = sanitizeManagedServiceToken(appendScheduleSuffixOnce(workId, 'service')) || 'task-service';
|
||||
const serviceKey = `schedule-${scheduleId}-${workToken}`;
|
||||
const relativeDirectory = `.auto_codex/schedule/${scheduleId}`;
|
||||
const packageName = workToken || `schedule-${scheduleId}-service`;
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
serviceKey,
|
||||
packageName,
|
||||
relativeDirectory,
|
||||
manifestPath: `${relativeDirectory}/service-manifest.json`,
|
||||
readmePath: `${relativeDirectory}/README.md`,
|
||||
sourcePath: `${relativeDirectory}/service.ts`,
|
||||
runtimePath: `${relativeDirectory}/service.mjs`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareManagedScheduleServiceDirectory(scheduleId: number) {
|
||||
const repoRoot = getScheduleRepoRoot();
|
||||
const absoluteDirectory = path.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId));
|
||||
|
||||
await mkdir(absoluteDirectory, { recursive: true });
|
||||
await rm(path.join(absoluteDirectory, 'managed-service'), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function removeManagedScheduleServiceArtifacts(scheduleId: number) {
|
||||
const repoRoot = getScheduleRepoRoot();
|
||||
const scheduleDirectory = path.join(repoRoot, '.auto_codex', 'schedule', String(scheduleId));
|
||||
|
||||
await rm(path.join(scheduleDirectory, 'managed-service'), { recursive: true, force: true });
|
||||
await rm(path.join(scheduleDirectory, 'README.md'), { force: true });
|
||||
await rm(path.join(scheduleDirectory, 'service-manifest.json'), { force: true });
|
||||
await rm(path.join(scheduleDirectory, 'service.ts'), { force: true });
|
||||
await rm(path.join(scheduleDirectory, 'service.mjs'), { force: true });
|
||||
}
|
||||
|
||||
export async function hasManagedScheduleServicePackage(relativeDirectory: string | null | undefined) {
|
||||
const trimmedDirectory = String(relativeDirectory ?? '').trim();
|
||||
|
||||
if (!trimmedDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const repoRoot = getScheduleRepoRoot();
|
||||
const runtimePath = path.join(repoRoot, trimmedDirectory, 'service.mjs');
|
||||
const manifestPath = path.join(repoRoot, trimmedDirectory, 'service-manifest.json');
|
||||
|
||||
try {
|
||||
await Promise.all([access(runtimePath), access(manifestPath)]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runManagedScheduleService(relativeDirectory: string) {
|
||||
const repoRoot = getScheduleRepoRoot();
|
||||
const runtimePath = path.join(repoRoot, relativeDirectory, 'service.mjs');
|
||||
const importedModule = await import(`${pathToFileURL(runtimePath).href}?t=${Date.now()}`);
|
||||
const run =
|
||||
typeof importedModule.run === 'function'
|
||||
? importedModule.run
|
||||
: typeof importedModule.default?.run === 'function'
|
||||
? importedModule.default.run
|
||||
: null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error(`스케줄 서비스 실행 함수를 찾을 수 없습니다: ${relativeDirectory}/service.mjs`);
|
||||
}
|
||||
|
||||
return run({
|
||||
scheduleRoot: path.join(repoRoot, trimmedRelativeDirectory(relativeDirectory)),
|
||||
scheduleDirectory: trimmedRelativeDirectory(relativeDirectory),
|
||||
repoRoot,
|
||||
now: new Date().toISOString(),
|
||||
runCurrentPriceStockAlertService(definition: { scheduleId: number; serviceKey: string; title: string }) {
|
||||
return sendManagedStockAlertWebPush({
|
||||
scheduleId: definition.scheduleId,
|
||||
serviceKey: definition.serviceKey,
|
||||
title: definition.title,
|
||||
mode: 'price',
|
||||
});
|
||||
},
|
||||
runChangeRateThresholdStockAlertService(
|
||||
definition: { scheduleId: number; serviceKey: string; title: string; thresholdPercent: number },
|
||||
) {
|
||||
return sendManagedStockAlertWebPush({
|
||||
scheduleId: definition.scheduleId,
|
||||
serviceKey: definition.serviceKey,
|
||||
title: definition.title,
|
||||
mode: 'change-threshold',
|
||||
thresholdPercent: definition.thresholdPercent,
|
||||
});
|
||||
},
|
||||
}) as Promise<ManagedScheduleServiceResult>;
|
||||
}
|
||||
|
||||
function trimmedRelativeDirectory(relativeDirectory: string) {
|
||||
return String(relativeDirectory ?? '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ export const registerAutomationNotificationPreferenceSchema = z.object({
|
||||
export const registerIosTokenSchema = z.object({
|
||||
token: z.string().trim().min(1),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
appOrigin: z.string().trim().url().max(500).optional(),
|
||||
appDomain: z.string().trim().min(1).max(255).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -50,6 +52,8 @@ export const registerWebPushSubscriptionSchema = z.object({
|
||||
}),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
userAgent: z.string().trim().max(500).optional(),
|
||||
appOrigin: z.string().trim().url().max(500).optional(),
|
||||
appDomain: z.string().trim().min(1).max(255).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -62,6 +66,9 @@ export const sendIosNotificationSchema = z.object({
|
||||
body: z.string().trim().min(1),
|
||||
data: z.record(z.string(), z.string()).default({}),
|
||||
threadId: z.string().trim().min(1).optional(),
|
||||
targetClientIds: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
||||
targetAppOrigins: z.array(z.string().trim().url().max(500)).max(50).optional(),
|
||||
targetAppDomains: z.array(z.string().trim().min(1).max(255)).max(50).optional(),
|
||||
});
|
||||
|
||||
type IosNotificationPayload = z.infer<typeof sendIosNotificationSchema>;
|
||||
@@ -73,6 +80,84 @@ type NotificationPreferenceTarget = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
|
||||
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeTargetAppOrigins(targetAppOrigins: string[] | undefined) {
|
||||
return [...new Set((targetAppOrigins ?? []).map((value) => normalizeAppOrigin(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
|
||||
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
|
||||
if (targetClientIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(deviceId) && targetClientIds.includes(deviceId);
|
||||
}
|
||||
|
||||
function normalizeAppOrigin(value: unknown) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
return url.origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAppDomain(value: unknown) {
|
||||
return String(value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveAppDomainFromOrigin(origin: string) {
|
||||
if (!origin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(origin).hostname.trim().toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedAppTarget(
|
||||
item: {
|
||||
appOrigin?: string;
|
||||
appDomain?: string;
|
||||
},
|
||||
targetAppOrigins: string[],
|
||||
targetAppDomains: string[],
|
||||
) {
|
||||
if (targetAppOrigins.length === 0 && targetAppDomains.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (targetAppOrigins.length > 0) {
|
||||
if (!item.appOrigin || !targetAppOrigins.includes(item.appOrigin)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetAppDomains.length > 0) {
|
||||
if (!item.appDomain || !targetAppDomains.includes(item.appDomain)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type WebPushFailureDetail = {
|
||||
endpoint: string;
|
||||
statusCode?: number;
|
||||
@@ -244,6 +329,8 @@ async function ensureNotificationTokenTable() {
|
||||
table.string('platform', 20).notNullable().defaultTo('ios');
|
||||
table.string('device_token', 255).notNullable().unique();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.string('app_origin', 500).nullable();
|
||||
table.string('app_domain', 255).nullable();
|
||||
table.boolean('is_enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
@@ -257,6 +344,8 @@ async function ensureNotificationTokenTable() {
|
||||
['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')],
|
||||
['device_token', (table) => table.string('device_token', 255).notNullable()],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['app_origin', (table) => table.string('app_origin', 500).nullable()],
|
||||
['app_domain', (table) => table.string('app_domain', 255).nullable()],
|
||||
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
|
||||
[
|
||||
'last_registered_at',
|
||||
@@ -287,6 +376,8 @@ async function ensureWebPushSubscriptionTable() {
|
||||
table.jsonb('subscription_json').notNullable();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.string('app_origin', 500).nullable();
|
||||
table.string('app_domain', 255).nullable();
|
||||
table.boolean('is_enabled').notNullable().defaultTo(true);
|
||||
table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now());
|
||||
@@ -301,6 +392,8 @@ async function ensureWebPushSubscriptionTable() {
|
||||
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['user_agent', (table) => table.text('user_agent').nullable()],
|
||||
['app_origin', (table) => table.string('app_origin', 500).nullable()],
|
||||
['app_domain', (table) => table.string('app_domain', 255).nullable()],
|
||||
['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)],
|
||||
[
|
||||
'last_registered_at',
|
||||
@@ -389,6 +482,8 @@ export async function listIosNotificationTokens() {
|
||||
platform: row.platform,
|
||||
token: row.device_token,
|
||||
deviceId: row.device_id,
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
enabled: row.is_enabled,
|
||||
lastRegisteredAt: row.last_registered_at,
|
||||
createdAt: row.created_at,
|
||||
@@ -396,8 +491,29 @@ export async function listIosNotificationTokens() {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listWebPushSubscriptions() {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
|
||||
const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE).orderBy('updated_at', 'desc');
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
endpoint: String(row.endpoint ?? ''),
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
userAgent: row.user_agent ? String(row.user_agent) : '',
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
enabled: Boolean(row.is_enabled),
|
||||
lastRegisteredAt: row.last_registered_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function registerIosNotificationToken(payload: z.infer<typeof registerIosTokenSchema>) {
|
||||
await ensureNotificationTokenTable();
|
||||
const appOrigin = normalizeAppOrigin(payload.appOrigin);
|
||||
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterIosNotificationToken(payload.token);
|
||||
@@ -414,6 +530,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
|
||||
platform: 'ios',
|
||||
device_token: payload.token,
|
||||
device_id: payload.deviceId ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -422,6 +540,8 @@ export async function registerIosNotificationToken(payload: z.infer<typeof regis
|
||||
.merge({
|
||||
platform: 'ios',
|
||||
device_id: payload.deviceId ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -519,6 +639,8 @@ export async function registerWebPushSubscription(
|
||||
payload: z.infer<typeof registerWebPushSubscriptionSchema>,
|
||||
) {
|
||||
await ensureWebPushSubscriptionTable();
|
||||
const appOrigin = normalizeAppOrigin(payload.appOrigin);
|
||||
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterWebPushSubscription(payload.subscription.endpoint);
|
||||
@@ -536,6 +658,8 @@ export async function registerWebPushSubscription(
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -545,6 +669,8 @@ export async function registerWebPushSubscription(
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
is_enabled: true,
|
||||
last_registered_at: db.fn.now(),
|
||||
updated_at: db.fn.now(),
|
||||
@@ -583,11 +709,13 @@ async function getEnabledIosTokens() {
|
||||
platform: 'ios',
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('device_token', 'device_id');
|
||||
.select('device_token', 'device_id', 'app_origin', 'app_domain');
|
||||
|
||||
return rows.map((row) => ({
|
||||
token: String(row.device_token),
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -598,12 +726,14 @@ async function getEnabledWebPushSubscriptions() {
|
||||
.where({
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('endpoint', 'subscription_json', 'device_id');
|
||||
.select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
|
||||
|
||||
return rows.map((row) => ({
|
||||
endpoint: String(row.endpoint),
|
||||
subscription: row.subscription_json as WebPushSubscriptionPayload,
|
||||
deviceId: row.device_id ? String(row.device_id) : '',
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -704,6 +834,9 @@ async function isNotificationRecipientAllowed(
|
||||
export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const provider = await getProvider();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
|
||||
if (!provider || !env.APNS_BUNDLE_ID) {
|
||||
return {
|
||||
@@ -720,6 +853,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
await Promise.all(
|
||||
tokenRows.map(async (row) => ({
|
||||
token: row.token,
|
||||
deviceId: row.deviceId,
|
||||
appOrigin: row.appOrigin,
|
||||
appDomain: row.appDomain,
|
||||
allowed: await isNotificationRecipientAllowed(
|
||||
[
|
||||
{ kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) },
|
||||
@@ -731,7 +867,12 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
})),
|
||||
)
|
||||
)
|
||||
.filter((row) => row.allowed)
|
||||
.filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
)
|
||||
.map((row) => row.token);
|
||||
|
||||
if (!tokens.length) {
|
||||
@@ -778,6 +919,9 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
|
||||
async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
const env = getEnv();
|
||||
const targetClientIds = normalizeTargetClientIds(payload.targetClientIds);
|
||||
const targetAppOrigins = normalizeTargetAppOrigins(payload.targetAppOrigins);
|
||||
const targetAppDomains = normalizeTargetAppDomains(payload.targetAppDomains);
|
||||
if (!ensureWebPushConfigured(env)) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -801,7 +945,12 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
),
|
||||
})),
|
||||
)
|
||||
).filter((row) => row.allowed);
|
||||
).filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
);
|
||||
|
||||
if (!subscriptions.length) {
|
||||
return {
|
||||
@@ -880,19 +1029,73 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendNotifications(payload: IosNotificationPayload) {
|
||||
export async function sendNotifications(
|
||||
payload: IosNotificationPayload,
|
||||
options?: {
|
||||
disableIos?: boolean;
|
||||
disableWebPush?: boolean;
|
||||
},
|
||||
) {
|
||||
const [ios, web] = await Promise.all([
|
||||
sendIosNotifications(payload),
|
||||
sendWebPushNotifications(payload),
|
||||
options?.disableIos
|
||||
? Promise.resolve({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '요청 설정에 따라 iOS 알림 발송을 건너뛰었습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
invalidTokens: [],
|
||||
})
|
||||
: sendIosNotifications(payload),
|
||||
options?.disableWebPush
|
||||
? Promise.resolve({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: '요청 설정에 따라 Web Push 발송을 건너뛰었습니다.',
|
||||
sentCount: 0,
|
||||
failedCount: 0,
|
||||
invalidEndpoints: [],
|
||||
})
|
||||
: sendWebPushNotifications(payload),
|
||||
]);
|
||||
|
||||
const aggregate = resolveNotificationAggregateResult(
|
||||
{
|
||||
ios,
|
||||
web,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: ios.ok || web.ok,
|
||||
ok: aggregate.ok,
|
||||
skipped: aggregate.skipped,
|
||||
ios,
|
||||
web,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNotificationAggregateResult(
|
||||
result: {
|
||||
ios: { ok: boolean; skipped: boolean };
|
||||
web: { ok: boolean; skipped: boolean };
|
||||
},
|
||||
options?: {
|
||||
disableIos?: boolean;
|
||||
disableWebPush?: boolean;
|
||||
},
|
||||
) {
|
||||
const enabledResults = [
|
||||
options?.disableIos ? null : result.ios,
|
||||
options?.disableWebPush ? null : result.web,
|
||||
].filter((entry): entry is { ok: boolean; skipped: boolean } => Boolean(entry));
|
||||
|
||||
return {
|
||||
ok: enabledResults.length === 0 ? true : enabledResults.every((entry) => entry.ok),
|
||||
skipped: enabledResults.length === 0 ? true : enabledResults.every((entry) => entry.skipped),
|
||||
};
|
||||
}
|
||||
|
||||
export async function shutdownNotificationProvider() {
|
||||
const provider = await getProvider();
|
||||
provider?.shutdown();
|
||||
|
||||
@@ -110,5 +110,7 @@ export async function notifyPlanEvent(
|
||||
body,
|
||||
threadId: `plan-${planId}`,
|
||||
data: buildPlanNotificationData(planId, String(item.workId), eventType),
|
||||
}, {
|
||||
disableWebPush: Boolean(item.suppressWebPush),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildScheduledBoardPostTitle,
|
||||
buildScheduledPlanWorkIdBase,
|
||||
isPlanScheduledTaskDue,
|
||||
mapPlanScheduledTaskRow,
|
||||
shouldCreatePlanForScheduleExecution,
|
||||
updatePlanScheduledTaskSchema,
|
||||
} from './plan-schedule-service.js';
|
||||
|
||||
test('buildScheduledBoardPostTitle prefers the first memo line when present', () => {
|
||||
assert.equal(
|
||||
buildScheduledBoardPostTitle({
|
||||
work_id: '반복작업',
|
||||
note: ' 첫 줄 제목 \n둘째 줄 설명',
|
||||
}),
|
||||
'첫 줄 제목',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScheduledBoardPostTitle falls back to normalized work id when memo is empty', () => {
|
||||
assert.equal(
|
||||
buildScheduledBoardPostTitle({
|
||||
work_id: ' 작업 ID ',
|
||||
note: ' \n ',
|
||||
}),
|
||||
'반복작업',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScheduledPlanWorkIdBase uses the schedule work id for automation registration', () => {
|
||||
assert.equal(
|
||||
buildScheduledPlanWorkIdBase({
|
||||
work_id: ' 반복-정리 ',
|
||||
}),
|
||||
'반복-정리',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScheduledPlanWorkIdBase keeps managed-service runs on the schedule work id with schedule pk prefix', () => {
|
||||
assert.equal(
|
||||
buildScheduledPlanWorkIdBase({
|
||||
id: 10,
|
||||
work_id: ' 반복-정리 ',
|
||||
execution_mode: 'managed-service',
|
||||
}),
|
||||
'schedule-10-반복-정리',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
buildScheduledPlanWorkIdBase({
|
||||
id: 10,
|
||||
work_id: '반복-정리-service',
|
||||
execution_mode: 'managed-service',
|
||||
}),
|
||||
'schedule-10-반복-정리-service',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScheduledPlanWorkIdBase falls back when managed-service schedule id is missing', () => {
|
||||
assert.equal(
|
||||
buildScheduledPlanWorkIdBase({
|
||||
work_id: '반복-정리-service',
|
||||
execution_mode: 'managed-service',
|
||||
}),
|
||||
'반복-정리-service',
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldCreatePlanForScheduleExecution only returns true for managed-service schedules', () => {
|
||||
assert.equal(
|
||||
shouldCreatePlanForScheduleExecution({
|
||||
execution_mode: 'codex',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldCreatePlanForScheduleExecution({
|
||||
execution_mode: 'managed-service',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('updatePlanScheduledTaskSchema keeps partial updates partial', () => {
|
||||
assert.deepEqual(updatePlanScheduledTaskSchema.parse({ recreateManagedServiceOnNextSave: true }), {
|
||||
recreateManagedServiceOnNextSave: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('updatePlanScheduledTaskSchema accepts second-based interval updates', () => {
|
||||
assert.deepEqual(
|
||||
updatePlanScheduledTaskSchema.parse({
|
||||
repeatIntervalValue: 10,
|
||||
repeatIntervalUnit: 'second',
|
||||
repeatIntervalSeconds: 10,
|
||||
}),
|
||||
{
|
||||
repeatIntervalValue: 10,
|
||||
repeatIntervalUnit: 'second',
|
||||
repeatIntervalSeconds: 10,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('interval schedule with start time waits until start time when immediate run is enabled', () => {
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: true,
|
||||
repeat_interval_minutes: 60,
|
||||
repeat_window_start_time: '09:00',
|
||||
created_at: '2026-04-30T07:00:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T08:59:00+09:00'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: true,
|
||||
repeat_interval_minutes: 60,
|
||||
repeat_window_start_time: '09:00',
|
||||
created_at: '2026-04-30T07:00:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T09:00:00+09:00'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('interval schedule with start time waits one interval after the start when immediate run is disabled', () => {
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: false,
|
||||
repeat_interval_minutes: 60,
|
||||
repeat_window_start_time: '09:00',
|
||||
created_at: '2026-04-30T07:00:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T09:59:00+09:00'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: false,
|
||||
repeat_interval_minutes: 60,
|
||||
repeat_window_start_time: '09:00',
|
||||
created_at: '2026-04-30T07:00:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T10:00:00+09:00'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('interval schedule supports second-based due calculation', () => {
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: false,
|
||||
repeat_interval_unit: 'second',
|
||||
repeat_interval_value: 10,
|
||||
repeat_interval_seconds: 10,
|
||||
created_at: '2026-04-30T09:00:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T09:00:09+09:00'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: false,
|
||||
repeat_interval_unit: 'second',
|
||||
repeat_interval_value: 10,
|
||||
repeat_interval_seconds: 10,
|
||||
created_at: '2026-04-30T09:00:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T09:00:10+09:00'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('interval schedule uses repeat interval value and unit when stored seconds are stale', () => {
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: false,
|
||||
repeat_interval_unit: 'minute',
|
||||
repeat_interval_value: 10,
|
||||
repeat_interval_seconds: 3600,
|
||||
created_at: '2026-04-30T09:50:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T09:59:59+09:00'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isPlanScheduledTaskDue(
|
||||
{
|
||||
schedule_mode: 'interval',
|
||||
immediate_run_enabled: false,
|
||||
repeat_interval_unit: 'minute',
|
||||
repeat_interval_value: 10,
|
||||
repeat_interval_seconds: 3600,
|
||||
created_at: '2026-04-30T09:50:00+09:00',
|
||||
},
|
||||
new Date('2026-04-30T10:00:00+09:00'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('mapPlanScheduledTaskRow normalizes stale stored repeat interval seconds', () => {
|
||||
const mapped = mapPlanScheduledTaskRow({
|
||||
id: 2,
|
||||
work_id: 'stock-alert',
|
||||
schedule_mode: 'interval',
|
||||
repeat_interval_unit: 'minute',
|
||||
repeat_interval_value: 10,
|
||||
repeat_interval_seconds: 3600,
|
||||
repeat_interval_minutes: 60,
|
||||
});
|
||||
|
||||
assert.equal(mapped.repeatIntervalValue, 10);
|
||||
assert.equal(mapped.repeatIntervalUnit, 'minute');
|
||||
assert.equal(mapped.repeatIntervalSeconds, 600);
|
||||
assert.equal(mapped.repeatIntervalMinutes, 10);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import { db } from '../db/client.js';
|
||||
import {
|
||||
parseAutomationContextIds,
|
||||
stringifyAutomationContextIds,
|
||||
} from './automation-context-service.js';
|
||||
import {
|
||||
normalizeLegacyAutomationBehaviorType,
|
||||
resolveAutomationType,
|
||||
@@ -78,9 +82,11 @@ export const createPlanSchema = z.object({
|
||||
workId: z.string().trim().optional().default('작업ID'),
|
||||
note: z.string().default(''),
|
||||
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).default('none')),
|
||||
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional().default([]),
|
||||
releaseTarget: z.string().trim().min(1).default('release'),
|
||||
jangsingProcessingRequired: z.boolean().default(true),
|
||||
autoDeployToMain: z.boolean().default(true),
|
||||
suppressWebPush: z.boolean().default(false),
|
||||
repeatRequestEnabled: z.boolean().default(false),
|
||||
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60),
|
||||
});
|
||||
@@ -89,9 +95,11 @@ export const updatePlanSchema = z.object({
|
||||
workId: z.string().trim().optional(),
|
||||
note: z.string().optional(),
|
||||
automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.string().trim().min(1).max(120).optional()),
|
||||
automationContextIds: z.array(z.string().trim().min(1).max(160)).max(20).optional(),
|
||||
releaseTarget: z.string().trim().min(1).optional(),
|
||||
jangsingProcessingRequired: z.boolean().optional(),
|
||||
autoDeployToMain: z.boolean().optional(),
|
||||
suppressWebPush: z.boolean().optional(),
|
||||
repeatRequestEnabled: z.boolean().optional(),
|
||||
repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(),
|
||||
});
|
||||
@@ -168,7 +176,7 @@ function sanitizeBranchToken(value: string) {
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function normalizePlanWorkId(value?: string | null) {
|
||||
export function normalizePlanWorkId(value?: string | null) {
|
||||
const workId = String(value ?? '').trim();
|
||||
const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase();
|
||||
|
||||
@@ -273,6 +281,7 @@ export function mapPlanRow(
|
||||
note: options?.maskNote ? maskPlanNote(row.note) : row.note,
|
||||
automationType: options?.exposeConfiguredAutomationType ? resolveStoredAutomationTypeId(row) : normalizePlanAutomationType(row.automation_type),
|
||||
automationBehaviorType: normalizePlanAutomationType(row.automation_type),
|
||||
automationContextIds: parseAutomationContextIds(row.automation_context_ids_json),
|
||||
releaseReviewNote: options?.releaseReviewNote ?? '',
|
||||
noteMasked: Boolean(options?.noteMasked),
|
||||
status: row.status,
|
||||
@@ -281,6 +290,7 @@ export function mapPlanRow(
|
||||
? row.jangsing_processing_required
|
||||
: row.normal_processing_level === '상',
|
||||
autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true),
|
||||
suppressWebPush: Boolean(row.suppress_web_push ?? false),
|
||||
repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false),
|
||||
repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60),
|
||||
assignedBranch: row.assigned_branch,
|
||||
@@ -819,9 +829,15 @@ async function syncPlanColumns() {
|
||||
await ensureColumn('automation_type_id', (table) => {
|
||||
table.string('automation_type_id', 120).nullable();
|
||||
});
|
||||
await ensureColumn('automation_context_ids_json', (table) => {
|
||||
table.text('automation_context_ids_json').notNullable().defaultTo('[]');
|
||||
});
|
||||
await ensureColumn('auto_deploy_to_main', (table) => {
|
||||
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
|
||||
});
|
||||
await ensureColumn('suppress_web_push', (table) => {
|
||||
table.boolean('suppress_web_push').notNullable().defaultTo(false);
|
||||
});
|
||||
await ensureColumn('repeat_request_enabled', (table) => {
|
||||
table.boolean('repeat_request_enabled').notNullable().defaultTo(false);
|
||||
});
|
||||
@@ -1024,6 +1040,7 @@ export async function ensurePlanTable() {
|
||||
table.string('status', 40).notNullable().defaultTo('등록');
|
||||
table.boolean('jangsing_processing_required').notNullable().defaultTo(true);
|
||||
table.boolean('auto_deploy_to_main').notNullable().defaultTo(true);
|
||||
table.boolean('suppress_web_push').notNullable().defaultTo(false);
|
||||
table.string('assigned_branch', 200).nullable();
|
||||
table.string('release_target', 120).notNullable().defaultTo('release');
|
||||
table.string('worker_status', 80).nullable();
|
||||
@@ -1056,6 +1073,11 @@ export async function ensurePlanTable() {
|
||||
.update({
|
||||
jangsing_processing_required: true,
|
||||
});
|
||||
await db(PLAN_TABLE)
|
||||
.whereNull('suppress_web_push')
|
||||
.update({
|
||||
suppress_web_push: false,
|
||||
});
|
||||
await db(PLAN_TABLE)
|
||||
.whereIn('status', ['작업완료', '릴리즈완료', '완료'] as never[])
|
||||
.whereNull('completed_at')
|
||||
@@ -1098,9 +1120,11 @@ export async function createPlanItem(payload: z.infer<typeof createPlanSchema>)
|
||||
status: '등록',
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
suppress_web_push: payload.suppressWebPush,
|
||||
repeat_request_enabled: payload.repeatRequestEnabled,
|
||||
repeat_interval_minutes: payload.repeatIntervalMinutes,
|
||||
worker_status: '대기',
|
||||
@@ -1124,9 +1148,11 @@ export async function createCompletedPlanExecutionLogItem(payload: z.infer<typeo
|
||||
status: '완료',
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(payload.automationContextIds),
|
||||
release_target: payload.releaseTarget,
|
||||
jangsing_processing_required: payload.jangsingProcessingRequired,
|
||||
auto_deploy_to_main: payload.autoDeployToMain,
|
||||
suppress_web_push: payload.suppressWebPush,
|
||||
repeat_request_enabled: payload.repeatRequestEnabled,
|
||||
repeat_interval_minutes: payload.repeatIntervalMinutes,
|
||||
worker_status: '자동완료',
|
||||
@@ -1150,7 +1176,9 @@ export async function upsertAutoPlanItem(args: {
|
||||
releaseTarget: string;
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
suppressWebPush?: boolean;
|
||||
automationType?: PlanAutomationType;
|
||||
automationContextIds?: string[];
|
||||
requeue: boolean;
|
||||
}) {
|
||||
await ensurePlanTable();
|
||||
@@ -1168,9 +1196,11 @@ export async function upsertAutoPlanItem(args: {
|
||||
workId,
|
||||
note: args.note,
|
||||
automationType: automationType.id,
|
||||
automationContextIds: args.automationContextIds ?? [],
|
||||
releaseTarget: args.releaseTarget,
|
||||
jangsingProcessingRequired: args.jangsingProcessingRequired,
|
||||
autoDeployToMain: args.autoDeployToMain,
|
||||
suppressWebPush: args.suppressWebPush ?? false,
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
}),
|
||||
@@ -1182,7 +1212,9 @@ export async function upsertAutoPlanItem(args: {
|
||||
const nextAutomationType = await resolveAutomationType(args.automationType ?? existingRow.automation_type_id ?? existingRow.automation_type);
|
||||
const nextJangsingProcessingRequired = args.jangsingProcessingRequired;
|
||||
const nextAutoDeployToMain = args.autoDeployToMain;
|
||||
const nextSuppressWebPush = args.suppressWebPush ?? Boolean(existingRow.suppress_web_push ?? false);
|
||||
const nextNote = args.note;
|
||||
const nextAutomationContextIds = args.automationContextIds ?? parseAutomationContextIds(existingRow.automation_context_ids_json);
|
||||
const currentJangsingProcessingRequired =
|
||||
typeof existingRow.jangsing_processing_required === 'boolean'
|
||||
? existingRow.jangsing_processing_required
|
||||
@@ -1192,6 +1224,8 @@ export async function upsertAutoPlanItem(args: {
|
||||
String(existingRow.automation_type_id ?? existingRow.automation_type ?? 'none') !== nextAutomationType.id ||
|
||||
(existingRow.release_target ?? 'release') !== nextReleaseTarget ||
|
||||
Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain ||
|
||||
Boolean(existingRow.suppress_web_push ?? false) !== nextSuppressWebPush ||
|
||||
JSON.stringify(parseAutomationContextIds(existingRow.automation_context_ids_json)) !== JSON.stringify(nextAutomationContextIds) ||
|
||||
Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired;
|
||||
|
||||
if (!args.requeue) {
|
||||
@@ -1208,9 +1242,11 @@ export async function upsertAutoPlanItem(args: {
|
||||
note: nextNote,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
suppress_web_push: nextSuppressWebPush,
|
||||
updated_at: db.fn.now(),
|
||||
})
|
||||
.returning('*');
|
||||
@@ -1230,9 +1266,11 @@ export async function upsertAutoPlanItem(args: {
|
||||
assigned_branch: null,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
suppress_web_push: nextSuppressWebPush,
|
||||
worker_status: '대기',
|
||||
last_error: null,
|
||||
locked_by: null,
|
||||
@@ -1278,17 +1316,22 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
? currentRow.jangsing_processing_required
|
||||
: currentRow.normal_processing_level === '상');
|
||||
const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true;
|
||||
const nextSuppressWebPush = payload.suppressWebPush ?? currentRow.suppress_web_push ?? false;
|
||||
const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false;
|
||||
const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60;
|
||||
const nextNote = payload.note ?? currentRow.note;
|
||||
const nextAutomationContextIds =
|
||||
payload.automationContextIds ?? parseAutomationContextIds(currentRow.automation_context_ids_json);
|
||||
|
||||
const isOnlyJangsingUpdate =
|
||||
nextWorkId === currentRow.work_id &&
|
||||
nextAutomationType.id === String(currentRow.automation_type_id ?? currentRow.automation_type ?? 'none') &&
|
||||
nextReleaseTarget === (currentRow.release_target ?? 'release') &&
|
||||
nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) &&
|
||||
nextSuppressWebPush === (currentRow.suppress_web_push ?? false) &&
|
||||
nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) &&
|
||||
nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) &&
|
||||
JSON.stringify(parseAutomationContextIds(currentRow.automation_context_ids_json)) === JSON.stringify(nextAutomationContextIds) &&
|
||||
nextNote === currentRow.note;
|
||||
|
||||
if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) {
|
||||
@@ -1307,9 +1350,11 @@ export async function updatePlanItem(id: number, payload: z.infer<typeof updateP
|
||||
status: currentRow.status,
|
||||
automation_type: nextAutomationType.behaviorType,
|
||||
automation_type_id: nextAutomationType.id,
|
||||
automation_context_ids_json: stringifyAutomationContextIds(nextAutomationContextIds),
|
||||
release_target: nextReleaseTarget,
|
||||
jangsing_processing_required: nextJangsingProcessingRequired,
|
||||
auto_deploy_to_main: nextAutoDeployToMain,
|
||||
suppress_web_push: nextSuppressWebPush,
|
||||
repeat_request_enabled: nextRepeatRequestEnabled,
|
||||
repeat_interval_minutes: nextRepeatIntervalMinutes,
|
||||
worker_status: currentRow.worker_status,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
buildHealthCheckUrls,
|
||||
@@ -88,6 +90,7 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
workServerScript,
|
||||
/docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/,
|
||||
);
|
||||
assert.doesNotMatch(workServerScript, /kill -HUP 1/);
|
||||
assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/);
|
||||
});
|
||||
|
||||
@@ -271,3 +274,65 @@ test('listServerCommands marks command-runner online when localhost fallback res
|
||||
assert.equal(runnerCommand.httpStatus, 200);
|
||||
assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/);
|
||||
});
|
||||
|
||||
test('listServerCommands ignores public codex chat resources when checking app source updates', async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-app-source-scan-'));
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
const buildTargetPath = '/tmp/ai-code-test-app-dist/index.html';
|
||||
const previousBuildContents = fs.existsSync(buildTargetPath) ? await fs.promises.readFile(buildTargetPath) : null;
|
||||
const previousBuildStat = fs.existsSync(buildTargetPath) ? await fs.promises.stat(buildTargetPath) : null;
|
||||
|
||||
try {
|
||||
const staleDate = new Date('2026-04-19T00:00:00.000Z');
|
||||
await mkdir(path.join(tempRoot, 'src'), { recursive: true });
|
||||
await mkdir(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource'), { recursive: true });
|
||||
await writeFile(path.join(tempRoot, 'src', 'main.tsx'), 'export const app = true;\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'index.html'), '<!doctype html>\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"tmp"}\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'tsconfig.json'), '{}\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'tsconfig.app.json'), '{}\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'vite.config.ts'), 'export default {};\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), 'resource only\n', 'utf8');
|
||||
await Promise.all([
|
||||
fs.promises.utimes(path.join(tempRoot, 'src', 'main.tsx'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'index.html'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'package.json'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'tsconfig.json'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'tsconfig.app.json'), staleDate, staleDate),
|
||||
fs.promises.utimes(path.join(tempRoot, 'vite.config.ts'), staleDate, staleDate),
|
||||
]);
|
||||
|
||||
await mkdir(path.dirname(buildTargetPath), { recursive: true });
|
||||
await writeFile(buildTargetPath, '<!doctype html>\n', 'utf8');
|
||||
const buildDate = new Date('2026-04-20T00:00:00.000Z');
|
||||
await fs.promises.utimes(buildTargetPath, buildDate, buildDate);
|
||||
|
||||
const resourceDate = new Date('2026-04-28T00:00:00.000Z');
|
||||
await fs.promises.utimes(path.join(tempRoot, 'public', '.codex_chat', 'session', 'resource', 'note.txt'), resourceDate, resourceDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const testCommand = commands.find((item) => item.key === 'test');
|
||||
|
||||
assert.ok(testCommand);
|
||||
assert.equal(testCommand.buildRequired, false);
|
||||
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
|
||||
if (previousBuildContents) {
|
||||
await writeFile(buildTargetPath, previousBuildContents, 'utf8');
|
||||
if (previousBuildStat) {
|
||||
await fs.promises.utimes(buildTargetPath, previousBuildStat.atime, previousBuildStat.mtime);
|
||||
}
|
||||
} else {
|
||||
await rm(buildTargetPath, { force: true });
|
||||
}
|
||||
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,6 +134,7 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [
|
||||
'/tmp/ai-code-test-app-dist/manifest.webmanifest',
|
||||
'/tmp/ai-code-test-app-dist/assets',
|
||||
] as const;
|
||||
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
|
||||
|
||||
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
|
||||
const allowLocal = options?.allowLocal ?? false;
|
||||
@@ -191,8 +192,17 @@ type SourceChangeInfo = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
function isExcludedAppSourcePath(rootPath: string, targetPath: string) {
|
||||
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
|
||||
return APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
|
||||
try {
|
||||
if (isExcludedAppSourcePath(rootPath, targetPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetStat = await stat(targetPath);
|
||||
|
||||
if (targetStat.isFile()) {
|
||||
|
||||
382
etc/servers/work-server/src/services/stock-alert-service.test.ts
Normal file
382
etc/servers/work-server/src/services/stock-alert-service.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildChangeRateThresholdStockAlertLines,
|
||||
buildCurrentPriceStockAlertLines,
|
||||
buildStockAlertNotificationIdentity,
|
||||
resolveLatestQuoteFromMeta,
|
||||
resolveLatestQuoteFromNaverRealtime,
|
||||
type StockAlertItem,
|
||||
} from './stock-alert-service.js';
|
||||
|
||||
test('resolveLatestQuoteFromMeta prefers post-market quote outside regular session', () => {
|
||||
const quote = resolveLatestQuoteFromMeta({
|
||||
regularMarketPrice: 210000,
|
||||
regularMarketTime: 1714377600,
|
||||
regularMarketChangePercent: 1.23,
|
||||
previousClose: 207450,
|
||||
postMarketPrice: 211500,
|
||||
postMarketTime: 1714381200,
|
||||
postMarketChangePercent: 1.95,
|
||||
marketState: 'POST',
|
||||
shortName: '삼성전자',
|
||||
});
|
||||
|
||||
assert.equal(quote.currentPrice, 211500);
|
||||
assert.equal(quote.changeRate, 1.95);
|
||||
assert.equal(quote.stockName, '삼성전자');
|
||||
assert.equal(quote.quotedAt, '2024-04-29T09:00:00.000Z');
|
||||
});
|
||||
|
||||
test('buildCurrentPriceStockAlertLines formats current-price stock alert lines', () => {
|
||||
const items: StockAlertItem[] = [
|
||||
{
|
||||
id: '005930',
|
||||
stockCode: '005930',
|
||||
stockName: '삼성전자',
|
||||
alertTypes: ['price'],
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: 210000,
|
||||
changeRate: 1.23,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '000660',
|
||||
stockCode: '000660',
|
||||
stockName: 'SK하이닉스',
|
||||
alertTypes: ['price'],
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: 198000,
|
||||
changeRate: -2.34,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(buildCurrentPriceStockAlertLines(items), [
|
||||
'삼성전자 210,000₩ (+1.23% ▲)',
|
||||
'SK하이닉스 198,000₩ (-2.34% ▼)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildCurrentPriceStockAlertLines skips rows without resolved current-price quote', () => {
|
||||
const items: StockAlertItem[] = [
|
||||
{
|
||||
id: '005930',
|
||||
stockCode: '005930',
|
||||
stockName: '삼성전자',
|
||||
alertTypes: ['price'],
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: 210000,
|
||||
changeRate: 1.23,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '035420',
|
||||
stockCode: '035420',
|
||||
stockName: 'NAVER',
|
||||
alertTypes: ['price'],
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: null,
|
||||
changeRate: null,
|
||||
quotedAt: null,
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(buildCurrentPriceStockAlertLines(items), ['삼성전자 210,000₩ (+1.23% ▲)']);
|
||||
});
|
||||
|
||||
test('buildCurrentPriceStockAlertLines excludes stocks not registered for current-price alerts', () => {
|
||||
const items: StockAlertItem[] = [
|
||||
{
|
||||
id: '005930',
|
||||
stockCode: '005930',
|
||||
stockName: '삼성전자',
|
||||
alertTypes: ['price', 'top3'],
|
||||
alertTypeLabels: ['현재가', '등락폭이 큰 상위3종목'],
|
||||
currentPrice: 210000,
|
||||
changeRate: 1.23,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '035420',
|
||||
stockCode: '035420',
|
||||
stockName: 'NAVER',
|
||||
alertTypes: ['top3'],
|
||||
alertTypeLabels: ['등락폭이 큰 상위3종목'],
|
||||
currentPrice: 230000,
|
||||
changeRate: 3.45,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(buildCurrentPriceStockAlertLines(items), ['삼성전자 210,000₩ (+1.23% ▲)']);
|
||||
});
|
||||
|
||||
test('buildChangeRateThresholdStockAlertLines includes every registered stock that crosses the threshold and sorts by absolute rate', () => {
|
||||
const items: StockAlertItem[] = [
|
||||
{
|
||||
id: '290550',
|
||||
stockCode: '290550',
|
||||
stockName: '디케이티',
|
||||
alertTypes: ['top3'],
|
||||
alertTypeLabels: ['등락폭이 큰 상위3종목'],
|
||||
currentPrice: 26500,
|
||||
changeRate: 11.11,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '005930',
|
||||
stockCode: '005930',
|
||||
stockName: '삼성전자',
|
||||
alertTypes: ['price'],
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: 210000,
|
||||
changeRate: 6.1,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '066570',
|
||||
stockCode: '066570',
|
||||
stockName: 'LG전자',
|
||||
alertTypes: ['top3'],
|
||||
alertTypeLabels: ['등락폭이 큰 상위3종목'],
|
||||
currentPrice: 137400,
|
||||
changeRate: -1.86,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: '035420',
|
||||
stockCode: '035420',
|
||||
stockName: 'NAVER',
|
||||
alertTypes: ['top3'],
|
||||
alertTypeLabels: ['등락폭이 큰 상위3종목'],
|
||||
currentPrice: 230000,
|
||||
changeRate: -7.23,
|
||||
quotedAt: '2026-04-29T09:00:00.000Z',
|
||||
createdAt: '2026-04-29T09:00:00.000Z',
|
||||
updatedAt: '2026-04-29T09:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(buildChangeRateThresholdStockAlertLines(items, 5), [
|
||||
'디케이티 26,500₩ (+11.11% ▲)',
|
||||
'NAVER 230,000₩ (-7.23% ▼)',
|
||||
'삼성전자 210,000₩ (+6.10% ▲)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildStockAlertNotificationIdentity keeps one stable notification per schedule and preserves legacy aliases', () => {
|
||||
assert.deepEqual(
|
||||
buildStockAlertNotificationIdentity({
|
||||
scheduleId: 2,
|
||||
serviceKey: 'schedule-2-stock-alert-service',
|
||||
mode: 'price',
|
||||
}),
|
||||
{
|
||||
threadId: 'schedule-stock-alert:2',
|
||||
notificationKey: 'schedule-stock-alert:2',
|
||||
notificationScope: 'schedule-stock-alert:2',
|
||||
notificationAliases: [
|
||||
'schedule-2-stock-alert-service',
|
||||
'schedule-2-stock-alert-service:current-price',
|
||||
'schedule-2-stock-alert',
|
||||
'schedule-2-stock-alert:current-price',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildStockAlertNotificationIdentity({
|
||||
scheduleId: 10,
|
||||
serviceKey: 'schedule-10-stock-high-service',
|
||||
mode: 'change-threshold',
|
||||
}),
|
||||
{
|
||||
threadId: 'schedule-stock-alert:10',
|
||||
notificationKey: 'schedule-stock-alert:10',
|
||||
notificationScope: 'schedule-stock-alert:10',
|
||||
notificationAliases: [
|
||||
'schedule-10-stock-high-service',
|
||||
'schedule-10-stock-high-service:current-price',
|
||||
'schedule-10-stock-high-service:change-threshold',
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime prefers extended-hours quote when available', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '005930',
|
||||
nm: '삼성전자',
|
||||
nv: 226000,
|
||||
cr: 1.8,
|
||||
nxtOverMarketPriceInfo: {
|
||||
tradingSessionType: 'AFTER_MARKET',
|
||||
overMarketStatus: 'OPEN',
|
||||
overPrice: '225,500',
|
||||
fluctuationsRatio: '1.58',
|
||||
localTradedAt: '2026-04-29T19:05:12.465411+09:00',
|
||||
},
|
||||
},
|
||||
1777457112465,
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 225500);
|
||||
assert.equal(quote.changeRate, 1.58);
|
||||
assert.equal(quote.stockName, '삼성전자');
|
||||
assert.equal(quote.quotedAt, '2026-04-29T19:05:12.465411+09:00');
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime keeps after-hours quote even after session close', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '066570',
|
||||
nm: 'LG전자',
|
||||
nv: 135800,
|
||||
cr: 3,
|
||||
nxtOverMarketPriceInfo: {
|
||||
tradingSessionType: 'AFTER_MARKET',
|
||||
overMarketStatus: 'CLOSE',
|
||||
overPrice: '137,400',
|
||||
compareToPreviousClosePrice: '-2,600',
|
||||
fluctuationsRatio: '-1.86',
|
||||
localTradedAt: '2026-04-29T20:00:00.000000+09:00',
|
||||
},
|
||||
},
|
||||
1777461821319,
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 137400);
|
||||
assert.equal(quote.changeRate, -1.86);
|
||||
assert.equal(quote.stockName, 'LG전자');
|
||||
assert.equal(quote.quotedAt, '2026-04-29T20:00:00.000000+09:00');
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime resets domestic quote to previous close from 5AM before premarket starts', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '005930',
|
||||
nm: '삼성전자',
|
||||
nv: 226000,
|
||||
cr: 1.8,
|
||||
ms: 'CLOSE',
|
||||
pcv: 222000,
|
||||
},
|
||||
'2026-04-30T05:30:00+09:00',
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 222000);
|
||||
assert.equal(quote.changeRate, 0);
|
||||
assert.equal(quote.stockName, '삼성전자');
|
||||
assert.equal(quote.quotedAt, '2026-04-29T20:30:00.000Z');
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime keeps before-market quote when available in the morning reset window', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '005930',
|
||||
nm: '삼성전자',
|
||||
nv: 226000,
|
||||
cr: 1.8,
|
||||
ms: 'CLOSE',
|
||||
pcv: 222000,
|
||||
nxtOverMarketPriceInfo: {
|
||||
tradingSessionType: 'BEFORE_MARKET',
|
||||
overMarketStatus: 'OPEN',
|
||||
overPrice: '223,500',
|
||||
fluctuationsRatio: '0.68',
|
||||
compareToPreviousClosePrice: '1,500',
|
||||
localTradedAt: '2026-04-30T08:10:00+09:00',
|
||||
},
|
||||
},
|
||||
'2026-04-30T08:10:05+09:00',
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 223500);
|
||||
assert.equal(quote.changeRate, 0.68);
|
||||
assert.equal(quote.stockName, '삼성전자');
|
||||
assert.equal(quote.quotedAt, '2026-04-30T08:10:00+09:00');
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime restores negative sign when ratio arrives unsigned', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '066570',
|
||||
nm: 'LG전자',
|
||||
nv: 135800,
|
||||
cr: 3,
|
||||
nxtOverMarketPriceInfo: {
|
||||
tradingSessionType: 'AFTER_MARKET',
|
||||
overMarketStatus: 'CLOSE',
|
||||
overPrice: '137,400',
|
||||
compareToPreviousClosePrice: '-2,600',
|
||||
fluctuationsRatio: '1.86',
|
||||
compareToPreviousPrice: {
|
||||
code: '5',
|
||||
name: 'FALLING',
|
||||
},
|
||||
localTradedAt: '2026-04-29T20:00:00.000000+09:00',
|
||||
},
|
||||
},
|
||||
1777461821319,
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 137400);
|
||||
assert.equal(quote.changeRate, -1.86);
|
||||
assert.equal(quote.stockName, 'LG전자');
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime falls back to regular quote when extended-hours quote is unavailable', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '005930',
|
||||
nm: '삼성전자',
|
||||
nv: 226000,
|
||||
cr: 1.8,
|
||||
},
|
||||
1777457112465,
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 226000);
|
||||
assert.equal(quote.changeRate, 1.8);
|
||||
assert.equal(quote.stockName, '삼성전자');
|
||||
assert.equal(quote.quotedAt, '2026-04-29T10:05:12.465Z');
|
||||
});
|
||||
|
||||
test('resolveLatestQuoteFromNaverRealtime restores negative sign for regular-session quotes from rf direction', () => {
|
||||
const quote = resolveLatestQuoteFromNaverRealtime(
|
||||
{
|
||||
cd: '024950',
|
||||
nm: '삼천리자전거',
|
||||
nv: 5130,
|
||||
cr: 2.47,
|
||||
rf: '5',
|
||||
pcv: 5260,
|
||||
},
|
||||
'2026-04-30T14:13:05+09:00',
|
||||
);
|
||||
|
||||
assert.equal(quote.currentPrice, 5130);
|
||||
assert.equal(quote.changeRate, -2.47);
|
||||
assert.equal(quote.stockName, '삼천리자전거');
|
||||
assert.equal(quote.quotedAt, '2026-04-30T05:13:05.000Z');
|
||||
});
|
||||
1427
etc/servers/work-server/src/services/stock-alert-service.ts
Normal file
1427
etc/servers/work-server/src/services/stock-alert-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const TEXT_MEMO_TABLE = 'text_memo_notes';
|
||||
const MEMO_LAYOUT_NAME = '메모';
|
||||
const MAX_NOTE_COUNT = 12;
|
||||
const MAX_BODY_LENGTH = 1200;
|
||||
const CLIENT_ID_MAX_LENGTH = 120;
|
||||
@@ -284,3 +285,103 @@ export async function importTextMemoNotes(
|
||||
|
||||
return listTextMemoNotes(clientId);
|
||||
}
|
||||
|
||||
export async function updateTextMemoLayoutFeatureDescription() {
|
||||
const layoutRecord = await db('play_layouts').select('id', 'tree').where({ name: MEMO_LAYOUT_NAME }).first();
|
||||
|
||||
if (!layoutRecord || typeof layoutRecord !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tree = layoutRecord.tree as
|
||||
| {
|
||||
root?: unknown;
|
||||
interactions?: Array<Record<string, unknown>>;
|
||||
interactionMode?: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
if (!tree || typeof tree !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextInteractions = [
|
||||
{
|
||||
id: 'memo-sync-first-line',
|
||||
sourceLeafId: 'layout-node-2',
|
||||
targetLeafId: 'layout-node-1',
|
||||
sourceComponent: {
|
||||
label: 'Text Memo Widget',
|
||||
optionId: 'widget:text-memo-widget:text-memo-widget',
|
||||
keywords: ['text-memo-widget', 'memo'],
|
||||
},
|
||||
targetComponent: {
|
||||
label: 'Base Input · Base',
|
||||
optionId: 'component:input:input-base',
|
||||
keywords: ['input', 'base'],
|
||||
},
|
||||
title: '메모 첫 줄 연결',
|
||||
description: '선택한 메모와 작성 중 본문의 첫 줄을 Primary Pane Base Input 값으로 동기화합니다.',
|
||||
implementationNotes:
|
||||
'메모 선택, 본문 입력, 저장 완료 시 첫 줄을 추출해 Base Input에 반영합니다. 빈 본문이면 빈 문자열을 유지합니다.',
|
||||
},
|
||||
{
|
||||
id: 'memo-title-commit',
|
||||
sourceLeafId: 'layout-node-1',
|
||||
targetLeafId: 'layout-node-2',
|
||||
sourceComponent: {
|
||||
label: 'Base Input · Base',
|
||||
optionId: 'component:input:input-base',
|
||||
keywords: ['input', 'base'],
|
||||
},
|
||||
targetComponent: {
|
||||
label: 'Text Memo Widget',
|
||||
optionId: 'widget:text-memo-widget:text-memo-widget',
|
||||
keywords: ['text-memo-widget', 'memo'],
|
||||
},
|
||||
title: '제목 확정 반영',
|
||||
description: 'Primary Pane Base Input에서 Enter 또는 blur로 확정한 값을 메모 첫 줄로 반영합니다.',
|
||||
implementationNotes:
|
||||
'InputUI 확정 이벤트를 사용해 현재 편집 중 본문의 첫 줄만 교체하고 나머지 줄바꿈/본문은 유지합니다.',
|
||||
},
|
||||
{
|
||||
id: 'memo-crud-api',
|
||||
sourceLeafId: 'layout-node-2',
|
||||
targetLeafId: 'layout-node-2',
|
||||
sourceComponent: {
|
||||
label: 'Text Memo Widget',
|
||||
optionId: 'widget:text-memo-widget:text-memo-widget',
|
||||
keywords: ['text-memo-widget', 'memo'],
|
||||
},
|
||||
targetComponent: {
|
||||
label: 'Text Memo Widget',
|
||||
optionId: 'widget:text-memo-widget:text-memo-widget',
|
||||
keywords: ['text-memo-widget', 'memo'],
|
||||
},
|
||||
title: '메모 저장/조회 API',
|
||||
description: '메모 목록 조회와 저장, 수정, 삭제를 text-memo API로 처리합니다.',
|
||||
implementationNotes:
|
||||
'GET /api/text-memo/notes, POST /api/text-memo/notes, PUT /api/text-memo/notes/:noteId, DELETE /api/text-memo/notes/:noteId 를 사용합니다. clientId 헤더 기준으로 사용자별 최근 12개 메모를 유지합니다.',
|
||||
},
|
||||
];
|
||||
|
||||
const previousInteractions = Array.isArray(tree.interactions) ? JSON.stringify(tree.interactions) : '';
|
||||
const nextInteractionsJson = JSON.stringify(nextInteractions);
|
||||
const nextInteractionMode = 'scoped-v2';
|
||||
|
||||
if (previousInteractions === nextInteractionsJson && tree.interactionMode === nextInteractionMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db('play_layouts')
|
||||
.where({ id: layoutRecord.id as string })
|
||||
.update({
|
||||
tree: {
|
||||
...tree,
|
||||
interactions: nextInteractions,
|
||||
interactionMode: nextInteractionMode,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
36
etc/servers/work-server/src/workers/plan-worker.test.ts
Normal file
36
etc/servers/work-server/src/workers/plan-worker.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { summarizeFailureOutput } from './plan-worker.js';
|
||||
|
||||
test('summarizeFailureOutput ignores structured zero-failure diff lines', () => {
|
||||
const output = [
|
||||
'```diff',
|
||||
'+ failedCount: 0,',
|
||||
'+ webFailed=0',
|
||||
'+ iosFailed=0,',
|
||||
'```',
|
||||
'actual failure: service file missing',
|
||||
].join('\n');
|
||||
|
||||
assert.equal(summarizeFailureOutput(output, 'fallback'), 'actual failure: service file missing');
|
||||
});
|
||||
|
||||
test('summarizeFailureOutput falls back when output only contains zero-failure noise', () => {
|
||||
const output = [
|
||||
'```diff',
|
||||
'+ failedCount: 0,',
|
||||
'+ webFailed=0',
|
||||
'+ failedCount: number;',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
assert.equal(summarizeFailureOutput(output, 'fallback failure'), 'fallback failure');
|
||||
});
|
||||
|
||||
test('summarizeFailureOutput ignores managed service success stub lines', () => {
|
||||
const output = [
|
||||
"return Promise.resolve({ ok: true, skipped: false, title: definition.title, body: '', itemCount: 0, lines: [], ios: { ok: true, skipped: true, sentCount: 0, failedCount: 0 }, web: { ok: true, skipped: true, sentCount: 0, failedCount: 0 }, definition });",
|
||||
].join('\n');
|
||||
|
||||
assert.equal(summarizeFailureOutput(output, 'fallback success'), 'fallback success');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user