chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Abort-reason vocabulary for Sim-originated cancellations.
|
||||
*
|
||||
* This is deliberately a zero-dependency module (no OTel, no logger,
|
||||
* no DB) so it can be imported from both the telemetry layer
|
||||
* (`request/otel.ts`) and the abort layer (`request/session/abort.ts`)
|
||||
* without creating a circular dependency. The longer prose lives in
|
||||
* `abort.ts`; anything here is the raw classification vocabulary
|
||||
* consumed by span-status / finalizer code.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reason strings passed to `AbortController.abort(reason)` for every
|
||||
* Sim-originated cancel path.
|
||||
*/
|
||||
export const AbortReason = {
|
||||
/** Same-process stop: browser→Sim→abortActiveStream. */
|
||||
UserStop: 'user_stop:abortActiveStream',
|
||||
/**
|
||||
* Cross-process stop: the Sim node that holds the SSE didn't
|
||||
* receive the Stop HTTP call, but it polled the Redis abort marker
|
||||
* that the node that DID receive it wrote, and aborts on the poll.
|
||||
*/
|
||||
RedisPoller: 'redis_abort_marker:poller',
|
||||
/**
|
||||
* Cross-process stop: same root cause as `RedisPoller`, but observed
|
||||
* by `runStreamLoop` at body close (the Go body ended before the
|
||||
* 250ms poller's next tick) rather than by the polling timer.
|
||||
*/
|
||||
MarkerObservedAtBodyClose: 'redis_abort_marker:body_close',
|
||||
/** Internal timeout on the outbound explicit-abort fetch to Go. */
|
||||
ExplicitAbortFetchTimeout: 'timeout:go_explicit_abort_fetch',
|
||||
} as const
|
||||
|
||||
export type AbortReasonValue = (typeof AbortReason)[keyof typeof AbortReason]
|
||||
|
||||
/**
|
||||
* True iff `reason` indicates the user explicitly triggered the abort
|
||||
* (as opposed to an implicit client disconnect or server timeout).
|
||||
* Treated as a small closed vocabulary — any string not in
|
||||
* `AbortReason` is presumed non-explicit. This is the canonical
|
||||
* "should I treat this cancellation as expected?" predicate: span
|
||||
* status-setters consult it to suppress ERROR only for user-initiated
|
||||
* stops, mirroring `requestctx.IsExplicitUserStop` on the Go side.
|
||||
*/
|
||||
export function isExplicitStopReason(reason: unknown): boolean {
|
||||
return (
|
||||
reason === AbortReason.UserStop ||
|
||||
reason === AbortReason.RedisPoller ||
|
||||
reason === AbortReason.MarkerObservedAtBodyClose
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.hoisted(() => ({
|
||||
mockHasAbortMarker: vi.fn().mockResolvedValue(false),
|
||||
mockClearAbortMarker: vi.fn().mockResolvedValue(undefined),
|
||||
mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
vi.mock('@/lib/copilot/request/session/buffer', () => ({
|
||||
hasAbortMarker: mockHasAbortMarker,
|
||||
clearAbortMarker: mockClearAbortMarker,
|
||||
writeAbortMarker: mockWriteAbortMarker,
|
||||
}))
|
||||
vi.mock('@/lib/copilot/request/otel', () => ({
|
||||
withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) =>
|
||||
fn({ setAttribute: vi.fn() }),
|
||||
}))
|
||||
|
||||
import {
|
||||
acquirePendingChatStream,
|
||||
getChatStreamLockOwners,
|
||||
releasePendingChatStream,
|
||||
startAbortPoller,
|
||||
} from '@/lib/copilot/request/session/abort'
|
||||
|
||||
describe('startAbortPoller heartbeat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockHasAbortMarker.mockResolvedValue(false)
|
||||
redisConfigMockFns.mockExtendLock.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('extends the chat stream lock approximately every heartbeat interval', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-heartbeat-1'
|
||||
const chatId = 'chat-heartbeat-1'
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, { chatId })
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenLastCalledWith(
|
||||
`copilot:chat-stream-lock:${chatId}`,
|
||||
streamId,
|
||||
60
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not extend the lock when no chatId is passed (backward compat)', async () => {
|
||||
const controller = new AbortController()
|
||||
const interval = startAbortPoller('stream-no-chat', controller, {})
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(90_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('retries on the next tick when extendLock throws (no 20s backoff)', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-retry'
|
||||
const chatId = 'chat-retry'
|
||||
|
||||
redisConfigMockFns.mockExtendLock.mockRejectedValueOnce(new Error('redis down'))
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, { chatId })
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('aborts the controller before clearing the marker so the marker is never observable as cleared while the signal is still unaborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-order-1'
|
||||
|
||||
let signalAbortedWhenMarkerCleared: boolean | null = null
|
||||
mockClearAbortMarker.mockImplementationOnce(async () => {
|
||||
signalAbortedWhenMarkerCleared = controller.signal.aborted
|
||||
})
|
||||
mockHasAbortMarker.mockResolvedValueOnce(true)
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, {})
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(mockClearAbortMarker).toHaveBeenCalledWith(streamId)
|
||||
expect(signalAbortedWhenMarkerCleared).toBe(true)
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clear the marker when the signal is already aborted (no double abort)', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort('preexisting')
|
||||
const streamId = 'stream-order-2'
|
||||
|
||||
mockHasAbortMarker.mockResolvedValueOnce(true)
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, {})
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
expect(mockClearAbortMarker).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops heartbeating after ownership is lost', async () => {
|
||||
const controller = new AbortController()
|
||||
const streamId = 'stream-lost'
|
||||
const chatId = 'chat-lost'
|
||||
|
||||
redisConfigMockFns.mockExtendLock.mockResolvedValueOnce(false)
|
||||
|
||||
const interval = startAbortPoller(streamId, controller, { chatId })
|
||||
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(21_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(redisConfigMockFns.mockExtendLock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getChatStreamLockOwners', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('returns a verified empty owner map when no chat ids are provided', async () => {
|
||||
const result = await getChatStreamLockOwners([])
|
||||
expect(result.status).toBe('verified')
|
||||
expect(result.ownersByChatId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns Redis lock owners keyed by chat id', async () => {
|
||||
const mget = vi.fn().mockResolvedValue(['stream-1', null, 'stream-3'])
|
||||
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
|
||||
|
||||
const result = await getChatStreamLockOwners(['chat-1', 'chat-2', 'chat-3'])
|
||||
|
||||
expect(mget).toHaveBeenCalledWith([
|
||||
'copilot:chat-stream-lock:chat-1',
|
||||
'copilot:chat-stream-lock:chat-2',
|
||||
'copilot:chat-stream-lock:chat-3',
|
||||
])
|
||||
expect(result.status).toBe('verified')
|
||||
expect(result.ownersByChatId).toEqual(
|
||||
new Map([
|
||||
['chat-1', 'stream-1'],
|
||||
['chat-3', 'stream-3'],
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a verified empty map when every lock has expired in Redis', async () => {
|
||||
const mget = vi.fn().mockResolvedValue([null, null])
|
||||
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
|
||||
|
||||
const result = await getChatStreamLockOwners(['chat-stuck-1', 'chat-stuck-2'])
|
||||
|
||||
expect(result.status).toBe('verified')
|
||||
expect(result.ownersByChatId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('trusts verified Redis null over a process-local pending stream', async () => {
|
||||
const mget = vi.fn().mockResolvedValue([null])
|
||||
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
|
||||
await acquirePendingChatStream('chat-local', 'stream-local')
|
||||
|
||||
try {
|
||||
const result = await getChatStreamLockOwners(['chat-local'])
|
||||
|
||||
expect(result.status).toBe('verified')
|
||||
expect(result.ownersByChatId.size).toBe(0)
|
||||
} finally {
|
||||
await releasePendingChatStream('chat-local', 'stream-local')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns unknown status when Redis is unavailable', async () => {
|
||||
redisConfigMockFns.mockGetRedisClient.mockReturnValue(null)
|
||||
|
||||
const result = await getChatStreamLockOwners(['chat-1', 'chat-2'])
|
||||
|
||||
expect(result.status).toBe('unknown')
|
||||
expect(result.ownersByChatId.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves local pending stream owners when Redis is unavailable', async () => {
|
||||
await acquirePendingChatStream('chat-local', 'stream-local')
|
||||
|
||||
try {
|
||||
const result = await getChatStreamLockOwners(['chat-local', 'chat-remote'])
|
||||
|
||||
expect(result.status).toBe('unknown')
|
||||
expect(result.ownersByChatId).toEqual(new Map([['chat-local', 'stream-local']]))
|
||||
} finally {
|
||||
await releasePendingChatStream('chat-local', 'stream-local')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns unknown status without throwing when mget rejects', async () => {
|
||||
const mget = vi.fn().mockRejectedValue(new Error('redis down'))
|
||||
redisConfigMockFns.mockGetRedisClient.mockReturnValue({ mget } as never)
|
||||
|
||||
const result = await getChatStreamLockOwners(['chat-1', 'chat-2'])
|
||||
|
||||
expect(result.status).toBe('unknown')
|
||||
expect(result.ownersByChatId.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { AbortBackend } 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 { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { AbortReason } from './abort-reason'
|
||||
import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer'
|
||||
|
||||
const logger = createLogger('SessionAbort')
|
||||
|
||||
const activeStreams = new Map<string, AbortController>()
|
||||
const pendingChatStreams = new Map<
|
||||
string,
|
||||
{ promise: Promise<void>; resolve: () => void; streamId: string }
|
||||
>()
|
||||
|
||||
const DEFAULT_ABORT_POLL_MS = 250
|
||||
|
||||
/**
|
||||
* TTL for the per-chat stream lock. Kept short so that if the Sim pod
|
||||
* holding the lock dies (SIGKILL, OOM, a SIGTERM drain that doesn't
|
||||
* reach the release path), the lock self-heals inside a minute rather
|
||||
* than stranding the chat for hours. A live stream keeps the lock alive
|
||||
* via `CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS` heartbeats.
|
||||
*/
|
||||
const CHAT_STREAM_LOCK_TTL_SECONDS = 60
|
||||
|
||||
/**
|
||||
* Heartbeat cadence for extending the per-chat stream lock. Set to a
|
||||
* third of the TTL so one missed beat still leaves room for recovery
|
||||
* before the lock expires under a still-live stream.
|
||||
*/
|
||||
const CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS = 20_000
|
||||
|
||||
export interface ChatStreamLockOwnersResult {
|
||||
status: 'verified' | 'unknown'
|
||||
ownersByChatId: Map<string, string>
|
||||
}
|
||||
|
||||
function registerPendingChatStream(chatId: string, streamId: string): void {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
pendingChatStreams.set(chatId, { promise, resolve, streamId })
|
||||
}
|
||||
|
||||
function resolvePendingChatStream(chatId: string, streamId: string): void {
|
||||
const entry = pendingChatStreams.get(chatId)
|
||||
if (entry && entry.streamId === streamId) {
|
||||
entry.resolve()
|
||||
pendingChatStreams.delete(chatId)
|
||||
}
|
||||
}
|
||||
|
||||
function getChatStreamLockKey(chatId: string): string {
|
||||
return `copilot:chat-stream-lock:${chatId}`
|
||||
}
|
||||
|
||||
export function registerActiveStream(streamId: string, controller: AbortController): void {
|
||||
activeStreams.set(streamId, controller)
|
||||
}
|
||||
|
||||
export function unregisterActiveStream(streamId: string): void {
|
||||
activeStreams.delete(streamId)
|
||||
}
|
||||
|
||||
export async function waitForPendingChatStream(
|
||||
chatId: string,
|
||||
timeoutMs = 5_000,
|
||||
expectedStreamId?: string
|
||||
): Promise<boolean> {
|
||||
const redis = getRedisClient()
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
for (;;) {
|
||||
const entry = pendingChatStreams.get(chatId)
|
||||
const localPending = !!entry && (!expectedStreamId || entry.streamId === expectedStreamId)
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
|
||||
const lockReleased =
|
||||
!ownerStreamId || (expectedStreamId !== undefined && ownerStreamId !== expectedStreamId)
|
||||
if (!localPending && lockReleased) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to inspect chat stream lock while waiting', {
|
||||
chatId,
|
||||
expectedStreamId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
} else if (!localPending) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
return false
|
||||
}
|
||||
await sleep(200)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPendingChatStreamId(chatId: string): Promise<string | null> {
|
||||
const localEntry = pendingChatStreams.get(chatId)
|
||||
if (localEntry?.streamId) {
|
||||
return localEntry.streamId
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return (await redis.get(getChatStreamLockKey(chatId))) || null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load chat stream lock owner', {
|
||||
chatId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads canonical stream lock owners for chat IDs.
|
||||
*
|
||||
* `status: 'verified'` means Redis was queried successfully, so a missing
|
||||
* owner is authoritative. `status: 'unknown'` means only the process-local
|
||||
* pending map is known, which is not enough to declare remote streams inactive
|
||||
* in a multi-pod deployment.
|
||||
*/
|
||||
export async function getChatStreamLockOwners(
|
||||
chatIds: string[]
|
||||
): Promise<ChatStreamLockOwnersResult> {
|
||||
const localOwnersByChatId = new Map<string, string>()
|
||||
if (chatIds.length === 0) {
|
||||
return { status: 'verified', ownersByChatId: localOwnersByChatId }
|
||||
}
|
||||
|
||||
for (const chatId of chatIds) {
|
||||
const entry = pendingChatStreams.get(chatId)
|
||||
if (entry?.streamId) localOwnersByChatId.set(chatId, entry.streamId)
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
return { status: 'unknown', ownersByChatId: localOwnersByChatId }
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = chatIds.map(getChatStreamLockKey)
|
||||
const values = await redis.mget(keys)
|
||||
const redisOwnersByChatId = new Map<string, string>()
|
||||
for (let i = 0; i < chatIds.length; i++) {
|
||||
const owner = values[i]
|
||||
if (owner) redisOwnersByChatId.set(chatIds[i], owner)
|
||||
}
|
||||
return { status: 'verified', ownersByChatId: redisOwnersByChatId }
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load chat stream lock owners (batch)', {
|
||||
count: chatIds.length,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return { status: 'unknown', ownersByChatId: localOwnersByChatId }
|
||||
}
|
||||
}
|
||||
|
||||
export async function releasePendingChatStream(chatId: string, streamId: string): Promise<void> {
|
||||
try {
|
||||
await releaseLock(getChatStreamLockKey(chatId), streamId)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to release chat stream lock', {
|
||||
chatId,
|
||||
streamId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
} finally {
|
||||
resolvePendingChatStream(chatId, streamId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquirePendingChatStream(
|
||||
chatId: string,
|
||||
streamId: string,
|
||||
timeoutMs = 5_000
|
||||
): Promise<boolean> {
|
||||
// Span records wall time spent waiting for the per-chat stream lock.
|
||||
// Typical case: sub-10ms uncontested acquire. Worst case: up to
|
||||
// `timeoutMs` spent polling while a prior stream finishes. Previously
|
||||
// this time looked like "unexplained gap before llm.stream".
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotChatAcquirePendingStreamLock,
|
||||
{
|
||||
[TraceAttr.ChatId]: chatId,
|
||||
[TraceAttr.StreamId]: streamId,
|
||||
[TraceAttr.LockTimeoutMs]: timeoutMs,
|
||||
},
|
||||
async (span) => {
|
||||
const redis = getRedisClient()
|
||||
span.setAttribute(TraceAttr.LockBackend, redis ? AbortBackend.Redis : AbortBackend.InProcess)
|
||||
if (redis) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
try {
|
||||
const acquired = await acquireLock(
|
||||
getChatStreamLockKey(chatId),
|
||||
streamId,
|
||||
CHAT_STREAM_LOCK_TTL_SECONDS
|
||||
)
|
||||
if (acquired) {
|
||||
registerPendingChatStream(chatId, streamId)
|
||||
span.setAttribute(TraceAttr.LockAcquired, true)
|
||||
return true
|
||||
}
|
||||
if (!pendingChatStreams.has(chatId)) {
|
||||
const ownerStreamId = await redis.get(getChatStreamLockKey(chatId))
|
||||
if (ownerStreamId) {
|
||||
const settled = await waitForPendingChatStream(chatId, 0, ownerStreamId)
|
||||
if (settled) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to acquire chat stream lock', {
|
||||
chatId,
|
||||
streamId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
span.setAttribute(TraceAttr.LockAcquired, false)
|
||||
span.setAttribute(TraceAttr.LockTimedOut, true)
|
||||
return false
|
||||
}
|
||||
await sleep(200)
|
||||
}
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const existing = pendingChatStreams.get(chatId)
|
||||
if (!existing) {
|
||||
registerPendingChatStream(chatId, streamId)
|
||||
span.setAttribute(TraceAttr.LockAcquired, true)
|
||||
return true
|
||||
}
|
||||
|
||||
const settled = await Promise.race([
|
||||
existing.promise.then(() => true),
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
|
||||
])
|
||||
if (!settled) {
|
||||
span.setAttribute(TraceAttr.LockAcquired, false)
|
||||
span.setAttribute(TraceAttr.LockTimedOut, true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if it aborted an in-process controller,
|
||||
* `false` if it only wrote the marker (no local controller found).
|
||||
*
|
||||
* Spanned because the two operations inside can stall independently
|
||||
* — Redis latency on `writeAbortMarker` was previously invisible, and
|
||||
* the "no local controller" branch (happens when the stream handler
|
||||
* is on a different Sim box than the one receiving /chat/abort) is
|
||||
* a subtle but important outcome to distinguish from "aborted a live
|
||||
* controller" in dashboards.
|
||||
*/
|
||||
export async function abortActiveStream(streamId: string): Promise<boolean> {
|
||||
return withCopilotSpan(
|
||||
TraceSpan.CopilotChatAbortActiveStream,
|
||||
{ [TraceAttr.StreamId]: streamId },
|
||||
async (span) => {
|
||||
await writeAbortMarker(streamId)
|
||||
span.setAttribute(TraceAttr.CopilotAbortMarkerWritten, true)
|
||||
const controller = activeStreams.get(streamId)
|
||||
if (!controller) {
|
||||
span.setAttribute(TraceAttr.CopilotAbortControllerFired, false)
|
||||
return false
|
||||
}
|
||||
controller.abort(AbortReason.UserStop)
|
||||
activeStreams.delete(streamId)
|
||||
span.setAttribute(TraceAttr.CopilotAbortControllerFired, true)
|
||||
return true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export type { AbortReasonValue } from './abort-reason'
|
||||
/**
|
||||
* `AbortReason` vocabulary and the `isExplicitStopReason` classifier
|
||||
* live in a sibling zero-dependency module so the telemetry layer
|
||||
* (`request/otel.ts`) can import them without creating a circular
|
||||
* import back through `session/abort.ts`'s OTel-wrapped helpers.
|
||||
*
|
||||
* Context on why the distinction matters: when the user clicks Stop,
|
||||
* we fire `abortController.abort(AbortReason.UserStop)` from
|
||||
* `abortActiveStream()`. That causes Sim's SSE writer to close,
|
||||
* which in turn makes the BROWSER's SSE reader see the stream end
|
||||
* — which fires the browser-side fetch AbortController and
|
||||
* propagates back to Sim as `publisher.markDisconnected()`. So on
|
||||
* an explicit Stop you observe BOTH "explicit reason" AND
|
||||
* "client disconnected" — the discriminator is the reason string,
|
||||
* not the client flag.
|
||||
*
|
||||
* For any NEW abort path, add its reason in `./abort-reason.ts` and
|
||||
* update `isExplicitStopReason` if it should be classified as a user
|
||||
* stop.
|
||||
*/
|
||||
export { AbortReason, isExplicitStopReason } from './abort-reason'
|
||||
|
||||
const pollingStreams = new Set<string>()
|
||||
|
||||
export function startAbortPoller(
|
||||
streamId: string,
|
||||
abortController: AbortController,
|
||||
options?: { pollMs?: number; requestId?: string; chatId?: string }
|
||||
): ReturnType<typeof setInterval> {
|
||||
const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS
|
||||
const requestId = options?.requestId
|
||||
const chatId = options?.chatId
|
||||
|
||||
let lastHeartbeatAt = Date.now()
|
||||
let heartbeatOwnershipLost = false
|
||||
|
||||
return setInterval(() => {
|
||||
if (pollingStreams.has(streamId)) return
|
||||
pollingStreams.add(streamId)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const shouldAbort = await hasAbortMarker(streamId)
|
||||
if (shouldAbort && !abortController.signal.aborted) {
|
||||
abortController.abort(AbortReason.RedisPoller)
|
||||
await clearAbortMarker(streamId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to poll stream abort marker', {
|
||||
streamId,
|
||||
...(requestId ? { requestId } : {}),
|
||||
error: toError(error).message,
|
||||
})
|
||||
} finally {
|
||||
pollingStreams.delete(streamId)
|
||||
}
|
||||
|
||||
if (!chatId || heartbeatOwnershipLost) return
|
||||
if (Date.now() - lastHeartbeatAt < CHAT_STREAM_LOCK_HEARTBEAT_INTERVAL_MS) return
|
||||
|
||||
try {
|
||||
const owned = await extendLock(
|
||||
getChatStreamLockKey(chatId),
|
||||
streamId,
|
||||
CHAT_STREAM_LOCK_TTL_SECONDS
|
||||
)
|
||||
lastHeartbeatAt = Date.now()
|
||||
if (!owned) {
|
||||
heartbeatOwnershipLost = true
|
||||
logger.warn('Lost ownership of chat stream lock — stopping heartbeat', {
|
||||
chatId,
|
||||
streamId,
|
||||
...(requestId ? { requestId } : {}),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extend chat stream lock TTL', {
|
||||
chatId,
|
||||
streamId,
|
||||
...(requestId ? { requestId } : {}),
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
})()
|
||||
}, pollMs)
|
||||
}
|
||||
|
||||
export async function cleanupAbortMarker(streamId: string): Promise<void> {
|
||||
try {
|
||||
await clearAbortMarker(streamId)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to clear stream abort marker during cleanup', {
|
||||
streamId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { createEvent } from '@/lib/copilot/request/session/event'
|
||||
|
||||
type StoredEnvelope = {
|
||||
score: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const createRedisStub = () => {
|
||||
const counters = new Map<string, number>()
|
||||
const values = new Map<string, string>()
|
||||
const sortedSets = new Map<string, StoredEnvelope[]>()
|
||||
|
||||
const api = {
|
||||
incr: vi.fn().mockImplementation((key: string) => {
|
||||
const next = (counters.get(key) ?? 0) + 1
|
||||
counters.set(key, next)
|
||||
return next
|
||||
}),
|
||||
expire: vi.fn().mockResolvedValue(1),
|
||||
del: vi.fn().mockImplementation((...keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
values.delete(key)
|
||||
sortedSets.delete(key)
|
||||
counters.delete(key)
|
||||
}
|
||||
return Promise.resolve(keys.length)
|
||||
}),
|
||||
zadd: vi.fn().mockImplementation((key: string, score: number, value: string) => {
|
||||
const entries = sortedSets.get(key) ?? []
|
||||
entries.push({ score, value })
|
||||
sortedSets.set(key, entries)
|
||||
return Promise.resolve(1)
|
||||
}),
|
||||
zremrangebyrank: vi.fn().mockImplementation((key: string, start: number, stop: number) => {
|
||||
const entries = [...(sortedSets.get(key) ?? [])].sort((a, b) => a.score - b.score)
|
||||
const normalizedStart = start < 0 ? Math.max(entries.length + start, 0) : start
|
||||
const normalizedStop = stop < 0 ? entries.length + stop : stop
|
||||
const next = entries.filter(
|
||||
(_entry, index) => index < normalizedStart || index > normalizedStop
|
||||
)
|
||||
sortedSets.set(key, next)
|
||||
return Promise.resolve(1)
|
||||
}),
|
||||
zrangebyscore: vi.fn().mockImplementation((key: string, min: number, max: string) => {
|
||||
const upperBound = max === '+inf' ? Number.POSITIVE_INFINITY : Number(max)
|
||||
const entries = [...(sortedSets.get(key) ?? [])]
|
||||
.filter((entry) => entry.score >= min && entry.score <= upperBound)
|
||||
.sort((a, b) => a.score - b.score)
|
||||
.map((entry) => entry.value)
|
||||
return Promise.resolve(entries)
|
||||
}),
|
||||
set: vi.fn().mockImplementation((key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
return Promise.resolve('OK')
|
||||
}),
|
||||
get: vi.fn().mockImplementation((key: string) => Promise.resolve(values.get(key) ?? null)),
|
||||
pipeline: vi.fn().mockImplementation(() => {
|
||||
const operations: Array<() => Promise<unknown>> = []
|
||||
const pipeline = {
|
||||
zadd: (...args: [string, number, string]) => {
|
||||
operations.push(() => api.zadd(...args))
|
||||
return pipeline
|
||||
},
|
||||
expire: (...args: [string, number]) => {
|
||||
operations.push(() => api.expire(...args))
|
||||
return pipeline
|
||||
},
|
||||
set: (...args: [string, string, 'EX', number]) => {
|
||||
operations.push(() => api.set(args[0], args[1]))
|
||||
return pipeline
|
||||
},
|
||||
zremrangebyrank: (...args: [string, number, number]) => {
|
||||
operations.push(() => api.zremrangebyrank(...args))
|
||||
return pipeline
|
||||
},
|
||||
exec: vi.fn().mockImplementation(async () => {
|
||||
const results: Array<[null, unknown]> = []
|
||||
for (const operation of operations) {
|
||||
results.push([null, await operation()])
|
||||
}
|
||||
return results
|
||||
}),
|
||||
}
|
||||
return pipeline
|
||||
}),
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
let mockRedis: ReturnType<typeof createRedisStub>
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
|
||||
|
||||
import {
|
||||
allocateCursor,
|
||||
appendEvent,
|
||||
clearBuffer,
|
||||
readEvents,
|
||||
scheduleBufferCleanup,
|
||||
} from '@/lib/copilot/request/session/buffer'
|
||||
|
||||
describe('mothership-stream-outbox', () => {
|
||||
beforeEach(() => {
|
||||
mockRedis = createRedisStub()
|
||||
vi.clearAllMocks()
|
||||
redisConfigMockFns.mockGetRedisClient.mockImplementation(() => mockRedis)
|
||||
})
|
||||
|
||||
it('replays envelopes after a given cursor', async () => {
|
||||
const firstCursor = await allocateCursor('stream-1')
|
||||
const secondCursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: firstCursor.cursor,
|
||||
seq: firstCursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: secondCursor.cursor,
|
||||
seq: secondCursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'world' },
|
||||
})
|
||||
)
|
||||
|
||||
const allEvents = await readEvents('stream-1', '0')
|
||||
expect(allEvents.map((entry) => entry.payload.text)).toEqual(['hello', 'world'])
|
||||
|
||||
const replayed = await readEvents('stream-1', '1')
|
||||
expect(replayed.map((entry) => entry.payload.text)).toEqual(['world'])
|
||||
})
|
||||
|
||||
it('trims active stream history to eventLimit on every append', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRedis.zremrangebyrank).toHaveBeenCalledWith(
|
||||
'mothership_stream:stream-1:events',
|
||||
0,
|
||||
-100_001
|
||||
)
|
||||
})
|
||||
|
||||
it('clears persisted stream state during teardown cleanup', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
expect((await readEvents('stream-1', '0')).length).toBe(1)
|
||||
|
||||
await clearBuffer('stream-1')
|
||||
|
||||
expect(await readEvents('stream-1', '0')).toEqual([])
|
||||
})
|
||||
|
||||
it('shortens completed stream retention without deleting replay data immediately', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
await scheduleBufferCleanup('stream-1', 30)
|
||||
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:events', 30)
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:seq', 30)
|
||||
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:abort', 30)
|
||||
expect((await readEvents('stream-1', '0')).map((entry) => entry.payload.text)).toEqual([
|
||||
'hello',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips corrupt replay entries that fail stream validation', async () => {
|
||||
const cursor = await allocateCursor('stream-1')
|
||||
|
||||
await appendEvent(
|
||||
createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: cursor.cursor,
|
||||
seq: cursor.seq,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
)
|
||||
|
||||
await mockRedis.zadd(
|
||||
'mothership_stream:stream-1:events',
|
||||
cursor.seq + 1,
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
type: 'tool',
|
||||
seq: cursor.seq + 1,
|
||||
ts: '2026-04-11T00:00:00.000Z',
|
||||
stream: { streamId: 'stream-1' },
|
||||
payload: { toolCallId: 'broken-tool' },
|
||||
})
|
||||
)
|
||||
|
||||
const replayed = await readEvents('stream-1', '0')
|
||||
|
||||
expect(replayed).toHaveLength(1)
|
||||
expect(replayed[0]?.payload.text).toBe('hello')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { env, envNumber } from '@/lib/core/config/env'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import {
|
||||
type PersistedStreamEventEnvelope,
|
||||
parsePersistedStreamEventEnvelopeJson,
|
||||
} from './contract'
|
||||
|
||||
const logger = createLogger('SessionBuffer')
|
||||
|
||||
const STREAM_OUTBOX_PREFIX = 'mothership_stream:'
|
||||
const DEFAULT_TTL_SECONDS = 60 * 60
|
||||
const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60
|
||||
const DEFAULT_EVENT_LIMIT = 100_000
|
||||
const RETRY_DELAYS_MS = [0, 50, 150] as const
|
||||
|
||||
type RedisOperationMetadata = {
|
||||
operation: string
|
||||
streamId: string
|
||||
}
|
||||
|
||||
function getEventsKey(streamId: string) {
|
||||
return `${STREAM_OUTBOX_PREFIX}${streamId}:events`
|
||||
}
|
||||
|
||||
function getSeqKey(streamId: string) {
|
||||
return `${STREAM_OUTBOX_PREFIX}${streamId}:seq`
|
||||
}
|
||||
|
||||
function getAbortKey(streamId: string) {
|
||||
return `${STREAM_OUTBOX_PREFIX}${streamId}:abort`
|
||||
}
|
||||
|
||||
export type StreamConfig = {
|
||||
ttlSeconds: number
|
||||
eventLimit: number
|
||||
}
|
||||
|
||||
export function getStreamConfig(): StreamConfig {
|
||||
return {
|
||||
ttlSeconds: envNumber(env.COPILOT_STREAM_TTL_SECONDS, DEFAULT_TTL_SECONDS, { min: 1 }),
|
||||
eventLimit: envNumber(env.COPILOT_STREAM_EVENT_LIMIT, DEFAULT_EVENT_LIMIT, { min: 1 }),
|
||||
}
|
||||
}
|
||||
|
||||
async function withRedisRetry<T>(
|
||||
metadata: RedisOperationMetadata,
|
||||
operation: (redis: NonNullable<ReturnType<typeof getRedisClient>>) => Promise<T>
|
||||
): Promise<T> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
throw new Error('Redis is required for mothership stream durability')
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
||||
const delay = RETRY_DELAYS_MS[attempt]
|
||||
if (delay > 0) {
|
||||
await sleep(delay)
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation(redis)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
logger.warn('Redis stream operation failed', {
|
||||
operation: metadata.operation,
|
||||
streamId: metadata.streamId,
|
||||
attempt: attempt + 1,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`${metadata.operation} failed for stream ${metadata.streamId}`)
|
||||
}
|
||||
|
||||
export async function allocateCursor(streamId: string): Promise<{
|
||||
seq: number
|
||||
cursor: string
|
||||
}> {
|
||||
const config = getStreamConfig()
|
||||
const seq = await withRedisRetry({ operation: 'allocate_cursor', streamId }, async (redis) => {
|
||||
const nextValue = await redis.incr(getSeqKey(streamId))
|
||||
await redis.expire(getSeqKey(streamId), config.ttlSeconds)
|
||||
return typeof nextValue === 'number' ? nextValue : Number(nextValue)
|
||||
})
|
||||
|
||||
return { seq, cursor: String(seq) }
|
||||
}
|
||||
|
||||
export async function resetBuffer(streamId: string): Promise<void> {
|
||||
await clearBuffer(streamId, 'reset_outbox')
|
||||
}
|
||||
|
||||
export async function clearBuffer(streamId: string, operation = 'clear_outbox'): Promise<void> {
|
||||
await withRedisRetry({ operation, streamId }, async (redis) => {
|
||||
await redis.del(getEventsKey(streamId), getSeqKey(streamId), getAbortKey(streamId))
|
||||
})
|
||||
}
|
||||
|
||||
export async function scheduleBufferCleanup(
|
||||
streamId: string,
|
||||
ttlSeconds = DEFAULT_COMPLETED_TTL_SECONDS
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withRedisRetry({ operation: 'schedule_outbox_cleanup', streamId }, async (redis) => {
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.expire(getEventsKey(streamId), ttlSeconds)
|
||||
pipeline.expire(getSeqKey(streamId), ttlSeconds)
|
||||
pipeline.expire(getAbortKey(streamId), ttlSeconds)
|
||||
await pipeline.exec()
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to shorten stream buffer TTL during cleanup', {
|
||||
streamId,
|
||||
ttlSeconds,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendEvents(
|
||||
envelopes: PersistedStreamEventEnvelope[]
|
||||
): Promise<PersistedStreamEventEnvelope[]> {
|
||||
if (envelopes.length === 0) {
|
||||
return envelopes
|
||||
}
|
||||
|
||||
const streamId = envelopes[0].stream.streamId
|
||||
const config = getStreamConfig()
|
||||
|
||||
await withRedisRetry({ operation: 'append_event', streamId }, async (redis) => {
|
||||
const key = getEventsKey(streamId)
|
||||
const seqKey = getSeqKey(streamId)
|
||||
const pipeline = redis.pipeline()
|
||||
const zaddArgs: Array<number | string> = []
|
||||
for (const envelope of envelopes) {
|
||||
zaddArgs.push(envelope.seq, JSON.stringify(envelope))
|
||||
}
|
||||
pipeline.zadd(key, ...(zaddArgs as [number, string, ...Array<number | string>]))
|
||||
pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1)
|
||||
pipeline.expire(key, config.ttlSeconds)
|
||||
pipeline.set(seqKey, String(envelopes[envelopes.length - 1].seq), 'EX', config.ttlSeconds)
|
||||
await pipeline.exec()
|
||||
})
|
||||
|
||||
return envelopes
|
||||
}
|
||||
|
||||
export async function appendEvent(
|
||||
envelope: PersistedStreamEventEnvelope
|
||||
): Promise<PersistedStreamEventEnvelope> {
|
||||
await appendEvents([envelope])
|
||||
return envelope
|
||||
}
|
||||
|
||||
export class InvalidCursorError extends Error {
|
||||
constructor(
|
||||
public readonly streamId: string,
|
||||
public readonly cursor: string
|
||||
) {
|
||||
super(`Invalid non-numeric cursor "${cursor}" for stream ${streamId}`)
|
||||
this.name = 'InvalidCursorError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function readEvents(
|
||||
streamId: string,
|
||||
afterCursor: string
|
||||
): Promise<PersistedStreamEventEnvelope[]> {
|
||||
const afterSeq = Number(afterCursor || '0')
|
||||
if (!Number.isFinite(afterSeq)) {
|
||||
throw new InvalidCursorError(streamId, afterCursor)
|
||||
}
|
||||
const minScore = afterSeq + 1
|
||||
|
||||
const rawEntries = await withRedisRetry({ operation: 'read_events', streamId }, async (redis) => {
|
||||
return redis.zrangebyscore(getEventsKey(streamId), minScore, '+inf')
|
||||
})
|
||||
|
||||
const envelopes: PersistedStreamEventEnvelope[] = []
|
||||
for (const entry of rawEntries) {
|
||||
const parsed = parsePersistedStreamEventEnvelopeJson(entry)
|
||||
if (!parsed.ok) {
|
||||
logger.warn('Skipping corrupt outbox entry', {
|
||||
streamId,
|
||||
reason: parsed.reason,
|
||||
message: parsed.message,
|
||||
errors: parsed.errors,
|
||||
})
|
||||
continue
|
||||
}
|
||||
envelopes.push(parsed.event)
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
export async function getOldestSeq(streamId: string): Promise<number | null> {
|
||||
return withRedisRetry({ operation: 'get_oldest_seq', streamId }, async (redis) => {
|
||||
const entries = await redis.zrangebyscore(getEventsKey(streamId), '-inf', '+inf', 'LIMIT', 0, 1)
|
||||
if (!entries || entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(entries[0]) as { seq?: number }
|
||||
return typeof parsed.seq === 'number' ? parsed.seq : null
|
||||
} catch {
|
||||
logger.warn('Failed to parse oldest outbox entry', { streamId })
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function getLatestSeq(streamId: string): Promise<number | null> {
|
||||
return withRedisRetry({ operation: 'get_latest_seq', streamId }, async (redis) => {
|
||||
const currentSeq = await redis.get(getSeqKey(streamId))
|
||||
if (currentSeq === null) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(currentSeq)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
})
|
||||
}
|
||||
|
||||
export async function writeAbortMarker(streamId: string): Promise<void> {
|
||||
const ttlSeconds = getStreamConfig().ttlSeconds
|
||||
await withRedisRetry({ operation: 'write_abort_marker', streamId }, async (redis) => {
|
||||
await redis.set(getAbortKey(streamId), '1', 'EX', ttlSeconds)
|
||||
})
|
||||
}
|
||||
|
||||
export async function hasAbortMarker(streamId: string): Promise<boolean> {
|
||||
return withRedisRetry({ operation: 'read_abort_marker', streamId }, async (redis) => {
|
||||
const marker = await redis.get(getAbortKey(streamId))
|
||||
return marker === '1'
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearAbortMarker(streamId: string): Promise<void> {
|
||||
await withRedisRetry({ operation: 'clear_abort_marker', streamId }, async (redis) => {
|
||||
await redis.del(getAbortKey(streamId))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isContractStreamEventEnvelope,
|
||||
isSyntheticFilePreviewEventEnvelope,
|
||||
parsePersistedStreamEventEnvelope,
|
||||
parsePersistedStreamEventEnvelopeJson,
|
||||
} from './contract'
|
||||
|
||||
const BASE_ENVELOPE = {
|
||||
v: 1 as const,
|
||||
seq: 1,
|
||||
ts: '2026-04-11T00:00:00.000Z',
|
||||
stream: {
|
||||
streamId: 'stream-1',
|
||||
cursor: '1',
|
||||
},
|
||||
trace: {
|
||||
requestId: 'req-1',
|
||||
},
|
||||
}
|
||||
|
||||
describe('stream session contract parser', () => {
|
||||
it('accepts contract text events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
trace: {
|
||||
...BASE_ENVELOPE.trace,
|
||||
goTraceId: 'go-trace-1',
|
||||
},
|
||||
type: 'text' as const,
|
||||
payload: {
|
||||
channel: 'assistant' as const,
|
||||
text: 'hello',
|
||||
},
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
|
||||
const parsed = parsePersistedStreamEventEnvelope(event)
|
||||
expect(parsed).toEqual({
|
||||
ok: true,
|
||||
event,
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts contract session chat events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'session' as const,
|
||||
payload: { kind: 'chat' as const, chatId: 'chat-1' },
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts contract complete events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'complete' as const,
|
||||
payload: { status: 'complete' as const },
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts contract error events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'error' as const,
|
||||
payload: { message: 'something went wrong' },
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts contract tool call events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'tool' as const,
|
||||
payload: {
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'read',
|
||||
phase: 'call' as const,
|
||||
executor: 'sim' as const,
|
||||
mode: 'sync' as const,
|
||||
},
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts contract span events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'span' as const,
|
||||
payload: {
|
||||
kind: 'subagent' as const,
|
||||
event: 'start' as const,
|
||||
agent: 'file',
|
||||
},
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts contract resource events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'resource' as const,
|
||||
payload: {
|
||||
op: 'upsert' as const,
|
||||
resource: { id: 'r-1', type: 'file', title: 'test.md' },
|
||||
},
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts contract run events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'run' as const,
|
||||
payload: { kind: 'compaction_start' as const },
|
||||
}
|
||||
|
||||
expect(isContractStreamEventEnvelope(event)).toBe(true)
|
||||
expect(parsePersistedStreamEventEnvelope(event).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts synthetic file preview events', () => {
|
||||
const event = {
|
||||
...BASE_ENVELOPE,
|
||||
type: 'tool' as const,
|
||||
payload: {
|
||||
toolCallId: 'preview-1',
|
||||
toolName: 'workspace_file' as const,
|
||||
previewPhase: 'file_preview_content' as const,
|
||||
content: 'draft body',
|
||||
contentMode: 'snapshot' as const,
|
||||
previewVersion: 2,
|
||||
fileName: 'draft.md',
|
||||
},
|
||||
}
|
||||
|
||||
expect(isSyntheticFilePreviewEventEnvelope(event)).toBe(true)
|
||||
|
||||
const parsed = parsePersistedStreamEventEnvelope(event)
|
||||
expect(parsed).toEqual({
|
||||
ok: true,
|
||||
event,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid tool events with structured validation errors', () => {
|
||||
const parsed = parsePersistedStreamEventEnvelope({
|
||||
...BASE_ENVELOPE,
|
||||
type: 'tool',
|
||||
payload: {
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read',
|
||||
},
|
||||
})
|
||||
|
||||
expect(parsed.ok).toBe(false)
|
||||
if (parsed.ok) {
|
||||
throw new Error('expected invalid result')
|
||||
}
|
||||
expect(parsed.reason).toBe('invalid_stream_event')
|
||||
})
|
||||
|
||||
it('rejects unknown event types', () => {
|
||||
const parsed = parsePersistedStreamEventEnvelope({
|
||||
...BASE_ENVELOPE,
|
||||
type: 'unknown_type',
|
||||
payload: {},
|
||||
})
|
||||
|
||||
expect(parsed.ok).toBe(false)
|
||||
if (parsed.ok) {
|
||||
throw new Error('expected invalid result')
|
||||
}
|
||||
expect(parsed.reason).toBe('invalid_stream_event')
|
||||
expect(parsed.errors).toContain('unknown type="unknown_type"')
|
||||
})
|
||||
|
||||
it('rejects non-object values', () => {
|
||||
const parsed = parsePersistedStreamEventEnvelope('not an object')
|
||||
|
||||
expect(parsed.ok).toBe(false)
|
||||
if (parsed.ok) {
|
||||
throw new Error('expected invalid result')
|
||||
}
|
||||
expect(parsed.reason).toBe('invalid_stream_event')
|
||||
expect(parsed.errors).toContain('value is not an object')
|
||||
})
|
||||
|
||||
it('reports invalid JSON separately from schema failures', () => {
|
||||
const parsed = parsePersistedStreamEventEnvelopeJson('{')
|
||||
|
||||
expect(parsed.ok).toBe(false)
|
||||
if (parsed.ok) {
|
||||
throw new Error('expected invalid json result')
|
||||
}
|
||||
expect(parsed.reason).toBe('invalid_json')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,475 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { isRecordLike } from '@sim/utils/object'
|
||||
import type {
|
||||
MothershipStreamV1EventEnvelope,
|
||||
MothershipStreamV1StreamRef,
|
||||
MothershipStreamV1StreamScope,
|
||||
MothershipStreamV1Trace,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1ResourceOp,
|
||||
MothershipStreamV1RunKind,
|
||||
MothershipStreamV1SessionKind,
|
||||
MothershipStreamV1SpanPayloadKind,
|
||||
MothershipStreamV1TextChannel,
|
||||
MothershipStreamV1ToolPhase,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { FilePreviewTargetKind } from './file-preview-session-contract'
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const FILE_PREVIEW_PHASE = {
|
||||
start: 'file_preview_start',
|
||||
target: 'file_preview_target',
|
||||
editMeta: 'file_preview_edit_meta',
|
||||
content: 'file_preview_content',
|
||||
complete: 'file_preview_complete',
|
||||
} as const
|
||||
|
||||
type EnvelopeToStreamEvent<T> = T extends {
|
||||
type: infer TType
|
||||
payload: infer TPayload
|
||||
scope?: infer TScope
|
||||
}
|
||||
? { type: TType; payload: TPayload; scope?: Exclude<TScope, undefined> }
|
||||
: never
|
||||
|
||||
export type SyntheticFilePreviewPhase = (typeof FILE_PREVIEW_PHASE)[keyof typeof FILE_PREVIEW_PHASE]
|
||||
|
||||
export interface SyntheticFilePreviewTarget {
|
||||
kind: FilePreviewTargetKind
|
||||
fileId?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface SyntheticFilePreviewStartPayload {
|
||||
previewPhase: typeof FILE_PREVIEW_PHASE.start
|
||||
toolCallId: string
|
||||
toolName: 'workspace_file'
|
||||
}
|
||||
|
||||
export interface SyntheticFilePreviewTargetPayload {
|
||||
operation?: string
|
||||
previewPhase: typeof FILE_PREVIEW_PHASE.target
|
||||
target: SyntheticFilePreviewTarget
|
||||
title?: string
|
||||
toolCallId: string
|
||||
toolName: 'workspace_file'
|
||||
}
|
||||
|
||||
export interface SyntheticFilePreviewEditMetaPayload {
|
||||
edit: JsonRecord
|
||||
previewPhase: typeof FILE_PREVIEW_PHASE.editMeta
|
||||
toolCallId: string
|
||||
toolName: 'workspace_file'
|
||||
}
|
||||
|
||||
export interface SyntheticFilePreviewContentPayload {
|
||||
content: string
|
||||
contentMode: 'delta' | 'snapshot'
|
||||
edit?: JsonRecord
|
||||
fileId?: string
|
||||
fileName: string
|
||||
operation?: string
|
||||
previewPhase: typeof FILE_PREVIEW_PHASE.content
|
||||
previewVersion: number
|
||||
targetKind?: string
|
||||
toolCallId: string
|
||||
toolName: 'workspace_file'
|
||||
}
|
||||
|
||||
export interface SyntheticFilePreviewCompletePayload {
|
||||
fileId?: string
|
||||
output?: unknown
|
||||
previewPhase: typeof FILE_PREVIEW_PHASE.complete
|
||||
previewVersion?: number
|
||||
toolCallId: string
|
||||
toolName: 'workspace_file'
|
||||
}
|
||||
|
||||
export type SyntheticFilePreviewPayload =
|
||||
| SyntheticFilePreviewStartPayload
|
||||
| SyntheticFilePreviewTargetPayload
|
||||
| SyntheticFilePreviewEditMetaPayload
|
||||
| SyntheticFilePreviewContentPayload
|
||||
| SyntheticFilePreviewCompletePayload
|
||||
|
||||
export interface SyntheticFilePreviewEventEnvelope {
|
||||
payload: SyntheticFilePreviewPayload
|
||||
scope?: MothershipStreamV1StreamScope
|
||||
seq: number
|
||||
stream: MothershipStreamV1StreamRef
|
||||
trace?: MothershipStreamV1Trace
|
||||
ts: string
|
||||
type: 'tool'
|
||||
v: 1
|
||||
}
|
||||
|
||||
export type PersistedStreamEventEnvelope =
|
||||
| MothershipStreamV1EventEnvelope
|
||||
| SyntheticFilePreviewEventEnvelope
|
||||
|
||||
export type ContractStreamEvent = EnvelopeToStreamEvent<MothershipStreamV1EventEnvelope>
|
||||
export type SyntheticStreamEvent = EnvelopeToStreamEvent<SyntheticFilePreviewEventEnvelope>
|
||||
export type SessionStreamEvent = ContractStreamEvent | SyntheticStreamEvent
|
||||
export type StreamEvent = SessionStreamEvent
|
||||
export type ToolCallStreamEvent = Extract<
|
||||
ContractStreamEvent,
|
||||
{ type: 'tool'; payload: { phase: 'call' } }
|
||||
>
|
||||
export type ToolArgsDeltaStreamEvent = Extract<
|
||||
ContractStreamEvent,
|
||||
{ type: 'tool'; payload: { phase: 'args_delta' } }
|
||||
>
|
||||
export type ToolResultStreamEvent = Extract<
|
||||
ContractStreamEvent,
|
||||
{ type: 'tool'; payload: { phase: 'result' } }
|
||||
>
|
||||
export type SubagentSpanStreamEvent = Extract<
|
||||
ContractStreamEvent,
|
||||
{ type: 'span'; payload: { kind: 'subagent' } }
|
||||
>
|
||||
|
||||
export interface ParseStreamEventEnvelopeSuccess {
|
||||
ok: true
|
||||
event: PersistedStreamEventEnvelope
|
||||
}
|
||||
|
||||
export interface ParseStreamEventEnvelopeFailure {
|
||||
errors?: string[]
|
||||
message: string
|
||||
ok: false
|
||||
reason: 'invalid_json' | 'invalid_stream_event'
|
||||
}
|
||||
|
||||
export type ParseStreamEventEnvelopeResult =
|
||||
| ParseStreamEventEnvelopeSuccess
|
||||
| ParseStreamEventEnvelopeFailure
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural helpers (CSP-safe – no codegen / eval / new Function)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isOptionalString(value: unknown): value is string | undefined {
|
||||
return value === undefined || typeof value === 'string'
|
||||
}
|
||||
|
||||
function isOptionalFiniteNumber(value: unknown): value is number | undefined {
|
||||
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
|
||||
}
|
||||
|
||||
function isStreamRef(value: unknown): value is MothershipStreamV1StreamRef {
|
||||
return (
|
||||
isRecordLike(value) &&
|
||||
typeof value.streamId === 'string' &&
|
||||
value.streamId.length > 0 &&
|
||||
isOptionalString(value.chatId) &&
|
||||
isOptionalString(value.cursor)
|
||||
)
|
||||
}
|
||||
|
||||
function isTrace(value: unknown): value is MothershipStreamV1Trace {
|
||||
return (
|
||||
isRecordLike(value) &&
|
||||
typeof value.requestId === 'string' &&
|
||||
isOptionalString(value.goTraceId) &&
|
||||
isOptionalString(value.spanId)
|
||||
)
|
||||
}
|
||||
|
||||
function isStreamScope(value: unknown): value is MothershipStreamV1StreamScope {
|
||||
return (
|
||||
isRecordLike(value) &&
|
||||
value.lane === 'subagent' &&
|
||||
isOptionalString(value.agentId) &&
|
||||
isOptionalString(value.parentToolCallId)
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Contract envelope validator (replaces Ajv runtime compilation)
|
||||
//
|
||||
// Validates the envelope shell (v, seq, ts, stream, trace?, scope?) and that
|
||||
// `type` is one of the known event types with a non-null payload object.
|
||||
// Per-payload-variant validation is intentionally lightweight: the server
|
||||
// already performs strict schema validation; the client only needs enough
|
||||
// structural checking to safely dispatch inside the switch statement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KNOWN_EVENT_TYPES: ReadonlySet<string> = new Set(Object.values(MothershipStreamV1EventType))
|
||||
|
||||
function isValidEnvelopeShell(value: unknown): value is JsonRecord & {
|
||||
v: 1
|
||||
seq: number
|
||||
ts: string
|
||||
stream: MothershipStreamV1StreamRef
|
||||
type: string
|
||||
payload: JsonRecord
|
||||
} {
|
||||
if (!isRecordLike(value)) return false
|
||||
if (value.v !== 1) return false
|
||||
if (typeof value.seq !== 'number' || !Number.isFinite(value.seq)) return false
|
||||
if (typeof value.ts !== 'string') return false
|
||||
if (!isStreamRef(value.stream)) return false
|
||||
if (value.trace !== undefined && !isTrace(value.trace)) return false
|
||||
if (value.scope !== undefined && !isStreamScope(value.scope)) return false
|
||||
if (typeof value.type !== 'string' || !KNOWN_EVENT_TYPES.has(value.type)) return false
|
||||
if (!isRecordLike(value.payload)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isValidSessionPayload(payload: JsonRecord): boolean {
|
||||
const kind = payload.kind
|
||||
if (typeof kind !== 'string') return false
|
||||
switch (kind) {
|
||||
case MothershipStreamV1SessionKind.start:
|
||||
return true
|
||||
case MothershipStreamV1SessionKind.chat:
|
||||
return typeof payload.chatId === 'string'
|
||||
case MothershipStreamV1SessionKind.title:
|
||||
return typeof payload.title === 'string'
|
||||
case MothershipStreamV1SessionKind.trace:
|
||||
return typeof payload.requestId === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isValidTextPayload(payload: JsonRecord): boolean {
|
||||
return (
|
||||
(payload.channel === MothershipStreamV1TextChannel.assistant ||
|
||||
payload.channel === MothershipStreamV1TextChannel.thinking) &&
|
||||
typeof payload.text === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidToolPayload(payload: JsonRecord): boolean {
|
||||
if (typeof payload.toolCallId !== 'string') return false
|
||||
if (typeof payload.toolName !== 'string') return false
|
||||
const phase = payload.phase
|
||||
return (
|
||||
phase === MothershipStreamV1ToolPhase.call ||
|
||||
phase === MothershipStreamV1ToolPhase.args_delta ||
|
||||
phase === MothershipStreamV1ToolPhase.result
|
||||
)
|
||||
}
|
||||
|
||||
function isValidSpanPayload(payload: JsonRecord): boolean {
|
||||
const kind = payload.kind
|
||||
return (
|
||||
kind === MothershipStreamV1SpanPayloadKind.subagent ||
|
||||
kind === MothershipStreamV1SpanPayloadKind.structured_result ||
|
||||
kind === MothershipStreamV1SpanPayloadKind.subagent_result
|
||||
)
|
||||
}
|
||||
|
||||
function isValidResourcePayload(payload: JsonRecord): boolean {
|
||||
return (
|
||||
(payload.op === MothershipStreamV1ResourceOp.upsert ||
|
||||
payload.op === MothershipStreamV1ResourceOp.remove) &&
|
||||
isRecordLike(payload.resource) &&
|
||||
typeof (payload.resource as JsonRecord).id === 'string' &&
|
||||
typeof (payload.resource as JsonRecord).type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isValidRunPayload(payload: JsonRecord): boolean {
|
||||
const kind = payload.kind
|
||||
return (
|
||||
kind === MothershipStreamV1RunKind.checkpoint_pause ||
|
||||
kind === MothershipStreamV1RunKind.resumed ||
|
||||
kind === MothershipStreamV1RunKind.compaction_start ||
|
||||
kind === MothershipStreamV1RunKind.compaction_done
|
||||
)
|
||||
}
|
||||
|
||||
function isValidErrorPayload(payload: JsonRecord): boolean {
|
||||
return typeof payload.message === 'string' || typeof payload.error === 'string'
|
||||
}
|
||||
|
||||
function isValidCompletePayload(payload: JsonRecord): boolean {
|
||||
return typeof payload.status === 'string'
|
||||
}
|
||||
|
||||
function isContractEnvelope(value: unknown): value is MothershipStreamV1EventEnvelope {
|
||||
if (!isValidEnvelopeShell(value)) return false
|
||||
const payload = value.payload as JsonRecord
|
||||
switch (value.type) {
|
||||
case MothershipStreamV1EventType.session:
|
||||
return isValidSessionPayload(payload)
|
||||
case MothershipStreamV1EventType.text:
|
||||
return isValidTextPayload(payload)
|
||||
case MothershipStreamV1EventType.tool:
|
||||
return isValidToolPayload(payload)
|
||||
case MothershipStreamV1EventType.span:
|
||||
return isValidSpanPayload(payload)
|
||||
case MothershipStreamV1EventType.resource:
|
||||
return isValidResourcePayload(payload)
|
||||
case MothershipStreamV1EventType.run:
|
||||
return isValidRunPayload(payload)
|
||||
case MothershipStreamV1EventType.error:
|
||||
return isValidErrorPayload(payload)
|
||||
case MothershipStreamV1EventType.complete:
|
||||
return isValidCompletePayload(payload)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Synthetic file-preview envelope validators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isSyntheticEnvelopeBase(value: unknown): value is Omit<
|
||||
SyntheticFilePreviewEventEnvelope,
|
||||
'payload'
|
||||
> & {
|
||||
payload?: unknown
|
||||
} {
|
||||
return (
|
||||
isRecordLike(value) &&
|
||||
value.v === 1 &&
|
||||
value.type === 'tool' &&
|
||||
typeof value.seq === 'number' &&
|
||||
Number.isFinite(value.seq) &&
|
||||
typeof value.ts === 'string' &&
|
||||
isStreamRef(value.stream) &&
|
||||
(value.trace === undefined || isTrace(value.trace)) &&
|
||||
(value.scope === undefined || isStreamScope(value.scope))
|
||||
)
|
||||
}
|
||||
|
||||
function isSyntheticFilePreviewTarget(value: unknown): value is SyntheticFilePreviewTarget {
|
||||
return (
|
||||
isRecordLike(value) &&
|
||||
(value.kind === 'new_file' || value.kind === 'file_id') &&
|
||||
isOptionalString(value.fileId) &&
|
||||
isOptionalString(value.fileName)
|
||||
)
|
||||
}
|
||||
|
||||
function isSyntheticFilePreviewPayload(value: unknown): value is SyntheticFilePreviewPayload {
|
||||
if (!isRecordLike(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof value.toolCallId !== 'string' || value.toolName !== 'workspace_file') {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (value.previewPhase) {
|
||||
case FILE_PREVIEW_PHASE.start:
|
||||
return true
|
||||
case FILE_PREVIEW_PHASE.target:
|
||||
return (
|
||||
isSyntheticFilePreviewTarget(value.target) &&
|
||||
isOptionalString(value.operation) &&
|
||||
isOptionalString(value.title)
|
||||
)
|
||||
case FILE_PREVIEW_PHASE.editMeta:
|
||||
return isRecordLike(value.edit)
|
||||
case FILE_PREVIEW_PHASE.content:
|
||||
return (
|
||||
typeof value.content === 'string' &&
|
||||
(value.contentMode === 'delta' || value.contentMode === 'snapshot') &&
|
||||
typeof value.previewVersion === 'number' &&
|
||||
Number.isFinite(value.previewVersion) &&
|
||||
typeof value.fileName === 'string' &&
|
||||
isOptionalString(value.fileId) &&
|
||||
isOptionalString(value.targetKind) &&
|
||||
isOptionalString(value.operation) &&
|
||||
(value.edit === undefined || isRecordLike(value.edit))
|
||||
)
|
||||
case FILE_PREVIEW_PHASE.complete:
|
||||
return isOptionalString(value.fileId) && isOptionalFiniteNumber(value.previewVersion)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isSyntheticFilePreviewEventEnvelope(
|
||||
value: unknown
|
||||
): value is SyntheticFilePreviewEventEnvelope {
|
||||
return isSyntheticEnvelopeBase(value) && isSyntheticFilePreviewPayload(value.payload)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream event type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isToolCallStreamEvent(event: SessionStreamEvent): event is ToolCallStreamEvent {
|
||||
return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'call'
|
||||
}
|
||||
|
||||
export function isToolArgsDeltaStreamEvent(
|
||||
event: SessionStreamEvent
|
||||
): event is ToolArgsDeltaStreamEvent {
|
||||
return (
|
||||
event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'args_delta'
|
||||
)
|
||||
}
|
||||
|
||||
export function isToolResultStreamEvent(event: SessionStreamEvent): event is ToolResultStreamEvent {
|
||||
return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'result'
|
||||
}
|
||||
|
||||
export function isSubagentSpanStreamEvent(
|
||||
event: SessionStreamEvent
|
||||
): event is SubagentSpanStreamEvent {
|
||||
return event.type === 'span' && isRecordLike(event.payload) && event.payload.kind === 'subagent'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public contract validators & parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isContractStreamEventEnvelope(
|
||||
value: unknown
|
||||
): value is MothershipStreamV1EventEnvelope {
|
||||
return isContractEnvelope(value)
|
||||
}
|
||||
|
||||
export function parsePersistedStreamEventEnvelope(value: unknown): ParseStreamEventEnvelopeResult {
|
||||
if (isContractEnvelope(value)) {
|
||||
return { ok: true, event: value }
|
||||
}
|
||||
|
||||
if (isSyntheticFilePreviewEventEnvelope(value)) {
|
||||
return { ok: true, event: value }
|
||||
}
|
||||
|
||||
const hints: string[] = []
|
||||
if (!isRecordLike(value)) {
|
||||
hints.push('value is not an object')
|
||||
} else {
|
||||
if (value.v !== 1) hints.push(`unexpected v=${JSON.stringify(value.v)}`)
|
||||
if (typeof value.type !== 'string') hints.push('missing type')
|
||||
else if (!KNOWN_EVENT_TYPES.has(value.type)) hints.push(`unknown type="${value.type}"`)
|
||||
if (!isRecordLike(value.payload)) hints.push('missing or invalid payload')
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid_stream_event',
|
||||
message: 'A stream event failed validation.',
|
||||
...(hints.length > 0 ? { errors: hints } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePersistedStreamEventEnvelopeJson(raw: string): ParseStreamEventEnvelopeResult {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
const rawMessage = getErrorMessage(error, 'Invalid JSON')
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid_json',
|
||||
message: 'Received invalid JSON while parsing a stream event.',
|
||||
...(rawMessage ? { errors: [rawMessage] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return parsePersistedStreamEventEnvelope(parsed)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { parsePersistedStreamEventEnvelope } from '@/lib/copilot/request/session'
|
||||
import { createEvent, eventToStreamEvent } from '@/lib/copilot/request/session/event'
|
||||
|
||||
describe('createEvent', () => {
|
||||
it('creates contract envelopes that pass validation', () => {
|
||||
const envelope = createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: '1',
|
||||
seq: 1,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: {
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
text: 'hello',
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = parsePersistedStreamEventEnvelope(envelope)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(envelope.type).toBe(MothershipStreamV1EventType.text)
|
||||
expect(envelope.payload).toEqual({
|
||||
channel: MothershipStreamV1TextChannel.assistant,
|
||||
text: 'hello',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates synthetic preview envelopes that round-trip to stream events', () => {
|
||||
const envelope = createEvent({
|
||||
streamId: 'stream-1',
|
||||
cursor: '2',
|
||||
seq: 2,
|
||||
requestId: 'req-1',
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
previewPhase: 'file_preview_start',
|
||||
toolCallId: 'preview-1',
|
||||
toolName: 'workspace_file',
|
||||
},
|
||||
})
|
||||
|
||||
const parsed = parsePersistedStreamEventEnvelope(envelope)
|
||||
expect(parsed.ok).toBe(true)
|
||||
|
||||
const streamEvent = eventToStreamEvent(envelope)
|
||||
expect(streamEvent).toEqual({
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
previewPhase: 'file_preview_start',
|
||||
toolCallId: 'preview-1',
|
||||
toolName: 'workspace_file',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
type PersistedStreamEventEnvelope,
|
||||
parsePersistedStreamEventEnvelope,
|
||||
type SessionStreamEvent,
|
||||
} from './contract'
|
||||
|
||||
type CreateEventBase = {
|
||||
chatId?: string
|
||||
cursor: string
|
||||
requestId: string
|
||||
seq: number
|
||||
streamId: string
|
||||
ts?: string
|
||||
}
|
||||
|
||||
type CreateEventVariant<TEvent extends SessionStreamEvent> = CreateEventBase &
|
||||
Pick<TEvent, 'type' | 'payload' | 'scope'>
|
||||
|
||||
export type CreateEventInput = SessionStreamEvent extends infer TEvent
|
||||
? TEvent extends SessionStreamEvent
|
||||
? CreateEventVariant<TEvent>
|
||||
: never
|
||||
: never
|
||||
|
||||
type CreateEventResult<TInput extends CreateEventInput> = Extract<
|
||||
PersistedStreamEventEnvelope,
|
||||
{ type: TInput['type']; payload: TInput['payload'] }
|
||||
>
|
||||
|
||||
type StreamEventFromEnvelope<TEnvelope extends PersistedStreamEventEnvelope> = Extract<
|
||||
SessionStreamEvent,
|
||||
{ type: TEnvelope['type']; payload: TEnvelope['payload'] }
|
||||
>
|
||||
|
||||
export const TOOL_CALL_STATUS = {
|
||||
generating: 'generating',
|
||||
} as const
|
||||
|
||||
export function createEvent<TInput extends CreateEventInput>(
|
||||
input: TInput
|
||||
): CreateEventResult<TInput> {
|
||||
const { streamId, chatId, cursor, seq, requestId, type, payload, scope, ts } = input
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
type,
|
||||
seq,
|
||||
ts: ts ?? new Date().toISOString(),
|
||||
stream: {
|
||||
streamId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
cursor,
|
||||
},
|
||||
trace: {
|
||||
requestId,
|
||||
},
|
||||
...(scope ? { scope } : {}),
|
||||
payload,
|
||||
} as CreateEventResult<TInput>
|
||||
}
|
||||
|
||||
export function isEventRecord(value: unknown): value is PersistedStreamEventEnvelope {
|
||||
return parsePersistedStreamEventEnvelope(value).ok
|
||||
}
|
||||
|
||||
export function eventToStreamEvent<TEnvelope extends PersistedStreamEventEnvelope>(
|
||||
envelope: TEnvelope
|
||||
): StreamEventFromEnvelope<TEnvelope> {
|
||||
return {
|
||||
type: envelope.type,
|
||||
payload: envelope.payload,
|
||||
...(envelope.scope ? { scope: envelope.scope } : {}),
|
||||
} as StreamEventFromEnvelope<TEnvelope>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Context } from '@opentelemetry/api'
|
||||
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
||||
import { fetchGo } from '@/lib/copilot/request/go/fetch'
|
||||
import { AbortReason } from '@/lib/copilot/request/session/abort'
|
||||
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000
|
||||
|
||||
export async function requestExplicitStreamAbort(params: {
|
||||
streamId: string
|
||||
userId: string
|
||||
chatId?: string
|
||||
workspaceId?: string
|
||||
timeoutMs?: number
|
||||
otelContext?: Context
|
||||
}): Promise<void> {
|
||||
const {
|
||||
streamId,
|
||||
userId,
|
||||
chatId,
|
||||
workspaceId,
|
||||
timeoutMs = DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS,
|
||||
otelContext,
|
||||
} = params
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (env.COPILOT_API_KEY) {
|
||||
headers['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
Object.assign(headers, getMothershipSourceEnvHeaders())
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(AbortReason.ExplicitAbortFetchTimeout),
|
||||
timeoutMs
|
||||
)
|
||||
|
||||
try {
|
||||
const mothershipBaseURL = await getMothershipBaseURL({ userId })
|
||||
const response = await fetchGo(`${mothershipBaseURL}/api/streams/explicit-abort`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
messageId: streamId,
|
||||
userId,
|
||||
...(chatId ? { chatId } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
}),
|
||||
otelContext,
|
||||
spanName: 'sim → go /api/streams/explicit-abort',
|
||||
operation: 'explicit_abort',
|
||||
attributes: {
|
||||
[TraceAttr.StreamId]: streamId,
|
||||
...(chatId ? { [TraceAttr.ChatId]: chatId } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Explicit abort marker request failed: ${response.status}`)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export const FILE_PREVIEW_SESSION_SCHEMA_VERSION = 1 as const
|
||||
|
||||
export type FilePreviewTargetKind = 'new_file' | 'file_id'
|
||||
export type FilePreviewStatus = 'pending' | 'streaming' | 'complete'
|
||||
export type FilePreviewContentMode = 'delta' | 'snapshot'
|
||||
|
||||
export interface FilePreviewSession {
|
||||
schemaVersion: typeof FILE_PREVIEW_SESSION_SCHEMA_VERSION
|
||||
id: string
|
||||
streamId: string
|
||||
toolCallId: string
|
||||
status: FilePreviewStatus
|
||||
fileName: string
|
||||
fileId?: string
|
||||
targetKind?: FilePreviewTargetKind
|
||||
operation?: string
|
||||
edit?: Record<string, unknown>
|
||||
baseContent?: string
|
||||
previewText: string
|
||||
previewVersion: number
|
||||
updatedAt: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export function isFilePreviewSession(value: unknown): value is FilePreviewSession {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
record.schemaVersion === FILE_PREVIEW_SESSION_SCHEMA_VERSION &&
|
||||
typeof record.id === 'string' &&
|
||||
typeof record.streamId === 'string' &&
|
||||
typeof record.toolCallId === 'string' &&
|
||||
typeof record.status === 'string' &&
|
||||
typeof record.fileName === 'string' &&
|
||||
(record.baseContent === undefined || typeof record.baseContent === 'string') &&
|
||||
typeof record.previewText === 'string' &&
|
||||
typeof record.previewVersion === 'number' &&
|
||||
typeof record.updatedAt === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export function sortFilePreviewSessions(sessions: FilePreviewSession[]): FilePreviewSession[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
const updatedAtCompare = a.updatedAt.localeCompare(b.updatedAt)
|
||||
if (updatedAtCompare !== 0) {
|
||||
return updatedAtCompare
|
||||
}
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createFilePreviewSession,
|
||||
sortFilePreviewSessions,
|
||||
} from '@/lib/copilot/request/session/file-preview-session'
|
||||
|
||||
describe('file preview session helpers', () => {
|
||||
it('preserves baseContent when creating a preview session', () => {
|
||||
const session = createFilePreviewSession({
|
||||
streamId: 'stream-1',
|
||||
toolCallId: 'preview-1',
|
||||
fileName: 'draft.md',
|
||||
baseContent: 'existing content',
|
||||
})
|
||||
|
||||
expect(session.baseContent).toBe('existing content')
|
||||
})
|
||||
|
||||
it('sorts preview sessions by updatedAt across tool call ids', () => {
|
||||
const sessions = sortFilePreviewSessions([
|
||||
createFilePreviewSession({
|
||||
streamId: 'stream-1',
|
||||
toolCallId: 'preview-2',
|
||||
fileName: 'b.md',
|
||||
previewVersion: 10,
|
||||
updatedAt: '2026-04-10T00:00:02.000Z',
|
||||
}),
|
||||
createFilePreviewSession({
|
||||
streamId: 'stream-1',
|
||||
toolCallId: 'preview-1',
|
||||
fileName: 'a.md',
|
||||
previewVersion: 1,
|
||||
updatedAt: '2026-04-10T00:00:01.000Z',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(['preview-1', 'preview-2'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import { getStreamConfig } from './buffer'
|
||||
import {
|
||||
FILE_PREVIEW_SESSION_SCHEMA_VERSION,
|
||||
type FilePreviewSession,
|
||||
type FilePreviewStatus,
|
||||
type FilePreviewTargetKind,
|
||||
isFilePreviewSession,
|
||||
sortFilePreviewSessions,
|
||||
} from './file-preview-session-contract'
|
||||
|
||||
const logger = createLogger('FilePreviewSessionStore')
|
||||
|
||||
const STREAM_OUTBOX_PREFIX = 'mothership_stream:'
|
||||
const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60
|
||||
const RETRY_DELAYS_MS = [0, 50, 150] as const
|
||||
|
||||
export type {
|
||||
FilePreviewSession,
|
||||
FilePreviewStatus,
|
||||
FilePreviewTargetKind,
|
||||
} from './file-preview-session-contract'
|
||||
export { sortFilePreviewSessions } from './file-preview-session-contract'
|
||||
|
||||
function getPreviewSessionsKey(streamId: string): string {
|
||||
return `${STREAM_OUTBOX_PREFIX}${streamId}:preview_sessions`
|
||||
}
|
||||
|
||||
type RedisOperationMetadata = {
|
||||
operation: string
|
||||
streamId: string
|
||||
}
|
||||
|
||||
async function withRedisRetry<T>(
|
||||
metadata: RedisOperationMetadata,
|
||||
operation: (redis: NonNullable<ReturnType<typeof getRedisClient>>) => Promise<T>
|
||||
): Promise<T> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
throw new Error('Redis is required for mothership preview durability')
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
||||
const delay = RETRY_DELAYS_MS[attempt]
|
||||
if (delay > 0) {
|
||||
await sleep(delay)
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation(redis)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
logger.warn('Redis preview session operation failed', {
|
||||
operation: metadata.operation,
|
||||
streamId: metadata.streamId,
|
||||
attempt: attempt + 1,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`${metadata.operation} failed for stream ${metadata.streamId}`)
|
||||
}
|
||||
|
||||
export function createFilePreviewSession(input: {
|
||||
streamId: string
|
||||
toolCallId: string
|
||||
fileName?: string
|
||||
fileId?: string
|
||||
targetKind?: FilePreviewTargetKind
|
||||
operation?: string
|
||||
edit?: Record<string, unknown>
|
||||
baseContent?: string
|
||||
previewText?: string
|
||||
previewVersion?: number
|
||||
status?: FilePreviewStatus
|
||||
updatedAt?: string
|
||||
completedAt?: string
|
||||
}): FilePreviewSession {
|
||||
return {
|
||||
schemaVersion: FILE_PREVIEW_SESSION_SCHEMA_VERSION,
|
||||
id: input.toolCallId,
|
||||
streamId: input.streamId,
|
||||
toolCallId: input.toolCallId,
|
||||
status: input.status ?? 'pending',
|
||||
fileName: input.fileName ?? '',
|
||||
...(input.fileId ? { fileId: input.fileId } : {}),
|
||||
...(input.targetKind ? { targetKind: input.targetKind } : {}),
|
||||
...(input.operation ? { operation: input.operation } : {}),
|
||||
...(input.edit ? { edit: input.edit } : {}),
|
||||
...(typeof input.baseContent === 'string' ? { baseContent: input.baseContent } : {}),
|
||||
previewText: input.previewText ?? '',
|
||||
previewVersion: input.previewVersion ?? 0,
|
||||
updatedAt: input.updatedAt ?? new Date().toISOString(),
|
||||
...(input.completedAt ? { completedAt: input.completedAt } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertFilePreviewSession(
|
||||
session: FilePreviewSession
|
||||
): Promise<FilePreviewSession> {
|
||||
const config = getStreamConfig()
|
||||
await withRedisRetry(
|
||||
{ operation: 'upsert_preview_session', streamId: session.streamId },
|
||||
async (redis) => {
|
||||
const key = getPreviewSessionsKey(session.streamId)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.hset(key, session.id, JSON.stringify(session))
|
||||
pipeline.expire(key, config.ttlSeconds)
|
||||
await pipeline.exec()
|
||||
}
|
||||
)
|
||||
return session
|
||||
}
|
||||
|
||||
export async function readFilePreviewSessions(streamId: string): Promise<FilePreviewSession[]> {
|
||||
const raw = await withRedisRetry(
|
||||
{ operation: 'read_preview_sessions', streamId },
|
||||
async (redis) => redis.hgetall(getPreviewSessionsKey(streamId))
|
||||
)
|
||||
|
||||
const sessions: FilePreviewSession[] = []
|
||||
const values = Object.values(raw ?? {})
|
||||
for (const entry of values) {
|
||||
try {
|
||||
const parsed = JSON.parse(entry) as unknown
|
||||
if (!isFilePreviewSession(parsed)) {
|
||||
logger.warn('Skipping invalid file preview session entry', { streamId })
|
||||
continue
|
||||
}
|
||||
sessions.push(parsed)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse file preview session entry', {
|
||||
streamId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sortFilePreviewSessions(sessions)
|
||||
}
|
||||
|
||||
export async function clearFilePreviewSessions(streamId: string): Promise<void> {
|
||||
await withRedisRetry({ operation: 'clear_preview_sessions', streamId }, async (redis) => {
|
||||
await redis.del(getPreviewSessionsKey(streamId))
|
||||
})
|
||||
}
|
||||
|
||||
export async function scheduleFilePreviewSessionCleanup(
|
||||
streamId: string,
|
||||
ttlSeconds = DEFAULT_COMPLETED_TTL_SECONDS
|
||||
): Promise<void> {
|
||||
try {
|
||||
await withRedisRetry(
|
||||
{ operation: 'schedule_preview_session_cleanup', streamId },
|
||||
async (redis) => {
|
||||
await redis.expire(getPreviewSessionsKey(streamId), ttlSeconds)
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to shorten preview session retention', {
|
||||
streamId,
|
||||
ttlSeconds,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type { ChatStreamLockOwnersResult } from './abort'
|
||||
export {
|
||||
AbortReason,
|
||||
type AbortReasonValue,
|
||||
abortActiveStream,
|
||||
acquirePendingChatStream,
|
||||
cleanupAbortMarker,
|
||||
getChatStreamLockOwners,
|
||||
getPendingChatStreamId,
|
||||
isExplicitStopReason,
|
||||
registerActiveStream,
|
||||
releasePendingChatStream,
|
||||
startAbortPoller,
|
||||
unregisterActiveStream,
|
||||
waitForPendingChatStream,
|
||||
} from './abort'
|
||||
export {
|
||||
allocateCursor,
|
||||
appendEvent,
|
||||
appendEvents,
|
||||
clearAbortMarker,
|
||||
clearBuffer,
|
||||
getLatestSeq,
|
||||
getOldestSeq,
|
||||
hasAbortMarker,
|
||||
InvalidCursorError,
|
||||
readEvents,
|
||||
resetBuffer,
|
||||
scheduleBufferCleanup,
|
||||
writeAbortMarker,
|
||||
} from './buffer'
|
||||
export type {
|
||||
ContractStreamEvent,
|
||||
PersistedStreamEventEnvelope,
|
||||
SessionStreamEvent,
|
||||
StreamEvent,
|
||||
SubagentSpanStreamEvent,
|
||||
SyntheticFilePreviewEventEnvelope,
|
||||
SyntheticFilePreviewPayload,
|
||||
SyntheticStreamEvent,
|
||||
ToolArgsDeltaStreamEvent,
|
||||
ToolCallStreamEvent,
|
||||
ToolResultStreamEvent,
|
||||
} from './contract'
|
||||
export {
|
||||
isContractStreamEventEnvelope,
|
||||
isSubagentSpanStreamEvent,
|
||||
isSyntheticFilePreviewEventEnvelope,
|
||||
isToolArgsDeltaStreamEvent,
|
||||
isToolCallStreamEvent,
|
||||
isToolResultStreamEvent,
|
||||
parsePersistedStreamEventEnvelope,
|
||||
parsePersistedStreamEventEnvelopeJson,
|
||||
} from './contract'
|
||||
export { createEvent, eventToStreamEvent, isEventRecord, TOOL_CALL_STATUS } from './event'
|
||||
export {
|
||||
clearFilePreviewSessions,
|
||||
createFilePreviewSession,
|
||||
readFilePreviewSessions,
|
||||
scheduleFilePreviewSessionCleanup,
|
||||
upsertFilePreviewSession,
|
||||
} from './file-preview-session'
|
||||
export type {
|
||||
FilePreviewContentMode,
|
||||
FilePreviewSession,
|
||||
FilePreviewStatus,
|
||||
FilePreviewTargetKind,
|
||||
} from './file-preview-session-contract'
|
||||
export {
|
||||
FILE_PREVIEW_SESSION_SCHEMA_VERSION,
|
||||
isFilePreviewSession,
|
||||
} from './file-preview-session-contract'
|
||||
export { checkForReplayGap, type ReplayGapResult } from './recovery'
|
||||
export { encodeSSEComment, encodeSSEEnvelope, SSE_RESPONSE_HEADERS } from './sse'
|
||||
export type { StreamBatchEvent } from './types'
|
||||
export { StreamWriter, type StreamWriterOptions } from './writer'
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getLatestSeq, getOldestSeq, readEvents } = vi.hoisted(() => ({
|
||||
getLatestSeq: vi.fn(),
|
||||
getOldestSeq: vi.fn(),
|
||||
readEvents: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./buffer', () => ({
|
||||
getLatestSeq,
|
||||
getOldestSeq,
|
||||
readEvents,
|
||||
}))
|
||||
|
||||
import { checkForReplayGap } from './recovery'
|
||||
|
||||
describe('checkForReplayGap', () => {
|
||||
it('uses the latest buffered request id when run metadata is missing it', async () => {
|
||||
getOldestSeq.mockResolvedValue(10)
|
||||
getLatestSeq.mockResolvedValue(12)
|
||||
readEvents.mockResolvedValue([
|
||||
{
|
||||
trace: { requestId: 'req-live-123' },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await checkForReplayGap('stream-1', '1')
|
||||
|
||||
expect(readEvents).toHaveBeenCalledWith('stream-1', '11')
|
||||
expect(result?.gapDetected).toBe(true)
|
||||
expect(result?.envelopes[0].trace.requestId).toBe('req-live-123')
|
||||
expect(result?.envelopes[1].trace.requestId).toBe('req-live-123')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
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<typeof createEvent>[]
|
||||
}
|
||||
|
||||
export async function checkForReplayGap(
|
||||
streamId: string,
|
||||
afterCursor: string,
|
||||
requestId?: string
|
||||
): Promise<ReplayGapResult | null> {
|
||||
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<string> {
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
export function encodeSSEEnvelope(envelope: unknown): Uint8Array {
|
||||
return encoder.encode(`data: ${JSON.stringify(envelope)}\n\n`)
|
||||
}
|
||||
|
||||
export function encodeSSEComment(comment: string): Uint8Array {
|
||||
return encoder.encode(`: ${comment}\n\n`)
|
||||
}
|
||||
|
||||
export const SSE_RESPONSE_HEADERS = {
|
||||
...SSE_HEADERS,
|
||||
'Content-Encoding': 'none',
|
||||
} as const
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PersistedStreamEventEnvelope, SessionStreamEvent } from './contract'
|
||||
import { parsePersistedStreamEventEnvelope } from './contract'
|
||||
|
||||
export type StreamEvent = SessionStreamEvent
|
||||
|
||||
export interface StreamBatchEvent {
|
||||
eventId: number
|
||||
streamId: string
|
||||
event: PersistedStreamEventEnvelope
|
||||
}
|
||||
|
||||
export function toStreamBatchEvent(envelope: PersistedStreamEventEnvelope): StreamBatchEvent {
|
||||
return {
|
||||
eventId: envelope.seq,
|
||||
streamId: envelope.stream.streamId,
|
||||
event: envelope,
|
||||
}
|
||||
}
|
||||
|
||||
export function isStreamBatchEvent(value: unknown): value is StreamBatchEvent {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
typeof record.eventId !== 'number' ||
|
||||
!Number.isFinite(record.eventId) ||
|
||||
typeof record.streamId !== 'string'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return parsePersistedStreamEventEnvelope(record.event).ok
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
MothershipStreamV1EventType,
|
||||
MothershipStreamV1TextChannel,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import type { StreamEvent } from '@/lib/copilot/request/session'
|
||||
|
||||
const { appendEvents } = vi.hoisted(() => ({
|
||||
appendEvents: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/session/buffer', () => ({
|
||||
appendEvents,
|
||||
}))
|
||||
|
||||
import { StreamWriter } from '@/lib/copilot/request/session/writer'
|
||||
|
||||
function decodeChunk(value: Uint8Array): string {
|
||||
return new TextDecoder().decode(value)
|
||||
}
|
||||
|
||||
describe('StreamWriter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('enqueues before persistence completes and flushes pending writes on close', async () => {
|
||||
let releasePersist: (() => void) | null = null
|
||||
appendEvents.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releasePersist = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const writer = new StreamWriter({
|
||||
streamId: 'stream-1',
|
||||
chatId: 'chat-1',
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
let closeCount = 0
|
||||
const controller = {
|
||||
enqueue: vi.fn((value: Uint8Array) => {
|
||||
chunks.push(decodeChunk(value))
|
||||
}),
|
||||
close: vi.fn(() => {
|
||||
closeCount += 1
|
||||
}),
|
||||
} as unknown as ReadableStreamDefaultController
|
||||
|
||||
writer.attach(controller)
|
||||
await writer.publish({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
|
||||
})
|
||||
|
||||
expect(controller.enqueue).toHaveBeenCalledOnce()
|
||||
expect(appendEvents).not.toHaveBeenCalled()
|
||||
expect(chunks[0]).toContain('"text":"hello"')
|
||||
expect(closeCount).toBe(0)
|
||||
|
||||
const closePromise = writer.close()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(appendEvents).toHaveBeenCalledOnce()
|
||||
expect(closeCount).toBe(0)
|
||||
|
||||
const resolvePersist = releasePersist
|
||||
if (typeof resolvePersist === 'function') {
|
||||
resolvePersist()
|
||||
}
|
||||
await closePromise
|
||||
|
||||
expect(closeCount).toBe(1)
|
||||
})
|
||||
|
||||
it('batches publishes on the flush timer and preserves sequence order', async () => {
|
||||
vi.useFakeTimers()
|
||||
const persistedSeqs: number[] = []
|
||||
appendEvents.mockImplementation(async (envelopes) => {
|
||||
persistedSeqs.push(...envelopes.map((envelope) => envelope.seq))
|
||||
return envelopes
|
||||
})
|
||||
|
||||
const writer = new StreamWriter({
|
||||
streamId: 'stream-1',
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
const controller = {
|
||||
enqueue: vi.fn((value: Uint8Array) => {
|
||||
chunks.push(decodeChunk(value))
|
||||
}),
|
||||
close: vi.fn(),
|
||||
} as unknown as ReadableStreamDefaultController
|
||||
|
||||
writer.attach(controller)
|
||||
await Promise.all([
|
||||
writer.publish({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'one' },
|
||||
}),
|
||||
writer.publish({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'two' },
|
||||
}),
|
||||
])
|
||||
expect(appendEvents).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15)
|
||||
await writer.close()
|
||||
|
||||
expect(persistedSeqs).toEqual([1, 2])
|
||||
expect(appendEvents).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ seq: 1 }),
|
||||
expect.objectContaining({ seq: 2 }),
|
||||
])
|
||||
expect(chunks[0]).toContain('"seq":1')
|
||||
expect(chunks[1]).toContain('"seq":2')
|
||||
})
|
||||
|
||||
it('flush waits for persistence and surfaces failures', async () => {
|
||||
appendEvents.mockRejectedValueOnce(new Error('redis down'))
|
||||
|
||||
const writer = new StreamWriter({
|
||||
streamId: 'stream-1',
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
writer.attach({
|
||||
enqueue: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} as unknown as ReadableStreamDefaultController)
|
||||
|
||||
await writer.publish({
|
||||
type: MothershipStreamV1EventType.text,
|
||||
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'boom' },
|
||||
})
|
||||
|
||||
await expect(writer.flush()).rejects.toThrow('redis down')
|
||||
})
|
||||
|
||||
it('persists synthetic preview events alongside contract events', async () => {
|
||||
appendEvents.mockResolvedValue([])
|
||||
|
||||
const writer = new StreamWriter({
|
||||
streamId: 'stream-1',
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
writer.attach({
|
||||
enqueue: vi.fn((value: Uint8Array) => {
|
||||
chunks.push(decodeChunk(value))
|
||||
}),
|
||||
close: vi.fn(),
|
||||
} as unknown as ReadableStreamDefaultController)
|
||||
|
||||
await writer.publish({
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: {
|
||||
toolCallId: 'preview-1',
|
||||
toolName: 'workspace_file',
|
||||
previewPhase: 'file_preview_start',
|
||||
},
|
||||
} satisfies StreamEvent)
|
||||
|
||||
await writer.flush()
|
||||
|
||||
expect(chunks[0]).toContain('"previewPhase":"file_preview_start"')
|
||||
expect(appendEvents).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: MothershipStreamV1EventType.tool,
|
||||
payload: expect.objectContaining({
|
||||
toolCallId: 'preview-1',
|
||||
previewPhase: 'file_preview_start',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
import { appendEvents } from './buffer'
|
||||
import type { PersistedStreamEventEnvelope } from './contract'
|
||||
import { createEvent } from './event'
|
||||
import { encodeSSEComment, encodeSSEEnvelope } from './sse'
|
||||
import type { StreamEvent } from './types'
|
||||
|
||||
const logger = createLogger('StreamWriter')
|
||||
|
||||
const DEFAULT_KEEPALIVE_MS = 15_000
|
||||
const DEFAULT_PERSIST_FLUSH_INTERVAL_MS = 15
|
||||
const DEFAULT_PERSIST_FLUSH_MAX_BATCH = 200
|
||||
|
||||
export interface StreamWriterOptions {
|
||||
streamId: string
|
||||
chatId?: string
|
||||
requestId: string
|
||||
keepaliveMs?: number
|
||||
}
|
||||
|
||||
export class StreamWriter {
|
||||
private readonly streamId: string
|
||||
private readonly chatId: string | undefined
|
||||
private requestId: string
|
||||
private readonly keepaliveMs: number
|
||||
private readonly flushIntervalMs: number
|
||||
private readonly flushMaxBatch: number
|
||||
private readonly encoder: TextEncoder
|
||||
private controller: ReadableStreamDefaultController | null = null
|
||||
private keepaliveInterval: ReturnType<typeof setInterval> | null = null
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private _clientDisconnected = false
|
||||
private _sawComplete = false
|
||||
private nextSeq = 0
|
||||
private pendingEnvelopes: PersistedStreamEventEnvelope[] = []
|
||||
private persistenceTail: Promise<void> = Promise.resolve()
|
||||
private lastPersistenceError: Error | null = null
|
||||
|
||||
constructor(options: StreamWriterOptions) {
|
||||
this.streamId = options.streamId
|
||||
this.chatId = options.chatId
|
||||
this.requestId = options.requestId
|
||||
this.keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS
|
||||
this.flushIntervalMs = DEFAULT_PERSIST_FLUSH_INTERVAL_MS
|
||||
this.flushMaxBatch = DEFAULT_PERSIST_FLUSH_MAX_BATCH
|
||||
this.encoder = new TextEncoder()
|
||||
}
|
||||
|
||||
get clientDisconnected(): boolean {
|
||||
return this._clientDisconnected
|
||||
}
|
||||
|
||||
get sawComplete(): boolean {
|
||||
return this._sawComplete
|
||||
}
|
||||
|
||||
updateRequestId(id: string): void {
|
||||
this.requestId = id
|
||||
}
|
||||
|
||||
attach(controller: ReadableStreamDefaultController): void {
|
||||
this.controller = controller
|
||||
}
|
||||
|
||||
startKeepalive(): void {
|
||||
this.keepaliveInterval = setInterval(() => {
|
||||
if (this._clientDisconnected || !this.controller) return
|
||||
try {
|
||||
this.controller.enqueue(encodeSSEComment('keepalive'))
|
||||
} catch (error) {
|
||||
this._clientDisconnected = true
|
||||
logger.warn('Keepalive enqueue failed, marking client disconnected', {
|
||||
streamId: this.streamId,
|
||||
requestId: this.requestId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}, this.keepaliveMs)
|
||||
}
|
||||
|
||||
stopKeepalive(): void {
|
||||
if (this.keepaliveInterval) {
|
||||
clearInterval(this.keepaliveInterval)
|
||||
this.keepaliveInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
publish(event: StreamEvent): void {
|
||||
const envelope = this.createEnvelope(event)
|
||||
this.enqueue(envelope)
|
||||
this.queuePersistence(envelope)
|
||||
if (event.type === MothershipStreamV1EventType.complete) {
|
||||
this._sawComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
markDisconnected(): void {
|
||||
this._clientDisconnected = true
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.flushPendingPersistence()
|
||||
await this.persistenceTail
|
||||
if (this.lastPersistenceError) {
|
||||
const error = this.lastPersistenceError
|
||||
this.lastPersistenceError = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.stopKeepalive()
|
||||
this.clearFlushTimer()
|
||||
await this.flush()
|
||||
if (!this.controller) return
|
||||
try {
|
||||
this.controller.close()
|
||||
} catch {
|
||||
// Controller already closed
|
||||
}
|
||||
this.controller = null
|
||||
}
|
||||
|
||||
private enqueue(envelope: PersistedStreamEventEnvelope): void {
|
||||
if (this._clientDisconnected || !this.controller) return
|
||||
try {
|
||||
this.controller.enqueue(encodeSSEEnvelope(envelope))
|
||||
} catch (error) {
|
||||
this._clientDisconnected = true
|
||||
logger.warn('Envelope enqueue failed, marking client disconnected', {
|
||||
streamId: this.streamId,
|
||||
requestId: this.requestId,
|
||||
seq: envelope.seq,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private createEnvelope(event: StreamEvent): PersistedStreamEventEnvelope {
|
||||
const seq = ++this.nextSeq
|
||||
return createEvent({
|
||||
...event,
|
||||
streamId: this.streamId,
|
||||
chatId: this.chatId,
|
||||
cursor: String(seq),
|
||||
seq,
|
||||
requestId: this.requestId,
|
||||
})
|
||||
}
|
||||
|
||||
private queuePersistence(envelope: PersistedStreamEventEnvelope): void {
|
||||
this.pendingEnvelopes.push(envelope)
|
||||
if (this.pendingEnvelopes.length >= this.flushMaxBatch) {
|
||||
this.flushPendingPersistence()
|
||||
return
|
||||
}
|
||||
if (this.flushTimer || this.pendingEnvelopes.length === 0) {
|
||||
return
|
||||
}
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null
|
||||
this.flushPendingPersistence()
|
||||
}, this.flushIntervalMs)
|
||||
}
|
||||
|
||||
private flushPendingPersistence(): void {
|
||||
this.clearFlushTimer()
|
||||
if (this.pendingEnvelopes.length === 0) {
|
||||
return
|
||||
}
|
||||
const batch = this.pendingEnvelopes
|
||||
this.pendingEnvelopes = []
|
||||
this.persistenceTail = this.persistenceTail
|
||||
.catch(() => undefined)
|
||||
.then(() => appendEvents(batch))
|
||||
.then(() => {
|
||||
this.lastPersistenceError = null
|
||||
})
|
||||
.catch((error) => {
|
||||
this.lastPersistenceError = toError(error)
|
||||
logger.warn('Failed to persist stream envelope batch', {
|
||||
streamId: this.streamId,
|
||||
requestId: this.requestId,
|
||||
batchSize: batch.length,
|
||||
firstSeq: batch[0]?.seq,
|
||||
lastSeq: batch[batch.length - 1]?.seq,
|
||||
error: toError(error).message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private clearFlushTimer(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer)
|
||||
this.flushTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user