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';
|
} from './types.js';
|
||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
import {
|
import {
|
||||||
buildWebDashboardRoomActivity,
|
|
||||||
buildWebDashboardOverview,
|
buildWebDashboardOverview,
|
||||||
sanitizeScheduledTask,
|
sanitizeScheduledTask,
|
||||||
} from './web-dashboard-data.js';
|
} from './web-dashboard-data.js';
|
||||||
|
import {
|
||||||
|
handleRoomTimelineRoute,
|
||||||
|
startRoomsTimelineCacheRefresh,
|
||||||
|
type RoomsTimelineRouteDependencies,
|
||||||
|
} from './web-dashboard-room-routes.js';
|
||||||
import { handleSimpleGetRoute } from './web-dashboard-routes.js';
|
import { handleSimpleGetRoute } from './web-dashboard-routes.js';
|
||||||
import {
|
import {
|
||||||
handleServiceRoute,
|
handleServiceRoute,
|
||||||
@@ -153,14 +157,6 @@ export interface StartedWebDashboardServer {
|
|||||||
stop: () => void;
|
stop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000;
|
|
||||||
let roomsTimelineCache: {
|
|
||||||
key: string;
|
|
||||||
builtAt: number;
|
|
||||||
rawJson: string;
|
|
||||||
gzipBuffer: Uint8Array;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
function jsonResponse(
|
function jsonResponse(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
init?: ResponseInit,
|
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(
|
function parsePairedInboxTarget(
|
||||||
inboxId: string,
|
inboxId: string,
|
||||||
): { taskId: string; status: PairedTask['status'] } | null {
|
): { taskId: string; status: PairedTask['status'] } | null {
|
||||||
@@ -682,100 +668,20 @@ export function createWebDashboardHandler(
|
|||||||
opts.restartServiceStack ?? restartEjclawStackServices;
|
opts.restartServiceStack ?? restartEjclawStackServices;
|
||||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||||
|
|
||||||
function buildRoomsTimelineResult(): Record<
|
const roomsTimelineDeps: RoomsTimelineRouteDependencies = {
|
||||||
string,
|
statusMaxAgeMs,
|
||||||
ReturnType<typeof buildWebDashboardRoomActivity>
|
readSnapshots,
|
||||||
> {
|
loadLatestPairedTaskForChat,
|
||||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
loadPairedTurnsForTask,
|
||||||
const uniqueByJid = new Map<
|
loadLatestPairedTurnForTask,
|
||||||
string,
|
loadPairedTurnAttempts,
|
||||||
{
|
loadPairedTurnOutputs,
|
||||||
snapshot: (typeof snapshots)[number];
|
loadRecentPairedTurnOutputsForChat,
|
||||||
entry: (typeof snapshots)[number]['entries'][number];
|
loadRecentChatMessages,
|
||||||
}
|
|
||||||
>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.startBackgroundCacheRefresh !== false) {
|
if (opts.startBackgroundCacheRefresh !== false) {
|
||||||
setTimeout(() => {
|
startRoomsTimelineCacheRefresh(roomsTimelineDeps);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenRoomMessageIds: string[] = [];
|
const seenRoomMessageIds: string[] = [];
|
||||||
@@ -811,7 +717,6 @@ export function createWebDashboardHandler(
|
|||||||
const taskId = parseTaskPath(url.pathname);
|
const taskId = parseTaskPath(url.pathname);
|
||||||
const actionInboxId = parseInboxActionPath(url.pathname);
|
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||||
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
||||||
const timelineRoomJid = parseRoomTimelinePath(url.pathname);
|
|
||||||
|
|
||||||
if (actionTaskId) {
|
if (actionTaskId) {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
@@ -1061,47 +966,13 @@ export function createWebDashboardHandler(
|
|||||||
return jsonResponse({ ok: true, id, queued: true });
|
return jsonResponse({ ok: true, id, queued: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineRoomJid) {
|
const roomTimelineRoute = handleRoomTimelineRoute({
|
||||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
url,
|
||||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
request,
|
||||||
}
|
jsonResponse,
|
||||||
|
...roomsTimelineDeps,
|
||||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
});
|
||||||
const matched = snapshots
|
if (roomTimelineRoute) return roomTimelineRoute;
|
||||||
.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 serviceRoute = await handleServiceRoute({
|
const serviceRoute = await handleServiceRoute({
|
||||||
url,
|
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/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user