Extract dashboard room timeline routes (#73)
* review: harden readonly git checks and lint verification * test: satisfy readonly reviewer sandbox typing * test: fix readonly reviewer sandbox assertions * test: remove impossible readonly sandbox guards * test: relax readonly sandbox assertions * test: cast readonly sandbox expectation shape * test: cast readonly sandbox expectation through unknown * Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke * Add STEP_DONE guards, verdict storage, and stale delivery suppression * style: format paired stepdone telemetry files * Route STEP_DONE through reviewer * Add structured Discord attachments * style: format structured attachment test * Fix room thread bot output parity * Remove duplicate work item attachment migration from owner branch * Remove legacy dashboard rooms renderer * Extract dashboard parsed body renderer * Extract dashboard redaction helpers * Extract dashboard RoomCardV2 component * Include RoomCardV2 test in vitest suite * Extract dashboard RoomBoardV2 component * Extract dashboard EmptyState component * Extract dashboard InboxPanel component * Split dashboard InboxPanel card renderer * Extract dashboard TaskPanel component * Extract dashboard UsagePanel component * Fix dashboard room thread chunk rendering * Extract dashboard ServicePanel component * Render dashboard live progress markdown * Extract dashboard SettingsPanel component * Fix structured attachment rendering * Extract dashboard simple route table * Extract dashboard settings routes * Extract dashboard service routes * Extract dashboard room timeline routes
This commit is contained in:
256
src/web-dashboard-room-routes.test.ts
Normal file
256
src/web-dashboard-room-routes.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||
import type { StatusSnapshot } from './status-dashboard.js';
|
||||
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
||||
import {
|
||||
handleRoomTimelineRoute,
|
||||
type RoomsTimelineRouteDependencies,
|
||||
} from './web-dashboard-room-routes.js';
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
...(init?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makePairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
|
||||
return {
|
||||
id: 'paired-room-1',
|
||||
chat_jid: 'dc:ops',
|
||||
group_folder: 'ops-room',
|
||||
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: 2,
|
||||
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: 'in_review',
|
||||
arbiter_verdict: null,
|
||||
arbiter_requested_at: null,
|
||||
completion_reason: null,
|
||||
created_at: '2026-04-26T04:00:00.000Z',
|
||||
updated_at: '2026-04-26T05:20:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(updatedAt = '2026-04-26T05:22:00.000Z'): StatusSnapshot {
|
||||
return {
|
||||
serviceId: 'codex-main',
|
||||
agentType: 'codex',
|
||||
assistantName: 'Codex',
|
||||
updatedAt,
|
||||
entries: [
|
||||
{
|
||||
jid: 'dc:ops',
|
||||
name: '#ops',
|
||||
folder: 'ops-room',
|
||||
agentType: 'codex',
|
||||
status: 'processing',
|
||||
elapsedMs: 120_000,
|
||||
pendingMessages: true,
|
||||
pendingTasks: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function roomDeps(
|
||||
overrides: Partial<RoomsTimelineRouteDependencies> = {},
|
||||
): RoomsTimelineRouteDependencies {
|
||||
return {
|
||||
statusMaxAgeMs: 1234,
|
||||
readSnapshots: () => [snapshot()],
|
||||
loadLatestPairedTaskForChat: () => null,
|
||||
loadPairedTurnsForTask: () => [],
|
||||
loadLatestPairedTurnForTask: () => null,
|
||||
loadPairedTurnAttempts: () => [],
|
||||
loadPairedTurnOutputs: () => [],
|
||||
loadRecentPairedTurnOutputsForChat: () => [],
|
||||
loadRecentChatMessages: () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('web dashboard room routes', () => {
|
||||
it('serves room timeline with paired progress and recent messages', async () => {
|
||||
const pairedTask = makePairedTask();
|
||||
const turns: PairedTurnRecord[] = [
|
||||
{
|
||||
turn_id: 'paired-room-1:reviewer-turn',
|
||||
task_id: pairedTask.id,
|
||||
task_updated_at: pairedTask.updated_at,
|
||||
role: 'reviewer',
|
||||
intent_kind: 'reviewer-turn',
|
||||
state: 'queued',
|
||||
executor_service_id: null,
|
||||
executor_agent_type: null,
|
||||
attempt_no: 0,
|
||||
created_at: '2026-04-26T05:18:30.000Z',
|
||||
updated_at: '2026-04-26T05:21:00.000Z',
|
||||
completed_at: null,
|
||||
last_error: null,
|
||||
},
|
||||
];
|
||||
const attempts: PairedTurnAttemptRecord[] = [
|
||||
{
|
||||
attempt_id: 'paired-room-1:reviewer-turn:attempt:2',
|
||||
parent_attempt_id: null,
|
||||
parent_handoff_id: null,
|
||||
continuation_handoff_id: null,
|
||||
turn_id: 'paired-room-1:reviewer-turn',
|
||||
task_id: pairedTask.id,
|
||||
task_updated_at: pairedTask.updated_at,
|
||||
role: 'reviewer',
|
||||
intent_kind: 'reviewer-turn',
|
||||
state: 'running',
|
||||
executor_service_id: 'claude-reviewer',
|
||||
executor_agent_type: 'claude-code',
|
||||
active_run_id: 'run-reviewer-1',
|
||||
attempt_no: 2,
|
||||
created_at: '2026-04-26T05:19:00.000Z',
|
||||
updated_at: '2026-04-26T05:21:00.000Z',
|
||||
completed_at: null,
|
||||
last_error: 'OPENAI_API_KEY=plain-secret-value',
|
||||
},
|
||||
];
|
||||
const outputs: PairedTurnOutput[] = [
|
||||
{
|
||||
id: 1,
|
||||
task_id: pairedTask.id,
|
||||
turn_number: 1,
|
||||
role: 'owner',
|
||||
output_text: 'owner final output',
|
||||
verdict: 'step_done',
|
||||
created_at: '2026-04-26T05:18:00.000Z',
|
||||
},
|
||||
];
|
||||
const messages: NewMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
chat_jid: 'dc:ops',
|
||||
sender: 'u1',
|
||||
sender_name: '눈쟁이',
|
||||
content: '진행 어디까지야? BOT_TOKEN=plain-secret-value',
|
||||
timestamp: '2026-04-26T05:17:00.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: false,
|
||||
message_source_kind: 'human',
|
||||
},
|
||||
];
|
||||
const progressMessages: NewMessage[] = [
|
||||
...messages,
|
||||
{
|
||||
id: 'msg-progress',
|
||||
chat_jid: 'dc:ops',
|
||||
sender: 'reviewer-bot',
|
||||
sender_name: '리뷰어',
|
||||
content: 'checking current output\n\n9s',
|
||||
timestamp: '2026-04-26T05:20:00.000Z',
|
||||
is_from_me: true,
|
||||
is_bot_message: true,
|
||||
message_source_kind: 'bot',
|
||||
},
|
||||
];
|
||||
const requestedMessageLimits: number[] = [];
|
||||
const response = handleRoomTimelineRoute({
|
||||
url: new URL(
|
||||
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`,
|
||||
),
|
||||
request: new Request('http://localhost'),
|
||||
jsonResponse,
|
||||
...roomDeps({
|
||||
loadLatestPairedTaskForChat: () => pairedTask,
|
||||
loadPairedTurnsForTask: () => turns,
|
||||
loadPairedTurnAttempts: () => attempts,
|
||||
loadPairedTurnOutputs: () => outputs,
|
||||
loadRecentChatMessages: (_jid, limit) => {
|
||||
requestedMessageLimits.push(limit ?? 20);
|
||||
return limit && limit > 8 ? progressMessages : messages;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
const body = (await response?.json()) as {
|
||||
pairedTask: {
|
||||
currentTurn: { lastError: string; progressText: string };
|
||||
outputs: Array<{ outputText: string }>;
|
||||
};
|
||||
messages: Array<{ content: string; senderName: string }>;
|
||||
};
|
||||
expect(body.pairedTask.currentTurn).toMatchObject({
|
||||
lastError: 'OPENAI_API_KEY=<redacted>',
|
||||
progressText: 'checking current output',
|
||||
});
|
||||
expect(body.pairedTask.outputs).toMatchObject([
|
||||
{ outputText: 'owner final output' },
|
||||
]);
|
||||
expect(requestedMessageLimits).toEqual([8, 40]);
|
||||
expect(body.messages[0]).toMatchObject({
|
||||
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
|
||||
senderName: '눈쟁이',
|
||||
});
|
||||
});
|
||||
|
||||
it('serves cached room timelines and falls through outside room routes', async () => {
|
||||
const pairedTask = makePairedTask({ id: 'paired-cache-1' });
|
||||
const response = handleRoomTimelineRoute({
|
||||
url: new URL('http://localhost/api/rooms-timeline'),
|
||||
request: new Request('http://localhost/api/rooms-timeline', {
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
}),
|
||||
jsonResponse,
|
||||
...roomDeps({
|
||||
readSnapshots: () => [snapshot('2026-04-26T06:22:00.000Z')],
|
||||
loadLatestPairedTaskForChat: () => pairedTask,
|
||||
loadLatestPairedTurnForTask: () => null,
|
||||
loadRecentChatMessages: () => [
|
||||
{
|
||||
id: 'msg-cache',
|
||||
chat_jid: 'dc:ops',
|
||||
sender: 'u1',
|
||||
sender_name: '눈쟁이',
|
||||
content: 'cache message',
|
||||
timestamp: '2026-04-26T06:17:00.000Z',
|
||||
is_from_me: false,
|
||||
is_bot_message: false,
|
||||
message_source_kind: 'human',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(response?.headers.get('content-encoding')).toBe('gzip');
|
||||
const bytes = new Uint8Array(await response!.arrayBuffer());
|
||||
const body = JSON.parse(
|
||||
new TextDecoder().decode(gunzipSync(bytes)),
|
||||
) as Record<string, { pairedTask: { id: string } }>;
|
||||
expect(body['dc:ops']?.pairedTask.id).toBe('paired-cache-1');
|
||||
expect(
|
||||
handleRoomTimelineRoute({
|
||||
url: new URL('http://localhost/api/overview'),
|
||||
request: new Request('http://localhost/api/overview'),
|
||||
jsonResponse,
|
||||
...roomDeps(),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
345
src/web-dashboard-room-routes.ts
Normal file
345
src/web-dashboard-room-routes.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
import type { PairedTurnAttemptRecord, PairedTurnRecord } from './db.js';
|
||||
import type { StatusSnapshot } from './status-dashboard.js';
|
||||
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
|
||||
import {
|
||||
buildWebDashboardRoomActivity,
|
||||
type WebDashboardRoomActivity,
|
||||
} from './web-dashboard-data.js';
|
||||
|
||||
type JsonResponse = (
|
||||
value: unknown,
|
||||
init?: ResponseInit,
|
||||
request?: Request,
|
||||
) => Response;
|
||||
|
||||
export interface RoomsTimelineRouteDependencies {
|
||||
statusMaxAgeMs: number;
|
||||
readSnapshots: (maxAgeMs: number) => StatusSnapshot[];
|
||||
loadLatestPairedTaskForChat: (
|
||||
chatJid: string,
|
||||
) => PairedTask | null | undefined;
|
||||
loadPairedTurnsForTask: (taskId: string) => PairedTurnRecord[];
|
||||
loadLatestPairedTurnForTask: (
|
||||
taskId: string,
|
||||
) => PairedTurnRecord | null | undefined;
|
||||
loadPairedTurnAttempts: (turnId: string) => PairedTurnAttemptRecord[];
|
||||
loadPairedTurnOutputs: (taskId: string) => PairedTurnOutput[];
|
||||
loadRecentPairedTurnOutputsForChat: (
|
||||
chatJid: string,
|
||||
limit: number,
|
||||
) => PairedTurnOutput[];
|
||||
loadRecentChatMessages: (chatJid: string, limit?: number) => NewMessage[];
|
||||
}
|
||||
|
||||
interface RoomsTimelineRouteContext extends RoomsTimelineRouteDependencies {
|
||||
url: URL;
|
||||
request: Request;
|
||||
jsonResponse: JsonResponse;
|
||||
}
|
||||
|
||||
interface RoomsTimelineCache {
|
||||
key: string;
|
||||
builtAt: number;
|
||||
rawJson: string;
|
||||
gzipBuffer: Uint8Array;
|
||||
}
|
||||
|
||||
const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000;
|
||||
|
||||
let roomsTimelineCache: RoomsTimelineCache | null = null;
|
||||
|
||||
function parseRoomTimelinePath(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/timeline$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isReadMethod(method: string): boolean {
|
||||
return method === 'GET' || method === 'HEAD';
|
||||
}
|
||||
|
||||
function latestSnapshotEntry(
|
||||
snapshots: StatusSnapshot[],
|
||||
jid: string,
|
||||
):
|
||||
| { snapshot: StatusSnapshot; entry: StatusSnapshot['entries'][number] }
|
||||
| undefined {
|
||||
return snapshots
|
||||
.flatMap((snapshot) =>
|
||||
snapshot.entries
|
||||
.filter((entry) => entry.jid === jid)
|
||||
.map((entry) => ({ snapshot, entry })),
|
||||
)
|
||||
.sort((a, b) => b.snapshot.updatedAt.localeCompare(a.snapshot.updatedAt))
|
||||
.at(0);
|
||||
}
|
||||
|
||||
function buildRoomActivity(
|
||||
deps: RoomsTimelineRouteDependencies,
|
||||
snapshot: StatusSnapshot,
|
||||
entry: StatusSnapshot['entries'][number],
|
||||
): WebDashboardRoomActivity {
|
||||
const pairedTask = deps.loadLatestPairedTaskForChat(entry.jid) ?? null;
|
||||
const turns = pairedTask ? deps.loadPairedTurnsForTask(pairedTask.id) : [];
|
||||
const attempts = turns.flatMap((turn) =>
|
||||
deps.loadPairedTurnAttempts(turn.turn_id),
|
||||
);
|
||||
return buildWebDashboardRoomActivity({
|
||||
serviceId: snapshot.serviceId,
|
||||
entry,
|
||||
pairedTask,
|
||||
turns,
|
||||
attempts,
|
||||
outputs: pairedTask ? deps.loadPairedTurnOutputs(pairedTask.id) : [],
|
||||
messages: deps.loadRecentChatMessages(entry.jid, 8),
|
||||
progressMessages: deps.loadRecentChatMessages(entry.jid, 40),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRoomsTimelineResult(
|
||||
deps: RoomsTimelineRouteDependencies,
|
||||
): Record<string, WebDashboardRoomActivity> {
|
||||
const snapshots = deps.readSnapshots(deps.statusMaxAgeMs);
|
||||
const uniqueByJid = new Map<
|
||||
string,
|
||||
{
|
||||
snapshot: StatusSnapshot;
|
||||
entry: StatusSnapshot['entries'][number];
|
||||
}
|
||||
>();
|
||||
for (const snapshot of snapshots) {
|
||||
for (const entry of snapshot.entries) {
|
||||
const existing = uniqueByJid.get(entry.jid);
|
||||
if (
|
||||
!existing ||
|
||||
existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0
|
||||
) {
|
||||
uniqueByJid.set(entry.jid, { snapshot, entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, WebDashboardRoomActivity> = {};
|
||||
for (const [jid, { snapshot, entry }] of uniqueByJid) {
|
||||
const pairedTask = deps.loadLatestPairedTaskForChat(jid) ?? null;
|
||||
const messages = deps.loadRecentChatMessages(jid, 8);
|
||||
const progressMessages = pairedTask
|
||||
? deps.loadRecentChatMessages(jid, 40)
|
||||
: messages;
|
||||
const outputs = deps.loadRecentPairedTurnOutputsForChat(jid, 8);
|
||||
if (!pairedTask && messages.length === 0 && outputs.length === 0) continue;
|
||||
|
||||
const latestTurn = pairedTask
|
||||
? deps.loadLatestPairedTurnForTask(pairedTask.id)
|
||||
: null;
|
||||
const turns = latestTurn ? [latestTurn] : [];
|
||||
result[jid] = buildWebDashboardRoomActivity({
|
||||
serviceId: snapshot.serviceId,
|
||||
entry,
|
||||
pairedTask,
|
||||
turns,
|
||||
attempts: [],
|
||||
outputs,
|
||||
messages,
|
||||
progressMessages,
|
||||
outputLimit: 8,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function computeRoomsCacheKey(deps: RoomsTimelineRouteDependencies): string {
|
||||
return deps
|
||||
.readSnapshots(deps.statusMaxAgeMs)
|
||||
.map((s) => s.updatedAt)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
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));
|
||||
roomsTimelineCache = {
|
||||
key,
|
||||
builtAt: Date.now(),
|
||||
rawJson,
|
||||
gzipBuffer,
|
||||
};
|
||||
return roomsTimelineCache;
|
||||
}
|
||||
|
||||
export function startRoomsTimelineCacheRefresh(
|
||||
deps: RoomsTimelineRouteDependencies,
|
||||
): void {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ensureRoomsTimelineCache(deps);
|
||||
} catch {
|
||||
/* warm-up failure is non-fatal */
|
||||
}
|
||||
}, 0);
|
||||
setInterval(() => {
|
||||
try {
|
||||
ensureRoomsTimelineCache(deps);
|
||||
} catch {
|
||||
/* refresh failure is non-fatal */
|
||||
}
|
||||
}, ROOMS_TIMELINE_BG_INTERVAL_MS).unref();
|
||||
}
|
||||
|
||||
function createRoomsTimelineStream(
|
||||
deps: RoomsTimelineRouteDependencies,
|
||||
request: Request,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
let lastBuiltAt = 0;
|
||||
let closed = false;
|
||||
|
||||
const enqueue = (chunk: string) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
enqueue(`retry: 3000\n\n`);
|
||||
try {
|
||||
const cache = ensureRoomsTimelineCache(deps);
|
||||
lastBuiltAt = cache.builtAt;
|
||||
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
|
||||
} catch {
|
||||
/* warm-up failure is non-fatal */
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const cache = ensureRoomsTimelineCache(deps);
|
||||
if (cache.builtAt !== lastBuiltAt) {
|
||||
lastBuiltAt = cache.builtAt;
|
||||
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
|
||||
} else {
|
||||
enqueue(`: ping ${Date.now()}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
/* skip this tick */
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(tick, 1500);
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
request.signal.addEventListener('abort', close);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoomsStream(
|
||||
deps: RoomsTimelineRouteDependencies,
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
): Response {
|
||||
if (!isReadMethod(request.method)) {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
return new Response(createRoomsTimelineStream(deps, request), {
|
||||
headers: {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoomsTimelineSnapshot(
|
||||
deps: RoomsTimelineRouteDependencies,
|
||||
request: Request,
|
||||
jsonResponse: JsonResponse,
|
||||
): Response {
|
||||
if (!isReadMethod(request.method)) {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
const cache = ensureRoomsTimelineCache(deps);
|
||||
const acceptsGzip =
|
||||
request.headers.get('accept-encoding')?.includes('gzip') ?? false;
|
||||
if (acceptsGzip) {
|
||||
return new Response(cache.gzipBuffer, {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-encoding': 'gzip',
|
||||
vary: 'accept-encoding',
|
||||
'x-cache-age': String(Date.now() - cache.builtAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(cache.rawJson, {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'x-cache-age': String(Date.now() - cache.builtAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSingleRoomTimeline({
|
||||
request,
|
||||
jsonResponse,
|
||||
...deps
|
||||
}: RoomsTimelineRouteContext & { timelineRoomJid: string }): Response {
|
||||
if (!isReadMethod(request.method)) {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
const snapshots = deps.readSnapshots(deps.statusMaxAgeMs);
|
||||
const matched = latestSnapshotEntry(snapshots, deps.timelineRoomJid);
|
||||
if (!matched) {
|
||||
return jsonResponse({ error: 'Room timeline not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return jsonResponse(buildRoomActivity(deps, matched.snapshot, matched.entry));
|
||||
}
|
||||
|
||||
export function handleRoomTimelineRoute(
|
||||
context: RoomsTimelineRouteContext,
|
||||
): Response | null {
|
||||
const timelineRoomJid = parseRoomTimelinePath(context.url.pathname);
|
||||
if (timelineRoomJid) {
|
||||
return handleSingleRoomTimeline({ ...context, timelineRoomJid });
|
||||
}
|
||||
if (context.url.pathname === '/api/stream') {
|
||||
return handleRoomsStream(context, context.request, context.jsonResponse);
|
||||
}
|
||||
if (context.url.pathname === '/api/rooms-timeline') {
|
||||
return handleRoomsTimelineSnapshot(
|
||||
context,
|
||||
context.request,
|
||||
context.jsonResponse,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -42,10 +42,14 @@ import type {
|
||||
} from './types.js';
|
||||
import { isWatchCiTask } from './task-watch-status.js';
|
||||
import {
|
||||
buildWebDashboardRoomActivity,
|
||||
buildWebDashboardOverview,
|
||||
sanitizeScheduledTask,
|
||||
} from './web-dashboard-data.js';
|
||||
import {
|
||||
handleRoomTimelineRoute,
|
||||
startRoomsTimelineCacheRefresh,
|
||||
type RoomsTimelineRouteDependencies,
|
||||
} from './web-dashboard-room-routes.js';
|
||||
import { handleSimpleGetRoute } from './web-dashboard-routes.js';
|
||||
import {
|
||||
handleServiceRoute,
|
||||
@@ -153,14 +157,6 @@ export interface StartedWebDashboardServer {
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000;
|
||||
let roomsTimelineCache: {
|
||||
key: string;
|
||||
builtAt: number;
|
||||
rawJson: string;
|
||||
gzipBuffer: Uint8Array;
|
||||
} | null = null;
|
||||
|
||||
function jsonResponse(
|
||||
value: unknown,
|
||||
init?: ResponseInit,
|
||||
@@ -300,16 +296,6 @@ function parseRoomMessagePath(pathname: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoomTimelinePath(pathname: string): string | null {
|
||||
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/timeline$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return decodeURIComponent(match[1]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePairedInboxTarget(
|
||||
inboxId: string,
|
||||
): { taskId: string; status: PairedTask['status'] } | null {
|
||||
@@ -682,100 +668,20 @@ export function createWebDashboardHandler(
|
||||
opts.restartServiceStack ?? restartEjclawStackServices;
|
||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||
|
||||
function buildRoomsTimelineResult(): Record<
|
||||
string,
|
||||
ReturnType<typeof buildWebDashboardRoomActivity>
|
||||
> {
|
||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||
const uniqueByJid = new Map<
|
||||
string,
|
||||
{
|
||||
snapshot: (typeof snapshots)[number];
|
||||
entry: (typeof snapshots)[number]['entries'][number];
|
||||
}
|
||||
>();
|
||||
for (const snapshot of snapshots) {
|
||||
for (const entry of snapshot.entries) {
|
||||
const existing = uniqueByJid.get(entry.jid);
|
||||
if (
|
||||
!existing ||
|
||||
existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0
|
||||
) {
|
||||
uniqueByJid.set(entry.jid, { snapshot, entry });
|
||||
}
|
||||
}
|
||||
}
|
||||
const result: Record<
|
||||
string,
|
||||
ReturnType<typeof buildWebDashboardRoomActivity>
|
||||
> = {};
|
||||
for (const [jid, { snapshot, entry }] of uniqueByJid) {
|
||||
const pairedTask = loadLatestPairedTaskForChat(jid) ?? null;
|
||||
const messages = loadRecentChatMessages(jid, 8);
|
||||
const progressMessages = pairedTask
|
||||
? loadRecentChatMessages(jid, 40)
|
||||
: messages;
|
||||
const outputs = loadRecentPairedTurnOutputsForChat(jid, 8);
|
||||
if (!pairedTask && messages.length === 0 && outputs.length === 0)
|
||||
continue;
|
||||
const latestTurn = pairedTask
|
||||
? loadLatestPairedTurnForTask(pairedTask.id)
|
||||
: null;
|
||||
const turns = latestTurn ? [latestTurn] : [];
|
||||
result[jid] = buildWebDashboardRoomActivity({
|
||||
serviceId: snapshot.serviceId,
|
||||
entry,
|
||||
pairedTask,
|
||||
turns,
|
||||
attempts: [],
|
||||
outputs,
|
||||
messages,
|
||||
progressMessages,
|
||||
outputLimit: 8,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function computeRoomsCacheKey(): string {
|
||||
return readSnapshots(statusMaxAgeMs)
|
||||
.map((s) => s.updatedAt)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function ensureRoomsTimelineCache(): NonNullable<typeof roomsTimelineCache> {
|
||||
const key = computeRoomsCacheKey();
|
||||
if (roomsTimelineCache && roomsTimelineCache.key === key) {
|
||||
return roomsTimelineCache;
|
||||
}
|
||||
const result = buildRoomsTimelineResult();
|
||||
const rawJson = JSON.stringify(result);
|
||||
const gzipBuffer = Bun.gzipSync(new TextEncoder().encode(rawJson));
|
||||
roomsTimelineCache = {
|
||||
key,
|
||||
builtAt: Date.now(),
|
||||
rawJson,
|
||||
gzipBuffer,
|
||||
};
|
||||
return roomsTimelineCache;
|
||||
}
|
||||
const roomsTimelineDeps: RoomsTimelineRouteDependencies = {
|
||||
statusMaxAgeMs,
|
||||
readSnapshots,
|
||||
loadLatestPairedTaskForChat,
|
||||
loadPairedTurnsForTask,
|
||||
loadLatestPairedTurnForTask,
|
||||
loadPairedTurnAttempts,
|
||||
loadPairedTurnOutputs,
|
||||
loadRecentPairedTurnOutputsForChat,
|
||||
loadRecentChatMessages,
|
||||
};
|
||||
|
||||
if (opts.startBackgroundCacheRefresh !== false) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ensureRoomsTimelineCache();
|
||||
} catch {
|
||||
/* warm-up failure is non-fatal */
|
||||
}
|
||||
}, 0);
|
||||
setInterval(() => {
|
||||
try {
|
||||
ensureRoomsTimelineCache();
|
||||
} catch {
|
||||
/* refresh failure is non-fatal */
|
||||
}
|
||||
}, ROOMS_TIMELINE_BG_INTERVAL_MS).unref();
|
||||
startRoomsTimelineCacheRefresh(roomsTimelineDeps);
|
||||
}
|
||||
|
||||
const seenRoomMessageIds: string[] = [];
|
||||
@@ -811,7 +717,6 @@ export function createWebDashboardHandler(
|
||||
const taskId = parseTaskPath(url.pathname);
|
||||
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
||||
const timelineRoomJid = parseRoomTimelinePath(url.pathname);
|
||||
|
||||
if (actionTaskId) {
|
||||
if (request.method !== 'POST') {
|
||||
@@ -1061,47 +966,13 @@ export function createWebDashboardHandler(
|
||||
return jsonResponse({ ok: true, id, queued: true });
|
||||
}
|
||||
|
||||
if (timelineRoomJid) {
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||
const matched = snapshots
|
||||
.flatMap((snapshot) =>
|
||||
snapshot.entries
|
||||
.filter((entry) => entry.jid === timelineRoomJid)
|
||||
.map((entry) => ({ snapshot, entry })),
|
||||
)
|
||||
.sort((a, b) =>
|
||||
b.snapshot.updatedAt.localeCompare(a.snapshot.updatedAt),
|
||||
)
|
||||
.at(0);
|
||||
if (!matched) {
|
||||
return jsonResponse(
|
||||
{ error: 'Room timeline not found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const pairedTask = loadLatestPairedTaskForChat(timelineRoomJid) ?? null;
|
||||
const turns = pairedTask ? loadPairedTurnsForTask(pairedTask.id) : [];
|
||||
const attempts = turns.flatMap((turn) =>
|
||||
loadPairedTurnAttempts(turn.turn_id),
|
||||
);
|
||||
return jsonResponse(
|
||||
buildWebDashboardRoomActivity({
|
||||
serviceId: matched.snapshot.serviceId,
|
||||
entry: matched.entry,
|
||||
pairedTask,
|
||||
turns,
|
||||
attempts,
|
||||
outputs: pairedTask ? loadPairedTurnOutputs(pairedTask.id) : [],
|
||||
messages: loadRecentChatMessages(timelineRoomJid, 8),
|
||||
progressMessages: loadRecentChatMessages(timelineRoomJid, 40),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const roomTimelineRoute = handleRoomTimelineRoute({
|
||||
url,
|
||||
request,
|
||||
jsonResponse,
|
||||
...roomsTimelineDeps,
|
||||
});
|
||||
if (roomTimelineRoute) return roomTimelineRoute;
|
||||
|
||||
const serviceRoute = await handleServiceRoute({
|
||||
url,
|
||||
@@ -1312,95 +1183,6 @@ export function createWebDashboardHandler(
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/stream') {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
let lastBuiltAt = 0;
|
||||
let closed = false;
|
||||
|
||||
const enqueue = (chunk: string) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial: send retry hint and seed event
|
||||
enqueue(`retry: 3000\n\n`);
|
||||
try {
|
||||
const cache = ensureRoomsTimelineCache();
|
||||
lastBuiltAt = cache.builtAt;
|
||||
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
|
||||
} catch {
|
||||
/* warm-up failure is non-fatal */
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const cache = ensureRoomsTimelineCache();
|
||||
if (cache.builtAt !== lastBuiltAt) {
|
||||
lastBuiltAt = cache.builtAt;
|
||||
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
|
||||
} else {
|
||||
// heartbeat to keep connection alive through proxies
|
||||
enqueue(`: ping ${Date.now()}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
/* skip this tick */
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(tick, 1500);
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
request.signal.addEventListener('abort', close);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
'x-accel-buffering': 'no',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/rooms-timeline') {
|
||||
const cache = ensureRoomsTimelineCache();
|
||||
const acceptsGzip =
|
||||
request.headers.get('accept-encoding')?.includes('gzip') ?? false;
|
||||
if (acceptsGzip) {
|
||||
return new Response(cache.gzipBuffer, {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'content-encoding': 'gzip',
|
||||
vary: 'accept-encoding',
|
||||
'x-cache-age': String(Date.now() - cache.builtAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(cache.rawJson, {
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'x-cache-age': String(Date.now() - cache.builtAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user