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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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
}
}
}