diff --git a/src/app/admin/components/SessionTraceViewer.tsx b/src/app/admin/components/SessionTraceViewer.tsx index 21092d851..048755886 100644 --- a/src/app/admin/components/SessionTraceViewer.tsx +++ b/src/app/admin/components/SessionTraceViewer.tsx @@ -8,9 +8,12 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { MessageBubble } from '@/components/cloud-agent/MessageBubble'; -import { MessageErrorBoundary } from '@/components/cloud-agent/MessageErrorBoundary'; +import { MessageBubble as V1MessageBubble } from '@/components/cloud-agent/MessageBubble'; +import { MessageErrorBoundary as V1MessageErrorBoundary } from '@/components/cloud-agent/MessageErrorBoundary'; import { convertToCloudMessages } from '@/components/cloud-agent/store/db-session-atoms'; +import { MessageBubble as V2MessageBubble } from '@/components/cloud-agent-next/MessageBubble'; +import { MessageErrorBoundary as V2MessageErrorBoundary } from '@/components/cloud-agent-next/MessageErrorBoundary'; +import { isNewSession } from '@/lib/cloud-agent/session-type'; import { useAdminSessionTrace, useAdminSessionMessages, @@ -18,8 +21,10 @@ import { } from '@/app/admin/api/session-traces/hooks'; import { Search, User, Calendar, Globe, GitBranch, Loader2, Download } from 'lucide-react'; import type { CloudMessage, Message } from '@/components/cloud-agent/types'; +import type { StoredMessage } from '@/components/cloud-agent-next/types'; const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SES_PREFIX = 'ses_'; function convertToMessage(cloudMessage: CloudMessage): Message & { say?: string; @@ -55,7 +60,10 @@ export function SessionTraceViewer() { // Initialize from URL parameter on mount useEffect(() => { - if (sessionIdFromUrl && UUID_REGEX.test(sessionIdFromUrl)) { + if ( + sessionIdFromUrl && + (UUID_REGEX.test(sessionIdFromUrl) || sessionIdFromUrl.startsWith(SES_PREFIX)) + ) { setInputValue(sessionIdFromUrl); setSearchedSessionId(sessionIdFromUrl); } @@ -71,9 +79,9 @@ export function SessionTraceViewer() { setValidationError('Please enter a session ID'); return; } - if (!UUID_REGEX.test(trimmed)) { + if (!UUID_REGEX.test(trimmed) && !trimmed.startsWith(SES_PREFIX)) { setValidationError( - 'Invalid UUID format. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' + 'Invalid session ID. Expected a UUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) or a v2 ID (ses_...)' ); return; } @@ -117,14 +125,25 @@ export function SessionTraceViewer() { } }; - const messages = useMemo(() => { - if (!messagesQuery.data?.messages) return []; + const isV2 = searchedSessionId ? isNewSession(searchedSessionId) : false; + + const v1Messages = useMemo(() => { + if (!messagesQuery.data?.messages || messagesQuery.data.format === 'v2') return []; const cloudMessages = convertToCloudMessages( messagesQuery.data.messages as Array> ); return cloudMessages.map(convertToMessage); }, [messagesQuery.data]); + const v2Messages = useMemo(() => { + if (!messagesQuery.data?.messages || messagesQuery.data.format !== 'v2') return []; + // Server-side Zod validates minimal shape; full StoredMessage structure is + // guaranteed by the session-ingest worker that originally created the data. + return messagesQuery.data.messages as unknown as StoredMessage[]; + }, [messagesQuery.data]); + + const messageCount = isV2 ? v2Messages.length : v1Messages.length; + const breadcrumbs = ( Session Traces @@ -138,13 +157,13 @@ export function SessionTraceViewer() { Session Trace Viewer - Enter a CLI session ID (UUID) to view the full session trace + Enter a CLI session ID (UUID or ses_...) to view the full session trace
setInputValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} @@ -204,6 +223,12 @@ export function SessionTraceViewer() { {sessionQuery.data.git_url}
)} + {sessionQuery.data.git_branch && ( +
+ + {sessionQuery.data.git_branch} +
+ )}
@@ -236,7 +261,7 @@ export function SessionTraceViewer() {
- Messages ({messages.length}) + Messages ({messageCount}) {messagesQuery.data?.messages && (
- ) : messages.length === 0 ? ( + ) : messageCount === 0 ? (

No messages in this session

+ ) : isV2 ? ( +
+ {v2Messages.map((msg, index) => ( + + + + ))} +
) : (
- {messages.map((msg, index) => ( - - - + {v1Messages.map((msg, index) => ( + + + ))}
)} diff --git a/src/routers/admin-router.ts b/src/routers/admin-router.ts index ebecdf27a..b3d0b9e52 100644 --- a/src/routers/admin-router.ts +++ b/src/routers/admin-router.ts @@ -9,8 +9,11 @@ import { user_auth_provider, modelStats, cliSessions, + cli_sessions_v2, credit_transactions, } from '@/db/schema'; +import { isNewSession } from '@/lib/cloud-agent/session-type'; +import { fetchSessionSnapshot, type SessionMessage } from '@/lib/session-ingest-client'; import { adminAppBuilderRouter } from '@/routers/admin-app-builder-router'; import { adminDeploymentsRouter } from '@/routers/admin-deployments-router'; import { adminKiloclawInstancesRouter } from '@/routers/admin-kiloclaw-instances-router'; @@ -69,6 +72,14 @@ const DeleteNoteSchema = z.object({ note_id: z.string(), }); +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const sessionIdSchema = z + .string() + .min(1) + .refine(s => UUID_REGEX.test(s) || s.startsWith('ses_'), { + message: 'Must be a UUID or ses_-prefixed session ID', + }); + const ResetAPIKeySchema = z.object({ userId: z.string(), }); @@ -873,8 +884,42 @@ export const adminRouter = createTRPCRouter({ sessionTraces: createTRPCRouter({ get: adminProcedure - .input(z.object({ session_id: z.string().uuid() })) + .input(z.object({ session_id: sessionIdSchema })) .query(async ({ input }) => { + if (isNewSession(input.session_id)) { + // V2 session — query cli_sessions_v2 + const [session] = await db + .select() + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.session_id, input.session_id)) + .limit(1); + + if (!session) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Session not found', + }); + } + + const user = await findUserById(session.kilo_user_id); + + return { + ...session, + // Fields that don't exist in v2 — null them out so the UI can handle both shapes + last_mode: null, + last_model: null, + user: user + ? { + id: user.id, + email: user.google_user_email, + name: user.google_user_name, + image: user.google_user_image_url, + } + : null, + }; + } + + // V1 session — original logic const [session] = await db .select() .from(cliSessions) @@ -892,6 +937,8 @@ export const adminRouter = createTRPCRouter({ return { ...session, + // V1 doesn't have git_branch — null it out for a consistent shape + git_branch: null, user: user ? { id: user.id, @@ -904,8 +951,40 @@ export const adminRouter = createTRPCRouter({ }), getMessages: adminProcedure - .input(z.object({ session_id: z.string().uuid() })) + .input(z.object({ session_id: sessionIdSchema })) .query(async ({ input }) => { + if (isNewSession(input.session_id)) { + // V2 session — fetch messages from the session-ingest worker. + // We need the owner's kilo_user_id to generate a service token. + const [session] = await db + .select({ kilo_user_id: cli_sessions_v2.kilo_user_id }) + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.session_id, input.session_id)) + .limit(1); + + if (!session) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Session not found', + }); + } + + try { + const snapshot = await fetchSessionSnapshot(input.session_id, session.kilo_user_id); + return { + messages: snapshot?.messages ?? ([] satisfies SessionMessage[]), + format: 'v2' as const, + }; + } catch (error) { + console.error('[SessionTraces] Failed to fetch v2 session snapshot', { + sessionId: input.session_id, + error, + }); + return { messages: [], format: 'v2' as const }; + } + } + + // V1 session — original logic const [session] = await db .select({ ui_messages_blob_url: cliSessions.ui_messages_blob_url, @@ -922,20 +1001,26 @@ export const adminRouter = createTRPCRouter({ } if (!session.ui_messages_blob_url) { - return { messages: [] }; + return { messages: [], format: 'v1' as const }; } try { const messages = await getBlobContent(session.ui_messages_blob_url); - return { messages: (messages as unknown[]) ?? [] }; + return { messages: (messages as unknown[]) ?? [], format: 'v1' as const }; } catch { - return { messages: [] }; + return { messages: [], format: 'v1' as const }; } }), getApiConversationHistory: adminProcedure - .input(z.object({ session_id: z.string().uuid() })) + .input(z.object({ session_id: sessionIdSchema })) .query(async ({ input }) => { + if (isNewSession(input.session_id)) { + // V2 sessions have no separate raw API conversation history + return { history: null }; + } + + // V1 session — original logic const [session] = await db .select({ api_conversation_history_blob_url: cliSessions.api_conversation_history_blob_url,