import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, } from '@/lib/copilot/generated/mothership-stream-v1' import { CopilotRecoveryOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { withCopilotSpan } from '@/lib/copilot/request/otel' import { getLatestSeq, getOldestSeq, readEvents } from './buffer' import { createEvent } from './event' const logger = createLogger('SessionRecovery') export interface ReplayGapResult { gapDetected: true envelopes: ReturnType[] } export async function checkForReplayGap( streamId: string, afterCursor: string, requestId?: string ): Promise { const requestedAfterSeq = Number(afterCursor || '0') if (requestedAfterSeq <= 0) { // Fast path: no cursor → nothing to check. Skip the span to avoid // emitting zero-work spans on every stream connect. return null } return withCopilotSpan( TraceSpan.CopilotRecoveryCheckReplayGap, { [TraceAttr.StreamId]: streamId, [TraceAttr.CopilotRecoveryRequestedAfterSeq]: requestedAfterSeq, ...(requestId ? { [TraceAttr.RequestId]: requestId } : {}), }, async (span) => { const oldestSeq = await getOldestSeq(streamId) const latestSeq = await getLatestSeq(streamId) span.setAttributes({ [TraceAttr.CopilotRecoveryOldestSeq]: oldestSeq ?? -1, [TraceAttr.CopilotRecoveryLatestSeq]: latestSeq ?? -1, }) if ( latestSeq !== null && latestSeq > 0 && oldestSeq !== null && requestedAfterSeq < oldestSeq - 1 ) { const resolvedRequestId = await resolveReplayGapRequestId(streamId, latestSeq, requestId) logger.warn('Replay gap detected: requested cursor is below oldest available event', { streamId, requestedAfterSeq, oldestAvailableSeq: oldestSeq, latestSeq, }) span.setAttribute(TraceAttr.CopilotRecoveryOutcome, CopilotRecoveryOutcome.GapDetected) const gapEnvelope = createEvent({ streamId, cursor: String(latestSeq + 1), seq: latestSeq + 1, requestId: resolvedRequestId, type: MothershipStreamV1EventType.error, payload: { message: 'Replay history is no longer available. Some events may have been lost.', code: 'replay_gap', data: { oldestAvailableSeq: oldestSeq, requestedAfterSeq, }, }, }) const terminalEnvelope = createEvent({ streamId, cursor: String(latestSeq + 2), seq: latestSeq + 2, requestId: resolvedRequestId, type: MothershipStreamV1EventType.complete, payload: { status: MothershipStreamV1CompletionStatus.error, reason: 'replay_gap', }, }) return { gapDetected: true, envelopes: [gapEnvelope, terminalEnvelope], } } span.setAttribute(TraceAttr.CopilotRecoveryOutcome, CopilotRecoveryOutcome.InRange) return null } ) } async function resolveReplayGapRequestId( streamId: string, latestSeq: number, requestId?: string ): Promise { if (typeof requestId === 'string' && requestId.length > 0) { return requestId } try { const latestEvents = await readEvents(streamId, String(Math.max(latestSeq - 1, 0))) const latestRequestId = latestEvents[0]?.trace?.requestId return typeof latestRequestId === 'string' ? latestRequestId : '' } catch (error) { logger.warn('Failed to resolve request ID for replay gap', { streamId, latestSeq, error: getErrorMessage(error), }) return '' } }