fix: use work items as dashboard outbound SSOT

This commit is contained in:
ejclaw
2026-04-29 01:23:29 +09:00
parent 9655501bdf
commit 6b2ef42374
14 changed files with 523 additions and 81 deletions

View File

@@ -136,7 +136,7 @@ describe('DiscordChannel structured output', () => {
}; };
clientRef.current.channels.fetch.mockResolvedValue(mockChannel); clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage( const result = await channel.sendMessage(
'dc:1234567890123456', 'dc:1234567890123456',
JSON.stringify({ JSON.stringify({
ejclaw: { 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({ expect(mockChannel.send).toHaveBeenCalledWith({
content: '라벨 좌측 클리핑 회귀 수정했습니다.', content: '라벨 좌측 클리핑 회귀 수정했습니다.',
files: [ files: [

View File

@@ -22,7 +22,7 @@ import { logger } from '../logger.js';
import { validateOutboundAttachments } from '../outbound-attachments.js'; import { validateOutboundAttachments } from '../outbound-attachments.js';
import { formatOutbound } from '../router.js'; import { formatOutbound } from '../router.js';
import { hasReviewerLease } from '../service-routing.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'; import { prepareDiscordOutbound } from './discord-outbound.js';
const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments');
@@ -408,7 +408,7 @@ export class DiscordChannel implements Channel {
jid: string, jid: string,
text: string, text: string,
options: SendMessageOptions = {}, options: SendMessageOptions = {},
): Promise<void> { ): Promise<SendMessageResult> {
if (!this.client) { if (!this.client) {
throw new Error('Discord client not initialized'); throw new Error('Discord client not initialized');
} }
@@ -429,7 +429,7 @@ export class DiscordChannel implements Channel {
{ jid, channelName: this.name }, { jid, channelName: this.name },
'Skipping silent structured Discord outbound message', 'Skipping silent structured Discord outbound message',
); );
return; return { primaryMessageId: null, messageIds: [], visible: false };
} }
const validation = validateOutboundAttachments(outbound.attachments, { const validation = validateOutboundAttachments(outbound.attachments, {
baseDirs: options.attachmentBaseDirs, baseDirs: options.attachmentBaseDirs,
@@ -478,7 +478,7 @@ export class DiscordChannel implements Channel {
{ jid, channelName: this.name }, { jid, channelName: this.name },
'Skipping empty Discord outbound message', 'Skipping empty Discord outbound message',
); );
return; return { primaryMessageId: null, messageIds: [], visible: false };
} }
// Split files into batches of MAX_ATTACHMENTS // Split files into batches of MAX_ATTACHMENTS
@@ -546,6 +546,11 @@ export class DiscordChannel implements Channel {
}, },
'Discord message sent', 'Discord message sent',
); );
return {
primaryMessageId: sentMessageIds[0] ?? null,
messageIds: sentMessageIds,
visible: chunkCount > 0,
};
} catch (err) { } catch (err) {
logger.error( logger.error(
{ jid, channelName: this.name, err }, { jid, channelName: this.name, err },

51
src/db-work-items.test.ts Normal file
View File

@@ -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',
}),
]);
});
});

View File

@@ -21,6 +21,7 @@ export {
getNewMessagesBySeq, getNewMessagesBySeq,
getOpenWorkItem, getOpenWorkItem,
getOpenWorkItemForChat, getOpenWorkItemForChat,
getRecentDeliveredWorkItemsForChat,
getRecentChatMessages, getRecentChatMessages,
getRecentChatMessagesBatch, getRecentChatMessagesBatch,
hasMessage, hasMessage,

View File

@@ -38,6 +38,7 @@ import {
createProducedWorkItemInDatabase, createProducedWorkItemInDatabase,
getOpenWorkItemForChatFromDatabase, getOpenWorkItemForChatFromDatabase,
getOpenWorkItemFromDatabase, getOpenWorkItemFromDatabase,
getRecentDeliveredWorkItemsForChatFromDatabase,
markWorkItemDeliveredInDatabase, markWorkItemDeliveredInDatabase,
markWorkItemDeliveryRetryInDatabase, markWorkItemDeliveryRetryInDatabase,
} from './work-items.js'; } 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( export function createProducedWorkItem(
input: CreateProducedWorkItemInput, input: CreateProducedWorkItemInput,
): WorkItem { ): WorkItem {

View File

@@ -8,6 +8,8 @@ import {
} from '../role-service-shadow.js'; } from '../role-service-shadow.js';
import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js'; import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js';
const SUPERSEDED_WORK_ITEM_ERROR = 'superseded_by_newer_canonical_output';
export interface WorkItem { export interface WorkItem {
id: number; id: number;
group_folder: string; group_folder: string;
@@ -239,6 +241,57 @@ export function getOpenWorkItemForChatFromDatabase(
return row ? hydrateWorkItemRow(row) : undefined; 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( export function createProducedWorkItemInDatabase(
database: Database, database: Database,
input: CreateProducedWorkItemInput, input: CreateProducedWorkItemInput,
@@ -250,46 +303,57 @@ export function createProducedWorkItemInDatabase(
deliveryRole: input.delivery_role, deliveryRole: input.delivery_role,
serviceId: input.service_id, serviceId: input.service_id,
}); });
database return database.transaction(() => {
.prepare( supersedeOpenWorkItemsForCanonicalKey(
`INSERT INTO work_items ( database,
group_folder, input,
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, agentType,
serviceId, serviceId,
input.delivery_role ?? null,
input.start_seq,
input.end_seq,
input.result_payload,
serializeAttachmentPayload(input.attachments),
now,
now, now,
); );
const lastId = (
database.prepare('SELECT last_insert_rowid() as id').get() as { id: number }
).id;
return hydrateWorkItemRow(
database database
.prepare('SELECT * FROM work_items WHERE id = ?') .prepare(
.get(lastId) as StoredWorkItemRow, `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( export function markWorkItemDeliveredInDatabase(

View File

@@ -102,22 +102,20 @@ export async function deliverOpenWorkItem(args: {
}), }),
'Attempting to deliver produced work item as a new message', 'Attempting to deliver produced work item as a new message',
); );
if (hasAttachments) { const sendResult = hasAttachments
await args.channel.sendMessage( ? await args.channel.sendMessage(
args.item.chat_jid, args.item.chat_jid,
args.item.result_payload, args.item.result_payload,
{ {
attachmentBaseDirs: args.attachmentBaseDirs, attachmentBaseDirs: args.attachmentBaseDirs,
attachments, attachments,
}, },
); )
} else { : await args.channel.sendMessage(
await args.channel.sendMessage( args.item.chat_jid,
args.item.chat_jid, args.item.result_payload,
args.item.result_payload, );
); markWorkItemDelivered(args.item.id, sendResult?.primaryMessageId ?? null);
}
markWorkItemDelivered(args.item.id);
args.openContinuation(args.item.chat_jid); args.openContinuation(args.item.chat_jid);
args.log.info( args.log.info(
buildDeliveryLogContext(args.channel, args.item, { buildDeliveryLogContext(args.channel, args.item, {

View File

@@ -42,6 +42,12 @@ export interface SendMessageOptions {
attachmentBaseDirs?: string[]; attachmentBaseDirs?: string[];
} }
export interface SendMessageResult {
primaryMessageId: string | null;
messageIds: string[];
visible: boolean;
}
export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter';
export type PairedTaskStatus = export type PairedTaskStatus =
@@ -255,7 +261,7 @@ export interface Channel {
jid: string, jid: string,
text: string, text: string,
options?: SendMessageOptions, options?: SendMessageOptions,
): Promise<void>; ): Promise<SendMessageResult | void>;
isConnected(): boolean; isConnected(): boolean;
ownsJid(jid: string): boolean; ownsJid(jid: string): boolean;
// Optional: whether a stored inbound message was authored by this channel's own bot/user. // Optional: whether a stored inbound message was authored by this channel's own bot/user.

View File

@@ -1,7 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { StatusSnapshot } from './status-dashboard.js'; import type { StatusSnapshot } from './status-dashboard.js';
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; import type {
PairedTurnAttemptRecord,
PairedTurnRecord,
WorkItem,
} from './db.js';
import type { import type {
NewMessage, NewMessage,
PairedTask, PairedTask,
@@ -65,6 +69,29 @@ function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
}; };
} }
function makeDeliveredWorkItem(overrides: Partial<WorkItem> = {}): 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', () => { describe('web dashboard data', () => {
it('builds overview counts from status snapshots and scheduled tasks', () => { it('builds overview counts from status snapshots and scheduled tasks', () => {
const snapshots: StatusSnapshot[] = [ 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', () => { it('uses canonical turn progress without exposing status messages as recent chat', () => {
const task = makePairedTask({ const task = makePairedTask({
id: 'paired-room-progress', id: 'paired-room-progress',

View File

@@ -3,7 +3,11 @@ import {
normalizeEjclawStructuredOutput, normalizeEjclawStructuredOutput,
} from './agent-protocol.js'; } from './agent-protocol.js';
import { isWatchCiTask } from './task-watch-status.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 { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
import type { import type {
NewMessage, 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<string, string[]> = {
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 = ''; const TASK_STATUS_PREFIX = '';
function isTaskStatusMessage(message: NewMessage): boolean { function isTaskStatusMessage(message: NewMessage): boolean {
@@ -558,6 +651,7 @@ export function buildWebDashboardRoomActivity(args: {
turns: PairedTurnRecord[]; turns: PairedTurnRecord[];
attempts: PairedTurnAttemptRecord[]; attempts: PairedTurnAttemptRecord[];
outputs: PairedTurnOutput[]; outputs: PairedTurnOutput[];
outboundItems?: WorkItem[];
messages: NewMessage[]; messages: NewMessage[];
outputLimit?: number; outputLimit?: number;
}): WebDashboardRoomActivity { }): WebDashboardRoomActivity {
@@ -576,6 +670,24 @@ export function buildWebDashboardRoomActivity(args: {
? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null) ? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null)
: null; : null;
const outputLimit = args.outputLimit ?? 4; 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 { return {
serviceId: args.serviceId, serviceId: args.serviceId,
@@ -587,12 +699,9 @@ export function buildWebDashboardRoomActivity(args: {
elapsedMs: args.entry.elapsedMs, elapsedMs: args.entry.elapsedMs,
pendingMessages: args.entry.pendingMessages, pendingMessages: args.entry.pendingMessages,
pendingTasks: args.entry.pendingTasks, pendingTasks: args.entry.pendingTasks,
messages: args.messages messages: [...recentMessages, ...canonicalOutboundMessages].sort(
.filter((message) => !isTaskStatusMessage(message)) compareRoomMessagesByTimestamp,
.map(sanitizeRoomMessage) ),
.filter((message): message is WebDashboardRoomMessage =>
Boolean(message),
),
pairedTask: args.pairedTask pairedTask: args.pairedTask
? { ? {
id: args.pairedTask.id, id: args.pairedTask.id,
@@ -603,12 +712,14 @@ export function buildWebDashboardRoomActivity(args: {
currentTurn: currentTurn currentTurn: currentTurn
? sanitizeRoomTurn(currentTurn, currentAttempt) ? sanitizeRoomTurn(currentTurn, currentAttempt)
: null, : null,
outputs: args.outputs outputs: shouldExposeExecutionOutputs
.slice(-outputLimit) ? args.outputs
.map(sanitizeRoomTurnOutput) .slice(-outputLimit)
.filter((output): output is WebDashboardRoomTurnOutput => .map(sanitizeRoomTurnOutput)
Boolean(output), .filter((output): output is WebDashboardRoomTurnOutput =>
), Boolean(output),
)
: [],
} }
: null, : null,
}; };

View File

@@ -2,7 +2,11 @@ import { gunzipSync } from 'node:zlib';
import { describe, expect, it } from 'vitest'; 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 { StatusSnapshot } from './status-dashboard.js';
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
import { import {
@@ -83,11 +87,35 @@ function roomDeps(
loadPairedTurnAttempts: () => [], loadPairedTurnAttempts: () => [],
loadPairedTurnOutputs: () => [], loadPairedTurnOutputs: () => [],
loadRecentPairedTurnOutputsForChat: () => [], loadRecentPairedTurnOutputsForChat: () => [],
loadRecentDeliveredWorkItemsForChat: () => [],
loadRecentChatMessages: () => [], loadRecentChatMessages: () => [],
...overrides, ...overrides,
}; };
} }
function workItem(overrides: Partial<WorkItem> = {}): 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', () => { describe('web dashboard room routes', () => {
it('serves room timeline with paired progress and recent messages', async () => { it('serves room timeline with paired progress and recent messages', async () => {
const pairedTask = makePairedTask(); const pairedTask = makePairedTask();
@@ -168,6 +196,7 @@ describe('web dashboard room routes', () => {
loadPairedTurnsForTask: () => turns, loadPairedTurnsForTask: () => turns,
loadPairedTurnAttempts: () => attempts, loadPairedTurnAttempts: () => attempts,
loadPairedTurnOutputs: () => outputs, loadPairedTurnOutputs: () => outputs,
loadRecentDeliveredWorkItemsForChat: () => [workItem()],
loadRecentChatMessages: (_jid, limit) => { loadRecentChatMessages: (_jid, limit) => {
requestedMessageLimits.push(limit ?? 20); requestedMessageLimits.push(limit ?? 20);
return messages; return messages;
@@ -187,10 +216,18 @@ describe('web dashboard room routes', () => {
lastError: 'OPENAI_API_KEY=<redacted>', lastError: 'OPENAI_API_KEY=<redacted>',
progressText: 'checking current output', progressText: 'checking current output',
}); });
expect(body.pairedTask.outputs).toMatchObject([ expect(body.pairedTask.outputs).toEqual([]);
{ outputText: 'owner final output' },
]);
expect(requestedMessageLimits).toEqual([8]); expect(requestedMessageLimits).toEqual([8]);
expect(body.messages).toEqual([
expect.objectContaining({
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
senderName: '눈쟁이',
}),
expect.objectContaining({
content: 'owner final output',
senderName: 'owner',
}),
]);
expect(body.messages[0]).toMatchObject({ expect(body.messages[0]).toMatchObject({
content: expect.stringContaining('BOT_TOKEN=<redacted>'), content: expect.stringContaining('BOT_TOKEN=<redacted>'),
senderName: '눈쟁이', senderName: '눈쟁이',

View File

@@ -1,6 +1,10 @@
import { gzipSync } from 'node:zlib'; 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 { StatusSnapshot } from './status-dashboard.js';
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js'; import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
import { import {
@@ -30,6 +34,10 @@ export interface RoomsTimelineRouteDependencies {
chatJid: string, chatJid: string,
limit: number, limit: number,
) => PairedTurnOutput[]; ) => PairedTurnOutput[];
loadRecentDeliveredWorkItemsForChat: (
chatJid: string,
limit: number,
) => WorkItem[];
loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[]; loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[];
} }
@@ -97,6 +105,7 @@ function buildRoomActivity(
turns, turns,
attempts, attempts,
outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [], outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [],
outboundItems: deps.loadRecentDeliveredWorkItemsForChat(entry.jid, 12),
messages: deps.loadRecentChatMessages(entry.jid, 8), messages: deps.loadRecentChatMessages(entry.jid, 8),
}); });
} }
@@ -129,7 +138,15 @@ export function buildRoomsTimelineResult(
const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null; const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null;
const messages = deps.loadRecentChatMessages(jid, 8); const messages = deps.loadRecentChatMessages(jid, 8);
const outputs = deps.loadRecentPairedTurnOutputsForChat(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 const latestTurn = pairedTask
? deps.loadLatestPairedTurnForTask(pairedTask.id) ? deps.loadLatestPairedTurnForTask(pairedTask.id)
@@ -142,6 +159,7 @@ export function buildRoomsTimelineResult(
turns, turns,
attempts: [], attempts: [],
outputs, outputs,
outboundItems,
messages, messages,
outputLimit: 8, outputLimit: 8,
}); });
@@ -161,9 +179,6 @@ export function ensureRoomsTimelineCache(
deps: RoomsTimelineRouteDependencies, deps: RoomsTimelineRouteDependencies,
): RoomsTimelineCache { ): RoomsTimelineCache {
const key = computeRoomsCacheKey(deps); const key = computeRoomsCacheKey(deps);
if (roomsTimelineCache && roomsTimelineCache.key === key) {
return roomsTimelineCache;
}
const result = buildRoomsTimelineResult(deps); const result = buildRoomsTimelineResult(deps);
const rawJson = JSON.stringify(result); const rawJson = JSON.stringify(result);
const gzipBuffer = gzipSync(new TextEncoder().encode(rawJson)); const gzipBuffer = gzipSync(new TextEncoder().encode(rawJson));

View File

@@ -313,13 +313,13 @@ describe('web dashboard server handler', () => {
getPairedTurnAttempts: () => [], getPairedTurnAttempts: () => [],
getPairedTurnOutputs: () => [], getPairedTurnOutputs: () => [],
getRecentPairedTurnOutputsForChat: () => [], getRecentPairedTurnOutputsForChat: () => [],
getRecentDeliveredWorkItemsForChat: () => [],
getRecentChatMessages: (_jid, limit) => { getRecentChatMessages: (_jid, limit) => {
requestedMessageLimits.push(limit ?? 20); requestedMessageLimits.push(limit ?? 20);
return messages; return messages;
}, },
startBackgroundCacheRefresh: false, startBackgroundCacheRefresh: false,
}); });
const response = await handler( const response = await handler(
new Request( new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`, `http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`,

View File

@@ -15,6 +15,7 @@ import {
getRecentPairedTurnOutputsForChat, getRecentPairedTurnOutputsForChat,
getPairedTurnsForTask, getPairedTurnsForTask,
getLatestPairedTurnForTask, getLatestPairedTurnForTask,
getRecentDeliveredWorkItemsForChat,
getRecentChatMessages, getRecentChatMessages,
getRecentChatMessagesBatch, getRecentChatMessagesBatch,
getTaskById, getTaskById,
@@ -111,6 +112,7 @@ export interface WebDashboardHandlerOptions {
getPairedTurnAttempts?: typeof getPairedTurnAttempts; getPairedTurnAttempts?: typeof getPairedTurnAttempts;
getPairedTurnOutputs?: typeof getPairedTurnOutputs; getPairedTurnOutputs?: typeof getPairedTurnOutputs;
getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat; getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat;
getRecentDeliveredWorkItemsForChat?: typeof getRecentDeliveredWorkItemsForChat;
getRecentChatMessages?: typeof getRecentChatMessages; getRecentChatMessages?: typeof getRecentChatMessages;
getPairedTaskById?: (id: string) => PairedTask | undefined; getPairedTaskById?: (id: string) => PairedTask | undefined;
updatePairedTaskIfUnchanged?: ( updatePairedTaskIfUnchanged?: (
@@ -310,6 +312,9 @@ export function createWebDashboardHandler(
opts.getPairedTurnOutputs ?? getPairedTurnOutputs; opts.getPairedTurnOutputs ?? getPairedTurnOutputs;
const loadRecentPairedTurnOutputsForChat = const loadRecentPairedTurnOutputsForChat =
opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat; opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat;
const loadRecentDeliveredWorkItemsForChat =
opts.getRecentDeliveredWorkItemsForChat ??
getRecentDeliveredWorkItemsForChat;
const loadPairedTurnAttempts = const loadPairedTurnAttempts =
opts.getPairedTurnAttempts ?? getPairedTurnAttempts; opts.getPairedTurnAttempts ?? getPairedTurnAttempts;
const loadRecentChatMessages = const loadRecentChatMessages =
@@ -335,6 +340,7 @@ export function createWebDashboardHandler(
loadPairedTurnAttempts, loadPairedTurnAttempts,
loadPairedTurnOutputs, loadPairedTurnOutputs,
loadRecentPairedTurnOutputsForChat, loadRecentPairedTurnOutputsForChat,
loadRecentDeliveredWorkItemsForChat,
loadRecentChatMessages, loadRecentChatMessages,
}; };