From 85e04e8a6773016f2445481c532a7de40e629562 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Tue, 28 Apr 2026 22:53:00 +0900 Subject: [PATCH] Use canonical turn progress for dashboard rooms --- src/web-dashboard-data.test.ts | 43 +++------------ src/web-dashboard-data.ts | 76 ++++----------------------- src/web-dashboard-room-routes.test.ts | 20 ++----- src/web-dashboard-room-routes.ts | 5 -- src/web-dashboard-server.test.ts | 2 +- 5 files changed, 22 insertions(+), 124 deletions(-) diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index b4b8143..1b2e826 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -356,7 +356,7 @@ describe('web dashboard room activity data', () => { ]); }); - it('uses Discord live progress messages without exposing them as recent chat', () => { + it('uses canonical turn progress without exposing status messages as recent chat', () => { const task = makePairedTask({ id: 'paired-room-progress', chat_jid: 'dc:ops', @@ -377,7 +377,7 @@ describe('web dashboard room activity data', () => { updated_at: '2026-04-26T06:10:00.000Z', completed_at: null, last_error: null, - progress_text: 'internal progress that Discord never showed', + progress_text: 'building mobile parity', progress_updated_at: '2026-04-26T06:07:00.000Z', }; const statusPrefix = '⁣⁣⁣'; @@ -393,25 +393,12 @@ describe('web dashboard room activity data', () => { is_bot_message: false, message_source_kind: 'human', }, - ]; - const progressMessages: NewMessage[] = [ { - id: 'msg-stale-progress', + id: 'msg-status-progress', chat_jid: 'dc:ops', sender: 'bot-1', sender_name: '오너', - content: `${statusPrefix}stale progress`, - timestamp: '2026-04-26T05:59:00.000Z', - is_from_me: true, - is_bot_message: true, - message_source_kind: 'bot', - }, - { - id: 'msg-live-progress', - chat_jid: 'dc:ops', - sender: 'bot-1', - sender_name: 'clone-코덱스', - content: `${statusPrefix}building mobile parity\n\n12s`, + content: `${statusPrefix}status noise that belongs to display only\n\n12s`, timestamp: '2026-04-26T06:08:00.000Z', is_from_me: true, is_bot_message: true, @@ -436,12 +423,11 @@ describe('web dashboard room activity data', () => { attempts: [], outputs: [], messages, - progressMessages, }); expect(activity.pairedTask?.currentTurn).toMatchObject({ progressText: 'building mobile parity', - progressUpdatedAt: '2026-04-26T06:08:00.000Z', + progressUpdatedAt: '2026-04-26T06:07:00.000Z', }); expect(activity.messages).toEqual([ expect.objectContaining({ @@ -451,7 +437,7 @@ describe('web dashboard room activity data', () => { ]); }); - it('does not show internal turn progress without a Discord-visible status message', () => { + it('does not show active turn placeholders when canonical progress is absent', () => { const task = makePairedTask({ id: 'paired-progress-internal', chat_jid: 'dc:ops', @@ -472,8 +458,8 @@ describe('web dashboard room activity data', () => { updated_at: '2026-04-26T06:10:00.000Z', completed_at: null, last_error: null, - progress_text: 'site-only internal progress', - progress_updated_at: '2026-04-26T06:09:00.000Z', + progress_text: null, + progress_updated_at: null, }; const activity = buildWebDashboardRoomActivity({ @@ -493,19 +479,6 @@ describe('web dashboard room activity data', () => { attempts: [], outputs: [], messages: [], - progressMessages: [ - { - id: 'spoofed-progress', - chat_jid: 'dc:ops', - sender: 'user-1', - sender_name: '눈쟁이', - content: '⁣⁣⁣human spoofed progress', - timestamp: '2026-04-26T06:09:30.000Z', - is_from_me: false, - is_bot_message: false, - message_source_kind: 'human', - }, - ], }); expect(activity.pairedTask?.currentTurn).toMatchObject({ diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index e0b3c03..f4ff650 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -492,78 +492,25 @@ function sanitizeRoomMessage( }; } -/** - * SSOT for live progress: scan the `messages` table for the most recent - * TASK_STATUS_MESSAGE_PREFIX-prefixed message that belongs to the current - * turn (sender role match + timestamp >= turn.created_at). The Discord - * bridge ingests every edit of the bot's live message back into messages, - * so this single source replaces the legacy paired_turns.progress_text - * column for derivation. - */ const TASK_STATUS_PREFIX = '⁣⁣⁣'; function isTaskStatusMessage(message: NewMessage): boolean { return (message.content ?? '').startsWith(TASK_STATUS_PREFIX); } -function turnRoleFromSenderName(name: string | null | undefined): string { - const v = (name ?? '').toLowerCase(); - if (v.includes('오너') || v.includes('owner')) return 'owner'; - if (v.includes('리뷰어') || v.includes('reviewer')) return 'reviewer'; - if (v.includes('중재자') || v.includes('arbiter')) return 'arbiter'; - return 'human'; -} - -function isBotProgressSource(message: NewMessage): boolean { - return ( - !!message.is_bot_message || - !!message.is_from_me || - message.message_source_kind === 'bot' || - message.message_source_kind === 'ipc_injected_bot' || - message.message_source_kind === 'trusted_external_bot' - ); -} - -function deriveLiveProgress( - turnRole: string, - turnCreatedAt: string, - messages: NewMessage[], -): { progressText: string | null; progressUpdatedAt: string | null } { - const startMs = new Date(turnCreatedAt).getTime(); - const targetRole = turnRoleFromSenderName(turnRole); - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - const content = m.content ?? ''; - if (!content.startsWith(TASK_STATUS_PREFIX)) continue; - const ts = m.timestamp ? new Date(m.timestamp).getTime() : 0; - if (Number.isFinite(startMs) && ts < startMs) continue; - const role = turnRoleFromSenderName(m.sender_name); - if (role !== targetRole && (role !== 'human' || !isBotProgressSource(m))) { - continue; - } - const body = content.slice(TASK_STATUS_PREFIX.length); - const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim(); - if (!stripped) continue; - return { progressText: stripped, progressUpdatedAt: m.timestamp }; - } - return { progressText: null, progressUpdatedAt: null }; -} - function sanitizeRoomTurn( turn: PairedTurnRecord, attempt: PairedTurnAttemptRecord | null, - messages: NewMessage[] = [], ): WebDashboardRoomTurn { const role = attempt?.role ?? turn.role; const createdAt = attempt?.created_at ?? turn.created_at; const completedAt = attempt?.completed_at ?? turn.completed_at; - - // Dashboard progress must match Discord-visible progress. The legacy - // paired_turns.progress_text column is written before Discord delivery, so - // using it here can create site-only progress that never appeared in Discord. - const live = - completedAt === null - ? deriveLiveProgress(role, createdAt, messages) + const canonicalProgress = + completedAt === null && turn.progress_text?.trim() + ? { + progressText: buildRoomBody(turn.progress_text), + progressUpdatedAt: turn.progress_updated_at ?? turn.updated_at, + } : { progressText: null, progressUpdatedAt: null }; return { @@ -582,8 +529,8 @@ function sanitizeRoomTurn( (attempt?.last_error ?? turn.last_error) ? buildRoomBody(attempt?.last_error ?? turn.last_error ?? '') : null, - progressText: live.progressText, - progressUpdatedAt: live.progressUpdatedAt, + progressText: canonicalProgress.progressText, + progressUpdatedAt: canonicalProgress.progressUpdatedAt, }; } @@ -612,7 +559,6 @@ export function buildWebDashboardRoomActivity(args: { attempts: PairedTurnAttemptRecord[]; outputs: PairedTurnOutput[]; messages: NewMessage[]; - progressMessages?: NewMessage[]; outputLimit?: number; }): WebDashboardRoomActivity { const latestAttemptByTurnId = new Map(); @@ -655,11 +601,7 @@ export function buildWebDashboardRoomActivity(args: { roundTripCount: args.pairedTask.round_trip_count, updatedAt: args.pairedTask.updated_at, currentTurn: currentTurn - ? sanitizeRoomTurn( - currentTurn, - currentAttempt, - args.progressMessages ?? args.messages, - ) + ? sanitizeRoomTurn(currentTurn, currentAttempt) : null, outputs: args.outputs .slice(-outputLimit) diff --git a/src/web-dashboard-room-routes.test.ts b/src/web-dashboard-room-routes.test.ts index 7066c71..f74b60c 100644 --- a/src/web-dashboard-room-routes.test.ts +++ b/src/web-dashboard-room-routes.test.ts @@ -106,6 +106,8 @@ describe('web dashboard room routes', () => { updated_at: '2026-04-26T05:21:00.000Z', completed_at: null, last_error: null, + progress_text: 'checking current output', + progress_updated_at: '2026-04-26T05:20:00.000Z', }, ]; const attempts: PairedTurnAttemptRecord[] = [ @@ -154,20 +156,6 @@ describe('web dashboard room routes', () => { message_source_kind: 'human', }, ]; - const progressMessages: NewMessage[] = [ - ...messages, - { - id: 'msg-progress', - chat_jid: 'dc:ops', - sender: 'reviewer-bot', - sender_name: '리뷰어', - content: '⁣⁣⁣checking current output\n\n9s', - timestamp: '2026-04-26T05:20:00.000Z', - is_from_me: true, - is_bot_message: true, - message_source_kind: 'bot', - }, - ]; const requestedMessageLimits: number[] = []; const response = handleRoomTimelineRoute({ url: new URL( @@ -182,7 +170,7 @@ describe('web dashboard room routes', () => { loadPairedTurnOutputs: () => outputs, loadRecentChatMessages: (_jid, limit) => { requestedMessageLimits.push(limit ?? 20); - return limit && limit > 8 ? progressMessages : messages; + return messages; }, }), }); @@ -202,7 +190,7 @@ describe('web dashboard room routes', () => { expect(body.pairedTask.outputs).toMatchObject([ { outputText: 'owner final output' }, ]); - expect(requestedMessageLimits).toEqual([8, 40]); + expect(requestedMessageLimits).toEqual([8]); expect(body.messages[0]).toMatchObject({ content: expect.stringContaining('BOT_TOKEN='), senderName: '눈쟁이', diff --git a/src/web-dashboard-room-routes.ts b/src/web-dashboard-room-routes.ts index 8e98b47..f0b7027 100644 --- a/src/web-dashboard-room-routes.ts +++ b/src/web-dashboard-room-routes.ts @@ -98,7 +98,6 @@ function buildRoomActivity( attempts, outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [], messages: deps.loadRecentChatMessages(entry.jid, 8), - progressMessages: deps.loadRecentChatMessages(entry.jid, 40), }); } @@ -129,9 +128,6 @@ export function buildRoomsTimelineResult( for (const [jid, { snapshot, entry }] of uniqueByJid) { const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null; const messages = deps.loadRecentChatMessages(jid, 8); - const progressMessages = pairedTask - ? deps.loadRecentChatMessages(jid, 40) - : messages; const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8); if (!pairedTask && messages.length === 0 && outputs.length === 0) continue; @@ -147,7 +143,6 @@ export function buildRoomsTimelineResult( attempts: [], outputs, messages, - progressMessages, outputLimit: 8, }); } diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index b1ff908..a882465 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -337,7 +337,7 @@ describe('web dashboard server handler', () => { id: 'paired-room-1', roundTripCount: 2, }); - expect(requestedMessageLimits).toEqual([8, 40]); + expect(requestedMessageLimits).toEqual([8]); expect(body.messages).toMatchObject([ { content: '진행 어디까지야?', senderName: '눈쟁이' }, ]);