import { extractImageTagPaths, normalizeEjclawStructuredOutput, } from './agent-protocol.js'; import { isWatchCiTask } from './task-watch-status.js'; import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js'; import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js'; import type { NewMessage, OutboundAttachment, PairedTask, PairedTurnOutput, ScheduledTask, } from './types.js'; export interface SanitizedScheduledTask { id: string; groupFolder: string; chatJid: string; agentType: ScheduledTask['agent_type']; ciProvider: ScheduledTask['ci_provider']; ciMetadata: ScheduledTask['ci_metadata']; scheduleType: ScheduledTask['schedule_type']; scheduleValue: string; contextMode: ScheduledTask['context_mode']; nextRun: string | null; lastRun: string | null; lastResult: string | null; status: ScheduledTask['status']; suspendedUntil: string | null; createdAt: string; promptPreview: string; promptLength: number; isWatcher: boolean; } export interface WebDashboardOverview { generatedAt: string; services: Array<{ serviceId: string; assistantName: string; agentType: StatusSnapshot['agentType']; updatedAt: string; totalRooms: number; activeRooms: number; }>; rooms: { total: number; active: number; waiting: number; inactive: number; }; tasks: { total: number; active: number; paused: number; completed: number; watchers: { active: number; paused: number; completed: number; }; }; usage: { rows: UsageRowSnapshot[]; fetchedAt: string | null; }; inbox: InboxItem[]; } export interface WebDashboardRoomMessage { id: string; sender: string; senderName: string; content: string; attachments: OutboundAttachment[]; timestamp: string; isFromMe: boolean; isBotMessage: boolean; sourceKind: NonNullable; } export interface WebDashboardRoomTurn { turnId: string; role: PairedTurnRecord['role']; intentKind: PairedTurnRecord['intent_kind']; state: PairedTurnRecord['state']; attemptNo: number; executorServiceId: string | null; executorAgentType: PairedTurnRecord['executor_agent_type']; activeRunId: string | null; createdAt: string; updatedAt: string; completedAt: string | null; lastError: string | null; progressText: string | null; progressUpdatedAt: string | null; } export interface WebDashboardRoomTurnOutput { id: number; turnNumber: number; role: PairedTurnOutput['role']; verdict: PairedTurnOutput['verdict'] | null; createdAt: string; outputText: string; attachments: OutboundAttachment[]; } export interface WebDashboardRoomActivity { serviceId: string; jid: string; name: string; folder: string; agentType: StatusSnapshot['agentType']; status: StatusSnapshot['entries'][number]['status']; elapsedMs: number | null; pendingMessages: boolean; pendingTasks: number; messages: WebDashboardRoomMessage[]; pairedTask: { id: string; title: string | null; status: PairedTask['status']; roundTripCount: number; updatedAt: string; currentTurn: WebDashboardRoomTurn | null; outputs: WebDashboardRoomTurnOutput[]; } | null; } export type InboxItemKind = | 'pending-room' | 'reviewer-request' | 'approval' | 'arbiter-request' | 'ci-failure' | 'mention'; export type InboxItemSeverity = 'info' | 'warn' | 'error'; export interface InboxItem { id: string; groupKey: string; kind: InboxItemKind; severity: InboxItemSeverity; title: string; summary: string; occurredAt: string; lastOccurredAt: string; createdAt: string; occurrences: number; source: 'status-snapshot' | 'paired-task' | 'scheduled-task'; roomJid?: string; roomName?: string; groupFolder?: string; serviceId?: string; taskId?: string; taskStatus?: string; } const SECRET_ASSIGNMENT_RE = /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi; const SECRET_VALUE_RE = /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g; function redactSensitiveText(value: string): string { return value .replace(SECRET_ASSIGNMENT_RE, '$1=') .replace(SECRET_VALUE_RE, ''); } function truncateText(value: string, maxLength = 120): string { if (value.length <= maxLength) return value; return `${value.slice(0, maxLength)}...`; } function buildPromptPreview(prompt: string): string { return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim()); } function buildInboxPreview(value: string): string { return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim()); } function buildRoomBody(value: string): string { return redactSensitiveText(value).trim(); } function decodeCommonHtmlEntities(value: string): string { return value .replace(/"|"|"/gi, '"') .replace(/'|'|'/gi, "'") .replace(/<|<|</gi, '<') .replace(/>|>|>/gi, '>') .replace(/&|&|&/gi, '&'); } function hasStructuredOutputHint(value: string): boolean { return value.includes('"ejclaw"') || /"ejclaw"/i.test(value); } function imagePathsToAttachments(paths: string[]): OutboundAttachment[] { return paths.map((filePath) => ({ path: filePath, name: filePath.split(/[\\/]/).at(-1) || undefined, })); } function splitLegacyImageTags( text: string, attachments: OutboundAttachment[] = [], ): { text: string; attachments: OutboundAttachment[] } { const extracted = extractImageTagPaths(text); if (extracted.imagePaths.length === 0) { return { text, attachments }; } return { text: extracted.cleanText, attachments: [ ...attachments, ...imagePathsToAttachments(extracted.imagePaths), ], }; } function normalizeStructuredVisibleContent(value: string): { text: string | null; attachments: OutboundAttachment[]; } { if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(value); const candidates = [value, decodeCommonHtmlEntities(value)]; for (const candidate of candidates) { const normalized = normalizeEjclawStructuredOutput(candidate); if (normalized.output?.visibility === 'silent') { return { text: null, attachments: [] }; } if ( normalized.output?.visibility === 'public' && normalized.output.text !== candidate ) { return splitLegacyImageTags( normalized.output.text, normalized.output.attachments ?? [], ); } } return splitLegacyImageTags(value); } function stableHash(value: string): string { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) | 0; } return Math.abs(hash).toString(36); } function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] { const rows: UsageRowSnapshot[] = []; const seen = new Set(); const sortedSnapshots = [...snapshots].sort((a, b) => (b.usageRowsFetchedAt ?? b.updatedAt).localeCompare( a.usageRowsFetchedAt ?? a.updatedAt, ), ); for (const snapshot of sortedSnapshots) { for (const row of snapshot.usageRows ?? []) { if (seen.has(row.name)) continue; seen.add(row.name); rows.push(row); } } return rows; } function failedTaskResult(task: ScheduledTask): string | null { if (!task.last_result) return null; const normalized = task.last_result.toLowerCase(); if ( normalized.includes('fail') || normalized.includes('error') || normalized.includes('timeout') || normalized.includes('cancel') || normalized.includes('reject') ) { return buildInboxPreview(task.last_result); } return null; } function pairedTaskInboxKind( status: PairedTask['status'], ): InboxItemKind | null { if (status === 'merge_ready') return 'approval'; if (status === 'review_ready' || status === 'in_review') { return 'reviewer-request'; } if (status === 'arbiter_requested' || status === 'in_arbitration') { return 'arbiter-request'; } return null; } function collectInboxItems(args: { snapshots: StatusSnapshot[]; tasks: ScheduledTask[]; pairedTasks: PairedTask[]; createdAt: string; }): InboxItem[] { const items: InboxItem[] = []; for (const snapshot of args.snapshots) { for (const entry of snapshot.entries) { if (!entry.pendingMessages && entry.pendingTasks === 0) continue; const parts: string[] = []; if (entry.pendingMessages) parts.push('pending messages'); if (entry.pendingTasks > 0) parts.push(`${entry.pendingTasks} tasks`); items.push({ id: `room:${snapshot.serviceId}:${entry.jid}`, groupKey: `room:${snapshot.serviceId}:${entry.jid}`, kind: 'pending-room', severity: entry.pendingTasks > 0 ? 'warn' : 'info', title: entry.name || entry.folder || entry.jid, summary: parts.join(' · '), occurredAt: snapshot.updatedAt, lastOccurredAt: snapshot.updatedAt, createdAt: args.createdAt, occurrences: 1, source: 'status-snapshot', roomJid: entry.jid, roomName: entry.name, groupFolder: entry.folder, serviceId: snapshot.serviceId, }); } } for (const task of args.pairedTasks) { const kind = pairedTaskInboxKind(task.status); if (!kind) continue; items.push({ id: `paired:${task.id}:${task.status}`, groupKey: `paired:${task.id}:${task.status}`, kind, severity: kind === 'arbiter-request' ? 'error' : 'warn', title: task.title || task.group_folder, summary: task.status, occurredAt: kind === 'arbiter-request' ? (task.arbiter_requested_at ?? task.updated_at) : kind === 'reviewer-request' ? (task.review_requested_at ?? task.updated_at) : task.updated_at, lastOccurredAt: kind === 'arbiter-request' ? (task.arbiter_requested_at ?? task.updated_at) : kind === 'reviewer-request' ? (task.review_requested_at ?? task.updated_at) : task.updated_at, createdAt: args.createdAt, occurrences: 1, source: 'paired-task', roomJid: task.chat_jid, groupFolder: task.group_folder, serviceId: kind === 'reviewer-request' ? task.reviewer_service_id : task.owner_service_id, taskId: task.id, taskStatus: task.status, }); } for (const task of args.tasks) { if (!isWatchCiTask(task)) continue; const result = failedTaskResult(task); if (!result) continue; items.push({ id: `ci:${task.id}`, groupKey: `ci:${stableHash(result.toLowerCase())}`, kind: 'ci-failure', severity: task.status === 'paused' ? 'error' : 'warn', title: 'CI watcher failed', summary: result, occurredAt: task.last_run ?? task.created_at, lastOccurredAt: task.last_run ?? task.created_at, createdAt: args.createdAt, occurrences: 1, source: 'scheduled-task', roomJid: task.chat_jid, groupFolder: task.group_folder, serviceId: task.agent_type ?? undefined, taskId: task.id, taskStatus: task.status, }); } const severityRank: Record = { error: 0, warn: 1, info: 2, }; const groupedItems = new Map(); for (const item of items) { const existing = groupedItems.get(item.groupKey); if (!existing) { groupedItems.set(item.groupKey, item); continue; } const occurrences = existing.occurrences + item.occurrences; const severity = severityRank[item.severity] < severityRank[existing.severity] ? item.severity : existing.severity; const latest = item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0 ? item : existing; groupedItems.set(item.groupKey, { ...latest, severity, occurrences, lastOccurredAt: item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0 ? item.lastOccurredAt : existing.lastOccurredAt, }); } return [...groupedItems.values()] .sort((a, b) => { const severityDelta = severityRank[a.severity] - severityRank[b.severity]; if (severityDelta !== 0) return severityDelta; return b.lastOccurredAt.localeCompare(a.lastOccurredAt); }) .slice(0, 50); } export function sanitizeScheduledTask( task: ScheduledTask, ): SanitizedScheduledTask { return { id: task.id, groupFolder: task.group_folder, chatJid: task.chat_jid, agentType: task.agent_type, ciProvider: task.ci_provider ?? null, ciMetadata: task.ci_metadata ?? null, scheduleType: task.schedule_type, scheduleValue: task.schedule_value, contextMode: task.context_mode, nextRun: task.next_run, lastRun: task.last_run, lastResult: task.last_result, status: task.status, suspendedUntil: task.suspended_until ?? null, createdAt: task.created_at, promptPreview: buildPromptPreview(task.prompt), promptLength: task.prompt.length, isWatcher: isWatchCiTask(task), }; } function sanitizeRoomMessage( message: NewMessage, ): WebDashboardRoomMessage | null { const normalized = normalizeStructuredVisibleContent(message.content ?? ''); if (normalized.text === null) return null; return { id: message.id, sender: message.sender, senderName: message.sender_name || message.sender, content: buildRoomBody(normalized.text), attachments: normalized.attachments, timestamp: message.timestamp, isFromMe: !!message.is_from_me, isBotMessage: !!message.is_bot_message, sourceKind: message.message_source_kind ?? 'human', }; } const TASK_STATUS_PREFIX = '⁣⁣⁣'; function isTaskStatusMessage(message: NewMessage): boolean { return (message.content ?? '').startsWith(TASK_STATUS_PREFIX); } function sanitizeRoomTurn( turn: PairedTurnRecord, attempt: PairedTurnAttemptRecord | null, ): WebDashboardRoomTurn { const role = attempt?.role ?? turn.role; const createdAt = attempt?.created_at ?? turn.created_at; const completedAt = attempt?.completed_at ?? turn.completed_at; 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 { turnId: turn.turn_id, role, intentKind: attempt?.intent_kind ?? turn.intent_kind, state: attempt?.state ?? turn.state, attemptNo: attempt?.attempt_no ?? turn.attempt_no, executorServiceId: attempt?.executor_service_id ?? turn.executor_service_id, executorAgentType: attempt?.executor_agent_type ?? turn.executor_agent_type, activeRunId: attempt?.active_run_id ?? null, createdAt, updatedAt: attempt?.updated_at ?? turn.updated_at, completedAt, lastError: (attempt?.last_error ?? turn.last_error) ? buildRoomBody(attempt?.last_error ?? turn.last_error ?? '') : null, progressText: canonicalProgress.progressText, progressUpdatedAt: canonicalProgress.progressUpdatedAt, }; } function sanitizeRoomTurnOutput( output: PairedTurnOutput, ): WebDashboardRoomTurnOutput | null { const normalized = normalizeStructuredVisibleContent(output.output_text); if (normalized.text === null) return null; return { id: output.id, turnNumber: output.turn_number, role: output.role, verdict: output.verdict ?? null, createdAt: output.created_at, outputText: buildRoomBody(normalized.text), attachments: normalized.attachments, }; } export function buildWebDashboardRoomActivity(args: { serviceId: string; entry: StatusSnapshot['entries'][number]; pairedTask: PairedTask | null; turns: PairedTurnRecord[]; attempts: PairedTurnAttemptRecord[]; outputs: PairedTurnOutput[]; messages: NewMessage[]; outputLimit?: number; }): WebDashboardRoomActivity { const latestAttemptByTurnId = new Map(); for (const attempt of args.attempts) { const previous = latestAttemptByTurnId.get(attempt.turn_id); if (!previous || attempt.attempt_no > previous.attempt_no) { latestAttemptByTurnId.set(attempt.turn_id, attempt); } } const currentTurn = [...args.turns].sort((a, b) => b.updated_at.localeCompare(a.updated_at), )[0] ?? null; const currentAttempt = currentTurn ? (latestAttemptByTurnId.get(currentTurn.turn_id) ?? null) : null; const outputLimit = args.outputLimit ?? 4; return { serviceId: args.serviceId, jid: args.entry.jid, name: args.entry.name, folder: args.entry.folder, agentType: args.entry.agentType, status: args.entry.status, 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), ), pairedTask: args.pairedTask ? { id: args.pairedTask.id, title: args.pairedTask.title, status: args.pairedTask.status, roundTripCount: args.pairedTask.round_trip_count, updatedAt: args.pairedTask.updated_at, currentTurn: currentTurn ? sanitizeRoomTurn(currentTurn, currentAttempt) : null, outputs: args.outputs .slice(-outputLimit) .map(sanitizeRoomTurnOutput) .filter((output): output is WebDashboardRoomTurnOutput => Boolean(output), ), } : null, }; } export function buildWebDashboardOverview(args: { now?: string; snapshots: StatusSnapshot[]; tasks: ScheduledTask[]; pairedTasks?: PairedTask[]; }): WebDashboardOverview { const generatedAt = args.now ?? new Date().toISOString(); const rooms = { total: 0, active: 0, waiting: 0, inactive: 0, }; const services = args.snapshots.map((snapshot) => { let activeRooms = 0; for (const entry of snapshot.entries) { rooms.total += 1; if (entry.status === 'processing') { rooms.active += 1; activeRooms += 1; } else if (entry.status === 'waiting') { rooms.waiting += 1; activeRooms += 1; } else { rooms.inactive += 1; } } return { serviceId: snapshot.serviceId, assistantName: snapshot.assistantName, agentType: snapshot.agentType, updatedAt: snapshot.updatedAt, totalRooms: snapshot.entries.length, activeRooms, }; }); const tasks = { total: args.tasks.length, active: 0, paused: 0, completed: 0, watchers: { active: 0, paused: 0, completed: 0, }, }; for (const task of args.tasks) { if (task.status === 'active') tasks.active += 1; if (task.status === 'paused') tasks.paused += 1; if (task.status === 'completed') tasks.completed += 1; if (isWatchCiTask(task)) { if (task.status === 'active') tasks.watchers.active += 1; if (task.status === 'paused') tasks.watchers.paused += 1; if (task.status === 'completed') tasks.watchers.completed += 1; } } const usageRows = collectUsageRows(args.snapshots); const usageFetchedAt = args.snapshots .map((snapshot) => snapshot.usageRowsFetchedAt) .filter((value): value is string => !!value) .sort() .at(-1) ?? null; return { generatedAt, services, rooms, tasks, usage: { rows: usageRows, fetchedAt: usageFetchedAt, }, inbox: collectInboxItems({ snapshots: args.snapshots, tasks: args.tasks, pairedTasks: args.pairedTasks ?? [], createdAt: generatedAt, }), }; }