feat(dashboard): expose inbox activity data (#22)
This commit is contained in:
@@ -86,6 +86,7 @@ export {
|
|||||||
createServiceHandoff,
|
createServiceHandoff,
|
||||||
failPairedTurn,
|
failPairedTurn,
|
||||||
failServiceHandoff,
|
failServiceHandoff,
|
||||||
|
getAllOpenPairedTasks,
|
||||||
getAllChannelOwnerLeases,
|
getAllChannelOwnerLeases,
|
||||||
getAllPendingServiceHandoffs,
|
getAllPendingServiceHandoffs,
|
||||||
getChannelOwnerLease,
|
getChannelOwnerLease,
|
||||||
|
|||||||
@@ -266,6 +266,22 @@ export function getLatestOpenPairedTaskForChatFromDatabase(
|
|||||||
return row ? hydratePairedTaskRow(database, row) : undefined;
|
return row ? hydratePairedTaskRow(database, row) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllOpenPairedTasksFromDatabase(
|
||||||
|
database: Database,
|
||||||
|
): PairedTask[] {
|
||||||
|
const rows = database
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM paired_tasks
|
||||||
|
WHERE status NOT IN ('completed')
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all() as StoredPairedTaskRow[];
|
||||||
|
return rows.map((row) => hydratePairedTaskRow(database, row));
|
||||||
|
}
|
||||||
|
|
||||||
export function getLatestPreviousPairedTaskForChatFromDatabase(
|
export function getLatestPreviousPairedTaskForChatFromDatabase(
|
||||||
database: Database,
|
database: Database,
|
||||||
chatJid: string,
|
chatJid: string,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
clearPairedTurnReservationsInDatabase,
|
clearPairedTurnReservationsInDatabase,
|
||||||
type PairedTaskUpdates,
|
type PairedTaskUpdates,
|
||||||
createPairedTaskInDatabase,
|
createPairedTaskInDatabase,
|
||||||
|
getAllOpenPairedTasksFromDatabase,
|
||||||
getLastBotFinalMessageFromDatabase,
|
getLastBotFinalMessageFromDatabase,
|
||||||
getLatestOpenPairedTaskForChatFromDatabase,
|
getLatestOpenPairedTaskForChatFromDatabase,
|
||||||
getLatestPreviousPairedTaskForChatFromDatabase,
|
getLatestPreviousPairedTaskForChatFromDatabase,
|
||||||
@@ -113,6 +114,10 @@ export function getLatestPreviousPairedTaskForChat(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllOpenPairedTasks(): PairedTask[] {
|
||||||
|
return getAllOpenPairedTasksFromDatabase(requireDatabase());
|
||||||
|
}
|
||||||
|
|
||||||
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
|
export function updatePairedTask(id: string, updates: PairedTaskUpdates): void {
|
||||||
updatePairedTaskInDatabase(requireDatabase(), id, updates);
|
updatePairedTaskInDatabase(requireDatabase(), id, updates);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { ScheduledTask } from './types.js';
|
import type { PairedTask, ScheduledTask } from './types.js';
|
||||||
import {
|
import {
|
||||||
buildWebDashboardOverview,
|
buildWebDashboardOverview,
|
||||||
sanitizeScheduledTask,
|
sanitizeScheduledTask,
|
||||||
@@ -28,6 +28,36 @@ function makeTask(overrides: Partial<ScheduledTask>): ScheduledTask {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||||
|
return {
|
||||||
|
id: 'paired-1',
|
||||||
|
chat_jid: 'dc:general',
|
||||||
|
group_folder: 'general',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude-reviewer',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: 'codex',
|
||||||
|
title: 'Dashboard PR',
|
||||||
|
source_ref: null,
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
owner_failure_count: 0,
|
||||||
|
owner_step_done_streak: 0,
|
||||||
|
finalize_step_done_count: 0,
|
||||||
|
task_done_then_user_reopen_count: 0,
|
||||||
|
empty_step_done_streak: 0,
|
||||||
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-26T04:00:00.000Z',
|
||||||
|
updated_at: '2026-04-26T04:30:00.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[] = [
|
||||||
@@ -199,4 +229,114 @@ describe('web dashboard data', () => {
|
|||||||
);
|
);
|
||||||
expect(sanitized.promptPreview).not.toContain('plain-secret-value');
|
expect(sanitized.promptPreview).not.toContain('plain-secret-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => {
|
||||||
|
const snapshots: StatusSnapshot[] = [
|
||||||
|
{
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
agentType: 'codex',
|
||||||
|
assistantName: 'Codex',
|
||||||
|
updatedAt: '2026-04-26T05:00:00.000Z',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: '#ops',
|
||||||
|
folder: 'ops',
|
||||||
|
agentType: 'codex',
|
||||||
|
status: 'waiting',
|
||||||
|
elapsedMs: 1000,
|
||||||
|
pendingMessages: true,
|
||||||
|
pendingTasks: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jid: 'dc:idle',
|
||||||
|
name: '#idle',
|
||||||
|
folder: 'idle',
|
||||||
|
agentType: 'codex',
|
||||||
|
status: 'inactive',
|
||||||
|
elapsedMs: null,
|
||||||
|
pendingMessages: false,
|
||||||
|
pendingTasks: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const overview = buildWebDashboardOverview({
|
||||||
|
now: '2026-04-26T05:10:00.000Z',
|
||||||
|
snapshots,
|
||||||
|
pairedTasks: [
|
||||||
|
makePairedTask({
|
||||||
|
id: 'review-1',
|
||||||
|
status: 'review_ready',
|
||||||
|
review_requested_at: '2026-04-26T05:03:00.000Z',
|
||||||
|
}),
|
||||||
|
makePairedTask({
|
||||||
|
id: 'merge-1',
|
||||||
|
status: 'merge_ready',
|
||||||
|
title: 'Ready to merge',
|
||||||
|
updated_at: '2026-04-26T05:04:00.000Z',
|
||||||
|
}),
|
||||||
|
makePairedTask({
|
||||||
|
id: 'done-1',
|
||||||
|
status: 'completed',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
tasks: [
|
||||||
|
makeTask({
|
||||||
|
id: 'ci-1',
|
||||||
|
prompt:
|
||||||
|
'[BACKGROUND CI WATCH]\nWatch target:\nPR #21\n\nCheck instructions:\nwatch',
|
||||||
|
last_run: '2026-04-26T05:05:00.000Z',
|
||||||
|
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
|
||||||
|
status: 'paused',
|
||||||
|
}),
|
||||||
|
makeTask({
|
||||||
|
id: 'cron-1',
|
||||||
|
prompt: 'regular cron',
|
||||||
|
last_run: '2026-04-26T05:06:00.000Z',
|
||||||
|
last_result: 'Error: non-watch task failed',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(overview.inbox.map((item) => item.kind)).toEqual([
|
||||||
|
'ci-failure',
|
||||||
|
'approval',
|
||||||
|
'reviewer-request',
|
||||||
|
'pending-room',
|
||||||
|
]);
|
||||||
|
expect(overview.inbox).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'room:codex-main:dc:ops',
|
||||||
|
kind: 'pending-room',
|
||||||
|
severity: 'info',
|
||||||
|
roomJid: 'dc:ops',
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
occurredAt: '2026-04-26T05:00:00.000Z',
|
||||||
|
createdAt: '2026-04-26T05:10:00.000Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(overview.inbox).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'paired:review-1:review_ready',
|
||||||
|
kind: 'reviewer-request',
|
||||||
|
severity: 'warn',
|
||||||
|
serviceId: 'claude-reviewer',
|
||||||
|
taskStatus: 'review_ready',
|
||||||
|
occurredAt: '2026-04-26T05:03:00.000Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure');
|
||||||
|
expect(ciFailure).toMatchObject({
|
||||||
|
id: 'ci:ci-1',
|
||||||
|
severity: 'error',
|
||||||
|
source: 'scheduled-task',
|
||||||
|
taskId: 'ci-1',
|
||||||
|
});
|
||||||
|
expect(ciFailure?.summary).toContain('BOT_TOKEN=<redacted>');
|
||||||
|
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
||||||
|
expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false);
|
||||||
|
expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
|
||||||
import type { ScheduledTask } from './types.js';
|
import type { PairedTask, ScheduledTask } from './types.js';
|
||||||
|
|
||||||
export interface SanitizedScheduledTask {
|
export interface SanitizedScheduledTask {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -54,6 +54,34 @@ export interface WebDashboardOverview {
|
|||||||
rows: UsageRowSnapshot[];
|
rows: UsageRowSnapshot[];
|
||||||
fetchedAt: string | null;
|
fetchedAt: string | null;
|
||||||
};
|
};
|
||||||
|
inbox: InboxItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InboxItemKind =
|
||||||
|
| 'pending-room'
|
||||||
|
| 'reviewer-request'
|
||||||
|
| 'approval'
|
||||||
|
| 'arbiter-request'
|
||||||
|
| 'ci-failure'
|
||||||
|
| 'mention';
|
||||||
|
|
||||||
|
export type InboxItemSeverity = 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export interface InboxItem {
|
||||||
|
id: string;
|
||||||
|
kind: InboxItemKind;
|
||||||
|
severity: InboxItemSeverity;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
occurredAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
||||||
|
roomJid?: string;
|
||||||
|
roomName?: string;
|
||||||
|
groupFolder?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
taskStatus?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECRET_ASSIGNMENT_RE =
|
const SECRET_ASSIGNMENT_RE =
|
||||||
@@ -76,6 +104,10 @@ function buildPromptPreview(prompt: string): string {
|
|||||||
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
|
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInboxPreview(value: string): string {
|
||||||
|
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
|
||||||
|
}
|
||||||
|
|
||||||
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
||||||
const rows: UsageRowSnapshot[] = [];
|
const rows: UsageRowSnapshot[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -96,6 +128,134 @@ function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
|||||||
return rows;
|
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}`,
|
||||||
|
kind: 'pending-room',
|
||||||
|
severity: entry.pendingTasks > 0 ? 'warn' : 'info',
|
||||||
|
title: entry.name || entry.folder || entry.jid,
|
||||||
|
summary: parts.join(' · '),
|
||||||
|
occurredAt: snapshot.updatedAt,
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
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}`,
|
||||||
|
kind,
|
||||||
|
severity:
|
||||||
|
kind === 'approval' || 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,
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
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}`,
|
||||||
|
kind: 'ci-failure',
|
||||||
|
severity: task.status === 'paused' ? 'error' : 'warn',
|
||||||
|
title: 'CI watcher failed',
|
||||||
|
summary: result,
|
||||||
|
occurredAt: task.last_run ?? task.created_at,
|
||||||
|
createdAt: args.createdAt,
|
||||||
|
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<InboxItemSeverity, number> = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
info: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
return items
|
||||||
|
.sort((a, b) => {
|
||||||
|
const severityDelta = severityRank[a.severity] - severityRank[b.severity];
|
||||||
|
if (severityDelta !== 0) return severityDelta;
|
||||||
|
return b.occurredAt.localeCompare(a.occurredAt);
|
||||||
|
})
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeScheduledTask(
|
export function sanitizeScheduledTask(
|
||||||
task: ScheduledTask,
|
task: ScheduledTask,
|
||||||
): SanitizedScheduledTask {
|
): SanitizedScheduledTask {
|
||||||
@@ -125,7 +285,9 @@ export function buildWebDashboardOverview(args: {
|
|||||||
now?: string;
|
now?: string;
|
||||||
snapshots: StatusSnapshot[];
|
snapshots: StatusSnapshot[];
|
||||||
tasks: ScheduledTask[];
|
tasks: ScheduledTask[];
|
||||||
|
pairedTasks?: PairedTask[];
|
||||||
}): WebDashboardOverview {
|
}): WebDashboardOverview {
|
||||||
|
const generatedAt = args.now ?? new Date().toISOString();
|
||||||
const rooms = {
|
const rooms = {
|
||||||
total: 0,
|
total: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
@@ -191,7 +353,7 @@ export function buildWebDashboardOverview(args: {
|
|||||||
.at(-1) ?? null;
|
.at(-1) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generatedAt: args.now ?? new Date().toISOString(),
|
generatedAt,
|
||||||
services,
|
services,
|
||||||
rooms,
|
rooms,
|
||||||
tasks,
|
tasks,
|
||||||
@@ -199,5 +361,11 @@ export function buildWebDashboardOverview(args: {
|
|||||||
rows: usageRows,
|
rows: usageRows,
|
||||||
fetchedAt: usageFetchedAt,
|
fetchedAt: usageFetchedAt,
|
||||||
},
|
},
|
||||||
|
inbox: collectInboxItems({
|
||||||
|
snapshots: args.snapshots,
|
||||||
|
tasks: args.tasks,
|
||||||
|
pairedTasks: args.pairedTasks ?? [],
|
||||||
|
createdAt: generatedAt,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,63 @@ import path from 'path';
|
|||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { StatusSnapshot } from './status-dashboard.js';
|
||||||
|
import type { PairedTask, ScheduledTask } from './types.js';
|
||||||
import { createWebDashboardHandler } from './web-dashboard-server.js';
|
import { createWebDashboardHandler } from './web-dashboard-server.js';
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<ScheduledTask>): ScheduledTask {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
group_folder: 'general',
|
||||||
|
chat_jid: 'dc:general',
|
||||||
|
agent_type: null,
|
||||||
|
status_message_id: null,
|
||||||
|
status_started_at: null,
|
||||||
|
prompt: 'regular scheduled task',
|
||||||
|
schedule_type: 'cron',
|
||||||
|
schedule_value: '* * * * *',
|
||||||
|
context_mode: 'group',
|
||||||
|
next_run: '2026-04-26T05:00:00.000Z',
|
||||||
|
last_run: null,
|
||||||
|
last_result: null,
|
||||||
|
status: 'active',
|
||||||
|
created_at: '2026-04-26T04:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePairedTask(overrides: Partial<PairedTask>): PairedTask {
|
||||||
|
return {
|
||||||
|
id: 'paired-1',
|
||||||
|
chat_jid: 'dc:general',
|
||||||
|
group_folder: 'general',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude-reviewer',
|
||||||
|
owner_agent_type: 'codex',
|
||||||
|
reviewer_agent_type: 'claude-code',
|
||||||
|
arbiter_agent_type: 'codex',
|
||||||
|
title: 'Dashboard PR',
|
||||||
|
source_ref: null,
|
||||||
|
plan_notes: null,
|
||||||
|
review_requested_at: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
owner_failure_count: 0,
|
||||||
|
owner_step_done_streak: 0,
|
||||||
|
finalize_step_done_count: 0,
|
||||||
|
task_done_then_user_reopen_count: 0,
|
||||||
|
empty_step_done_streak: 0,
|
||||||
|
status: 'review_ready',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-26T04:00:00.000Z',
|
||||||
|
updated_at: '2026-04-26T04:30:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@@ -19,6 +72,7 @@ describe('web dashboard server handler', () => {
|
|||||||
const handler = createWebDashboardHandler({
|
const handler = createWebDashboardHandler({
|
||||||
readStatusSnapshots: () => [],
|
readStatusSnapshots: () => [],
|
||||||
getTasks: () => [],
|
getTasks: () => [],
|
||||||
|
getPairedTasks: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const health = await handler(new Request('http://localhost/api/health'));
|
const health = await handler(new Request('http://localhost/api/health'));
|
||||||
@@ -32,9 +86,11 @@ describe('web dashboard server handler', () => {
|
|||||||
const body = (await overview.json()) as {
|
const body = (await overview.json()) as {
|
||||||
rooms: { total: number };
|
rooms: { total: number };
|
||||||
tasks: { total: number };
|
tasks: { total: number };
|
||||||
|
inbox: unknown[];
|
||||||
};
|
};
|
||||||
expect(body.rooms.total).toBe(0);
|
expect(body.rooms.total).toBe(0);
|
||||||
expect(body.tasks.total).toBe(0);
|
expect(body.tasks.total).toBe(0);
|
||||||
|
expect(body.inbox).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serves full Claude, Kimi, and Codex usage rows through overview JSON', async () => {
|
it('serves full Claude, Kimi, and Codex usage rows through overview JSON', async () => {
|
||||||
@@ -73,6 +129,7 @@ describe('web dashboard server handler', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
getTasks: () => [],
|
getTasks: () => [],
|
||||||
|
getPairedTasks: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const overview = await handler(
|
const overview = await handler(
|
||||||
@@ -90,6 +147,92 @@ describe('web dashboard server handler', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serves typed inbox items through overview JSON', async () => {
|
||||||
|
const snapshots: StatusSnapshot[] = [
|
||||||
|
{
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
agentType: 'codex',
|
||||||
|
assistantName: 'Codex',
|
||||||
|
updatedAt: '2026-04-26T05:00:00.000Z',
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
jid: 'dc:ops',
|
||||||
|
name: '#ops',
|
||||||
|
folder: 'ops',
|
||||||
|
agentType: 'codex',
|
||||||
|
status: 'waiting',
|
||||||
|
elapsedMs: 2500,
|
||||||
|
pendingMessages: true,
|
||||||
|
pendingTasks: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handler = createWebDashboardHandler({
|
||||||
|
readStatusSnapshots: () => snapshots,
|
||||||
|
getTasks: () => [
|
||||||
|
makeTask({
|
||||||
|
id: 'ci-1',
|
||||||
|
prompt:
|
||||||
|
'[BACKGROUND CI WATCH]\nWatch target:\nPR #21\n\nCheck instructions:\nwatch',
|
||||||
|
last_run: '2026-04-26T05:05:00.000Z',
|
||||||
|
last_result: 'failed with API_KEY=plain-secret-value',
|
||||||
|
status: 'paused',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
getPairedTasks: () => [
|
||||||
|
makePairedTask({
|
||||||
|
id: 'merge-1',
|
||||||
|
status: 'merge_ready',
|
||||||
|
title: 'Ready to merge',
|
||||||
|
updated_at: '2026-04-26T05:04:00.000Z',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
now: () => '2026-04-26T05:10:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const overview = await handler(
|
||||||
|
new Request('http://localhost/api/overview'),
|
||||||
|
);
|
||||||
|
expect(overview.status).toBe(200);
|
||||||
|
const body = (await overview.json()) as {
|
||||||
|
inbox: Array<{
|
||||||
|
kind: string;
|
||||||
|
id: string;
|
||||||
|
summary: string;
|
||||||
|
roomJid?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
taskId?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.inbox.map((item) => item.kind)).toEqual([
|
||||||
|
'ci-failure',
|
||||||
|
'approval',
|
||||||
|
'pending-room',
|
||||||
|
]);
|
||||||
|
expect(body.inbox).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'pending-room',
|
||||||
|
id: 'room:codex-main:dc:ops',
|
||||||
|
roomJid: 'dc:ops',
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(body.inbox).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: 'approval',
|
||||||
|
id: 'paired:merge-1:merge_ready',
|
||||||
|
taskId: 'merge-1',
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ciFailure = body.inbox.find((item) => item.kind === 'ci-failure');
|
||||||
|
expect(ciFailure?.summary).toContain('API_KEY=<redacted>');
|
||||||
|
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
it('serves Vite static assets and falls back to index for SPA routes', async () => {
|
it('serves Vite static assets and falls back to index for SPA routes', async () => {
|
||||||
const staticDir = fs.mkdtempSync(
|
const staticDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'ejclaw-dashboard-'),
|
path.join(os.tmpdir(), 'ejclaw-dashboard-'),
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { WEB_DASHBOARD } from './config.js';
|
import { WEB_DASHBOARD } from './config.js';
|
||||||
import { getAllTasks } from './db.js';
|
import { getAllOpenPairedTasks, getAllTasks } from './db.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
readStatusSnapshots,
|
readStatusSnapshots,
|
||||||
type StatusSnapshot,
|
type StatusSnapshot,
|
||||||
} from './status-dashboard.js';
|
} from './status-dashboard.js';
|
||||||
import type { ScheduledTask } from './types.js';
|
import type { PairedTask, ScheduledTask } from './types.js';
|
||||||
import {
|
import {
|
||||||
buildWebDashboardOverview,
|
buildWebDashboardOverview,
|
||||||
sanitizeScheduledTask,
|
sanitizeScheduledTask,
|
||||||
@@ -21,6 +21,7 @@ export interface WebDashboardHandlerOptions {
|
|||||||
statusMaxAgeMs?: number;
|
statusMaxAgeMs?: number;
|
||||||
readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[];
|
readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[];
|
||||||
getTasks?: () => ScheduledTask[];
|
getTasks?: () => ScheduledTask[];
|
||||||
|
getPairedTasks?: () => PairedTask[];
|
||||||
now?: () => string;
|
now?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ export function createWebDashboardHandler(
|
|||||||
): (request: Request) => Response | Promise<Response> {
|
): (request: Request) => Response | Promise<Response> {
|
||||||
const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots;
|
const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots;
|
||||||
const loadTasks = opts.getTasks ?? getAllTasks;
|
const loadTasks = opts.getTasks ?? getAllTasks;
|
||||||
|
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
|
||||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||||
|
|
||||||
return (request: Request): Response => {
|
return (request: Request): Response => {
|
||||||
@@ -121,11 +123,13 @@ export function createWebDashboardHandler(
|
|||||||
if (url.pathname === '/api/overview') {
|
if (url.pathname === '/api/overview') {
|
||||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||||
const tasks = loadTasks();
|
const tasks = loadTasks();
|
||||||
|
const pairedTasks = loadPairedTasks();
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
buildWebDashboardOverview({
|
buildWebDashboardOverview({
|
||||||
now: opts.now?.(),
|
now: opts.now?.(),
|
||||||
snapshots,
|
snapshots,
|
||||||
tasks,
|
tasks,
|
||||||
|
pairedTasks,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user