Fix chat type persistence and board flow
This commit is contained in:
@@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}"
|
||||
RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}"
|
||||
RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}"
|
||||
SUPERVISOR_SCRIPT="${SERVER_COMMAND_RUNNER_SUPERVISOR_SCRIPT:-$PROJECT_ROOT/scripts/server-command-runner-supervisor.sh}"
|
||||
RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}"
|
||||
RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}"
|
||||
RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}"
|
||||
RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}"
|
||||
RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}"
|
||||
RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}"
|
||||
SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}"
|
||||
RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT")
|
||||
|
||||
if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then
|
||||
@@ -27,17 +29,36 @@ if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
|
||||
if [ -n "$RUNNER_PIDS" ]; then
|
||||
kill $RUNNER_PIDS || true
|
||||
if [ ! -x "$SUPERVISOR_SCRIPT" ]; then
|
||||
chmod +x "$SUPERVISOR_SCRIPT"
|
||||
fi
|
||||
|
||||
if [ -f "$SUPERVISOR_PID_FILE" ]; then
|
||||
SUPERVISOR_PID=$(cat "$SUPERVISOR_PID_FILE" 2>/dev/null || true)
|
||||
if [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then
|
||||
kill -HUP "$SUPERVISOR_PID"
|
||||
echo "server-command-runner reload requested"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -f "$SUPERVISOR_PID_FILE"
|
||||
fi
|
||||
|
||||
LEGACY_RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true)
|
||||
if [ -n "$LEGACY_RUNNER_PIDS" ]; then
|
||||
kill $LEGACY_RUNNER_PIDS || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
setsid env \
|
||||
PROJECT_ROOT="$PROJECT_ROOT" \
|
||||
SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \
|
||||
SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \
|
||||
SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \
|
||||
SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \
|
||||
"$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
||||
SERVER_COMMAND_RUNNER_SCRIPT="$RUNNER_SCRIPT" \
|
||||
SERVER_COMMAND_RUNNER_NODE_BIN="$RUNNER_NODE_BIN" \
|
||||
SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE="$SUPERVISOR_PID_FILE" \
|
||||
"$SUPERVISOR_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 </dev/null &
|
||||
|
||||
echo "server-command-runner restart requested"
|
||||
|
||||
@@ -4,6 +4,21 @@ set -eu
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
|
||||
COMPOSE_FILE="$REPO_ROOT/etc/servers/work-server/docker-compose.yml"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server
|
||||
|
||||
if docker inspect work-server >/dev/null 2>&1; then
|
||||
RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false')
|
||||
SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '')
|
||||
case "$SUPERVISOR_CMD" in
|
||||
*work-server-supervisor*)
|
||||
if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then
|
||||
echo "work-server reload requested"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server
|
||||
|
||||
@@ -2,6 +2,7 @@ create table if not exists board_posts (
|
||||
id serial primary key,
|
||||
title varchar(200) not null,
|
||||
content text not null,
|
||||
attachments_json text not null default '[]',
|
||||
automation_plan_item_id integer null,
|
||||
automation_received_at timestamptz null,
|
||||
created_at timestamptz not null default now(),
|
||||
|
||||
4
etc/servers/work-server/.dockerignore
Normal file
4
etc/servers/work-server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.docker
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log*
|
||||
@@ -11,4 +11,7 @@ COPY src ./src
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
COPY scripts/container-supervisor.sh /usr/local/bin/work-server-supervisor
|
||||
RUN chmod +x /usr/local/bin/work-server-supervisor
|
||||
|
||||
CMD ["/usr/local/bin/work-server-supervisor"]
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
ports:
|
||||
- '127.0.0.1:3100:3100'
|
||||
volumes:
|
||||
- ./:/app
|
||||
- work-server-node-modules:/app/node_modules
|
||||
- ../../../:/workspace/main-project
|
||||
- ../../../.auto_codex:/workspace/auto_codex
|
||||
- ../../../scripts:/workspace/repo-scripts:ro
|
||||
@@ -50,3 +52,6 @@ services:
|
||||
networks:
|
||||
work-backend:
|
||||
name: work-backend
|
||||
|
||||
volumes:
|
||||
work-server-node-modules:
|
||||
|
||||
96
etc/servers/work-server/scripts/container-supervisor.sh
Executable file
96
etc/servers/work-server/scripts/container-supervisor.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
APP_ROOT="${APP_ROOT:-/app}"
|
||||
STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}"
|
||||
LOCK_FILE="$APP_ROOT/package-lock.json"
|
||||
LOCK_HASH_FILE="$STATE_DIR/package-lock.sha256"
|
||||
CHILD_PID=""
|
||||
STOP_REQUESTED="0"
|
||||
RELOAD_REQUESTED="0"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
cd "$APP_ROOT"
|
||||
|
||||
log() {
|
||||
printf '[work-server-supervisor] %s\n' "$*"
|
||||
}
|
||||
|
||||
ensure_dependencies() {
|
||||
if [ ! -f "$LOCK_FILE" ]; then
|
||||
log "package-lock.json not found; skipping npm ci"
|
||||
return 0
|
||||
fi
|
||||
|
||||
CURRENT_HASH=$(sha256sum "$LOCK_FILE" | awk '{print $1}')
|
||||
PREVIOUS_HASH=""
|
||||
|
||||
if [ -f "$LOCK_HASH_FILE" ]; then
|
||||
PREVIOUS_HASH=$(cat "$LOCK_HASH_FILE")
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_ROOT/node_modules" ] || [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then
|
||||
log "installing dependencies"
|
||||
npm ci --legacy-peer-deps
|
||||
printf '%s' "$CURRENT_HASH" >"$LOCK_HASH_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_runtime() {
|
||||
ensure_dependencies
|
||||
log "building latest source"
|
||||
npm run build
|
||||
}
|
||||
|
||||
start_child() {
|
||||
log "starting server process"
|
||||
npm run start &
|
||||
CHILD_PID=$!
|
||||
}
|
||||
|
||||
request_reload() {
|
||||
log "reload requested"
|
||||
if prepare_runtime; then
|
||||
RELOAD_REQUESTED="1"
|
||||
if [ -n "$CHILD_PID" ]; then
|
||||
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
log "reload aborted because build failed; keeping current process"
|
||||
fi
|
||||
}
|
||||
|
||||
request_stop() {
|
||||
STOP_REQUESTED="1"
|
||||
log "shutdown requested"
|
||||
if [ -n "$CHILD_PID" ]; then
|
||||
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap 'request_reload' HUP
|
||||
trap 'request_stop' INT TERM
|
||||
|
||||
prepare_runtime
|
||||
|
||||
while :; do
|
||||
start_child
|
||||
set +e
|
||||
wait "$CHILD_PID"
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
CHILD_PID=""
|
||||
|
||||
if [ "$STOP_REQUESTED" = "1" ]; then
|
||||
exit "$EXIT_CODE"
|
||||
fi
|
||||
|
||||
if [ "$RELOAD_REQUESTED" = "1" ]; then
|
||||
RELOAD_REQUESTED="0"
|
||||
continue
|
||||
fi
|
||||
|
||||
log "server exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds"
|
||||
sleep 2
|
||||
done
|
||||
@@ -32,6 +32,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'general-inquiry',
|
||||
name: '일반 문의',
|
||||
description:
|
||||
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-24T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureAppConfigTable() {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
|
||||
id: 'none',
|
||||
name: '기본유형',
|
||||
description:
|
||||
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
|
||||
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
|
||||
behaviorType: 'none',
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
@@ -82,7 +82,7 @@ function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) {
|
||||
byId.set(defaultItem.id, {
|
||||
...existingItem,
|
||||
name: defaultItem.name,
|
||||
description: defaultItem.description,
|
||||
description: existingItem.description || defaultItem.description,
|
||||
behaviorType: defaultItem.behaviorType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-
|
||||
|
||||
test('buildBoardPostPlanNote formats automation work memo with clear sections', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', {
|
||||
name: '자동화 메모',
|
||||
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||
}),
|
||||
buildBoardPostPlanNote(
|
||||
' 알림 개선 ',
|
||||
'본문 첫 줄\n본문 둘째 줄\n',
|
||||
[],
|
||||
{
|
||||
name: '자동화 메모',
|
||||
description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.',
|
||||
},
|
||||
),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
@@ -27,10 +32,15 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections',
|
||||
|
||||
test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote('작업', '본문', {
|
||||
name: '빈 context 유형',
|
||||
description: ' ',
|
||||
}),
|
||||
buildBoardPostPlanNote(
|
||||
'작업',
|
||||
'본문',
|
||||
[],
|
||||
{
|
||||
name: '빈 context 유형',
|
||||
description: ' ',
|
||||
},
|
||||
),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
@@ -48,6 +58,42 @@ test('buildBoardPostPlanNote keeps context section even when automation type des
|
||||
);
|
||||
});
|
||||
|
||||
test('buildBoardPostPlanNote appends attachment lines when files exist', () => {
|
||||
assert.equal(
|
||||
buildBoardPostPlanNote(
|
||||
'작업',
|
||||
'본문',
|
||||
[
|
||||
{
|
||||
id: 'attachment-1',
|
||||
name: 'spec.png',
|
||||
path: 'public/.codex_chat/test/resource/uploads/spec.png',
|
||||
publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png',
|
||||
size: 1280,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
null,
|
||||
),
|
||||
[
|
||||
'# 자동화 작업메모',
|
||||
'',
|
||||
'- 게시판 제목: 작업',
|
||||
'- 메모 출처: board_posts 자동화 접수',
|
||||
'- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.',
|
||||
'',
|
||||
'## 자동화 유형 context',
|
||||
'선택된 자동화 유형 context 없음',
|
||||
'',
|
||||
'## 요청 본문',
|
||||
'본문',
|
||||
'',
|
||||
'## 첨부 파일',
|
||||
'- spec.png: public/.codex_chat/test/resource/uploads/spec.png',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('BoardPostAutomationLockedError keeps user-facing message by action', () => {
|
||||
assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.');
|
||||
assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.');
|
||||
|
||||
@@ -17,6 +17,16 @@ export const BOARD_POSTS_TABLE = 'board_posts';
|
||||
export const boardPostPayloadSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
content: z.string().min(1).max(200000),
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
id: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(255),
|
||||
path: z.string().trim().min(1).max(2000),
|
||||
publicUrl: z.string().trim().min(1).max(2000),
|
||||
size: z.coerce.number().int().min(0).max(10 * 1024 * 1024),
|
||||
mimeType: z.string().trim().min(1).max(200),
|
||||
}),
|
||||
).max(20).default([]),
|
||||
automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')),
|
||||
});
|
||||
|
||||
@@ -25,6 +35,7 @@ export type BoardPostItem = {
|
||||
title: string;
|
||||
content: string;
|
||||
preview: string;
|
||||
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'];
|
||||
automationType: z.infer<typeof boardPostPayloadSchema>['automationType'];
|
||||
automationPlanItemId: number | null;
|
||||
automationReceivedAt: string | null;
|
||||
@@ -57,12 +68,22 @@ function createPreview(content: string) {
|
||||
|
||||
function mapBoardPostRow(row: Record<string, unknown>): BoardPostItem {
|
||||
const content = String(row.content ?? '');
|
||||
const rawAttachments = row.attachments_json ?? row.attachments ?? '[]';
|
||||
let attachments: BoardPostItem['attachments'] = [];
|
||||
|
||||
try {
|
||||
const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments;
|
||||
attachments = boardPostPayloadSchema.shape.attachments.parse(parsed);
|
||||
} catch {
|
||||
attachments = [];
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
content,
|
||||
preview: createPreview(content),
|
||||
attachments,
|
||||
automationType: resolveStoredAutomationTypeId(row),
|
||||
automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined
|
||||
? null
|
||||
@@ -79,7 +100,33 @@ function isBoardPostAutomationLocked(row: Record<string, unknown>) {
|
||||
return Boolean(row.automation_received_at || row.automation_plan_item_id);
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null) {
|
||||
function buildBoardAttachmentSection(attachments: z.infer<typeof boardPostPayloadSchema>['attachments']) {
|
||||
const lines = attachments
|
||||
.map((attachment) => {
|
||||
const label = attachment.name.trim() || attachment.path.trim().split('/').pop() || '첨부 파일';
|
||||
const path = attachment.path.trim();
|
||||
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `- ${label}: ${path}`;
|
||||
})
|
||||
.filter((line): line is string => Boolean(line));
|
||||
|
||||
if (lines.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['## 첨부 파일', ...lines];
|
||||
}
|
||||
|
||||
export function buildBoardPostPlanNote(
|
||||
title: string,
|
||||
content: string,
|
||||
attachments: z.infer<typeof boardPostPayloadSchema>['attachments'] = [],
|
||||
automationType?: Pick<AutomationTypeRecord, 'name' | 'description'> | null,
|
||||
) {
|
||||
const normalizedTitle = title.trim();
|
||||
const normalizedContent = content.trim();
|
||||
const normalizedAutomationTypeName = String(automationType?.name ?? '').trim();
|
||||
@@ -98,6 +145,7 @@ export function buildBoardPostPlanNote(title: string, content: string, automatio
|
||||
'',
|
||||
'## 요청 본문',
|
||||
normalizedContent,
|
||||
...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
@@ -174,6 +222,7 @@ export async function ensureBoardPostsTable() {
|
||||
['content', (table) => table.text('content').notNullable().defaultTo('')],
|
||||
['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')],
|
||||
['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()],
|
||||
['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')],
|
||||
['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()],
|
||||
['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()],
|
||||
['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())],
|
||||
@@ -230,6 +279,7 @@ export async function createBoardPost(payload: z.infer<typeof boardPostPayloadSc
|
||||
const insertQuery = db(BOARD_POSTS_TABLE).insert({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
created_at: db.fn.now(),
|
||||
@@ -276,11 +326,12 @@ export async function receiveBoardPostAutomation(id: number) {
|
||||
|
||||
const title = String(currentRow.title ?? '').trim();
|
||||
const content = String(currentRow.content ?? '').trim();
|
||||
const attachments = mapBoardPostRow(currentRow).attachments;
|
||||
const workId = `board-post-${id}`;
|
||||
const automationType = await resolveAutomationType(currentRow.automation_type_id ?? currentRow.automation_type);
|
||||
const insertQuery = trx(PLAN_TABLE).insert({
|
||||
work_id: workId,
|
||||
note: buildBoardPostPlanNote(title, content, automationType),
|
||||
note: buildBoardPostPlanNote(title, content, attachments, automationType),
|
||||
automation_type: normalizePlanAutomationType(currentRow.automation_type),
|
||||
automation_type_id: currentRow.automation_type_id ?? currentRow.automation_type,
|
||||
status: '등록',
|
||||
@@ -344,6 +395,7 @@ export async function updateBoardPost(id: number, payload: z.infer<typeof boardP
|
||||
.update({
|
||||
title: parsedPayload.title,
|
||||
content: parsedPayload.content,
|
||||
attachments_json: JSON.stringify(parsedPayload.attachments),
|
||||
automation_type: automationType.behaviorType,
|
||||
automation_type_id: automationType.id,
|
||||
updated_at: trx.fn.now(),
|
||||
|
||||
@@ -1070,15 +1070,25 @@ export async function updateChatConversationContext(
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentChatTypeId = String(current.chat_type_id ?? '').trim() || null;
|
||||
const requestedChatTypeId = payload.chatTypeId?.trim() || null;
|
||||
const nextChatTypeId = currentChatTypeId || requestedChatTypeId || null;
|
||||
const requestedContextLabel = payload.contextLabel?.trim() || null;
|
||||
const requestedContextDescription = payload.contextDescription?.trim() || null;
|
||||
|
||||
await db(CHAT_CONVERSATION_TABLE)
|
||||
.where({ session_id: sessionId.trim() })
|
||||
.update({
|
||||
title: payload.title?.trim() || current.title || '새 대화',
|
||||
client_id: normalizedClientId || current.client_id || null,
|
||||
chat_type_id: payload.chatTypeId?.trim() || null,
|
||||
last_chat_type_id: payload.lastChatTypeId?.trim() || null,
|
||||
context_label: payload.contextLabel?.trim() || null,
|
||||
context_description: payload.contextDescription?.trim() || null,
|
||||
chat_type_id: nextChatTypeId,
|
||||
last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null,
|
||||
context_label:
|
||||
currentChatTypeId != null ? current.context_label || null : requestedContextLabel || current.context_label || null,
|
||||
context_description:
|
||||
currentChatTypeId != null
|
||||
? current.context_description || null
|
||||
: requestedContextDescription || current.context_description || null,
|
||||
notify_offline:
|
||||
normalizedClientId == null && payload.notifyOffline != null
|
||||
? payload.notifyOffline
|
||||
@@ -1613,14 +1623,21 @@ export async function appendChatConversationMessage(
|
||||
.update({
|
||||
client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null,
|
||||
title: nextTitle,
|
||||
chat_type_id: conversation.chatTypeId?.trim() || currentConversation?.chat_type_id || null,
|
||||
chat_type_id: currentConversation?.chat_type_id || conversation.chatTypeId?.trim() || null,
|
||||
last_chat_type_id:
|
||||
conversation.chatTypeId?.trim() ||
|
||||
currentConversation?.last_chat_type_id ||
|
||||
currentConversation?.chat_type_id ||
|
||||
currentConversation?.last_chat_type_id ||
|
||||
conversation.chatTypeId?.trim() ||
|
||||
conversation.lastChatTypeId?.trim() ||
|
||||
null,
|
||||
context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null,
|
||||
context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null,
|
||||
context_label:
|
||||
currentConversation?.chat_type_id || currentConversation?.context_label
|
||||
? currentConversation?.context_label || null
|
||||
: conversation.contextLabel?.trim() || null,
|
||||
context_description:
|
||||
currentConversation?.chat_type_id || currentConversation?.context_description
|
||||
? currentConversation?.context_description || null
|
||||
: conversation.contextDescription?.trim() || null,
|
||||
notify_offline:
|
||||
conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline,
|
||||
updated_at: db.fn.now(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
collectOfflineNotificationClientIds,
|
||||
createActivityLogMessage,
|
||||
extractDiffCodeBlocks,
|
||||
extractCodexStreamText,
|
||||
fitActivityLogLines,
|
||||
isAutomationRegistrationCountRequest,
|
||||
resolveResponseTimestamp,
|
||||
@@ -116,6 +117,45 @@ test('createActivityLogMessage keeps fitted activity history instead of the late
|
||||
);
|
||||
});
|
||||
|
||||
test('extractCodexStreamText ignores command execution item completions', () => {
|
||||
assert.deepEqual(
|
||||
extractCodexStreamText({
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_1',
|
||||
type: 'command_execution',
|
||||
command: '/bin/bash -lc pwd',
|
||||
aggregated_output: '/workspace/main-project\n',
|
||||
exit_code: 0,
|
||||
status: 'completed',
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: 'item.completed',
|
||||
completedText: '',
|
||||
deltaText: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('extractCodexStreamText keeps completed agent messages', () => {
|
||||
assert.deepEqual(
|
||||
extractCodexStreamText({
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_2',
|
||||
type: 'agent_message',
|
||||
text: '최종 응답입니다.',
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: 'item.completed',
|
||||
completedText: '최종 응답입니다.',
|
||||
deltaText: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveResponseTimestamp moves fast replies behind the request second', () => {
|
||||
assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01');
|
||||
});
|
||||
|
||||
@@ -1041,11 +1041,25 @@ function collectCodexTextFragments(value: unknown): string[] {
|
||||
return Object.values(record).flatMap((item) => collectCodexTextFragments(item));
|
||||
}
|
||||
|
||||
function extractCompletedAgentMessageText(item: unknown) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
if (record.type !== 'agent_message') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return collectCodexTextFragments(record.text ?? record.content ?? record.message).join('');
|
||||
}
|
||||
|
||||
export function extractCodexStreamText(parsed: Record<string, unknown>) {
|
||||
const type = typeof parsed.type === 'string' ? parsed.type : '';
|
||||
|
||||
if (type === 'item.completed') {
|
||||
const completedText = collectCodexTextFragments(parsed.item).join('');
|
||||
const completedText = extractCompletedAgentMessageText(parsed.item);
|
||||
return {
|
||||
type,
|
||||
completedText,
|
||||
@@ -3186,9 +3200,19 @@ export class ChatService {
|
||||
});
|
||||
|
||||
const progressMessages = buildProgressMessages(request.text);
|
||||
let progressIndex = 0;
|
||||
let progressIndex = progressMessages.length > 1 ? 1 : 0;
|
||||
let lastProgressMessage = progressMessages[0] ?? '';
|
||||
let progressTimer: ReturnType<typeof setInterval> | null = setInterval(() => {
|
||||
const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)];
|
||||
|
||||
if (!nextMessage || nextMessage === lastProgressMessage) {
|
||||
if (progressIndex >= progressMessages.length - 1) {
|
||||
stopProgressTimer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastProgressMessage = nextMessage;
|
||||
chatRuntimeService.appendLog(request.requestId, nextMessage);
|
||||
appendActivityLine(`# 진행: ${nextMessage}`);
|
||||
|
||||
@@ -3199,6 +3223,8 @@ export class ChatService {
|
||||
|
||||
if (progressIndex < progressMessages.length - 1) {
|
||||
progressIndex += 1;
|
||||
} else {
|
||||
stopProgressTimer();
|
||||
}
|
||||
}, 2200);
|
||||
|
||||
|
||||
@@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: {
|
||||
const createdPost = await createBoardPost({
|
||||
title: buildErrorLogBoardPostTitle(candidate),
|
||||
content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd),
|
||||
attachments: [],
|
||||
automationType: 'none',
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user