chore: sync backend and deployment changes
This commit is contained in:
@@ -10,10 +10,14 @@ import { registerAppConfigRoutes } from './routes/app-config.js';
|
||||
import { registerChatRoutes } from './routes/chat.js';
|
||||
import { registerNotificationRoutes } from './routes/notification.js';
|
||||
import { registerPlanRoutes } from './routes/plan.js';
|
||||
import { registerPhotoPrismRoutes } from './routes/photoprism.js';
|
||||
import { registerReaderRoutes } from './routes/reader.js';
|
||||
import { registerResourceManagerRoutes } from './routes/resource-manager.js';
|
||||
import { registerServerCommandRoutes } from './routes/server-command.js';
|
||||
import { registerSchemaRoutes } from './routes/schema.js';
|
||||
import { registerSharedResourceTokenRoutes } from './routes/shared-resource-token.js';
|
||||
import { registerStockAlertRoutes } from './routes/stock-alert.js';
|
||||
import { registerTestAppRoutes } from './routes/test-app.js';
|
||||
import { registerTextMemoRoutes } from './routes/text-memo.js';
|
||||
import { registerVisitorHistoryRoutes } from './routes/visitor-history.js';
|
||||
import { shouldPersistNotFoundErrorLog } from './not-found.js';
|
||||
@@ -22,6 +26,9 @@ import { createErrorLog } from './services/error-log-service.js';
|
||||
export function createApp() {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
routerOptions: {
|
||||
maxParamLength: 20000,
|
||||
},
|
||||
});
|
||||
|
||||
app.register(cors, {
|
||||
@@ -37,10 +44,14 @@ export function createApp() {
|
||||
app.register(registerDdlRoutes);
|
||||
app.register(registerCrudRoutes);
|
||||
app.register(registerStockAlertRoutes);
|
||||
app.register(registerTestAppRoutes);
|
||||
app.register(registerErrorLogRoutes);
|
||||
app.register(registerNotificationRoutes);
|
||||
app.register(registerPlanRoutes);
|
||||
app.register(registerPhotoPrismRoutes);
|
||||
app.register(registerReaderRoutes);
|
||||
app.register(registerResourceManagerRoutes);
|
||||
app.register(registerSharedResourceTokenRoutes);
|
||||
app.register(registerServerCommandRoutes);
|
||||
app.register(registerTextMemoRoutes);
|
||||
app.register(registerVisitorHistoryRoutes);
|
||||
|
||||
@@ -70,8 +70,11 @@ const envSchema = z.object({
|
||||
SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')),
|
||||
SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'),
|
||||
SERVER_COMMAND_TEST_URL: z.string().default('https://preview.sm-home.cloud/'),
|
||||
SERVER_COMMAND_TEST_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'),
|
||||
SERVER_COMMAND_REL_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'),
|
||||
SERVER_COMMAND_PROD_CHECK_URL: z.string().optional(),
|
||||
SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'),
|
||||
SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'),
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import { getSharedResourceTokenDetailBySharePath } from '../services/shared-resource-token-service.js';
|
||||
import {
|
||||
getChatContextSettingsConfig,
|
||||
getAppConfigSnapshot,
|
||||
@@ -14,6 +16,124 @@ import {
|
||||
upsertAutomationContextsConfig,
|
||||
} from '../services/automation-context-config-service.js';
|
||||
import { getAutomationTypesConfig, upsertAutomationTypesConfig } from '../services/automation-type-config-service.js';
|
||||
import {
|
||||
getTokenSettingsConfig,
|
||||
getTokenSettingById,
|
||||
upsertTokenSettingsConfig,
|
||||
type TokenSettingRecord,
|
||||
} from '../services/token-setting-config-service.js';
|
||||
|
||||
const CHAT_SHARE_PATH_PREFIX = '/chat/share/';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `${CHAT_SHARE_PATH_PREFIX}${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
type TokenSettingsAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared'; tokenSetting: TokenSettingRecord };
|
||||
|
||||
type AppConfigAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared' };
|
||||
|
||||
async function resolveTokenSettingsAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies TokenSettingsAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (managedResource.token.resourceType === 'chat-share') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const hasManagePermission = managedResource.token.permissions.includes('manage');
|
||||
const canOpenTokenSetting = normalizedAllowedAppIds.has('token-setting');
|
||||
const tokenSettingId = managedResource.token.tokenSettingId?.trim() ?? '';
|
||||
|
||||
if (!hasManagePermission || !canOpenTokenSetting || !tokenSettingId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenSetting = await getTokenSettingById(tokenSettingId);
|
||||
if (!tokenSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { scope: 'shared', tokenSetting } satisfies TokenSettingsAccessContext;
|
||||
}
|
||||
|
||||
async function resolveAppConfigAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies AppConfigAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (managedResource.token.resourceType === 'chat-share') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
const hasManagePermission = managedResource.token.permissions.includes('manage');
|
||||
const canOpenAppSettings = normalizedAllowedAppIds.has('app-settings');
|
||||
|
||||
if (!hasManagePermission || !canOpenAppSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { scope: 'shared' } satisfies AppConfigAccessContext;
|
||||
}
|
||||
|
||||
function sendTokenSettingsAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 토큰관리 관리 권한이 있는 공유 링크에서만 토큰 설정을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function sendAppConfigAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 앱 설정 관리 권한이 있는 공유 링크에서만 앱 설정을 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestAppOrigin(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const rawAppOrigin = request.headers['x-app-origin'];
|
||||
@@ -50,7 +170,15 @@ function getRequestAppDomain(request: { headers: Record<string, string | string[
|
||||
}
|
||||
|
||||
export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
app.get('/api/app-config', async (request) => {
|
||||
app.get('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
const hasShareToken = Boolean(getRequestChatShareToken(request));
|
||||
|
||||
if (hasShareToken && !accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const appOrigin = getRequestAppOrigin(request);
|
||||
const config = await getAppConfigSnapshot(appOrigin);
|
||||
|
||||
@@ -96,6 +224,22 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/token-settings', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenSettings =
|
||||
accessContext.scope === 'full' ? await getTokenSettingsConfig() : [accessContext.tokenSetting];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokenSettings,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/chat-types', async (request, reply) => {
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
@@ -219,7 +363,67 @@ export async function registerAppConfigRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/token-settings', async (request, reply) => {
|
||||
const accessContext = await resolveTokenSettingsAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendTokenSettingsAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = z.object({
|
||||
tokenSettings: z.array(z.unknown()),
|
||||
}).parse(payload ?? {});
|
||||
|
||||
const nextTokenSettingsInput = parsed.tokenSettings as Partial<TokenSettingRecord>[];
|
||||
const savedTokenSettings =
|
||||
accessContext.scope === 'full'
|
||||
? await upsertTokenSettingsConfig(nextTokenSettingsInput)
|
||||
: await (async () => {
|
||||
const authorizedSettingId = accessContext.tokenSetting.id;
|
||||
const requestedSetting = nextTokenSettingsInput.find(
|
||||
(item) => typeof item?.id === 'string' && item.id.trim().toLowerCase() === authorizedSettingId,
|
||||
);
|
||||
|
||||
if (!requestedSetting) {
|
||||
throw new Error('공유 링크에서는 현재 연결된 토큰 설정만 저장할 수 있습니다.');
|
||||
}
|
||||
|
||||
const currentTokenSettings = await getTokenSettingsConfig();
|
||||
const nextTokenSettings = currentTokenSettings.map((item) =>
|
||||
item.id === authorizedSettingId ? { ...requestedSetting, id: authorizedSettingId } : item,
|
||||
);
|
||||
return upsertTokenSettingsConfig(nextTokenSettings);
|
||||
})();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
tokenSettings: accessContext.scope === 'full' ? savedTokenSettings : savedTokenSettings.filter((item) => item.id === accessContext.tokenSetting.id),
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/app-config', async (request, reply) => {
|
||||
const accessContext = await resolveAppConfigAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAppConfigAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: unknown = request.body ?? {};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveStaticContentType } from './chat.js';
|
||||
import { resolveStaticContentType, shouldAutoCompleteShareReplyParentVerification } from './chat.js';
|
||||
|
||||
test('resolveStaticContentType returns html content type for chat resource html files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8');
|
||||
@@ -11,3 +11,41 @@ test('resolveStaticContentType keeps plain text content type for code resources'
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.ts'), 'text/plain; charset=utf-8');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.diff'), 'text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
test('shouldAutoCompleteShareReplyParentVerification only completes answered requests that are not already verified', () => {
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: 101,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: null,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteShareReplyParentVerification({
|
||||
responseMessageId: 102,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
1241
etc/servers/work-server/src/routes/photoprism.ts
Normal file
File diff suppressed because it is too large
Load Diff
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
1417
etc/servers/work-server/src/routes/reader.ts
Normal file
File diff suppressed because it is too large
Load Diff
57
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
57
etc/servers/work-server/src/routes/resource-manager.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import { env } from '../config/env.js';
|
||||
import { registerResourceManagerRoutes, resolveSingleRange } from './resource-manager.js';
|
||||
|
||||
const fallbackResourceRoot = path.resolve(process.cwd(), '../../../resource');
|
||||
|
||||
test('resolveSingleRange parses open-ended and suffix byte ranges', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=5-', 20), {
|
||||
isValid: true,
|
||||
start: 5,
|
||||
end: 19,
|
||||
});
|
||||
assert.deepEqual(resolveSingleRange('bytes=-4', 20), {
|
||||
isValid: true,
|
||||
start: 16,
|
||||
end: 19,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveSingleRange rejects malformed or out-of-bounds values', () => {
|
||||
assert.deepEqual(resolveSingleRange('bytes=', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=25-30', 20), { isValid: false });
|
||||
assert.deepEqual(resolveSingleRange('bytes=4-3', 20), { isValid: false });
|
||||
});
|
||||
|
||||
test('resource manager preview serves 206 partial content for byte ranges', async () => {
|
||||
const app = Fastify();
|
||||
await registerResourceManagerRoutes(app);
|
||||
|
||||
const relativePath = `range-test-${Date.now()}.wav`;
|
||||
const absolutePath = path.join(fallbackResourceRoot, relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, Buffer.from('0123456789', 'utf8'));
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/resource-manager/preview/${encodeURIComponent(relativePath)}?token=${encodeURIComponent(env.SERVER_COMMAND_ACCESS_TOKEN)}`,
|
||||
headers: {
|
||||
range: 'bytes=2-5',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 206);
|
||||
assert.equal(response.headers['accept-ranges'], 'bytes');
|
||||
assert.equal(response.headers['content-range'], 'bytes 2-5/10');
|
||||
assert.equal(response.headers['content-length'], '4');
|
||||
assert.equal(response.body, '2345');
|
||||
} finally {
|
||||
await fs.rm(absolutePath, { force: true });
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -48,6 +48,51 @@ const copyMoveBodySchema = z.object({
|
||||
nextName: z.string().trim().max(255).optional().nullable(),
|
||||
});
|
||||
|
||||
export function resolveSingleRange(rangeHeader: string | undefined, fileSize: number) {
|
||||
const rangeValue = String(rangeHeader ?? '').trim();
|
||||
|
||||
if (!rangeValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/u.exec(rangeValue);
|
||||
|
||||
if (!match) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
const [, startRaw, endRaw] = match;
|
||||
|
||||
if (!startRaw && !endRaw) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
if (!startRaw) {
|
||||
const suffixLength = Number(endRaw);
|
||||
|
||||
if (!Number.isInteger(suffixLength) || suffixLength <= 0) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
const start = Math.max(fileSize - suffixLength, 0);
|
||||
const end = fileSize - 1;
|
||||
return start <= end ? { isValid: true, start, end } as const : { isValid: false } as const;
|
||||
}
|
||||
|
||||
const start = Number(startRaw);
|
||||
const end = endRaw ? Number(endRaw) : fileSize - 1;
|
||||
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= fileSize) {
|
||||
return { isValid: false } as const;
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
start,
|
||||
end: Math.min(end, fileSize - 1),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function getRequestAccessToken(request: FastifyRequest) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
@@ -123,10 +168,29 @@ export async function registerResourceManagerRoutes(app: FastifyInstance) {
|
||||
|
||||
const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim();
|
||||
const preview = await openResourceManagerPreviewStream(resolveRepoRootPath(), decodeURIComponent(wildcard));
|
||||
const rangeHeader = Array.isArray(request.headers.range) ? request.headers.range[0] : request.headers.range;
|
||||
const range = resolveSingleRange(rangeHeader, preview.size);
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.type(preview.contentType);
|
||||
return reply.send(preview.stream);
|
||||
|
||||
if (range) {
|
||||
if (!range.isValid) {
|
||||
reply.status(416);
|
||||
reply.header('Content-Range', `bytes */${preview.size}`);
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const contentLength = range.end - range.start + 1;
|
||||
reply.status(206);
|
||||
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${preview.size}`);
|
||||
reply.header('Content-Length', String(contentLength));
|
||||
return reply.send(preview.createStream({ start: range.start, end: range.end }));
|
||||
}
|
||||
|
||||
reply.header('Content-Length', String(preview.size));
|
||||
return reply.send(preview.createStream());
|
||||
});
|
||||
|
||||
app.post('/api/resource-manager/directories', async (request, reply) => {
|
||||
|
||||
@@ -39,10 +39,16 @@ function getImmediateRestartBlockInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === 'work-server' && automationPendingCount > 0) {
|
||||
if (key === 'work-server') {
|
||||
const pendingCount = codexPendingCount + automationPendingCount;
|
||||
|
||||
if (pendingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pendingCount: automationPendingCount,
|
||||
message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
pendingCount,
|
||||
message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
337
etc/servers/work-server/src/routes/shared-resource-token.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
deleteSharedResourceTokens,
|
||||
deleteSharedResourceToken,
|
||||
getSharedResourceTokenDetail,
|
||||
getSharedResourceTokenDetailBySharePath,
|
||||
listSharedResourceTokens,
|
||||
recordSharedResourceTokenUsage,
|
||||
restoreSharedResourceToken,
|
||||
revokeSharedResourceToken,
|
||||
revokeSharedResourceTokens,
|
||||
sharedResourceTokenSchema,
|
||||
upsertSharedResourceToken,
|
||||
} from '../services/shared-resource-token-service.js';
|
||||
|
||||
function getRequestAccessToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-access-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function getRequestChatShareToken(request: { headers: Record<string, string | string[] | undefined> }) {
|
||||
const tokenHeader = request.headers['x-chat-share-token'];
|
||||
return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim();
|
||||
}
|
||||
|
||||
function resolveChatSharePath(token: string) {
|
||||
return `/chat/share/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
type SharedResourceTokenAccessContext =
|
||||
| { scope: 'full' }
|
||||
| { scope: 'shared'; tokenId: string };
|
||||
|
||||
async function resolveSharedResourceTokenAccessContext(
|
||||
request: { headers: Record<string, string | string[] | undefined> },
|
||||
) {
|
||||
if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) {
|
||||
return { scope: 'full' } satisfies SharedResourceTokenAccessContext;
|
||||
}
|
||||
|
||||
const shareToken = getRequestChatShareToken(request);
|
||||
if (!shareToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const managedResource = await getSharedResourceTokenDetailBySharePath(resolveChatSharePath(shareToken));
|
||||
if (!managedResource || managedResource.token.enabled === false || managedResource.token.revokedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allowedAppIds = managedResource.token.allowedAppIds ?? [];
|
||||
const normalizedAllowedAppIds = new Set(allowedAppIds.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!managedResource.token.permissions.includes('manage') || !normalizedAllowedAppIds.has('shared-resource')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'shared',
|
||||
tokenId: managedResource.token.id,
|
||||
} satisfies SharedResourceTokenAccessContext;
|
||||
}
|
||||
|
||||
function sendAccessDenied(
|
||||
reply: { code: (statusCode: number) => { send: (payload: { message: string }) => unknown } },
|
||||
) {
|
||||
reply.code(403).send({
|
||||
message: '권한 토큰 또는 shared-resource 관리 권한이 있는 공유 링크에서만 공유 리소스 관리를 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
function isAllowedSharedTokenTarget(accessContext: SharedResourceTokenAccessContext, tokenId: string) {
|
||||
return accessContext.scope === 'full' || accessContext.tokenId === tokenId;
|
||||
}
|
||||
|
||||
export async function registerSharedResourceTokenRoutes(app: FastifyInstance) {
|
||||
app.get('/api/shared-resource-tokens', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const sharedTokenDetail =
|
||||
accessContext.scope === 'shared' ? await getSharedResourceTokenDetail(accessContext.tokenId) : null;
|
||||
const items =
|
||||
accessContext.scope === 'full'
|
||||
? await listSharedResourceTokens()
|
||||
: sharedTokenDetail?.token
|
||||
? [sharedTokenDetail.token]
|
||||
: [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items,
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크로는 이 공유 토큰 상세를 열 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const item = await getSharedResourceTokenDetail(tokenId);
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({
|
||||
message: '공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...item,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/api/shared-resource-tokens', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = sharedResourceTokenSchema.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.id !== accessContext.tokenId) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰 상세만 수정할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await upsertSharedResourceToken(payload);
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
} catch (error) {
|
||||
return reply.code(409).send({
|
||||
message: error instanceof Error ? error.message : '공유 리소스 토큰 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/bulk-revoke', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await revokeSharedResourceTokens(payload.tokenIds, payload.reason);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/revoke', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 회수할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
reason: z.string().trim().max(500).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await revokeSharedResourceToken(tokenId, payload.reason);
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '회수할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/restore', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 복원할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const saved = await restoreSharedResourceToken(tokenId);
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '복원할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/:tokenId/usage', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 기록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
actorLabel: z.string().trim().max(120).optional().nullable(),
|
||||
summary: z.string().trim().max(400).optional().nullable(),
|
||||
detail: z.string().trim().max(2000).optional().nullable(),
|
||||
usageDelta: z.number().int().min(1).max(1_000_000).optional().nullable(),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
const saved = await recordSharedResourceTokenUsage(tokenId, payload);
|
||||
|
||||
if (!saved) {
|
||||
return reply.code(404).send({
|
||||
message: '사용량을 기록할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...saved,
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/shared-resource-tokens/bulk-delete', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = z
|
||||
.object({
|
||||
tokenIds: z.array(z.string().trim().min(1)).min(1).max(500),
|
||||
})
|
||||
.parse(request.body ?? {});
|
||||
|
||||
if (accessContext.scope === 'shared' && payload.tokenIds.some((tokenId) => tokenId !== accessContext.tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await deleteSharedResourceTokens(payload.tokenIds);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
app.delete('/api/shared-resource-tokens/:tokenId', async (request, reply) => {
|
||||
const accessContext = await resolveSharedResourceTokenAccessContext(request);
|
||||
if (!accessContext) {
|
||||
sendAccessDenied(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = z.string().trim().min(1).parse((request.params as { tokenId: string }).tokenId);
|
||||
if (!isAllowedSharedTokenTarget(accessContext, tokenId)) {
|
||||
return reply.code(403).send({
|
||||
message: '현재 공유 링크에서는 연결된 공유 토큰만 삭제할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await deleteSharedResourceToken(tokenId);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.code(404).send({
|
||||
message: '삭제할 공유 리소스 토큰을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deleted: true,
|
||||
tokenId,
|
||||
};
|
||||
});
|
||||
}
|
||||
357
etc/servers/work-server/src/routes/test-app.ts
Normal file
357
etc/servers/work-server/src/routes/test-app.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const TEST_APP_TABLE = 'test_app_maintenance_requests';
|
||||
const TEST_APP_SEED_COUNT = 7000;
|
||||
const PRIORITY_VALUES = ['긴급', '높음', '보통', '낮음'] as const;
|
||||
const STATUS_VALUES = ['접수', '배정완료', '조치중', '부품대기', '완료'] as const;
|
||||
const ISSUE_TYPES = ['센서 오차', '진동 이상', '누유 감지', '온도 상승', '부품 마모', '통신 장애'] as const;
|
||||
const REQUESTERS = ['김민재', '박서윤', '이도윤', '최하린', '정서준', '한지민'] as const;
|
||||
const ASSIGNEES = ['윤태호', '장우진', '서가은', '임현수', '강다온', '문시우'] as const;
|
||||
const LINE_EQUIPMENT_MAP = {
|
||||
PKG: ['실링기 1호', '실링기 2호', '포장로봇 1호', '라벨러 2호'],
|
||||
MFG: ['혼합기 A', '혼합기 B', '압출기 3호', '컨베이어 7호'],
|
||||
UTL: ['냉각펌프 1호', '공조기 2호', '콤프레서 1호', '보일러 1호'],
|
||||
QC: ['비전검사기 1호', '중량선별기 2호', '샘플러 1호', '검사컨베이어 1호'],
|
||||
} as const;
|
||||
const LINE_CODES = Object.keys(LINE_EQUIPMENT_MAP) as Array<keyof typeof LINE_EQUIPMENT_MAP>;
|
||||
|
||||
const listQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).default(100),
|
||||
keyword: z.string().trim().default(''),
|
||||
lineCode: z.string().trim().optional(),
|
||||
priority: z.string().trim().optional(),
|
||||
status: z.string().trim().optional(),
|
||||
requestedFrom: z.string().trim().optional(),
|
||||
requestedTo: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
const updateItemSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
priority: z.enum(PRIORITY_VALUES),
|
||||
status: z.enum(STATUS_VALUES),
|
||||
assigneeName: z.string().trim().max(80).optional(),
|
||||
});
|
||||
|
||||
const saveBodySchema = z.object({
|
||||
items: z.array(updateItemSchema).min(1).max(200),
|
||||
});
|
||||
|
||||
const deleteParamsSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
type TestAppRow = {
|
||||
id: number;
|
||||
request_no: string;
|
||||
line_code: string;
|
||||
equipment_name: string;
|
||||
issue_type: (typeof ISSUE_TYPES)[number];
|
||||
priority: (typeof PRIORITY_VALUES)[number];
|
||||
requester_name: string;
|
||||
assignee_name: string | null;
|
||||
status: (typeof STATUS_VALUES)[number];
|
||||
requested_at: Date | string;
|
||||
last_action_at: Date | string;
|
||||
};
|
||||
|
||||
function pad2(value: number) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTime(value: Date | string) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function toResponseRow(row: TestAppRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
requestNo: row.request_no,
|
||||
lineCode: row.line_code,
|
||||
equipmentName: row.equipment_name,
|
||||
issueType: row.issue_type,
|
||||
priority: row.priority,
|
||||
requesterName: row.requester_name,
|
||||
assigneeName: row.assignee_name ?? '',
|
||||
status: row.status,
|
||||
requestedAt: formatDateTime(row.requested_at),
|
||||
lastActionAt: formatDateTime(row.last_action_at),
|
||||
};
|
||||
}
|
||||
|
||||
function pickPriority(index: number) {
|
||||
const ratio = index % 100;
|
||||
|
||||
if (ratio < 6) {
|
||||
return '긴급' as const;
|
||||
}
|
||||
|
||||
if (ratio < 24) {
|
||||
return '높음' as const;
|
||||
}
|
||||
|
||||
if (ratio < 72) {
|
||||
return '보통' as const;
|
||||
}
|
||||
|
||||
return '낮음' as const;
|
||||
}
|
||||
|
||||
function pickStatus(index: number, priority: (typeof PRIORITY_VALUES)[number]) {
|
||||
const ratio = index % 100;
|
||||
|
||||
if (priority === '긴급') {
|
||||
if (ratio < 28) {
|
||||
return '접수' as const;
|
||||
}
|
||||
|
||||
if (ratio < 54) {
|
||||
return '배정완료' as const;
|
||||
}
|
||||
|
||||
if (ratio < 86) {
|
||||
return '조치중' as const;
|
||||
}
|
||||
|
||||
if (ratio < 93) {
|
||||
return '부품대기' as const;
|
||||
}
|
||||
|
||||
return '완료' as const;
|
||||
}
|
||||
|
||||
if (ratio < 22) {
|
||||
return '접수' as const;
|
||||
}
|
||||
|
||||
if (ratio < 45) {
|
||||
return '배정완료' as const;
|
||||
}
|
||||
|
||||
if (ratio < 70) {
|
||||
return '조치중' as const;
|
||||
}
|
||||
|
||||
if (ratio < 79) {
|
||||
return '부품대기' as const;
|
||||
}
|
||||
|
||||
return '완료' as const;
|
||||
}
|
||||
|
||||
function buildSeedRow(index: number) {
|
||||
const lineCode = LINE_CODES[index % LINE_CODES.length];
|
||||
const equipmentList = LINE_EQUIPMENT_MAP[lineCode];
|
||||
const equipmentName = equipmentList[Math.floor(index / LINE_CODES.length) % equipmentList.length];
|
||||
const priority = pickPriority(index);
|
||||
const status = pickStatus(index, priority);
|
||||
const issueType = ISSUE_TYPES[(index * 3 + Math.floor(index / 7)) % ISSUE_TYPES.length];
|
||||
const requesterName = REQUESTERS[(index * 5 + 1) % REQUESTERS.length];
|
||||
const assigneeName = status === '접수' ? null : ASSIGNEES[(index * 7 + 2) % ASSIGNEES.length];
|
||||
const requestedAt = new Date(Date.now() - ((index % (45 * 48)) * 30 + (index % 3) * 10) * 60_000);
|
||||
const lastActionAt =
|
||||
status === '접수'
|
||||
? requestedAt
|
||||
: new Date(requestedAt.getTime() + (((index % 9) + 1) * 45 + (priority === '긴급' ? 20 : 0)) * 60_000);
|
||||
const requestNo = `JR-${requestedAt.getFullYear()}${pad2(requestedAt.getMonth() + 1)}${pad2(requestedAt.getDate())}-${String(1000 + index).padStart(4, '0')}`;
|
||||
|
||||
return {
|
||||
request_no: requestNo,
|
||||
line_code: lineCode,
|
||||
equipment_name: equipmentName,
|
||||
issue_type: issueType,
|
||||
priority,
|
||||
requester_name: requesterName,
|
||||
assignee_name: assigneeName,
|
||||
status,
|
||||
requested_at: requestedAt,
|
||||
last_action_at: lastActionAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureTestAppTable() {
|
||||
const tableExists = await db.schema.hasTable(TEST_APP_TABLE);
|
||||
|
||||
if (!tableExists) {
|
||||
await db.schema.createTable(TEST_APP_TABLE, (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('request_no', 40).notNullable().unique();
|
||||
table.string('line_code', 20).notNullable();
|
||||
table.string('equipment_name', 120).notNullable();
|
||||
table.string('issue_type', 80).notNullable();
|
||||
table.string('priority', 20).notNullable();
|
||||
table.string('requester_name', 80).notNullable();
|
||||
table.string('assignee_name', 80).nullable();
|
||||
table.string('status', 40).notNullable();
|
||||
table.timestamp('requested_at').notNullable().defaultTo(db.fn.now());
|
||||
table.timestamp('last_action_at').notNullable().defaultTo(db.fn.now());
|
||||
table.index(['requested_at', 'id'], 'test_app_requests_requested_at_idx');
|
||||
table.index(['line_code', 'priority', 'status'], 'test_app_requests_filter_idx');
|
||||
});
|
||||
}
|
||||
|
||||
const countResult = await db(TEST_APP_TABLE).count<{ count: string }[]>({ count: '*' }).first();
|
||||
const currentCount = Number(countResult?.count ?? 0);
|
||||
|
||||
if (currentCount >= TEST_APP_SEED_COUNT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = 500;
|
||||
const rowsToInsert = Array.from({ length: TEST_APP_SEED_COUNT - currentCount }, (_, offset) =>
|
||||
buildSeedRow(currentCount + offset),
|
||||
);
|
||||
|
||||
for (let index = 0; index < rowsToInsert.length; index += chunkSize) {
|
||||
await db(TEST_APP_TABLE).insert(rowsToInsert.slice(index, index + chunkSize));
|
||||
}
|
||||
}
|
||||
|
||||
function applyListFilters(baseQuery: ReturnType<typeof db>, query: z.infer<typeof listQuerySchema>) {
|
||||
if (query.keyword) {
|
||||
baseQuery.where((builder) => {
|
||||
builder
|
||||
.whereILike('request_no', `%${query.keyword}%`)
|
||||
.orWhereILike('equipment_name', `%${query.keyword}%`)
|
||||
.orWhereILike('requester_name', `%${query.keyword}%`);
|
||||
});
|
||||
}
|
||||
|
||||
if (query.lineCode && query.lineCode !== '전체') {
|
||||
baseQuery.where('line_code', query.lineCode);
|
||||
}
|
||||
|
||||
if (query.priority && query.priority !== '전체') {
|
||||
baseQuery.where('priority', query.priority);
|
||||
}
|
||||
|
||||
if (query.status && query.status !== '전체') {
|
||||
baseQuery.where('status', query.status);
|
||||
}
|
||||
|
||||
if (query.requestedFrom) {
|
||||
baseQuery.where('requested_at', '>=', new Date(`${query.requestedFrom}T00:00:00+09:00`));
|
||||
}
|
||||
|
||||
if (query.requestedTo) {
|
||||
baseQuery.where('requested_at', '<=', new Date(`${query.requestedTo}T23:59:59+09:00`));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListRequests(request: { query?: unknown }) {
|
||||
await ensureTestAppTable();
|
||||
|
||||
const query = listQuerySchema.parse(request.query ?? {});
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
const baseQuery = db(TEST_APP_TABLE);
|
||||
|
||||
applyListFilters(baseQuery, query);
|
||||
|
||||
const [countResult, rows] = await Promise.all([
|
||||
baseQuery.clone().count<{ count: string }[]>({ count: '*' }).first(),
|
||||
baseQuery
|
||||
.clone()
|
||||
.select<TestAppRow[]>('*')
|
||||
.orderBy('requested_at', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(query.pageSize)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
const total = Number(countResult?.count ?? 0);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: rows.map(toResponseRow),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
hasNext: offset + rows.length < total,
|
||||
filters: {
|
||||
keyword: query.keyword,
|
||||
lineCode: query.lineCode ?? '전체',
|
||||
priority: query.priority ?? '전체',
|
||||
status: query.status ?? '전체',
|
||||
requestedFrom: query.requestedFrom ?? null,
|
||||
requestedTo: query.requestedTo ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveRequests(request: { body?: unknown }) {
|
||||
await ensureTestAppTable();
|
||||
|
||||
const payload = saveBodySchema.parse(request.body ?? {});
|
||||
const ids = payload.items.map((item) => item.id);
|
||||
const existingRows = await db(TEST_APP_TABLE).select<TestAppRow[]>('*').whereIn('id', ids);
|
||||
const existingIdSet = new Set(existingRows.map((row) => row.id));
|
||||
const missingIds = ids.filter((id) => !existingIdSet.has(id));
|
||||
|
||||
if (missingIds.length) {
|
||||
const error = new Error(`저장할 요청을 찾을 수 없습니다: ${missingIds.join(', ')}`) as Error & { statusCode?: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updatedRows: TestAppRow[] = [];
|
||||
|
||||
for (const item of payload.items) {
|
||||
const [updatedRow] = await db(TEST_APP_TABLE)
|
||||
.where({ id: item.id })
|
||||
.update(
|
||||
{
|
||||
priority: item.priority,
|
||||
status: item.status,
|
||||
assignee_name: item.assigneeName?.trim() || null,
|
||||
last_action_at: db.fn.now(),
|
||||
},
|
||||
'*',
|
||||
);
|
||||
|
||||
if (updatedRow) {
|
||||
updatedRows.push(updatedRow as TestAppRow);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
count: updatedRows.length,
|
||||
items: updatedRows.map(toResponseRow),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleDeleteRequest(request: { params?: unknown }) {
|
||||
await ensureTestAppTable();
|
||||
|
||||
const { id } = deleteParamsSchema.parse(request.params ?? {});
|
||||
const [deletedRow] = await db(TEST_APP_TABLE).where({ id }).delete('*');
|
||||
|
||||
if (!deletedRow) {
|
||||
const error = new Error(`삭제할 요청을 찾을 수 없습니다: ${id}`) as Error & { statusCode?: number };
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
deletedId: id,
|
||||
item: toResponseRow(deletedRow as TestAppRow),
|
||||
};
|
||||
}
|
||||
|
||||
export async function registerTestAppRoutes(app: FastifyInstance) {
|
||||
app.get('/api/test-app/maintenance-requests', handleListRequests);
|
||||
app.get('/api/test-app/measurements', handleListRequests);
|
||||
|
||||
app.put('/api/test-app/maintenance-requests', handleSaveRequests);
|
||||
app.put('/api/test-app/measurements', handleSaveRequests);
|
||||
app.delete('/api/test-app/maintenance-requests/:id', handleDeleteRequest);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createDefaultChatTypeExecutionPolicy,
|
||||
migrateLegacyChatTypeContexts,
|
||||
sanitizePersistedChatTypes,
|
||||
synchronizeBuiltinCodexChatTypes,
|
||||
resolveAppConfigByOrigin,
|
||||
resolveCanonicalChatTypesFromConfig,
|
||||
resolveCanonicalChatContextSettingsFromConfig,
|
||||
@@ -138,6 +140,34 @@ test('sanitizePersistedChatTypes keeps all saved chat types without special filt
|
||||
assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'general-request', 'plan-checklist-execution']);
|
||||
});
|
||||
|
||||
test('synchronizeBuiltinCodexChatTypes upgrades legacy codex summary execution policy', () => {
|
||||
const synced = synchronizeBuiltinCodexChatTypes([
|
||||
{
|
||||
id: 'codex-summary',
|
||||
name: 'Codex 종합',
|
||||
sortOrder: 13,
|
||||
description:
|
||||
'## 처리 범위\n- Codex 봇들의 대화와 의견 수렴 과정을 거친 뒤 최종 결과물을 정리해 제공하는 채팅에 사용합니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 결과를 정리합니다.\n\n## 응답 방식\n- 중간 대화에서 나온 핵심 의견, 판단 근거, 남은 쟁점을 압축해 정리합니다.\n- 최종 답변에는 필요한 산출물, 검증 결과, 적용 결론을 함께 제공합니다.',
|
||||
executionPolicy: createDefaultChatTypeExecutionPolicy(),
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-17T00:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
const codexSummary = synced.find((item) => item.id === 'codex-summary');
|
||||
const codexDispatcher = synced.find((item) => item.id === 'codex-dispatcher-workers');
|
||||
const codexLiveDefault = synced.find((item) => item.id === 'codex-live-default');
|
||||
|
||||
assert.ok(codexSummary);
|
||||
assert.equal(codexSummary.executionPolicy.mode, 'summary-free-talking');
|
||||
assert.match(codexSummary.description, /회의 기록자 1명/);
|
||||
assert.ok(codexDispatcher);
|
||||
assert.equal(codexDispatcher.executionPolicy.mode, 'dispatcher-workers');
|
||||
assert.ok(codexLiveDefault);
|
||||
assert.equal(codexLiveDefault.executionPolicy.mode, 'default');
|
||||
});
|
||||
|
||||
test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => {
|
||||
const migrated = migrateLegacyChatTypeContexts(
|
||||
{
|
||||
@@ -157,6 +187,7 @@ test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into d
|
||||
name: 'Plan 체크리스트 실행',
|
||||
sortOrder: 1,
|
||||
description: 'legacy plan context',
|
||||
executionPolicy: createDefaultChatTypeExecutionPolicy(),
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
|
||||
@@ -11,6 +11,20 @@ const CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings';
|
||||
const SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs';
|
||||
const SCOPED_CONTEXT_CONFIG_BACKUPS_KEY = 'scopedContextConfigBackups';
|
||||
const SHARED_CHAT_CONTEXT_APP_ORIGIN = 'https://preview.sm-home.cloud';
|
||||
const CODEX_LIVE_DEFAULT_CHAT_TYPE_ID = 'codex-live-default';
|
||||
const CODEX_LIVE_DEFAULT_CHAT_TYPE_NAME = '기본처리';
|
||||
const CODEX_LIVE_DEFAULT_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type=\"resource\"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type=\"html\"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인, 화면 테스트, 최종 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
|
||||
const CODEX_SUMMARY_CHAT_TYPE_ID = 'codex-summary';
|
||||
const CODEX_SUMMARY_CHAT_TYPE_NAME = 'Codex 종합';
|
||||
const CODEX_SUMMARY_LEGACY_DESCRIPTION =
|
||||
'## 처리 범위\n- Codex 봇들의 대화와 의견 수렴 과정을 거친 뒤 최종 결과물을 정리해 제공하는 채팅에 사용합니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 결과를 정리합니다.\n\n## 응답 방식\n- 중간 대화에서 나온 핵심 의견, 판단 근거, 남은 쟁점을 압축해 정리합니다.\n- 최종 답변에는 필요한 산출물, 검증 결과, 적용 결론을 함께 제공합니다.';
|
||||
const CODEX_SUMMARY_CHAT_TYPE_DESCRIPTION =
|
||||
'## 처리 범위\n- 회의 기록자 1명과 프리토킹 Codex들이 함께 논점을 정리한 뒤 최종 결과를 보고하는 채팅에 사용합니다.\n- 사용자는 최종 결과를 우선 확인하고, 필요할 때만 중간 대화 흐름을 다시 확인합니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 결과를 정리합니다.\n\n## 응답 방식\n- 첫 Codex는 회의 기록자 겸 중재자로서 논점, 검증 기준, 확인 포인트를 정리합니다.\n- 이어지는 Codex들은 프리토킹으로 자유롭게 보완·반박·구현 의견을 제시합니다.\n- 마지막에는 회의 기록자가 최종 결론, 검증 결과, 남은 쟁점을 종합해 보고합니다.';
|
||||
const CODEX_DISPATCHER_CHAT_TYPE_ID = 'codex-dispatcher-workers';
|
||||
const CODEX_DISPATCHER_CHAT_TYPE_NAME = 'Codex 작업형';
|
||||
const CODEX_DISPATCHER_CHAT_TYPE_DESCRIPTION =
|
||||
'## 처리 범위\n- 중계 지시자 1명과 실작업자 Codex들이 역할을 나눠 실제 작업을 진행하는 채팅에 사용합니다.\n- 필요하면 중계 지시자가 직접 최종 검토를 수행하고, 설정에 따라 별도 검토자를 지정할 수도 있습니다.\n- Plan 자동화 문맥을 기본값으로 섞지 않고, 현재 채팅방 공통 문맥과 사용자 요청만 기준으로 실행합니다.\n\n## 응답 방식\n- 첫 Codex는 중계 지시자로서 작업을 역할·기준·검증 축으로 분해하고 담당을 배분합니다.\n- 이어지는 Codex들은 실작업자로서 구현, 설계, 검증, 반례를 구체적으로 제시합니다.\n- 마지막에는 중계 지시자가 결과물, 검토 결과, 남은 리스크와 후속 액션을 종합 보고합니다.';
|
||||
const DEFAULT_CHAT_APP_CONFIG = {
|
||||
maxContextMessages: 12,
|
||||
maxContextChars: 3200,
|
||||
@@ -21,12 +35,29 @@ const DEFAULT_CHAT_APP_CONFIG = {
|
||||
} as const;
|
||||
|
||||
type ChatPermissionRole = 'guest' | 'token-user';
|
||||
export type ChatTypeExecutionMode = 'default' | 'summary-free-talking' | 'dispatcher-workers';
|
||||
export type ChatTypeReviewPolicy = 'self' | 'reviewer';
|
||||
export type ChatTypeResourceReportPolicy = 'none' | 'if-generated' | 'always';
|
||||
export type ChatTypeParticipantBinding =
|
||||
| 'manual'
|
||||
| 'first-moderator-rest-conversation'
|
||||
| 'first-moderator-rest-conversation-last-reviewer';
|
||||
|
||||
export type ChatTypeExecutionPolicy = {
|
||||
mode: ChatTypeExecutionMode;
|
||||
participantBinding: ChatTypeParticipantBinding;
|
||||
reviewPolicy: ChatTypeReviewPolicy;
|
||||
resourceReportPolicy: ChatTypeResourceReportPolicy;
|
||||
allowModeratorIntervention: boolean;
|
||||
finalSummaryRequired: boolean;
|
||||
};
|
||||
|
||||
type ChatTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
description: string;
|
||||
executionPolicy: ChatTypeExecutionPolicy;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
@@ -53,11 +84,22 @@ type ChatTypeDefaultContextSelection = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ChatRoomCodexParticipant = {
|
||||
id: string;
|
||||
name: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
chatTypeId: string | null;
|
||||
defaultContextIds: string[];
|
||||
role: 'default' | 'moderator' | 'conversation' | 'reviewer';
|
||||
};
|
||||
|
||||
type ChatRoomContextSettings = {
|
||||
sessionId: string;
|
||||
defaultContextIds: string[];
|
||||
customContextTitle: string;
|
||||
customContextContent: string;
|
||||
codexParticipants: ChatRoomCodexParticipant[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -595,17 +637,67 @@ function sanitizeRoomContexts(items: unknown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizeCodexParticipants = (participants: unknown) => {
|
||||
const sourceParticipants = Array.isArray(participants) ? participants : [];
|
||||
return Array.from(
|
||||
new Map(
|
||||
sourceParticipants
|
||||
.map((participant, index) => {
|
||||
if (!participant || typeof participant !== 'object' || Array.isArray(participant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = participant as Partial<ChatRoomCodexParticipant>;
|
||||
const id = normalizeText(record.id) || `codex-participant-${index + 1}`;
|
||||
const name = normalizeText(record.name);
|
||||
const model = normalizeText(record.model);
|
||||
const prompt = normalizeText(record.prompt);
|
||||
const chatTypeId = normalizeText(record.chatTypeId) || null;
|
||||
const defaultContextIds = normalizeDefaultContextIds(record.defaultContextIds);
|
||||
const role =
|
||||
normalizeText(record.role) === 'moderator'
|
||||
? 'moderator'
|
||||
: normalizeText(record.role) === 'conversation'
|
||||
? 'conversation'
|
||||
: normalizeText(record.role) === 'reviewer'
|
||||
? 'reviewer'
|
||||
: 'default';
|
||||
|
||||
if (!name || !model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
model,
|
||||
prompt,
|
||||
chatTypeId,
|
||||
defaultContextIds,
|
||||
role,
|
||||
} satisfies ChatRoomCodexParticipant,
|
||||
] as const;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, ChatRoomCodexParticipant] => Boolean(entry)),
|
||||
).values(),
|
||||
);
|
||||
};
|
||||
|
||||
const nextRecord: ChatRoomContextSettings = {
|
||||
sessionId,
|
||||
defaultContextIds: normalizeDefaultContextIds(record.defaultContextIds),
|
||||
customContextTitle: normalizeText(record.customContextTitle),
|
||||
customContextContent: normalizeText(record.customContextContent),
|
||||
codexParticipants: sanitizeCodexParticipants(record.codexParticipants),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent);
|
||||
const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0;
|
||||
const hasCodexParticipants = nextRecord.codexParticipants.length > 0;
|
||||
|
||||
if (!hasCustomContext && !hasDefaultOverrides) {
|
||||
if (!hasCustomContext && !hasDefaultOverrides && !hasCodexParticipants) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,12 +738,83 @@ function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null {
|
||||
name,
|
||||
sortOrder: normalizePositiveSortOrder(record.sortOrder),
|
||||
description: normalizeText(record.description),
|
||||
executionPolicy: normalizeChatTypeExecutionPolicy((record as { executionPolicy?: unknown }).executionPolicy),
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultChatTypeExecutionPolicy(
|
||||
mode: ChatTypeExecutionMode = 'default',
|
||||
): ChatTypeExecutionPolicy {
|
||||
if (mode === 'summary-free-talking') {
|
||||
return {
|
||||
mode,
|
||||
participantBinding: 'first-moderator-rest-conversation',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: false,
|
||||
finalSummaryRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'dispatcher-workers') {
|
||||
return {
|
||||
mode,
|
||||
participantBinding: 'first-moderator-rest-conversation',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
participantBinding: 'manual',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChatTypeExecutionMode(value: unknown): ChatTypeExecutionMode {
|
||||
if (value === 'summary-free-talking' || value === 'dispatcher-workers') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function normalizeChatTypeExecutionPolicy(value: unknown): ChatTypeExecutionPolicy {
|
||||
const record = normalizeConfigRecord(value);
|
||||
const mode = normalizeChatTypeExecutionMode(record.mode);
|
||||
const defaults = createDefaultChatTypeExecutionPolicy(mode);
|
||||
|
||||
return {
|
||||
mode,
|
||||
participantBinding:
|
||||
record.participantBinding === 'first-moderator-rest-conversation' ||
|
||||
record.participantBinding === 'first-moderator-rest-conversation-last-reviewer' ||
|
||||
record.participantBinding === 'manual'
|
||||
? record.participantBinding
|
||||
: defaults.participantBinding,
|
||||
reviewPolicy: record.reviewPolicy === 'reviewer' ? 'reviewer' : defaults.reviewPolicy,
|
||||
resourceReportPolicy:
|
||||
record.resourceReportPolicy === 'none' || record.resourceReportPolicy === 'always'
|
||||
? record.resourceReportPolicy
|
||||
: defaults.resourceReportPolicy,
|
||||
allowModeratorIntervention:
|
||||
typeof record.allowModeratorIntervention === 'boolean'
|
||||
? record.allowModeratorIntervention
|
||||
: defaults.allowModeratorIntervention,
|
||||
finalSummaryRequired:
|
||||
typeof record.finalSummaryRequired === 'boolean' ? record.finalSummaryRequired : defaults.finalSummaryRequired,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePositiveSortOrder(value: unknown) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return Number.NaN;
|
||||
@@ -744,6 +907,86 @@ function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) {
|
||||
return items.filter((item) => !isLegacyMigratedChatTypeId(item.id));
|
||||
}
|
||||
|
||||
function createBuiltinChatTypeRecord(
|
||||
overrides: Partial<ChatTypeRecord> & Pick<ChatTypeRecord, 'id' | 'name' | 'description' | 'executionPolicy'>,
|
||||
): ChatTypeRecord {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.name,
|
||||
sortOrder: overrides.sortOrder ?? Number.NaN,
|
||||
description: overrides.description,
|
||||
executionPolicy: overrides.executionPolicy,
|
||||
permissions: overrides.permissions ?? ['token-user'],
|
||||
enabled: overrides.enabled ?? true,
|
||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBuiltinCodexChatTypes() {
|
||||
return [
|
||||
createBuiltinChatTypeRecord({
|
||||
id: CODEX_LIVE_DEFAULT_CHAT_TYPE_ID,
|
||||
name: CODEX_LIVE_DEFAULT_CHAT_TYPE_NAME,
|
||||
description: CODEX_LIVE_DEFAULT_CHAT_TYPE_DESCRIPTION,
|
||||
executionPolicy: createDefaultChatTypeExecutionPolicy('default'),
|
||||
}),
|
||||
createBuiltinChatTypeRecord({
|
||||
id: CODEX_SUMMARY_CHAT_TYPE_ID,
|
||||
name: CODEX_SUMMARY_CHAT_TYPE_NAME,
|
||||
description: CODEX_SUMMARY_CHAT_TYPE_DESCRIPTION,
|
||||
executionPolicy: createDefaultChatTypeExecutionPolicy('summary-free-talking'),
|
||||
}),
|
||||
createBuiltinChatTypeRecord({
|
||||
id: CODEX_DISPATCHER_CHAT_TYPE_ID,
|
||||
name: CODEX_DISPATCHER_CHAT_TYPE_NAME,
|
||||
description: CODEX_DISPATCHER_CHAT_TYPE_DESCRIPTION,
|
||||
executionPolicy: createDefaultChatTypeExecutionPolicy('dispatcher-workers'),
|
||||
}),
|
||||
] satisfies ChatTypeRecord[];
|
||||
}
|
||||
|
||||
function shouldUpgradeLegacyCodexSummaryChatType(record: ChatTypeRecord) {
|
||||
if (record.id !== CODEX_SUMMARY_CHAT_TYPE_ID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
record.executionPolicy.mode === 'default' &&
|
||||
(record.description === '' || record.description === CODEX_SUMMARY_LEGACY_DESCRIPTION)
|
||||
);
|
||||
}
|
||||
|
||||
export function synchronizeBuiltinCodexChatTypes(items: ChatTypeRecord[]) {
|
||||
const builtins = buildBuiltinCodexChatTypes();
|
||||
const builtinById = new Map(builtins.map((item) => [item.id, item] as const));
|
||||
const merged = items.map((item) => {
|
||||
const builtin = builtinById.get(item.id);
|
||||
|
||||
if (!builtin) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.id === CODEX_SUMMARY_CHAT_TYPE_ID && shouldUpgradeLegacyCodexSummaryChatType(item)) {
|
||||
return {
|
||||
...item,
|
||||
description: builtin.description,
|
||||
executionPolicy: builtin.executionPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
const existingIds = new Set(merged.map((item) => item.id));
|
||||
|
||||
builtins.forEach((builtin) => {
|
||||
if (!existingIds.has(builtin.id)) {
|
||||
merged.push(builtin);
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizePersistedChatTypes(merged);
|
||||
}
|
||||
|
||||
function buildPlanChecklistDefaultContext(record?: ChatTypeRecord | null, existing?: ChatDefaultContextRecord | null) {
|
||||
const content = normalizeText(record?.description) || normalizeText(existing?.content) || PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT;
|
||||
|
||||
@@ -798,6 +1041,12 @@ function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) {
|
||||
item.id === target.id &&
|
||||
item.name === target.name &&
|
||||
item.description === target.description &&
|
||||
item.executionPolicy.mode === target.executionPolicy.mode &&
|
||||
item.executionPolicy.participantBinding === target.executionPolicy.participantBinding &&
|
||||
item.executionPolicy.reviewPolicy === target.executionPolicy.reviewPolicy &&
|
||||
item.executionPolicy.resourceReportPolicy === target.executionPolicy.resourceReportPolicy &&
|
||||
item.executionPolicy.allowModeratorIntervention === target.executionPolicy.allowModeratorIntervention &&
|
||||
item.executionPolicy.finalSummaryRequired === target.executionPolicy.finalSummaryRequired &&
|
||||
item.enabled === target.enabled &&
|
||||
item.sortOrder === target.sortOrder &&
|
||||
item.updatedAt === target.updatedAt &&
|
||||
@@ -990,13 +1239,15 @@ export async function getChatTypesConfig(appOrigin?: string | null): Promise<Cha
|
||||
const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin();
|
||||
const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin);
|
||||
const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []);
|
||||
const chatTypes = sanitizePersistedChatTypes(migratedChatTypeList);
|
||||
const chatTypes = synchronizeBuiltinCodexChatTypes(sanitizePersistedChatTypes(migratedChatTypeList));
|
||||
const migratedSettings = migrateLegacyChatTypeContexts(
|
||||
resolveCanonicalChatContextSettingsFromConfig(rawConfig, sharedChatContextAppOrigin),
|
||||
chatTypes,
|
||||
);
|
||||
const globalChatTypes = Array.isArray(rawConfig[CHAT_TYPES_CONFIG_KEY])
|
||||
? stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]))
|
||||
? synchronizeBuiltinCodexChatTypes(
|
||||
stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])),
|
||||
)
|
||||
: [];
|
||||
const globalSettings = sanitizeChatContextSettings(rawConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]);
|
||||
const { changed, scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
|
||||
@@ -1025,7 +1276,7 @@ export async function getChatTypesConfig(appOrigin?: string | null): Promise<Cha
|
||||
|
||||
export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) {
|
||||
const current = await getRawAppConfigRecord();
|
||||
const nextChatTypes = sanitizePersistedChatTypes(chatTypes);
|
||||
const nextChatTypes = synchronizeBuiltinCodexChatTypes(sanitizePersistedChatTypes(chatTypes));
|
||||
const { scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs(
|
||||
current,
|
||||
resolveSharedChatContextAppOrigin(),
|
||||
|
||||
@@ -71,7 +71,7 @@ type PromptStep = NonNullable<PromptPart['steps']>[number];
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
|
||||
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
|
||||
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
|
||||
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;
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
@@ -84,6 +84,17 @@ function normalizeText(value: unknown) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function unwrapMarkdownLinkTarget(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const matched = normalized.match(/^<([\s\S]+)>$/);
|
||||
return matched?.[1]?.trim() ?? normalized;
|
||||
}
|
||||
|
||||
function buildResourceManagerPreviewUrl(value: string) {
|
||||
const normalized = normalizeText(value).replace(/\\/g, '/');
|
||||
const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1];
|
||||
@@ -109,7 +120,7 @@ function buildResourceManagerPreviewUrl(value: string) {
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
const normalized = unwrapMarkdownLinkTarget(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
@@ -420,7 +431,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
readOnly: record.readOnly === true || resolvedBy != null,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
|
||||
@@ -2,8 +2,17 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH,
|
||||
CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES,
|
||||
applyChatPromptSelectionPatch,
|
||||
buildChatConversationRequestUsageBySharedResourceTokenIdsQuery,
|
||||
buildChatConversationContextUpdateFields,
|
||||
buildChatPromptTargetSignature,
|
||||
buildChatConversationRequestPatchFromMessage,
|
||||
collectPromptSelectionCandidateRequestIds,
|
||||
collectRegisteredNotificationClientIds,
|
||||
hasMeaningfulChatSourceArtifacts,
|
||||
inferSourceChangeScreenTitle,
|
||||
isManagedChatShareSessionId,
|
||||
isVisibleConversationMessage,
|
||||
mergeChatConversationRequestStatus,
|
||||
normalizeStaleRequestItem,
|
||||
@@ -39,6 +48,55 @@ test('resolveNextConversationContextValue prefers the requested chat type contex
|
||||
assert.equal(resolveNextConversationContextValue('old context', undefined, false), 'old context');
|
||||
});
|
||||
|
||||
test('isManagedChatShareSessionId detects managed shared chat rooms only', () => {
|
||||
assert.equal(isManagedChatShareSessionId('chat-share-room-mb2p1-1234abcd'), true);
|
||||
assert.equal(isManagedChatShareSessionId('chat-room-mb2p1-1234abcd'), false);
|
||||
assert.equal(isManagedChatShareSessionId(''), false);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields leaves title untouched when unrelated metadata changes', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '사용자 저장 제목',
|
||||
chat_type_id: 'codex-live',
|
||||
last_chat_type_id: 'codex-live',
|
||||
client_id: 'client-1',
|
||||
notify_offline: true,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-1',
|
||||
codexModel: 'gpt-5.4',
|
||||
},
|
||||
}),
|
||||
{
|
||||
codex_model: 'gpt-5.4',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationContextUpdateFields ignores undefined payload keys so title is not reset', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationContextUpdateFields({
|
||||
current: {
|
||||
title: '브라우저 실검증 제목',
|
||||
request_badge_label: '기존 배지',
|
||||
chat_type_id: 'codex-live',
|
||||
last_chat_type_id: 'codex-live',
|
||||
client_id: 'client-1',
|
||||
notify_offline: true,
|
||||
},
|
||||
payload: {
|
||||
title: undefined,
|
||||
requestBadgeLabel: '새 배지',
|
||||
},
|
||||
}),
|
||||
{
|
||||
request_badge_label: '새 배지',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => {
|
||||
assert.deepEqual(
|
||||
selectStaleOfflineNotificationClientIds(
|
||||
@@ -72,6 +130,23 @@ test('selectStaleOfflineNotificationClientIds keeps current or registered client
|
||||
);
|
||||
});
|
||||
|
||||
test('collectRegisteredNotificationClientIds keeps both web push client ids and device ids', () => {
|
||||
assert.deepEqual(
|
||||
Array.from(
|
||||
collectRegisteredNotificationClientIds([
|
||||
{
|
||||
device_id: 'web-device-1',
|
||||
client_id: 'client-preview-1',
|
||||
},
|
||||
{
|
||||
device_id: 'ios-device-1',
|
||||
},
|
||||
]),
|
||||
).sort(),
|
||||
['client-preview-1', 'ios-device-1', 'web-device-1'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => {
|
||||
assert.equal(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
@@ -84,10 +159,118 @@ test('buildChatConversationRequestPatchFromMessage ignores system progress messa
|
||||
);
|
||||
});
|
||||
|
||||
test('applyChatPromptSelectionPatch resolves the matched prompt with persisted selections', () => {
|
||||
const promptPart = {
|
||||
type: 'prompt' as const,
|
||||
title: '다음 단계 선택',
|
||||
description: '원하는 작업을 고르세요.',
|
||||
submitLabel: '선택 전달',
|
||||
mode: 'queue' as const,
|
||||
selectedValues: [],
|
||||
options: [],
|
||||
steps: [
|
||||
{
|
||||
key: 'scope',
|
||||
title: '범위',
|
||||
selectedValues: [],
|
||||
options: [
|
||||
{
|
||||
value: 'ui',
|
||||
label: 'UI',
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: 'API',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const patched = applyChatPromptSelectionPatch(
|
||||
[promptPart],
|
||||
{
|
||||
promptIndex: 0,
|
||||
promptTitle: promptPart.title,
|
||||
promptSignature: buildChatPromptTargetSignature(promptPart),
|
||||
selectedValues: ['ui'],
|
||||
stepSelections: [
|
||||
{
|
||||
stepKey: 'scope',
|
||||
selectedValues: ['ui'],
|
||||
freeText: '',
|
||||
},
|
||||
],
|
||||
summaryText: '범위: UI',
|
||||
},
|
||||
'2026-05-18T08:20:00.000Z',
|
||||
);
|
||||
|
||||
assert.ok(patched);
|
||||
assert.equal(patched?.[0]?.type, 'prompt');
|
||||
assert.deepEqual(patched?.[0]?.selectedValues, ['ui']);
|
||||
assert.equal(patched?.[0]?.readOnly, true);
|
||||
assert.equal(patched?.[0]?.resolvedBy, 'user');
|
||||
assert.equal(patched?.[0]?.resolvedAt, '2026-05-18T08:20:00.000Z');
|
||||
assert.equal(patched?.[0]?.resultText, '범위: UI');
|
||||
assert.deepEqual(patched?.[0]?.steps?.[0]?.selectedValues, ['ui']);
|
||||
});
|
||||
|
||||
test('collectPromptSelectionCandidateRequestIds includes descendant requests and prefers recent responses first', () => {
|
||||
assert.deepEqual(
|
||||
collectPromptSelectionCandidateRequestIds(
|
||||
[
|
||||
{
|
||||
request_id: 'root-request',
|
||||
parent_request_id: null,
|
||||
created_at: '2026-05-18T02:00:00.000Z',
|
||||
},
|
||||
{
|
||||
request_id: 'prompt-child',
|
||||
parent_request_id: 'root-request',
|
||||
created_at: '2026-05-18T02:01:00.000Z',
|
||||
},
|
||||
{
|
||||
request_id: 'composer-grandchild',
|
||||
parent_request_id: 'prompt-child',
|
||||
created_at: '2026-05-18T02:02:00.000Z',
|
||||
},
|
||||
{
|
||||
request_id: 'other-root',
|
||||
parent_request_id: null,
|
||||
created_at: '2026-05-18T02:03:00.000Z',
|
||||
},
|
||||
],
|
||||
'root-request',
|
||||
),
|
||||
['composer-grandchild', 'prompt-child', 'root-request'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH allows long chat type guidance text', () => {
|
||||
assert.ok(CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH >= 2691);
|
||||
});
|
||||
|
||||
test('chat request schema requires chat type columns for codex live persistence', () => {
|
||||
assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_id'), true);
|
||||
assert.equal(CHAT_CONVERSATION_REQUEST_REQUIRED_COLUMN_NAMES.includes('chat_type_label'), true);
|
||||
});
|
||||
|
||||
test('shared resource token usage summary query keeps aggregate aliases outside function bodies', () => {
|
||||
const query = buildChatConversationRequestUsageBySharedResourceTokenIdsQuery(['token-1', 'token-2']);
|
||||
|
||||
assert.ok(query);
|
||||
|
||||
const sql = query?.toSQL().sql ?? '';
|
||||
|
||||
assert.match(sql, /count\(\*\) as request_count/i);
|
||||
assert.match(sql, /sum\(COALESCE\(total_tokens, 0\)\) as total_tokens/i);
|
||||
assert.match(sql, /sum\(CASE WHEN status = 'completed' THEN 1 ELSE 0 END\) as completed_request_count/i);
|
||||
assert.match(sql, /max\(COALESCE\(answered_at, terminal_at, updated_at, created_at\)\) as last_used_at/i);
|
||||
assert.doesNotMatch(sql, /END as completed_request_count\)/i);
|
||||
assert.doesNotMatch(sql, /created_at\) as last_used_at\)/i);
|
||||
});
|
||||
|
||||
test('isVisibleConversationMessage hides internal system messages and keeps activity logs', () => {
|
||||
assert.equal(
|
||||
isVisibleConversationMessage({
|
||||
@@ -130,6 +313,50 @@ test('hasMeaningfulChatSourceArtifacts requires real file or diff artifacts', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('inferSourceChangeScreenTitle prefers changed source menu over generic Codex Live title', () => {
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['src/app/main/ResourceManagementPage.tsx'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'리소스 관리 / 리소스 관리',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['src/app/main/PreviewAppOverlay.tsx'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'Preview App / 모바일 앱 열기',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['etc/servers/work-server/src/routes/resource-manager.ts'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'리소스 관리 / 리소스 관리',
|
||||
);
|
||||
});
|
||||
|
||||
test('inferSourceChangeScreenTitle falls back to docs and stored title when no menu rule matches', () => {
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['docs/project/overview.md'],
|
||||
'Codex Live / Codex Live',
|
||||
),
|
||||
'Docs / 프로젝트 구조',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
inferSourceChangeScreenTitle(
|
||||
['scripts/dev/check.sh'],
|
||||
'직접 지정 제목',
|
||||
),
|
||||
'직접 지정 제목',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => {
|
||||
assert.deepEqual(
|
||||
buildChatConversationRequestPatchFromMessage({
|
||||
@@ -321,12 +548,17 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-queued',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
sharedResourceTokenId: null,
|
||||
parentRequestId: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
@@ -344,18 +576,22 @@ test('normalizeStaleRequestItem keeps queued requests when another request is cu
|
||||
sessionId: 'session-1',
|
||||
requestId: 'chat-req-queued',
|
||||
requesterClientId: null,
|
||||
requestOrigin: null,
|
||||
parentRequestId: null,
|
||||
status: 'queued',
|
||||
statusMessage: '대기열 1건',
|
||||
userMessageId: 11,
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:30.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
userText: '다음 요청',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
usageSnapshot: null,
|
||||
totalTokens: null,
|
||||
hasResponse: false,
|
||||
canDelete: false,
|
||||
createdAt: '2026-05-11T00:00:00.000Z',
|
||||
updatedAt: '2026-05-11T00:00:30.000Z',
|
||||
answeredAt: null,
|
||||
terminalAt: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
ChatService,
|
||||
collectOfflineNotificationClientIds,
|
||||
createActivityLogMessage,
|
||||
buildAgenticCodexPrompt,
|
||||
@@ -17,10 +18,15 @@ import {
|
||||
fitActivityLogLines,
|
||||
isChatClientActivelyViewing,
|
||||
isAutomationRegistrationCountRequest,
|
||||
buildParticipantRequestInput,
|
||||
resolveCodexExecutionStages,
|
||||
resolveCodexParticipantsForExecution,
|
||||
resolveResponseTimestamp,
|
||||
resolveChatContextAppOrigin,
|
||||
resolveChatContextAppDomain,
|
||||
rewriteCodexOutputWithChatResources,
|
||||
summarizeActivityProgressLine,
|
||||
shouldAutoCompleteReplyParentVerification,
|
||||
shouldSendOfflineChatNotification,
|
||||
shouldUseAgenticCodexReply,
|
||||
shouldUseTemplateMacroReply,
|
||||
@@ -28,9 +34,76 @@ import {
|
||||
} from './chat-service.js';
|
||||
import { extractChatMessageParts } from './chat-message-parts.js';
|
||||
|
||||
test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => {
|
||||
test('ChatService rebinds a websocket to the payload session before handling a send request', () => {
|
||||
const logger = {
|
||||
error() {},
|
||||
warn() {},
|
||||
info() {},
|
||||
} as any;
|
||||
const service = new ChatService(logger);
|
||||
let supersededSocketClosed = false;
|
||||
|
||||
try {
|
||||
const currentSocket = {
|
||||
readyState: 1,
|
||||
close() {},
|
||||
} as any;
|
||||
const supersededTargetSocket = {
|
||||
readyState: 1,
|
||||
close() {
|
||||
supersededSocketClosed = true;
|
||||
},
|
||||
} as any;
|
||||
const currentSession = {
|
||||
sessionId: 'session-a',
|
||||
clientId: 'client-1',
|
||||
socket: currentSocket,
|
||||
lastSeenAt: 0,
|
||||
isDeleted: false,
|
||||
context: null,
|
||||
queue: [],
|
||||
activeRequestCount: 0,
|
||||
pendingQueueReleaseEventId: null,
|
||||
nextEventId: 1,
|
||||
eventHistory: [],
|
||||
messagePersistenceTail: Promise.resolve(),
|
||||
watchedRuntimeRequestId: null,
|
||||
};
|
||||
const targetSession = {
|
||||
sessionId: 'session-b',
|
||||
clientId: 'client-1',
|
||||
socket: supersededTargetSocket,
|
||||
lastSeenAt: 0,
|
||||
isDeleted: false,
|
||||
context: null,
|
||||
queue: [],
|
||||
activeRequestCount: 0,
|
||||
pendingQueueReleaseEventId: null,
|
||||
nextEventId: 1,
|
||||
eventHistory: [],
|
||||
messagePersistenceTail: Promise.resolve(),
|
||||
watchedRuntimeRequestId: null,
|
||||
};
|
||||
|
||||
(service as any).sessions.set(currentSession.sessionId, currentSession);
|
||||
(service as any).sessions.set(targetSession.sessionId, targetSession);
|
||||
(service as any).clientStates.set(currentSocket, currentSession);
|
||||
|
||||
const rebound = (service as any).rebindSocketToSession(currentSocket, 'session-b');
|
||||
|
||||
assert.equal(rebound, targetSession);
|
||||
assert.equal(currentSession.socket, null);
|
||||
assert.equal(targetSession.socket, currentSocket);
|
||||
assert.equal((service as any).clientStates.get(currentSocket), targetSession);
|
||||
assert.equal(supersededSocketClosed, true);
|
||||
} finally {
|
||||
service.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('collectOfflineNotificationClientIds keeps only explicit notification targets without duplicates', () => {
|
||||
assert.deepEqual(
|
||||
collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
collectOfflineNotificationClientIds(['client-b', ' client-a ', '', 'client-c', 'client-b']),
|
||||
['client-b', 'client-a', 'client-c'],
|
||||
);
|
||||
});
|
||||
@@ -81,6 +154,58 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldAutoCompleteReplyParentVerification only completes answered composer followups that are not already verified', () => {
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: 101,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: null,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'prompt',
|
||||
responseMessageId: 101,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: null,
|
||||
responseText: '',
|
||||
manualVerificationCompletedAt: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldAutoCompleteReplyParentVerification({
|
||||
requestOrigin: 'composer',
|
||||
responseMessageId: 102,
|
||||
responseText: '답변 본문',
|
||||
manualVerificationCompletedAt: '2026-05-24T06:00:00.000Z',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppOrigin returns normalized origin from session page url', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppOrigin({
|
||||
@@ -92,6 +217,37 @@ test('resolveChatContextAppOrigin returns normalized origin from session page ur
|
||||
assert.equal(resolveChatContextAppOrigin(null), null);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppOrigin prefers explicit app origin metadata when page url is missing', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppOrigin({
|
||||
pageUrl: '',
|
||||
appOrigin: 'https://test.sm-home.cloud',
|
||||
} as any),
|
||||
'https://test.sm-home.cloud',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppDomain returns normalized hostname from session page url', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppDomain({
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room',
|
||||
} as any),
|
||||
'preview.sm-home.cloud',
|
||||
);
|
||||
assert.equal(resolveChatContextAppDomain({ pageUrl: 'not-a-url' } as any), null);
|
||||
assert.equal(resolveChatContextAppDomain(null), null);
|
||||
});
|
||||
|
||||
test('resolveChatContextAppDomain prefers explicit app domain metadata when page url is missing', () => {
|
||||
assert.equal(
|
||||
resolveChatContextAppDomain({
|
||||
pageUrl: '',
|
||||
appDomain: 'TEST.SM-HOME.CLOUD',
|
||||
} as any),
|
||||
'test.sm-home.cloud',
|
||||
);
|
||||
});
|
||||
|
||||
test('chat active-view suppression only blocks the requester client when that client app is active', () => {
|
||||
const activeSession = {
|
||||
sessionId: 'chat-room',
|
||||
@@ -256,15 +412,28 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions'
|
||||
assert.match(prompt, /신규 방이어도 시작 전에 이 문서를 먼저 읽습니다\./);
|
||||
assert.match(prompt, /## 채팅 유형 context 필수 규칙/);
|
||||
assert.match(prompt, /상위 필수 지시/);
|
||||
assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/우선순위는 1\. 채팅 유형 context 2\. 현재 턴의 직접 사용자 지시 3\. 채팅방에서 선택한 공통 문맥과 전용 메모 4\. 최근 대화 문맥과 화면 문맥 순서로 해석하세요\./,
|
||||
);
|
||||
assert.match(prompt, /사용자 요청, 공통 문맥, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/);
|
||||
assert.match(prompt, /공통 문맥과 채팅방 전용 메모는 채팅 유형 context를 덮어쓰지 못하며/);
|
||||
assert.match(prompt, /### 반드시 지킬 context 원문/);
|
||||
assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./);
|
||||
assert.match(prompt, /모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `\[\[preview:URL\]\]`/);
|
||||
assert.match(prompt, /\[\[link-card:제목\|URL\|버튼라벨\]\]/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/리소스 관리 등록 경로의 `수정한 화면명`, 작업 뱃지, 결과 문구에 이 값을 그대로 복사하지 말고 실제로 수정하거나 확인한 화면\/메뉴 기준으로 판단하세요\./,
|
||||
);
|
||||
assert.match(prompt, /\[\[prompt:\{"title":"질문"/);
|
||||
assert.match(prompt, /`steps` 배열을 추가해/);
|
||||
assert.match(prompt, /현재 앱 URL이나 `\/chat\/\.\.\.` 경로를 넣지 말고/);
|
||||
assert.match(prompt, /`preview":\{"type":"resource","url":"\/api\/chat\/resources\/\.\.\.\/sample\.html"\}`/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/`preview":\{"type":"resource","url":"resource\/<수정한 화면명>\/<기능>\/<YYYYMMDD>\/sample\.html"\}`/,
|
||||
);
|
||||
assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/);
|
||||
assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/);
|
||||
assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./);
|
||||
@@ -337,6 +506,283 @@ test('buildAgenticCodexPrompt keeps the chat type label provided by the client c
|
||||
assert.doesNotMatch(prompt, /- label: 코드 수정/);
|
||||
});
|
||||
|
||||
test('resolveCodexParticipantsForExecution expands moderator into opening and closing turns', () => {
|
||||
const participants = resolveCodexParticipantsForExecution({
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
codexParticipants: [
|
||||
{
|
||||
id: 'codex-1',
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
role: 'moderator',
|
||||
},
|
||||
{
|
||||
id: 'codex-2',
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
role: 'conversation',
|
||||
},
|
||||
{
|
||||
id: 'codex-3',
|
||||
name: 'Codex 3',
|
||||
model: 'gpt-5.4',
|
||||
role: 'conversation',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
assert.deepEqual(
|
||||
participants.map((participant) => `${participant.name}:${participant.turn}`),
|
||||
['Codex 1:opening', 'Codex 2:discussion', 'Codex 3:discussion', 'Codex 1:closing'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCodexParticipantsForExecution applies dispatcher policy with reviewer slot', () => {
|
||||
const participants = resolveCodexParticipantsForExecution({
|
||||
pageId: null,
|
||||
pageTitle: 'Codex Live',
|
||||
topMenu: 'chat',
|
||||
focusedComponentId: null,
|
||||
pageUrl: 'https://preview.sm-home.cloud/chat/live',
|
||||
chatTypeExecutionPolicy: {
|
||||
mode: 'dispatcher-workers',
|
||||
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
|
||||
reviewPolicy: 'reviewer',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
},
|
||||
codexParticipants: [
|
||||
{
|
||||
id: 'codex-1',
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
role: 'default',
|
||||
},
|
||||
{
|
||||
id: 'codex-2',
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
role: 'default',
|
||||
},
|
||||
{
|
||||
id: 'codex-3',
|
||||
name: 'Codex 3',
|
||||
model: 'gpt-5.4',
|
||||
role: 'default',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
assert.deepEqual(
|
||||
participants.map((participant) => `${participant.name}:${participant.turn}:${participant.role}`),
|
||||
[
|
||||
'Codex 1:opening:moderator',
|
||||
'Codex 2:discussion:conversation',
|
||||
'Codex 3:review:reviewer',
|
||||
'Codex 1:closing:moderator',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutionStages runs direct multi-Codex default requests in parallel without closing summary', () => {
|
||||
const stages = resolveCodexExecutionStages(
|
||||
{
|
||||
codexModel: 'gpt-5.4',
|
||||
codexParticipants: [
|
||||
{ name: 'Codex 1', model: 'gpt-5.4', role: 'moderator' },
|
||||
{ name: 'Codex 2', model: 'gpt-5.4-mini', role: 'conversation' },
|
||||
{ name: 'Codex 3', model: 'gpt-5.4', role: 'default' },
|
||||
],
|
||||
chatTypeExecutionPolicy: {
|
||||
mode: 'default',
|
||||
participantBinding: 'manual',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: false,
|
||||
},
|
||||
} as any,
|
||||
'direct',
|
||||
);
|
||||
|
||||
assert.equal(stages.length, 1);
|
||||
assert.equal(stages[0]?.parallel, true);
|
||||
assert.deepEqual(
|
||||
stages[0]?.participants.map((participant) => `${participant.name}:${participant.turn}`),
|
||||
['Codex 1:standard', 'Codex 2:discussion', 'Codex 3:standard'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCodexExecutionStages keeps moderator summary flow while parallelizing discussion stage', () => {
|
||||
const stages = resolveCodexExecutionStages(
|
||||
{
|
||||
codexModel: 'gpt-5.4',
|
||||
codexParticipants: [
|
||||
{ name: '회의기록자', model: 'gpt-5.4', role: 'moderator' },
|
||||
{ name: '구현자 A', model: 'gpt-5.4-mini', role: 'conversation' },
|
||||
{ name: '구현자 B', model: 'gpt-5.4', role: 'conversation' },
|
||||
],
|
||||
chatTypeExecutionPolicy: {
|
||||
mode: 'summary-free-talking',
|
||||
participantBinding: 'manual',
|
||||
reviewPolicy: 'self',
|
||||
resourceReportPolicy: 'if-generated',
|
||||
allowModeratorIntervention: false,
|
||||
finalSummaryRequired: true,
|
||||
},
|
||||
} as any,
|
||||
'direct',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
stages.map((stage) => ({
|
||||
parallel: stage.parallel,
|
||||
participants: stage.participants.map((participant) => `${participant.name}:${participant.turn}`),
|
||||
})),
|
||||
[
|
||||
{ parallel: false, participants: ['회의기록자:opening'] },
|
||||
{ parallel: true, participants: ['구현자 A:discussion', '구현자 B:discussion'] },
|
||||
{ parallel: false, participants: ['회의기록자:closing'] },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildParticipantRequestInput gives moderator and discussion participants distinct instructions', () => {
|
||||
const participants = [
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'opening' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'conversation' as const,
|
||||
turn: 'discussion' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'closing' as const,
|
||||
},
|
||||
];
|
||||
const openingInput = buildParticipantRequestInput('요청 본문', participants[0], participants, []);
|
||||
const closingInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
text: '쟁점을 정리합니다.',
|
||||
},
|
||||
{
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
text: '구현 관점 보완입니다.',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.match(openingInput, /회의 기록자 겸 중재자/);
|
||||
assert.match(openingInput, /실행 정책: default/);
|
||||
assert.match(openingInput, /Codex 1\(gpt-5\.4, 중재 시작\) -> Codex 2\(gpt-5\.4, 프리토킹\) -> Codex 1\(gpt-5\.4, 최종 정리\)/);
|
||||
assert.match(closingInput, /최종 결론과 남은 쟁점을 정리/);
|
||||
assert.match(closingInput, /이전 Codex 발언/);
|
||||
});
|
||||
|
||||
test('buildParticipantRequestInput uses dispatcher and reviewer instructions from execution policy', () => {
|
||||
const participants = [
|
||||
{
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'opening' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 2',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'conversation' as const,
|
||||
turn: 'discussion' as const,
|
||||
},
|
||||
{
|
||||
name: 'Codex 3',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'reviewer' as const,
|
||||
turn: 'review' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const dispatcherInput = buildParticipantRequestInput('요청 본문', participants[0], participants, [], null, {
|
||||
mode: 'dispatcher-workers',
|
||||
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
|
||||
reviewPolicy: 'reviewer',
|
||||
resourceReportPolicy: 'always',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
});
|
||||
const reviewerInput = buildParticipantRequestInput('요청 본문', participants[2], participants, [], null, {
|
||||
mode: 'dispatcher-workers',
|
||||
participantBinding: 'first-moderator-rest-conversation-last-reviewer',
|
||||
reviewPolicy: 'reviewer',
|
||||
resourceReportPolicy: 'always',
|
||||
allowModeratorIntervention: true,
|
||||
finalSummaryRequired: true,
|
||||
});
|
||||
|
||||
assert.match(dispatcherInput, /중계 지시자/);
|
||||
assert.match(dispatcherInput, /결과물 보고 정책: always/);
|
||||
assert.match(reviewerInput, /최종 검토자/);
|
||||
assert.match(reviewerInput, /최종 종합 강제: 예/);
|
||||
});
|
||||
|
||||
test('buildParticipantRequestInput keeps prompt parent question context in a separate server block', () => {
|
||||
const participant = {
|
||||
name: 'Codex 1',
|
||||
model: 'gpt-5.4',
|
||||
prompt: '',
|
||||
chatTypeId: null,
|
||||
role: 'moderator' as const,
|
||||
turn: 'opening' as const,
|
||||
};
|
||||
|
||||
const input = buildParticipantRequestInput(
|
||||
'실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요.\n\n추가 요청:\n테스트',
|
||||
participant,
|
||||
[participant],
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
{
|
||||
key: 'prompt_parent_question',
|
||||
promptTitle: '다음 확인 선택',
|
||||
promptDescription: '이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다.',
|
||||
parentQuestionText: 'prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요(질의 응답 부모 명확하게 전달)',
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(input, /^실화면 검증 기준으로 다음 단계를 이어서 진행해 주세요\./);
|
||||
assert.match(input, /prompt 문맥 참조:/);
|
||||
assert.match(input, /상위 사용자 질의: prompt답변시 해당 질의를 명확하게 찾아서 이해할수 있게 개선하세요/);
|
||||
assert.match(input, /대상 질의: 다음 확인 선택/);
|
||||
assert.match(input, /질의 설명: 이번 수정 다음 단계가 필요하면 바로 이어서 진행합니다\./);
|
||||
});
|
||||
|
||||
test('buildAgenticCodexPrompt includes room-selected default contexts as structured sections', () => {
|
||||
const prompt = buildAgenticCodexPrompt(
|
||||
{
|
||||
@@ -370,11 +816,13 @@ test('buildAgenticCodexPrompt includes room-selected default contexts as structu
|
||||
assert.match(prompt, /## 채팅 유형 context 원문/);
|
||||
assert.match(prompt, /채팅 유형 원문 규칙/);
|
||||
assert.match(prompt, /## 채팅방에서 선택한 공통 문맥/);
|
||||
assert.match(prompt, /채팅 유형 context와 충돌하지 않는 범위에서만 보조로 적용하세요\./);
|
||||
assert.match(prompt, /### 권한 관리 공통 문맥/);
|
||||
assert.match(prompt, /채팅방에서 선택된 공통 문맥도 항상 반영합니다\./);
|
||||
assert.match(prompt, /### 방 전용 공통 문맥/);
|
||||
assert.match(prompt, /신규 방에서도 같은 규칙으로 동작해야 합니다\./);
|
||||
assert.match(prompt, /## 채팅방 전용 Context · 운영 메모/);
|
||||
assert.match(prompt, /채팅 유형 context를 바꾸지 않으며, 충돌하지 않는 범위에서만 보조로 해석하세요\./);
|
||||
assert.match(prompt, /preview 기준으로 검증합니다\./);
|
||||
});
|
||||
|
||||
@@ -590,6 +1038,7 @@ test('ensureChatSessionReferenceResource summarizes default contexts without cop
|
||||
const content = await readFile(absolutePath, 'utf8');
|
||||
|
||||
assert.match(content, /## 현재 채팅 유형 context 요약/);
|
||||
assert.match(content, /채팅 유형 context가 최상위이며, 공통 문맥과 채팅방 전용 메모는 그 아래 보조 문맥으로만 사용합니다\./);
|
||||
assert.match(content, /### 적용 중인 공통 문맥/);
|
||||
assert.match(content, /- 개발 리소스 관리/);
|
||||
assert.match(content, /- 리소스 출력/);
|
||||
@@ -831,6 +1280,54 @@ test('extractChatMessageParts keeps readonly auto-selected prompt state', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps prompt writable when only selectedValues exist', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
[
|
||||
'이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
|
||||
'[[prompt:{"title":"후속 범위 선택","description":"미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.","selectedValues":["mobile-cleanup"],"options":[{"label":"모바일 정리","value":"mobile-cleanup","description":"모바일 여백 정리"},{"label":"데스크톱 정리","value":"desktop-cleanup","description":"데스크톱 여백 정리"}]}]]',
|
||||
].join('\n'),
|
||||
),
|
||||
{
|
||||
strippedText: '이전 선택이 표시되더라도 아직 전송 전 상태입니다.',
|
||||
parts: [
|
||||
{
|
||||
type: 'prompt',
|
||||
title: '후속 범위 선택',
|
||||
description: '미리 선택된 항목이 있어도 다시 제출할 수 있어야 합니다.',
|
||||
submitLabel: null,
|
||||
mode: null,
|
||||
multiple: false,
|
||||
responseTemplate: null,
|
||||
freeTextLabel: null,
|
||||
freeTextPlaceholder: null,
|
||||
currentStepKey: null,
|
||||
readOnly: false,
|
||||
selectedValues: ['mobile-cleanup'],
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resultText: null,
|
||||
steps: undefined,
|
||||
options: [
|
||||
{
|
||||
label: '모바일 정리',
|
||||
value: 'mobile-cleanup',
|
||||
description: '모바일 여백 정리',
|
||||
preview: null,
|
||||
},
|
||||
{
|
||||
label: '데스크톱 정리',
|
||||
value: 'desktop-cleanup',
|
||||
description: '데스크톱 여백 정리',
|
||||
preview: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps prompt preview payloads for image markdown html and resource', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
@@ -1154,6 +1651,39 @@ test('extractChatMessageParts promotes standalone markdown links into structured
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts keeps angle-bracket internal resource markdown links as plain text', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts(
|
||||
['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
{
|
||||
strippedText: ['문서 경로', '[채팅방 참고 문서](</api/chat/resources/.codex_chat/chat-room/resource/source/chat room reference.md>)'].join(
|
||||
'\n',
|
||||
),
|
||||
parts: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractChatMessageParts promotes standalone markdown links with angle-bracket external targets into structured link cards', () => {
|
||||
assert.deepEqual(
|
||||
extractChatMessageParts('- [판매글 열기](<https://www.daangn.com/kr/buy-sell/mac studio>)'),
|
||||
{
|
||||
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(
|
||||
@@ -1257,6 +1787,48 @@ test('parseStructuredCodexStdoutLine strips nested command execution JSON from r
|
||||
activityLog: '# 결과: 완료(0)\n# 출력: model = "gpt-5.4"',
|
||||
completedText: '',
|
||||
deltaText: '',
|
||||
usageSnapshot: null,
|
||||
shouldKeepRaw: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('parseStructuredCodexStdoutLine keeps usage snapshots from response.completed JSON', () => {
|
||||
assert.deepEqual(
|
||||
parseStructuredCodexStdoutLine(
|
||||
JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
content: [{ type: 'output_text', text: '최종 응답입니다.' }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 120,
|
||||
output_tokens: 45,
|
||||
cached_input_tokens: 30,
|
||||
reasoning_output_tokens: 10,
|
||||
total_tokens: 165,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
activityLog: '',
|
||||
completedText: '최종 응답입니다.',
|
||||
deltaText: '',
|
||||
usageSnapshot: {
|
||||
tokenTotals: {
|
||||
total: 165,
|
||||
input: 120,
|
||||
output: 45,
|
||||
cached: 30,
|
||||
reasoning: 10,
|
||||
},
|
||||
totalTokens: 165,
|
||||
},
|
||||
shouldKeepRaw: false,
|
||||
},
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = exports.UI_IMPROVEMENT_CHAT_TYPE_ID = void 0;
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement';
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선';
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
|
||||
exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 화면 테스트, 소스 변경 검증, 최종 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
|
||||
exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement';
|
||||
export const UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선';
|
||||
export const UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
|
||||
'## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 화면 테스트, 소스 변경 검증, 최종 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.';
|
||||
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution';
|
||||
export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolveNotificationAggregateResult } from './notification-service.js';
|
||||
import { resolveNotificationAggregateResult, withInferredNotificationOriginData } from './notification-service.js';
|
||||
|
||||
test('resolveNotificationAggregateResult marks managed-service web failures as failed when iOS is disabled', () => {
|
||||
const result = resolveNotificationAggregateResult(
|
||||
@@ -35,3 +35,43 @@ test('resolveNotificationAggregateResult treats fully skipped enabled channels a
|
||||
skipped: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('notification payload infers app origin metadata from target filters when missing', () => {
|
||||
const resolved = withInferredNotificationOriginData({
|
||||
title: 'Codex Live test',
|
||||
body: 'body',
|
||||
data: {},
|
||||
targetAppOrigins: ['https://test.sm-home.cloud'],
|
||||
} as any);
|
||||
|
||||
assert.equal(resolved.data.appOrigin, 'https://test.sm-home.cloud');
|
||||
assert.equal(resolved.data.appDomain, 'test.sm-home.cloud');
|
||||
});
|
||||
|
||||
test('notification payload infers app domain metadata from target filters when only domain is provided', () => {
|
||||
const resolved = withInferredNotificationOriginData({
|
||||
title: 'Codex Live test',
|
||||
body: 'body',
|
||||
data: {},
|
||||
targetAppDomains: ['test.sm-home.cloud'],
|
||||
} as any);
|
||||
|
||||
assert.equal(resolved.data.appOrigin, undefined);
|
||||
assert.equal(resolved.data.appDomain, 'test.sm-home.cloud');
|
||||
});
|
||||
|
||||
test('notification payload keeps explicit app origin metadata when already present', () => {
|
||||
const resolved = withInferredNotificationOriginData({
|
||||
title: 'Codex Live test',
|
||||
body: 'body',
|
||||
data: {
|
||||
appOrigin: 'https://preview.sm-home.cloud',
|
||||
appDomain: 'preview.sm-home.cloud',
|
||||
},
|
||||
targetAppOrigins: ['https://test.sm-home.cloud'],
|
||||
targetAppDomains: ['test.sm-home.cloud'],
|
||||
} as any);
|
||||
|
||||
assert.equal(resolved.data.appOrigin, 'https://preview.sm-home.cloud');
|
||||
assert.equal(resolved.data.appDomain, 'preview.sm-home.cloud');
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export const registerWebPushSubscriptionSchema = z.object({
|
||||
}),
|
||||
}),
|
||||
deviceId: z.string().trim().min(1).max(200).optional(),
|
||||
clientId: 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(),
|
||||
@@ -84,6 +85,34 @@ function normalizeTargetClientIds(targetClientIds: string[] | undefined) {
|
||||
return [...new Set((targetClientIds ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeRegistrationCleanupIds(...values: Array<string | undefined>) {
|
||||
return [...new Set(values.map((value) => String(value ?? '').trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function removeLegacyWebPushSubscriptionsForRegistration(args: {
|
||||
endpoint: string;
|
||||
deviceId?: string;
|
||||
clientId?: string;
|
||||
userAgent?: string;
|
||||
appOrigin?: string;
|
||||
}) {
|
||||
const appOrigin = normalizeAppOrigin(args.appOrigin);
|
||||
const userAgent = String(args.userAgent ?? '').trim();
|
||||
const deviceId = String(args.deviceId ?? '').trim();
|
||||
const clientId = String(args.clientId ?? '').trim();
|
||||
|
||||
if (!appOrigin || !userAgent || !deviceId || !clientId || deviceId === clientId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.whereNot({ endpoint: args.endpoint })
|
||||
.andWhere({ app_origin: appOrigin, user_agent: userAgent })
|
||||
.whereNull('client_id')
|
||||
.whereNotIn('device_id', [deviceId, clientId])
|
||||
.delete();
|
||||
}
|
||||
|
||||
function normalizeTargetAppOrigins(targetAppOrigins: string[] | undefined) {
|
||||
return [...new Set((targetAppOrigins ?? []).map((value) => normalizeAppOrigin(value)).filter(Boolean))];
|
||||
}
|
||||
@@ -92,12 +121,21 @@ function normalizeTargetAppDomains(targetAppDomains: string[] | undefined) {
|
||||
return [...new Set((targetAppDomains ?? []).map((value) => normalizeAppDomain(value)).filter(Boolean))];
|
||||
}
|
||||
|
||||
function isAllowedTargetClientId(deviceId: string, targetClientIds: string[]) {
|
||||
function isAllowedTargetClientId(
|
||||
target: {
|
||||
deviceId?: string;
|
||||
clientId?: string;
|
||||
},
|
||||
targetClientIds: string[],
|
||||
) {
|
||||
if (targetClientIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(deviceId) && targetClientIds.includes(deviceId);
|
||||
return [target.deviceId, target.clientId]
|
||||
.map((value) => String(value ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.some((value) => targetClientIds.includes(value));
|
||||
}
|
||||
|
||||
function normalizeAppOrigin(value: unknown) {
|
||||
@@ -218,6 +256,31 @@ function normalizeNotificationDetailText(text?: string | null) {
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
export function withInferredNotificationOriginData(payload: IosNotificationPayload): IosNotificationPayload {
|
||||
const targetAppOrigin = normalizeTargetAppOrigins(payload.targetAppOrigins)[0] ?? '';
|
||||
const targetAppDomain =
|
||||
normalizeTargetAppDomains(payload.targetAppDomains)[0] ?? resolveAppDomainFromOrigin(targetAppOrigin);
|
||||
const currentData = payload.data ?? {};
|
||||
const currentAppOrigin = normalizeAppOrigin(currentData.appOrigin);
|
||||
const currentAppDomain = normalizeAppDomain(currentData.appDomain);
|
||||
|
||||
if (
|
||||
(!targetAppOrigin || currentAppOrigin === targetAppOrigin) &&
|
||||
(!targetAppDomain || currentAppDomain === targetAppDomain)
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
data: {
|
||||
...currentData,
|
||||
...(currentAppOrigin ? {} : targetAppOrigin ? { appOrigin: targetAppOrigin } : {}),
|
||||
...(currentAppDomain ? {} : targetAppDomain ? { appDomain: targetAppDomain } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isChatNotificationPayload(payload: IosNotificationPayload) {
|
||||
const category = String(payload.data?.category ?? '').trim().toLowerCase();
|
||||
const threadId = String(payload.threadId ?? '').trim().toLowerCase();
|
||||
@@ -375,6 +438,7 @@ async function ensureWebPushSubscriptionTable() {
|
||||
table.string('endpoint', 1000).notNullable().unique();
|
||||
table.jsonb('subscription_json').notNullable();
|
||||
table.string('device_id', 200).nullable();
|
||||
table.string('client_id', 200).nullable();
|
||||
table.text('user_agent').nullable();
|
||||
table.string('app_origin', 500).nullable();
|
||||
table.string('app_domain', 255).nullable();
|
||||
@@ -391,6 +455,7 @@ async function ensureWebPushSubscriptionTable() {
|
||||
['endpoint', (table) => table.string('endpoint', 1000).notNullable()],
|
||||
['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')],
|
||||
['device_id', (table) => table.string('device_id', 200).nullable()],
|
||||
['client_id', (table) => table.string('client_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()],
|
||||
@@ -641,6 +706,7 @@ export async function registerWebPushSubscription(
|
||||
await ensureWebPushSubscriptionTable();
|
||||
const appOrigin = normalizeAppOrigin(payload.appOrigin);
|
||||
const appDomain = normalizeAppDomain(payload.appDomain) || resolveAppDomainFromOrigin(appOrigin);
|
||||
const cleanupTargetIds = normalizeRegistrationCleanupIds(payload.deviceId, payload.clientId);
|
||||
|
||||
if (!payload.enabled) {
|
||||
await unregisterWebPushSubscription(payload.subscription.endpoint);
|
||||
@@ -657,6 +723,7 @@ export async function registerWebPushSubscription(
|
||||
endpoint: payload.subscription.endpoint,
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
client_id: payload.clientId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
@@ -668,6 +735,7 @@ export async function registerWebPushSubscription(
|
||||
.merge({
|
||||
subscription_json: payload.subscription,
|
||||
device_id: payload.deviceId ?? null,
|
||||
client_id: payload.clientId ?? null,
|
||||
user_agent: payload.userAgent ?? null,
|
||||
app_origin: appOrigin || null,
|
||||
app_domain: appDomain || null,
|
||||
@@ -676,13 +744,23 @@ export async function registerWebPushSubscription(
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
|
||||
if (payload.deviceId?.trim()) {
|
||||
if (cleanupTargetIds.length > 0) {
|
||||
await db(WEB_PUSH_SUBSCRIPTION_TABLE)
|
||||
.where({ device_id: payload.deviceId.trim() })
|
||||
.whereNot({ endpoint: payload.subscription.endpoint })
|
||||
.andWhere((builder) => {
|
||||
builder.whereIn('device_id', cleanupTargetIds).orWhereIn('client_id', cleanupTargetIds);
|
||||
})
|
||||
.delete();
|
||||
}
|
||||
|
||||
await removeLegacyWebPushSubscriptionsForRegistration({
|
||||
endpoint: payload.subscription.endpoint,
|
||||
deviceId: payload.deviceId,
|
||||
clientId: payload.clientId,
|
||||
userAgent: payload.userAgent,
|
||||
appOrigin,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
endpoint: payload.subscription.endpoint,
|
||||
@@ -726,12 +804,13 @@ async function getEnabledWebPushSubscriptions() {
|
||||
.where({
|
||||
is_enabled: true,
|
||||
})
|
||||
.select('endpoint', 'subscription_json', 'device_id', 'app_origin', 'app_domain');
|
||||
.select('endpoint', 'subscription_json', 'device_id', 'client_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) : '',
|
||||
clientId: row.client_id ? String(row.client_id) : '',
|
||||
appOrigin: row.app_origin ? String(row.app_origin) : '',
|
||||
appDomain: row.app_domain ? String(row.app_domain) : '',
|
||||
}));
|
||||
@@ -870,7 +949,7 @@ export async function sendIosNotifications(payload: IosNotificationPayload) {
|
||||
.filter(
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
|
||||
isAllowedTargetClientId({ deviceId: row.deviceId }, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
)
|
||||
.map((row) => row.token);
|
||||
@@ -940,15 +1019,16 @@ async function sendWebPushNotifications(payload: IosNotificationPayload) {
|
||||
[
|
||||
{ kind: 'web-endpoint', id: row.endpoint },
|
||||
{ kind: 'client', id: row.deviceId },
|
||||
{ kind: 'client', id: row.clientId },
|
||||
],
|
||||
payload,
|
||||
),
|
||||
})),
|
||||
)
|
||||
).filter(
|
||||
(row) =>
|
||||
(row) =>
|
||||
row.allowed &&
|
||||
isAllowedTargetClientId(row.deviceId, targetClientIds) &&
|
||||
isAllowedTargetClientId(row, targetClientIds) &&
|
||||
isAllowedAppTarget(row, targetAppOrigins, targetAppDomains),
|
||||
);
|
||||
|
||||
@@ -1036,6 +1116,7 @@ export async function sendNotifications(
|
||||
disableWebPush?: boolean;
|
||||
},
|
||||
) {
|
||||
const resolvedPayload = withInferredNotificationOriginData(payload);
|
||||
const [ios, web] = await Promise.all([
|
||||
options?.disableIos
|
||||
? Promise.resolve({
|
||||
@@ -1046,7 +1127,7 @@ export async function sendNotifications(
|
||||
failedCount: 0,
|
||||
invalidTokens: [],
|
||||
})
|
||||
: sendIosNotifications(payload),
|
||||
: sendIosNotifications(resolvedPayload),
|
||||
options?.disableWebPush
|
||||
? Promise.resolve({
|
||||
ok: true,
|
||||
@@ -1056,7 +1137,7 @@ export async function sendNotifications(
|
||||
failedCount: 0,
|
||||
invalidEndpoints: [],
|
||||
})
|
||||
: sendWebPushNotifications(payload),
|
||||
: sendWebPushNotifications(resolvedPayload),
|
||||
]);
|
||||
|
||||
const aggregate = resolveNotificationAggregateResult(
|
||||
|
||||
@@ -30,6 +30,13 @@ test('resolveStaticContentType returns video content types for common video file
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.mov'), 'video/quicktime');
|
||||
});
|
||||
|
||||
test('resolveStaticContentType returns audio content types for common audio files', () => {
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.wav'), 'audio/wav');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.mp3'), 'audio/mpeg');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.ogg'), 'audio/ogg');
|
||||
assert.equal(resolveStaticContentType('/tmp/sample.m4a'), 'audio/mp4');
|
||||
});
|
||||
|
||||
async function withTempRepo(callback: (repoRoot: string) => Promise<void>) {
|
||||
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'resource-manager-test-'));
|
||||
|
||||
@@ -120,3 +127,27 @@ test('directory modifiedAt reflects the latest nested descendant change', async
|
||||
assert.equal(docsNode.modifiedAt, latestModifiedAt.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
test('resource manager tree and directory listing include dot-prefixed entries', async () => {
|
||||
await withTempRepo(async (repoRoot) => {
|
||||
await createResourceManagerDirectory(repoRoot, '', '.codex_chat');
|
||||
await createResourceManagerFile(repoRoot, '', '.env', 'TOKEN=1');
|
||||
await createResourceManagerFile(repoRoot, '.codex_chat', 'note.md', '# hidden');
|
||||
|
||||
const directory = await listResourceManagerDirectory(repoRoot, '');
|
||||
assert.deepEqual(
|
||||
directory.items.map((item) => item.path),
|
||||
['.codex_chat', '.env'],
|
||||
);
|
||||
|
||||
const tree = await getResourceManagerTree(repoRoot);
|
||||
assert.deepEqual(
|
||||
tree.tree.children?.map((item) => item.path),
|
||||
['.codex_chat', '.env'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
tree.tree.children?.[0]?.children?.map((item) => item.path),
|
||||
['.codex_chat/note.md'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createReadStream, type ReadStream } from 'node:fs';
|
||||
import { accessSync, existsSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -45,6 +45,12 @@ export type ResourceManagerFileDetail = {
|
||||
content: string | null;
|
||||
};
|
||||
|
||||
export type ResourceManagerPreviewStream = {
|
||||
contentType: string;
|
||||
size: number;
|
||||
createStream: (range?: { start?: number; end?: number }) => ReadStream;
|
||||
};
|
||||
|
||||
class ResourceManagerError extends Error {
|
||||
statusCode: number;
|
||||
|
||||
@@ -133,6 +139,14 @@ export function resolveStaticContentType(filePath: string) {
|
||||
return 'image/gif';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.wav':
|
||||
return 'audio/wav';
|
||||
case '.mp3':
|
||||
return 'audio/mpeg';
|
||||
case '.ogg':
|
||||
return 'audio/ogg';
|
||||
case '.m4a':
|
||||
return 'audio/mp4';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.webm':
|
||||
@@ -301,10 +315,6 @@ async function resolveDirectoryLatestModifiedAt(absolutePath: string, stats?: Aw
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryAbsolutePath = path.join(absolutePath, entry.name);
|
||||
const entryStats = await fs.stat(entryAbsolutePath);
|
||||
const entryModifiedAt = entry.isDirectory()
|
||||
@@ -336,7 +346,6 @@ async function buildTreeNode(absolutePath: string, relativePath: string): Promis
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
const children = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith('.'))
|
||||
.sort((left, right) => {
|
||||
if (left.isDirectory() && !right.isDirectory()) {
|
||||
return -1;
|
||||
@@ -395,7 +404,6 @@ export async function listResourceManagerDirectory(repoRootPath: string, directo
|
||||
const entries = await fs.readdir(absolutePath, { withFileTypes: true });
|
||||
const items: ResourceManagerDirectoryEntry[] = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => !entry.name.startsWith('.'))
|
||||
.sort((left, right) => {
|
||||
if (left.isDirectory() && !right.isDirectory()) {
|
||||
return -1;
|
||||
@@ -609,8 +617,9 @@ export async function openResourceManagerPreviewStream(repoRootPath: string, tar
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(absolutePath),
|
||||
contentType: resolveStaticContentType(absolutePath),
|
||||
};
|
||||
size: stats.size,
|
||||
createStream: (range?: { start?: number; end?: number }) => createReadStream(absolutePath, range),
|
||||
} satisfies ResourceManagerPreviewStream;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,8 +69,12 @@ test('test, release and prod restart scripts fall back to Docker socket when doc
|
||||
assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/);
|
||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/);
|
||||
assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/);
|
||||
assert.match(
|
||||
testScript,
|
||||
/docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/,
|
||||
);
|
||||
assert.match(testScript, /TEST_BUILD_STAMP_FILE="\$\{TEST_BUILD_STAMP_FILE:-\$MAIN_PROJECT_ROOT\/\.server-command-test-app-built-at\}"/);
|
||||
assert.match(testScript, /date -Iseconds > "\$TEST_BUILD_STAMP_FILE"/);
|
||||
assert.match(testScript, /restart-via-docker-socket\.mjs/);
|
||||
assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/);
|
||||
assert.match(relScript, /command -v docker >/);
|
||||
@@ -301,6 +305,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
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, 'src', 'main.test.ts'), 'export const testOnly = 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');
|
||||
@@ -309,6 +314,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
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, 'src', 'main.test.ts'), 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),
|
||||
@@ -323,6 +329,8 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
|
||||
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);
|
||||
const excludedTestDate = new Date('2026-05-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(path.join(tempRoot, 'src', 'main.test.ts'), excludedTestDate, excludedTestDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
@@ -333,6 +341,7 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
assert.ok(testCommand);
|
||||
assert.equal(testCommand.buildRequired, false);
|
||||
assert.notEqual(testCommand.latestSourceChangePath, 'public/.codex_chat/session/resource/note.txt');
|
||||
assert.notEqual(testCommand.latestSourceChangePath, 'src/main.test.ts');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
@@ -349,3 +358,49 @@ test('listServerCommands ignores public codex chat resources when checking app s
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('listServerCommands ignores work-server test-only source changes when computing buildRequired', async () => {
|
||||
const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT;
|
||||
const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT;
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-source-scan-'));
|
||||
|
||||
try {
|
||||
const workServerRoot = path.join(tempRoot, 'etc', 'servers', 'work-server');
|
||||
await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8');
|
||||
await writeFile(path.join(tempRoot, 'package.json'), '{"name":"main-project-temp"}\n', 'utf8');
|
||||
await mkdir(path.join(workServerRoot, 'src', 'services'), { recursive: true });
|
||||
await mkdir(path.join(workServerRoot, 'scripts'), { recursive: true });
|
||||
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.ts'), 'export const live = true;\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), 'export const testOnly = true;\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'package.json'), '{"name":"work-server"}\n', 'utf8');
|
||||
await writeFile(path.join(workServerRoot, 'tsconfig.json'), '{}\n', 'utf8');
|
||||
await mkdir(path.join(workServerRoot, 'dist'), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workServerRoot, 'dist', 'build-info.json'),
|
||||
JSON.stringify({ version: '0.1.0', buildId: '0.1.0@2026-05-25T07:58:59.046Z', builtAt: '2026-05-25T07:58:59.046Z' }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const staleDate = new Date('2026-05-20T00:00:00.000Z');
|
||||
const excludedTestDate = new Date('2026-06-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.ts'), staleDate, staleDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'package.json'), staleDate, staleDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'tsconfig.json'), staleDate, staleDate);
|
||||
await fs.promises.utimes(path.join(workServerRoot, 'src', 'services', 'service.test.ts'), excludedTestDate, excludedTestDate);
|
||||
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = tempRoot;
|
||||
|
||||
const commands = await listServerCommands();
|
||||
const workServerCommand = commands.find((item) => item.key === 'work-server');
|
||||
|
||||
assert.ok(workServerCommand);
|
||||
assert.equal(workServerCommand.buildRequired, false);
|
||||
assert.notEqual(workServerCommand.latestSourceChangePath, 'src/services/service.test.ts');
|
||||
} finally {
|
||||
env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot;
|
||||
env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot;
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ type ServerDefinition = {
|
||||
commandWorkingDirectory: string;
|
||||
commandEnvironment: Record<string, string>;
|
||||
restartStrategy: 'wait' | 'deferred';
|
||||
deferredResponseMode?: 'wait-for-result' | 'accept-immediately';
|
||||
};
|
||||
|
||||
export type ServerCommandSnapshot = {
|
||||
@@ -135,11 +136,26 @@ 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_BUILD_STAMP_RELATIVE_PATH = '.server-command-test-app-built-at';
|
||||
const APP_SOURCE_EXCLUDED_PREFIXES = ['public/.codex_chat/'] as const;
|
||||
const APP_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const;
|
||||
|
||||
export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) {
|
||||
const allowLocal = options?.allowLocal ?? false;
|
||||
let latestBuiltAt: string | null = null;
|
||||
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||
const buildStampCandidates = [
|
||||
path.join(mainProjectRoot, APP_BUILD_STAMP_RELATIVE_PATH),
|
||||
path.join(normalizePath(env.SERVER_COMMAND_PROJECT_ROOT), APP_BUILD_STAMP_RELATIVE_PATH),
|
||||
].filter((value, index, array) => array.indexOf(value) === index);
|
||||
|
||||
for (const targetPath of buildStampCandidates) {
|
||||
const candidate = allowLocal ? await readLocalBuildTimestamp(targetPath) : null;
|
||||
|
||||
if (candidate && (!latestBuiltAt || candidate > latestBuiltAt)) {
|
||||
latestBuiltAt = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) {
|
||||
const candidates = [
|
||||
@@ -195,7 +211,13 @@ type SourceChangeInfo = {
|
||||
|
||||
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));
|
||||
|
||||
if (APP_SOURCE_EXCLUDED_PREFIXES.some((prefix) => relativePath === prefix.slice(0, -1) || relativePath.startsWith(prefix))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseName = path.basename(relativePath);
|
||||
return APP_SOURCE_EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(baseName));
|
||||
}
|
||||
|
||||
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<SourceChangeInfo | null> {
|
||||
@@ -575,8 +597,10 @@ async function restartViaDockerSocket(definition: ServerDefinition) {
|
||||
}
|
||||
|
||||
function getServerDefinitions(): ServerDefinition[] {
|
||||
const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const mainProjectRoot = normalizePath(resolveMainProjectRoot());
|
||||
const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE);
|
||||
const projectRoot = normalizePath(useLocalMainMode ? mainProjectRoot : env.SERVER_COMMAND_PROJECT_ROOT);
|
||||
const scriptRootCandidates = [mainProjectRoot, projectRoot, '/workspace/main-project'];
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -585,11 +609,11 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
summary: '메인 프로젝트의 테스트 앱 컨테이너',
|
||||
environment: 'test',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_CHECK_URL || env.SERVER_COMMAND_TEST_URL),
|
||||
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_TEST_SERVICE,
|
||||
containerName: 'ai-code-app-app-1',
|
||||
commandScript: resolveCommandScriptPath('restart-test.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandScript: resolveCommandScriptPath('restart-test.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
@@ -607,11 +631,11 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
summary: 'release 브랜치를 서비스하는 릴리즈 앱 컨테이너',
|
||||
environment: 'release',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_CHECK_URL || env.SERVER_COMMAND_REL_URL),
|
||||
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_REL_SERVICE,
|
||||
containerName: 'ai-code-app-release',
|
||||
commandScript: resolveCommandScriptPath('restart-rel.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandScript: resolveCommandScriptPath('restart-rel.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
@@ -626,11 +650,11 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
summary: '프로덕션 앱 컨테이너',
|
||||
environment: 'production',
|
||||
publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL),
|
||||
checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_CHECK_URL || env.SERVER_COMMAND_PROD_URL),
|
||||
composeFile: path.join(mainProjectRoot, 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_PROD_SERVICE,
|
||||
containerName: 'ai-code-app-prod',
|
||||
commandScript: resolveCommandScriptPath('restart-prod.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandScript: resolveCommandScriptPath('restart-prod.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
MAIN_PROJECT_ROOT: mainProjectRoot,
|
||||
@@ -639,6 +663,7 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod',
|
||||
},
|
||||
restartStrategy: 'deferred',
|
||||
deferredResponseMode: 'wait-for-result',
|
||||
},
|
||||
{
|
||||
key: 'work-server',
|
||||
@@ -650,12 +675,13 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
composeFile: path.join(projectRoot, 'etc', 'servers', 'work-server', 'docker-compose.yml'),
|
||||
serviceName: env.SERVER_COMMAND_WORK_SERVER_SERVICE,
|
||||
containerName: 'work-server',
|
||||
commandScript: resolveCommandScriptPath('restart-work-server.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandWorkingDirectory: projectRoot,
|
||||
commandScript: resolveCommandScriptPath('restart-work-server.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
REPO_ROOT: projectRoot,
|
||||
REPO_ROOT: mainProjectRoot,
|
||||
},
|
||||
restartStrategy: 'deferred',
|
||||
deferredResponseMode: 'accept-immediately',
|
||||
},
|
||||
{
|
||||
key: 'command-runner',
|
||||
@@ -667,12 +693,13 @@ function getServerDefinitions(): ServerDefinition[] {
|
||||
composeFile: path.join(projectRoot, 'scripts', 'run-server-command-runner.mjs'),
|
||||
serviceName: 'server-command-runner',
|
||||
containerName: 'server-command-runner',
|
||||
commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']),
|
||||
commandWorkingDirectory: projectRoot,
|
||||
commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', scriptRootCandidates),
|
||||
commandWorkingDirectory: mainProjectRoot,
|
||||
commandEnvironment: {
|
||||
PROJECT_ROOT: projectRoot,
|
||||
PROJECT_ROOT: mainProjectRoot,
|
||||
},
|
||||
restartStrategy: 'deferred',
|
||||
deferredResponseMode: 'wait-for-result',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -930,6 +957,14 @@ async function restartServerCommandDeferred(definition: ServerDefinition): Promi
|
||||
});
|
||||
});
|
||||
|
||||
if (definition.deferredResponseMode === 'accept-immediately') {
|
||||
return {
|
||||
server: buildAcceptedRestartSnapshot(definition),
|
||||
commandOutput: `${definition.label} 재기동 요청을 접수했습니다. 잠시 후 상태를 다시 확인해 주세요.`,
|
||||
restartState: 'accepted',
|
||||
};
|
||||
}
|
||||
|
||||
const commandOutput = await waitForDeferredRestartResult(definition, statusPath, logPath);
|
||||
|
||||
return {
|
||||
|
||||
@@ -56,6 +56,22 @@ test('hasReservedRestartVerification keeps test restart pending until a new star
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasReservedRestartVerification(
|
||||
'test',
|
||||
{
|
||||
availability: 'online',
|
||||
startedAt: '2026-05-06T00:00:03.000Z',
|
||||
runningBuiltAt: '2026-05-06T00:00:05.000Z',
|
||||
runningVersion: null,
|
||||
buildRequired: true,
|
||||
updateAvailable: false,
|
||||
},
|
||||
reservationStartedAt,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('hasReservedRestartVerification keeps work-server restart pending until new runtime and build info are ready', () => {
|
||||
@@ -104,7 +120,23 @@ test('hasReservedRestartVerification keeps work-server restart pending until new
|
||||
},
|
||||
reservationStartedAt,
|
||||
),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
hasReservedRestartVerification(
|
||||
'work-server',
|
||||
{
|
||||
availability: 'online',
|
||||
startedAt: '2026-05-06T00:00:03.000Z',
|
||||
runningBuiltAt: '2026-05-06T00:00:04.000Z',
|
||||
runningVersion: '0.1.0@2026-05-06T00:00:04.000Z',
|
||||
buildRequired: true,
|
||||
updateAvailable: true,
|
||||
},
|
||||
reservationStartedAt,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -868,6 +868,32 @@ function hasRestartStartedAfterReservation(
|
||||
return serverStartedTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime;
|
||||
}
|
||||
|
||||
function hasRuntimeMarkerAfterReservation(
|
||||
runtimeMarkerAt: string | null | undefined,
|
||||
reservationStartedAt: string | null | undefined,
|
||||
) {
|
||||
const runtimeMarkerTime = Date.parse(runtimeMarkerAt ?? '');
|
||||
const reservationStartedTime = Date.parse(reservationStartedAt ?? '');
|
||||
|
||||
if (!Number.isFinite(runtimeMarkerTime) || !Number.isFinite(reservationStartedTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return runtimeMarkerTime + RESTART_VERIFICATION_CLOCK_SKEW_MS >= reservationStartedTime;
|
||||
}
|
||||
|
||||
function getWorkServerRuntimeMarkerAt(
|
||||
server: Pick<ServerCommandSnapshot, 'runningBuiltAt' | 'runningVersion'>,
|
||||
) {
|
||||
if (server.runningBuiltAt?.trim()) {
|
||||
return server.runningBuiltAt;
|
||||
}
|
||||
|
||||
const versionText = server.runningVersion?.trim() ?? '';
|
||||
const versionMarker = versionText.includes('@') ? versionText.split('@').at(-1)?.trim() ?? '' : '';
|
||||
return versionMarker || null;
|
||||
}
|
||||
|
||||
export function hasReservedRestartVerification(
|
||||
key: 'test' | 'work-server',
|
||||
server: Pick<
|
||||
@@ -881,10 +907,10 @@ export function hasReservedRestartVerification(
|
||||
}
|
||||
|
||||
if (key === 'test') {
|
||||
return Boolean(server.runningBuiltAt) && !server.buildRequired;
|
||||
return hasRuntimeMarkerAfterReservation(server.runningBuiltAt, reservationStartedAt);
|
||||
}
|
||||
|
||||
return Boolean(server.runningVersion ?? server.runningBuiltAt) && !server.buildRequired && !server.updateAvailable;
|
||||
return hasRuntimeMarkerAfterReservation(getWorkServerRuntimeMarkerAt(server), reservationStartedAt);
|
||||
}
|
||||
|
||||
async function finalizeReservedRestart(row: RestartReservationRow) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isLegacyChatShareTokenRowNeedingMigration } from './shared-resource-token-service.js';
|
||||
|
||||
const completeSnapshot = {
|
||||
id: 'token-setting',
|
||||
name: 'Token Setting',
|
||||
defaultExpiresInMinutes: 60,
|
||||
maxTokensPer30Days: 0,
|
||||
maxTokensPer7Days: 0,
|
||||
maxTokensPer5Hours: 0,
|
||||
oneTimeTokenLimit: 0,
|
||||
allowedAppIds: [],
|
||||
};
|
||||
|
||||
const completeContext = {
|
||||
kind: 'request-bundle',
|
||||
sessionId: 'session-1',
|
||||
requestId: 'request-1',
|
||||
};
|
||||
|
||||
test('isLegacyChatShareTokenRowNeedingMigration flags rows with legacy token_setting_id', () => {
|
||||
assert.equal(
|
||||
isLegacyChatShareTokenRowNeedingMigration({
|
||||
token_setting_id: 'legacy-setting',
|
||||
token_setting_snapshot_json: completeSnapshot,
|
||||
resource_context_json: completeContext,
|
||||
allowed_app_ids_json: '[]',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isLegacyChatShareTokenRowNeedingMigration flags rows with missing resource context', () => {
|
||||
assert.equal(
|
||||
isLegacyChatShareTokenRowNeedingMigration({
|
||||
token_setting_id: null,
|
||||
token_setting_snapshot_json: completeSnapshot,
|
||||
resource_context_json: null,
|
||||
allowed_app_ids_json: '[]',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isLegacyChatShareTokenRowNeedingMigration keeps valid current rows even when allowed apps are empty', () => {
|
||||
assert.equal(
|
||||
isLegacyChatShareTokenRowNeedingMigration({
|
||||
token_setting_id: null,
|
||||
token_setting_snapshot_json: completeSnapshot,
|
||||
resource_context_json: completeContext,
|
||||
allowed_app_ids_json: '[]',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,363 @@
|
||||
import { db } from '../db/client.js';
|
||||
|
||||
const TOKEN_SETTINGS_TABLE = 'token_settings';
|
||||
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type TokenSettingRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
defaultExpiresInMinutes: number;
|
||||
maxExpiresInMinutes: number;
|
||||
maxTokensPer30Days: number;
|
||||
maxTokensPer7Days: number;
|
||||
maxTokensPer5Hours: number;
|
||||
oneTimeTokenLimit: number;
|
||||
allowedAppIds: string[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
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 normalizeSettingId(value: unknown) {
|
||||
return normalizeText(value)
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]/g, '');
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number, min: number, max: number) {
|
||||
const resolved = typeof value === 'number' ? value : Number(value);
|
||||
|
||||
if (!Number.isFinite(resolved)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(max, Math.max(min, Math.round(resolved)));
|
||||
}
|
||||
|
||||
function normalizeAllowedAppIds(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort((left, right) => left.localeCompare(right, 'en'));
|
||||
}
|
||||
|
||||
function normalizeTokenSetting(record: Partial<TokenSettingRecord>): TokenSettingRecord | null {
|
||||
const id = normalizeSettingId(record.id);
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!id || !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT);
|
||||
const resolvedMaxExpiresInMinutes = normalizePositiveInteger(
|
||||
record.maxExpiresInMinutes,
|
||||
defaultExpiresInMinutes <= 0 ? 0 : 10_080,
|
||||
0,
|
||||
UNBOUNDED_NUMERIC_LIMIT,
|
||||
);
|
||||
const maxExpiresInMinutes =
|
||||
defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0
|
||||
? 0
|
||||
: Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes);
|
||||
|
||||
const legacyMaxTotalTokens =
|
||||
'maxTotalTokens' in record
|
||||
? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT)
|
||||
: 100_000;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
defaultExpiresInMinutes,
|
||||
maxExpiresInMinutes,
|
||||
maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT),
|
||||
allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds),
|
||||
enabled: normalizeEnabled(record.enabled),
|
||||
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function compareUpdatedAt(left: TokenSettingRecord, right: TokenSettingRecord) {
|
||||
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 sanitizeTokenSettings(items: Partial<TokenSettingRecord>[] | null | undefined) {
|
||||
const byId = new Map<string, TokenSettingRecord>();
|
||||
|
||||
for (const item of items ?? []) {
|
||||
const normalized = normalizeTokenSetting(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const current = byId.get(normalized.id);
|
||||
if (!current || compareUpdatedAt(current, normalized) <= 0) {
|
||||
byId.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byId.values()).sort((left, right) => {
|
||||
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
|
||||
if (nameCompare !== 0) {
|
||||
return nameCompare;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id, 'en');
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTokenSettingsTable() {
|
||||
const hasTable = await db.schema.hasTable(TOKEN_SETTINGS_TABLE);
|
||||
|
||||
if (!hasTable) {
|
||||
await db.schema.createTable(TOKEN_SETTINGS_TABLE, (table) => {
|
||||
table.string('id').primary();
|
||||
table.string('name').notNullable();
|
||||
table.text('description').notNullable().defaultTo('');
|
||||
table.integer('default_expires_in_minutes').notNullable().defaultTo(60);
|
||||
table.integer('max_expires_in_minutes').notNullable().defaultTo(10_080);
|
||||
table.bigInteger('max_total_tokens').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('max_tokens_per_30_days').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('max_tokens_per_7_days').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('max_tokens_per_5_hours').notNullable().defaultTo(100_000);
|
||||
table.bigInteger('one_time_token_limit').notNullable().defaultTo(0);
|
||||
table.text('allowed_app_ids_json').notNullable().defaultTo('[]');
|
||||
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('')],
|
||||
['default_expires_in_minutes', (table) => table.integer('default_expires_in_minutes').notNullable().defaultTo(60)],
|
||||
['max_expires_in_minutes', (table) => table.integer('max_expires_in_minutes').notNullable().defaultTo(10_080)],
|
||||
['max_total_tokens', (table) => table.bigInteger('max_total_tokens').notNullable().defaultTo(100_000)],
|
||||
['max_tokens_per_30_days', (table) => table.bigInteger('max_tokens_per_30_days').notNullable().defaultTo(100_000)],
|
||||
['max_tokens_per_7_days', (table) => table.bigInteger('max_tokens_per_7_days').notNullable().defaultTo(100_000)],
|
||||
['max_tokens_per_5_hours', (table) => table.bigInteger('max_tokens_per_5_hours').notNullable().defaultTo(100_000)],
|
||||
['one_time_token_limit', (table) => table.bigInteger('one_time_token_limit').notNullable().defaultTo(0)],
|
||||
['allowed_app_ids_json', (table) => table.text('allowed_app_ids_json').notNullable().defaultTo('[]')],
|
||||
['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(TOKEN_SETTINGS_TABLE, columnName);
|
||||
|
||||
if (!hasColumn) {
|
||||
await db.schema.alterTable(TOKEN_SETTINGS_TABLE, (table) => {
|
||||
createColumn(table);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedAppIds(row: Record<string, unknown>) {
|
||||
const rawValue = row.allowed_app_ids_json;
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toTokenSettingRecord(row: Record<string, unknown>) {
|
||||
return normalizeTokenSetting({
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
name: typeof row.name === 'string' ? row.name : undefined,
|
||||
description: typeof row.description === 'string' ? row.description : undefined,
|
||||
defaultExpiresInMinutes:
|
||||
typeof row.default_expires_in_minutes === 'number' || typeof row.default_expires_in_minutes === 'string'
|
||||
? Number(row.default_expires_in_minutes)
|
||||
: undefined,
|
||||
maxExpiresInMinutes:
|
||||
typeof row.max_expires_in_minutes === 'number' || typeof row.max_expires_in_minutes === 'string'
|
||||
? Number(row.max_expires_in_minutes)
|
||||
: undefined,
|
||||
maxTokensPer30Days:
|
||||
typeof row.max_tokens_per_30_days === 'number' || typeof row.max_tokens_per_30_days === 'string'
|
||||
? Number(row.max_tokens_per_30_days)
|
||||
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
|
||||
? Number(row.max_total_tokens)
|
||||
: undefined,
|
||||
maxTokensPer7Days:
|
||||
typeof row.max_tokens_per_7_days === 'number' || typeof row.max_tokens_per_7_days === 'string'
|
||||
? Number(row.max_tokens_per_7_days)
|
||||
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
|
||||
? Number(row.max_total_tokens)
|
||||
: undefined,
|
||||
maxTokensPer5Hours:
|
||||
typeof row.max_tokens_per_5_hours === 'number' || typeof row.max_tokens_per_5_hours === 'string'
|
||||
? Number(row.max_tokens_per_5_hours)
|
||||
: typeof row.max_total_tokens === 'number' || typeof row.max_total_tokens === 'string'
|
||||
? Number(row.max_total_tokens)
|
||||
: undefined,
|
||||
oneTimeTokenLimit:
|
||||
typeof row.one_time_token_limit === 'number' || typeof row.one_time_token_limit === 'string'
|
||||
? Number(row.one_time_token_limit)
|
||||
: undefined,
|
||||
allowedAppIds: parseAllowedAppIds(row),
|
||||
enabled:
|
||||
typeof row.enabled === 'boolean' || typeof row.enabled === 'number' || typeof row.enabled === 'string'
|
||||
? normalizeEnabled(row.enabled)
|
||||
: undefined,
|
||||
updatedAt: typeof row.updated_at === 'string' ? row.updated_at : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function readTokenSettingsFromTable() {
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const rows = await db(TOKEN_SETTINGS_TABLE)
|
||||
.select(
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'default_expires_in_minutes',
|
||||
'max_expires_in_minutes',
|
||||
'max_tokens_per_30_days',
|
||||
'max_tokens_per_7_days',
|
||||
'max_tokens_per_5_hours',
|
||||
'one_time_token_limit',
|
||||
'max_total_tokens',
|
||||
'allowed_app_ids_json',
|
||||
'enabled',
|
||||
'updated_at',
|
||||
)
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
return sanitizeTokenSettings(
|
||||
rows
|
||||
.map((row) => toTokenSettingRecord(row as Record<string, unknown>))
|
||||
.filter((item): item is TokenSettingRecord => Boolean(item)),
|
||||
);
|
||||
}
|
||||
|
||||
async function replaceTokenSettingsInTable(items: TokenSettingRecord[]) {
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const nextItems = sanitizeTokenSettings(items);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx(TOKEN_SETTINGS_TABLE).del();
|
||||
|
||||
if (nextItems.length > 0) {
|
||||
await trx(TOKEN_SETTINGS_TABLE).insert(
|
||||
nextItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
default_expires_in_minutes: item.defaultExpiresInMinutes,
|
||||
max_expires_in_minutes: item.maxExpiresInMinutes,
|
||||
max_total_tokens: item.maxTokensPer30Days,
|
||||
max_tokens_per_30_days: item.maxTokensPer30Days,
|
||||
max_tokens_per_7_days: item.maxTokensPer7Days,
|
||||
max_tokens_per_5_hours: item.maxTokensPer5Hours,
|
||||
one_time_token_limit: item.oneTimeTokenLimit,
|
||||
allowed_app_ids_json: JSON.stringify(item.allowedAppIds),
|
||||
enabled: item.enabled,
|
||||
updated_at: item.updatedAt,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
export async function getTokenSettingsConfig() {
|
||||
return readTokenSettingsFromTable();
|
||||
}
|
||||
|
||||
export async function upsertTokenSettingsConfig(items: Partial<TokenSettingRecord>[] | null | undefined) {
|
||||
return replaceTokenSettingsInTable(sanitizeTokenSettings(items));
|
||||
}
|
||||
|
||||
export async function getTokenSettingById(id: string) {
|
||||
const normalizedId = normalizeSettingId(id);
|
||||
if (!normalizedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureTokenSettingsTable();
|
||||
|
||||
const row = await db(TOKEN_SETTINGS_TABLE)
|
||||
.where({ id: normalizedId })
|
||||
.first(
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'default_expires_in_minutes',
|
||||
'max_expires_in_minutes',
|
||||
'max_tokens_per_30_days',
|
||||
'max_tokens_per_7_days',
|
||||
'max_tokens_per_5_hours',
|
||||
'one_time_token_limit',
|
||||
'max_total_tokens',
|
||||
'allowed_app_ids_json',
|
||||
'enabled',
|
||||
'updated_at',
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toTokenSettingRecord(row as Record<string, unknown>);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type WorkServerSourceChangeInfo = {
|
||||
const MODULE_DIR_PATH = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORK_SERVER_ROOT_PATH = path.resolve(MODULE_DIR_PATH, '..', '..');
|
||||
const SOURCE_TARGET_PATH_NAMES = ['src', 'scripts', 'package.json', 'tsconfig.json'] as const;
|
||||
const WORK_SERVER_SOURCE_EXCLUDED_FILE_PATTERNS = [/\.test\.[^/]+$/i, /\.spec\.[^/]+$/i] as const;
|
||||
|
||||
function normalizeRootPath(value: string | null | undefined) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
@@ -26,18 +27,17 @@ function normalizeRootPath(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
function resolveSourceTargetRoots() {
|
||||
const roots = [WORK_SERVER_ROOT_PATH];
|
||||
const mainProjectRoot = normalizeRootPath(resolveMainProjectRoot());
|
||||
|
||||
if (mainProjectRoot) {
|
||||
const mirroredWorkServerRoot = path.join(mainProjectRoot, 'etc', 'servers', 'work-server');
|
||||
|
||||
if (!roots.includes(mirroredWorkServerRoot)) {
|
||||
roots.push(mirroredWorkServerRoot);
|
||||
if (fs.existsSync(mirroredWorkServerRoot)) {
|
||||
return [mirroredWorkServerRoot];
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
return [WORK_SERVER_ROOT_PATH];
|
||||
}
|
||||
|
||||
function resolveBuildInfoDirectoryPath(rootPath: string, configuredDistDir: string) {
|
||||
@@ -138,8 +138,18 @@ export function getRuntimeWorkServerBuildInfo() {
|
||||
return runtimeWorkServerBuildInfo;
|
||||
}
|
||||
|
||||
function isExcludedWorkServerSourcePath(rootPath: string, targetPath: string) {
|
||||
const relativePath = path.relative(rootPath, targetPath).replace(/\\/g, '/');
|
||||
const baseName = path.basename(relativePath);
|
||||
return WORK_SERVER_SOURCE_EXCLUDED_FILE_PATTERNS.some((pattern) => pattern.test(baseName));
|
||||
}
|
||||
|
||||
async function findLatestSourceChangeInPath(rootPath: string, targetPath: string): Promise<WorkServerSourceChangeInfo | null> {
|
||||
try {
|
||||
if (isExcludedWorkServerSourcePath(rootPath, targetPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await fs.promises.stat(targetPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
|
||||
Reference in New Issue
Block a user