fix: use work items as dashboard outbound SSOT
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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<void> {
|
||||
): Promise<SendMessageResult> {
|
||||
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 },
|
||||
|
||||
51
src/db-work-items.test.ts
Normal file
51
src/db-work-items.test.ts
Normal 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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
getNewMessagesBySeq,
|
||||
getOpenWorkItem,
|
||||
getOpenWorkItemForChat,
|
||||
getRecentDeliveredWorkItemsForChat,
|
||||
getRecentChatMessages,
|
||||
getRecentChatMessagesBatch,
|
||||
hasMessage,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<void>;
|
||||
): Promise<SendMessageResult | void>;
|
||||
isConnected(): boolean;
|
||||
ownsJid(jid: string): boolean;
|
||||
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
|
||||
|
||||
@@ -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>): 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', () => {
|
||||
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',
|
||||
|
||||
@@ -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<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 = '';
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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> = {}): 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=<redacted>',
|
||||
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=<redacted>'),
|
||||
senderName: '눈쟁이',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
content: 'owner final output',
|
||||
senderName: 'owner',
|
||||
}),
|
||||
]);
|
||||
expect(body.messages[0]).toMatchObject({
|
||||
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
|
||||
senderName: '눈쟁이',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user