From 6b2ef423745b4bf563a914675394045b85ee5890 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Wed, 29 Apr 2026 01:23:29 +0900 Subject: [PATCH] fix: use work items as dashboard outbound SSOT --- .../discord-structured-output.test.ts | 7 +- src/channels/discord.ts | 13 +- src/db-work-items.test.ts | 51 +++++++ src/db.ts | 1 + src/db/runtime-memory-messages.ts | 12 ++ src/db/work-items.ts | 134 ++++++++++++----- src/message-runtime-delivery.ts | 30 ++-- src/types.ts | 8 +- src/web-dashboard-data.test.ts | 133 ++++++++++++++++- src/web-dashboard-data.ts | 137 ++++++++++++++++-- src/web-dashboard-room-routes.test.ts | 45 +++++- src/web-dashboard-room-routes.ts | 25 +++- src/web-dashboard-server.test.ts | 2 +- src/web-dashboard-server.ts | 6 + 14 files changed, 523 insertions(+), 81 deletions(-) create mode 100644 src/db-work-items.test.ts diff --git a/src/channels/discord-structured-output.test.ts b/src/channels/discord-structured-output.test.ts index f97c16f..eae847a 100644 --- a/src/channels/discord-structured-output.test.ts +++ b/src/channels/discord-structured-output.test.ts @@ -136,7 +136,7 @@ describe('DiscordChannel structured output', () => { }; clientRef.current.channels.fetch.mockResolvedValue(mockChannel); - await channel.sendMessage( + const result = await channel.sendMessage( 'dc:1234567890123456', JSON.stringify({ ejclaw: { @@ -154,6 +154,11 @@ describe('DiscordChannel structured output', () => { }), ); + expect(result).toMatchObject({ + primaryMessageId: 'discord-message-1', + messageIds: ['discord-message-1'], + visible: true, + }); expect(mockChannel.send).toHaveBeenCalledWith({ content: '라벨 좌측 클리핑 회귀 수정했습니다.', files: [ diff --git a/src/channels/discord.ts b/src/channels/discord.ts index d809096..f79d9b8 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -22,7 +22,7 @@ import { logger } from '../logger.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; -import type { SendMessageOptions } from '../types.js'; +import type { SendMessageOptions, SendMessageResult } from '../types.js'; import { prepareDiscordOutbound } from './discord-outbound.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); @@ -408,7 +408,7 @@ export class DiscordChannel implements Channel { jid: string, text: string, options: SendMessageOptions = {}, - ): Promise { + ): Promise { if (!this.client) { throw new Error('Discord client not initialized'); } @@ -429,7 +429,7 @@ export class DiscordChannel implements Channel { { jid, channelName: this.name }, 'Skipping silent structured Discord outbound message', ); - return; + return { primaryMessageId: null, messageIds: [], visible: false }; } const validation = validateOutboundAttachments(outbound.attachments, { baseDirs: options.attachmentBaseDirs, @@ -478,7 +478,7 @@ export class DiscordChannel implements Channel { { jid, channelName: this.name }, 'Skipping empty Discord outbound message', ); - return; + return { primaryMessageId: null, messageIds: [], visible: false }; } // Split files into batches of MAX_ATTACHMENTS @@ -546,6 +546,11 @@ export class DiscordChannel implements Channel { }, 'Discord message sent', ); + return { + primaryMessageId: sentMessageIds[0] ?? null, + messageIds: sentMessageIds, + visible: chunkCount > 0, + }; } catch (err) { logger.error( { jid, channelName: this.name, err }, diff --git a/src/db-work-items.test.ts b/src/db-work-items.test.ts new file mode 100644 index 0000000..d637ae7 --- /dev/null +++ b/src/db-work-items.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + _initTestDatabase, + createProducedWorkItem, + getOpenWorkItem, + getRecentDeliveredWorkItemsForChat, + markWorkItemDelivered, +} from './db.js'; + +beforeEach(() => { + _initTestDatabase(); +}); + +describe('work item canonical output queue', () => { + it('supersedes stale open work items before creating a newer canonical output', () => { + const stale = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:supersede', + agent_type: 'codex', + service_id: 'codex-main', + delivery_role: 'owner', + start_seq: 1, + end_seq: 2, + result_payload: 'stale owner output', + }); + const fresh = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:supersede', + agent_type: 'codex', + service_id: 'codex-main', + delivery_role: 'owner', + start_seq: 3, + end_seq: 4, + result_payload: 'fresh owner output', + }); + + expect(fresh.id).not.toBe(stale.id); + expect(getOpenWorkItem('dc:supersede', 'codex', 'codex-main')?.id).toBe( + fresh.id, + ); + + markWorkItemDelivered(fresh.id, 'discord-message-id'); + expect(getRecentDeliveredWorkItemsForChat('dc:supersede', 10)).toEqual([ + expect.objectContaining({ + id: fresh.id, + result_payload: 'fresh owner output', + }), + ]); + }); +}); diff --git a/src/db.ts b/src/db.ts index c460b80..34bf8a4 100644 --- a/src/db.ts +++ b/src/db.ts @@ -21,6 +21,7 @@ export { getNewMessagesBySeq, getOpenWorkItem, getOpenWorkItemForChat, + getRecentDeliveredWorkItemsForChat, getRecentChatMessages, getRecentChatMessagesBatch, hasMessage, diff --git a/src/db/runtime-memory-messages.ts b/src/db/runtime-memory-messages.ts index 8bf2a37..da86ae3 100644 --- a/src/db/runtime-memory-messages.ts +++ b/src/db/runtime-memory-messages.ts @@ -38,6 +38,7 @@ import { createProducedWorkItemInDatabase, getOpenWorkItemForChatFromDatabase, getOpenWorkItemFromDatabase, + getRecentDeliveredWorkItemsForChatFromDatabase, markWorkItemDeliveredInDatabase, markWorkItemDeliveryRetryInDatabase, } from './work-items.js'; @@ -283,6 +284,17 @@ export function getOpenWorkItemForChat( ); } +export function getRecentDeliveredWorkItemsForChat( + chatJid: string, + limit: number = 8, +): WorkItem[] { + return getRecentDeliveredWorkItemsForChatFromDatabase( + requireDatabase(), + chatJid, + limit, + ); +} + export function createProducedWorkItem( input: CreateProducedWorkItemInput, ): WorkItem { diff --git a/src/db/work-items.ts b/src/db/work-items.ts index 8a9e51c..468998f 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -8,6 +8,8 @@ import { } from '../role-service-shadow.js'; import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js'; +const SUPERSEDED_WORK_ITEM_ERROR = 'superseded_by_newer_canonical_output'; + export interface WorkItem { id: number; group_folder: string; @@ -239,6 +241,57 @@ export function getOpenWorkItemForChatFromDatabase( return row ? hydrateWorkItemRow(row) : undefined; } +export function getRecentDeliveredWorkItemsForChatFromDatabase( + database: Database, + chatJid: string, + limit: number = 8, +): WorkItem[] { + const rows = database + .prepare( + `SELECT * + FROM work_items + WHERE chat_jid = ? + AND status = 'delivered' + AND (last_error IS NULL OR last_error <> ?) + ORDER BY COALESCE(delivered_at, updated_at, created_at) DESC, id DESC + LIMIT ?`, + ) + .all(chatJid, SUPERSEDED_WORK_ITEM_ERROR, limit) as StoredWorkItemRow[]; + return rows.reverse().map(hydrateWorkItemRow); +} + +function supersedeOpenWorkItemsForCanonicalKey( + database: Database, + input: CreateProducedWorkItemInput, + agentType: AgentType, + serviceId: string, + now: string, +): void { + database + .prepare( + `UPDATE work_items + SET status = 'delivered', + delivered_at = ?, + delivery_message_id = NULL, + last_error = ?, + updated_at = ? + WHERE chat_jid = ? + AND agent_type = ? + AND IFNULL(service_id, '') = IFNULL(?, '') + AND IFNULL(delivery_role, '') = IFNULL(?, '') + AND status IN ('produced', 'delivery_retry')`, + ) + .run( + now, + SUPERSEDED_WORK_ITEM_ERROR, + now, + input.chat_jid, + agentType, + serviceId, + input.delivery_role ?? null, + ); +} + export function createProducedWorkItemInDatabase( database: Database, input: CreateProducedWorkItemInput, @@ -250,46 +303,57 @@ export function createProducedWorkItemInDatabase( deliveryRole: input.delivery_role, serviceId: input.service_id, }); - database - .prepare( - `INSERT INTO work_items ( - group_folder, - chat_jid, - agent_type, - service_id, - delivery_role, - status, - start_seq, - end_seq, - result_payload, - attachment_payload, - delivery_attempts, - created_at, - updated_at - ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`, - ) - .run( - input.group_folder, - input.chat_jid, + return database.transaction(() => { + supersedeOpenWorkItemsForCanonicalKey( + database, + input, agentType, serviceId, - input.delivery_role ?? null, - input.start_seq, - input.end_seq, - input.result_payload, - serializeAttachmentPayload(input.attachments), - now, now, ); - - const lastId = ( - database.prepare('SELECT last_insert_rowid() as id').get() as { id: number } - ).id; - return hydrateWorkItemRow( database - .prepare('SELECT * FROM work_items WHERE id = ?') - .get(lastId) as StoredWorkItemRow, - ); + .prepare( + `INSERT INTO work_items ( + group_folder, + chat_jid, + agent_type, + service_id, + delivery_role, + status, + start_seq, + end_seq, + result_payload, + attachment_payload, + delivery_attempts, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`, + ) + .run( + input.group_folder, + input.chat_jid, + agentType, + serviceId, + input.delivery_role ?? null, + input.start_seq, + input.end_seq, + input.result_payload, + serializeAttachmentPayload(input.attachments), + now, + now, + ); + + const lastId = ( + database.prepare('SELECT last_insert_rowid() as id').get() as { + id: number; + } + ).id; + return hydrateWorkItemRow( + database + .prepare('SELECT * FROM work_items WHERE id = ?') + .get(lastId) as StoredWorkItemRow, + ); + })(); } export function markWorkItemDeliveredInDatabase( diff --git a/src/message-runtime-delivery.ts b/src/message-runtime-delivery.ts index 4d01962..d442ace 100644 --- a/src/message-runtime-delivery.ts +++ b/src/message-runtime-delivery.ts @@ -102,22 +102,20 @@ export async function deliverOpenWorkItem(args: { }), 'Attempting to deliver produced work item as a new message', ); - if (hasAttachments) { - await args.channel.sendMessage( - args.item.chat_jid, - args.item.result_payload, - { - attachmentBaseDirs: args.attachmentBaseDirs, - attachments, - }, - ); - } else { - await args.channel.sendMessage( - args.item.chat_jid, - args.item.result_payload, - ); - } - markWorkItemDelivered(args.item.id); + const sendResult = hasAttachments + ? await args.channel.sendMessage( + args.item.chat_jid, + args.item.result_payload, + { + attachmentBaseDirs: args.attachmentBaseDirs, + attachments, + }, + ) + : await args.channel.sendMessage( + args.item.chat_jid, + args.item.result_payload, + ); + markWorkItemDelivered(args.item.id, sendResult?.primaryMessageId ?? null); args.openContinuation(args.item.chat_jid); args.log.info( buildDeliveryLogContext(args.channel, args.item, { diff --git a/src/types.ts b/src/types.ts index 07f72f6..8233453 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,12 @@ export interface SendMessageOptions { attachmentBaseDirs?: string[]; } +export interface SendMessageResult { + primaryMessageId: string | null; + messageIds: string[]; + visible: boolean; +} + export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedTaskStatus = @@ -255,7 +261,7 @@ export interface Channel { jid: string, text: string, options?: SendMessageOptions, - ): Promise; + ): Promise; isConnected(): boolean; ownsJid(jid: string): boolean; // Optional: whether a stored inbound message was authored by this channel's own bot/user. diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts index 1b2e826..3b3d6f5 100644 --- a/src/web-dashboard-data.test.ts +++ b/src/web-dashboard-data.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { StatusSnapshot } from './status-dashboard.js'; -import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { + PairedTurnAttemptRecord, + PairedTurnRecord, + WorkItem, +} from './db.js'; import type { NewMessage, PairedTask, @@ -65,6 +69,29 @@ function makePairedTask(overrides: Partial): PairedTask { }; } +function makeDeliveredWorkItem(overrides: Partial = {}): WorkItem { + return { + id: 100, + group_folder: 'ops', + chat_jid: 'dc:ops', + agent_type: 'codex', + service_id: 'codex-main', + delivery_role: 'owner', + status: 'delivered', + start_seq: null, + end_seq: null, + result_payload: 'TASK_DONE\n\ncanonical delivered output', + attachments: [], + delivery_attempts: 1, + delivery_message_id: 'discord-msg-100', + last_error: null, + created_at: '2026-04-26T05:30:00.000Z', + updated_at: '2026-04-26T05:30:10.000Z', + delivered_at: '2026-04-26T05:30:10.000Z', + ...overrides, + }; +} + describe('web dashboard data', () => { it('builds overview counts from status snapshots and scheduled tasks', () => { const snapshots: StatusSnapshot[] = [ @@ -356,6 +383,110 @@ describe('web dashboard room activity data', () => { ]); }); + it('uses delivered work items as the visible outbound source when supplied', () => { + const task = makePairedTask({ + id: 'paired-canonical-outbound', + chat_jid: 'dc:ops', + status: 'in_review', + updated_at: '2026-04-26T05:30:00.000Z', + }); + const output: PairedTurnOutput = { + id: 22, + task_id: task.id, + turn_number: 2, + role: 'owner', + output_text: 'TASK_DONE\n\nexecution artifact only', + verdict: 'task_done', + created_at: '2026-04-26T05:30:00.000Z', + }; + + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + pairedTask: task, + turns: [], + attempts: [], + outputs: [output], + outboundItems: [makeDeliveredWorkItem()], + messages: [ + { + id: 'discord-msg-100', + chat_jid: 'dc:ops', + sender: 'bot-owner', + sender_name: '오너', + content: 'TASK_DONE\n\ncanonical delivered output', + timestamp: '2026-04-26T05:30:10.000Z', + is_from_me: true, + is_bot_message: true, + message_source_kind: 'bot', + }, + ], + }); + + expect(activity.pairedTask?.outputs).toEqual([]); + expect(activity.messages).toEqual([ + expect.objectContaining({ + id: 'work:100', + senderName: 'owner', + content: 'TASK_DONE\n\ncanonical delivered output', + }), + ]); + }); + + it('merges canonical outbound and recent messages in chronological order', () => { + const activity = buildWebDashboardRoomActivity({ + serviceId: 'codex-main', + entry: { + jid: 'dc:ops', + name: '#ops', + folder: 'ops', + agentType: 'codex', + status: 'inactive', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }, + pairedTask: null, + turns: [], + attempts: [], + outputs: [], + outboundItems: [ + makeDeliveredWorkItem({ + id: 101, + delivered_at: '2026-04-26T05:30:00.000Z', + result_payload: 'owner canonical', + }), + ], + messages: [ + { + id: 'human-after', + chat_jid: 'dc:ops', + sender: 'user-1', + sender_name: '눈쟁이', + content: 'human after', + timestamp: '2026-04-26T05:31:00.000Z', + is_from_me: false, + is_bot_message: false, + message_source_kind: 'human', + }, + ], + }); + + expect(activity.messages.map((message) => message.id)).toEqual([ + 'work:101', + 'human-after', + ]); + }); + it('uses canonical turn progress without exposing status messages as recent chat', () => { const task = makePairedTask({ id: 'paired-room-progress', diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts index f4ff650..5d0785e 100644 --- a/src/web-dashboard-data.ts +++ b/src/web-dashboard-data.ts @@ -3,7 +3,11 @@ import { normalizeEjclawStructuredOutput, } from './agent-protocol.js'; import { isWatchCiTask } from './task-watch-status.js'; -import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { + PairedTurnAttemptRecord, + PairedTurnRecord, + WorkItem, +} from './db.js'; import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js'; import type { NewMessage, @@ -492,6 +496,95 @@ function sanitizeRoomMessage( }; } +function roleSenderName(role: WorkItem['delivery_role']): string { + return role ?? 'system'; +} + +function sanitizeCanonicalOutboundMessage( + item: WorkItem, +): WebDashboardRoomMessage | null { + if (item.status !== 'delivered') return null; + const normalized = normalizeStructuredVisibleContent(item.result_payload); + if (normalized.text === null) return null; + + return { + id: `work:${item.id}`, + sender: `work-item:${item.delivery_role ?? 'system'}`, + senderName: roleSenderName(item.delivery_role ?? null), + content: buildRoomBody(normalized.text), + attachments: + normalized.attachments.length > 0 + ? normalized.attachments + : (item.attachments ?? []), + timestamp: item.delivered_at ?? item.updated_at, + isFromMe: false, + isBotMessage: true, + sourceKind: 'bot', + }; +} + +function normalizeForOutboundDedupe(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function senderMatchesCanonicalOutbound( + messageSender: string, + outboundSender: string, +): boolean { + const message = messageSender.trim().toLowerCase(); + const outbound = outboundSender.trim().toLowerCase(); + if (message === outbound) return true; + const aliases: Record = { + owner: ['owner', '오너'], + reviewer: ['reviewer', '리뷰어'], + arbiter: ['arbiter', '중재자'], + system: ['system', '시스템'], + }; + return (aliases[outbound] ?? [outbound]).includes(message); +} + +function isDuplicateOfCanonicalOutbound( + message: WebDashboardRoomMessage, + canonical: WebDashboardRoomMessage[], +): boolean { + if (!message.isBotMessage) return false; + const messageText = normalizeForOutboundDedupe(message.content); + if (!messageText) return false; + const messageTime = new Date(message.timestamp).getTime(); + + return canonical.some((outbound) => { + if ( + !senderMatchesCanonicalOutbound(message.senderName, outbound.senderName) + ) { + return false; + } + const outboundText = normalizeForOutboundDedupe(outbound.content); + if (!outboundText) return false; + const contentMatches = + outboundText === messageText || + outboundText.startsWith(messageText) || + messageText.startsWith(outboundText) || + outboundText.includes(messageText) || + messageText.includes(outboundText); + if (!contentMatches) return false; + const outboundTime = new Date(outbound.timestamp).getTime(); + if (!Number.isFinite(messageTime) || !Number.isFinite(outboundTime)) { + return true; + } + return Math.abs(outboundTime - messageTime) <= 120_000; + }); +} + +function compareRoomMessagesByTimestamp( + a: WebDashboardRoomMessage, + b: WebDashboardRoomMessage, +): number { + const aTime = new Date(a.timestamp).getTime(); + const bTime = new Date(b.timestamp).getTime(); + if (!Number.isFinite(aTime) || !Number.isFinite(bTime)) return 0; + return aTime - bTime; +} + const TASK_STATUS_PREFIX = '⁣⁣⁣'; function isTaskStatusMessage(message: NewMessage): boolean { @@ -558,6 +651,7 @@ export function buildWebDashboardRoomActivity(args: { turns: PairedTurnRecord[]; attempts: PairedTurnAttemptRecord[]; outputs: PairedTurnOutput[]; + outboundItems?: WorkItem[]; messages: NewMessage[]; outputLimit?: number; }): WebDashboardRoomActivity { @@ -576,6 +670,24 @@ export function buildWebDashboardRoomActivity(args: { ? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null) : null; const outputLimit = args.outputLimit ?? 4; + const canonicalOutboundMessages = (args.outboundItems ?? []) + .map(sanitizeCanonicalOutboundMessage) + .filter((message): message is WebDashboardRoomMessage => Boolean(message)); + const canonicalDeliveryMessageIds = new Set( + (args.outboundItems ?? []) + .map((item) => item.delivery_message_id) + .filter((id): id is string => Boolean(id)), + ); + const recentMessages = args.messages + .filter((message) => !canonicalDeliveryMessageIds.has(message.id)) + .filter((message) => !isTaskStatusMessage(message)) + .map(sanitizeRoomMessage) + .filter((message): message is WebDashboardRoomMessage => Boolean(message)) + .filter( + (message) => + !isDuplicateOfCanonicalOutbound(message, canonicalOutboundMessages), + ); + const shouldExposeExecutionOutputs = args.outboundItems === undefined; return { serviceId: args.serviceId, @@ -587,12 +699,9 @@ export function buildWebDashboardRoomActivity(args: { elapsedMs: args.entry.elapsedMs, pendingMessages: args.entry.pendingMessages, pendingTasks: args.entry.pendingTasks, - messages: args.messages - .filter((message) => !isTaskStatusMessage(message)) - .map(sanitizeRoomMessage) - .filter((message): message is WebDashboardRoomMessage => - Boolean(message), - ), + messages: [...recentMessages, ...canonicalOutboundMessages].sort( + compareRoomMessagesByTimestamp, + ), pairedTask: args.pairedTask ? { id: args.pairedTask.id, @@ -603,12 +712,14 @@ export function buildWebDashboardRoomActivity(args: { currentTurn: currentTurn ? sanitizeRoomTurn(currentTurn, currentAttempt) : null, - outputs: args.outputs - .slice(-outputLimit) - .map(sanitizeRoomTurnOutput) - .filter((output): output is WebDashboardRoomTurnOutput => - Boolean(output), - ), + outputs: shouldExposeExecutionOutputs + ? args.outputs + .slice(-outputLimit) + .map(sanitizeRoomTurnOutput) + .filter((output): output is WebDashboardRoomTurnOutput => + Boolean(output), + ) + : [], } : null, }; diff --git a/src/web-dashboard-room-routes.test.ts b/src/web-dashboard-room-routes.test.ts index f74b60c..94b30c4 100644 --- a/src/web-dashboard-room-routes.test.ts +++ b/src/web-dashboard-room-routes.test.ts @@ -2,7 +2,11 @@ import { gunzipSync } from 'node:zlib'; import { describe, expect, it } from 'vitest'; -import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { + PairedTurnAttemptRecord, + PairedTurnRecord, + WorkItem, +} from './db.js'; import type { StatusSnapshot } from './status-dashboard.js'; import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; import { @@ -83,11 +87,35 @@ function roomDeps( loadPairedTurnAttempts: () => [], loadPairedTurnOutputs: () => [], loadRecentPairedTurnOutputsForChat: () => [], + loadRecentDeliveredWorkItemsForChat: () => [], loadRecentChatMessages: () => [], ...overrides, }; } +function workItem(overrides: Partial = {}): WorkItem { + return { + id: 9001, + group_folder: 'ops-room', + chat_jid: 'dc:ops', + agent_type: 'codex', + service_id: 'codex-main', + delivery_role: 'owner', + status: 'delivered', + start_seq: null, + end_seq: null, + result_payload: 'owner final output', + attachments: [], + delivery_attempts: 1, + delivery_message_id: 'discord-owner-final', + last_error: null, + created_at: '2026-04-26T05:18:00.000Z', + updated_at: '2026-04-26T05:18:30.000Z', + delivered_at: '2026-04-26T05:18:30.000Z', + ...overrides, + }; +} + describe('web dashboard room routes', () => { it('serves room timeline with paired progress and recent messages', async () => { const pairedTask = makePairedTask(); @@ -168,6 +196,7 @@ describe('web dashboard room routes', () => { loadPairedTurnsForTask: () => turns, loadPairedTurnAttempts: () => attempts, loadPairedTurnOutputs: () => outputs, + loadRecentDeliveredWorkItemsForChat: () => [workItem()], loadRecentChatMessages: (_jid, limit) => { requestedMessageLimits.push(limit ?? 20); return messages; @@ -187,10 +216,18 @@ describe('web dashboard room routes', () => { lastError: 'OPENAI_API_KEY=', progressText: 'checking current output', }); - expect(body.pairedTask.outputs).toMatchObject([ - { outputText: 'owner final output' }, - ]); + expect(body.pairedTask.outputs).toEqual([]); expect(requestedMessageLimits).toEqual([8]); + expect(body.messages).toEqual([ + expect.objectContaining({ + content: expect.stringContaining('BOT_TOKEN='), + senderName: '눈쟁이', + }), + expect.objectContaining({ + content: 'owner final output', + senderName: 'owner', + }), + ]); 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 f0b7027..445703c 100644 --- a/src/web-dashboard-room-routes.ts +++ b/src/web-dashboard-room-routes.ts @@ -1,6 +1,10 @@ import { gzipSync } from 'node:zlib'; -import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; +import type { + PairedTurnAttemptRecord, + PairedTurnRecord, + WorkItem, +} from './db.js'; import type { StatusSnapshot } from './status-dashboard.js'; import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; import { @@ -30,6 +34,10 @@ export interface RoomsTimelineRouteDependencies { chatJid: string, limit: number, ) => PairedTurnOutput[]; + loadRecentDeliveredWorkItemsForChat: ( + chatJid: string, + limit: number, + ) => WorkItem[]; loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[]; } @@ -97,6 +105,7 @@ function buildRoomActivity( turns, attempts, outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [], + outboundItems: deps.loadRecentDeliveredWorkItemsForChat(entry.jid, 12), messages: deps.loadRecentChatMessages(entry.jid, 8), }); } @@ -129,7 +138,15 @@ export function buildRoomsTimelineResult( const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null; const messages = deps.loadRecentChatMessages(jid, 8); const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8); - if (!pairedTask && messages.length === 0 && outputs.length === 0) continue; + const outboundItems = deps.loadRecentDeliveredWorkItemsForChat(jid, 8); + if ( + !pairedTask && + messages.length === 0 && + outputs.length === 0 && + outboundItems.length === 0 + ) { + continue; + } const latestTurn = pairedTask ? deps.loadLatestPairedTurnForTask(pairedTask.id) @@ -142,6 +159,7 @@ export function buildRoomsTimelineResult( turns, attempts: [], outputs, + outboundItems, messages, outputLimit: 8, }); @@ -161,9 +179,6 @@ export function ensureRoomsTimelineCache( deps: RoomsTimelineRouteDependencies, ): RoomsTimelineCache { const key = computeRoomsCacheKey(deps); - if (roomsTimelineCache && roomsTimelineCache.key === key) { - return roomsTimelineCache; - } const result = buildRoomsTimelineResult(deps); const rawJson = JSON.stringify(result); const gzipBuffer = gzipSync(new TextEncoder().encode(rawJson)); diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index a882465..87902db 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -313,13 +313,13 @@ describe('web dashboard server handler', () => { getPairedTurnAttempts: () => [], getPairedTurnOutputs: () => [], getRecentPairedTurnOutputsForChat: () => [], + getRecentDeliveredWorkItemsForChat: () => [], getRecentChatMessages: (_jid, limit) => { requestedMessageLimits.push(limit ?? 20); return messages; }, startBackgroundCacheRefresh: false, }); - const response = await handler( new Request( `http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`, diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index c12354c..cad2111 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -15,6 +15,7 @@ import { getRecentPairedTurnOutputsForChat, getPairedTurnsForTask, getLatestPairedTurnForTask, + getRecentDeliveredWorkItemsForChat, getRecentChatMessages, getRecentChatMessagesBatch, getTaskById, @@ -111,6 +112,7 @@ export interface WebDashboardHandlerOptions { getPairedTurnAttempts?: typeof getPairedTurnAttempts; getPairedTurnOutputs?: typeof getPairedTurnOutputs; getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat; + getRecentDeliveredWorkItemsForChat?: typeof getRecentDeliveredWorkItemsForChat; getRecentChatMessages?: typeof getRecentChatMessages; getPairedTaskById?: (id: string) => PairedTask | undefined; updatePairedTaskIfUnchanged?: ( @@ -310,6 +312,9 @@ export function createWebDashboardHandler( opts.getPairedTurnOutputs ?? getPairedTurnOutputs; const loadRecentPairedTurnOutputsForChat = opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat; + const loadRecentDeliveredWorkItemsForChat = + opts.getRecentDeliveredWorkItemsForChat ?? + getRecentDeliveredWorkItemsForChat; const loadPairedTurnAttempts = opts.getPairedTurnAttempts ?? getPairedTurnAttempts; const loadRecentChatMessages = @@ -335,6 +340,7 @@ export function createWebDashboardHandler( loadPairedTurnAttempts, loadPairedTurnOutputs, loadRecentPairedTurnOutputsForChat, + loadRecentDeliveredWorkItemsForChat, loadRecentChatMessages, };