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,180 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import {
authenticateCopilotRequestSessionOnly,
createBadRequestResponse,
createForbiddenResponse,
createInternalServerErrorResponse,
createNotFoundResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { MothershipResource } from '@/lib/copilot/resources/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
const logger = createLogger('ForkChatAPI')
/**
* POST /api/mothership/chats/[chatId]/fork
* Creates a new chat branched from the given chat, keeping messages up to and
* including the specified message. Resources and copilot-side state are copied.
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(forkMothershipChatContract, request, context, {
validationErrorResponse: () => createBadRequestResponse('upToMessageId is required'),
})
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
const { upToMessageId } = parsed.data.body
const [parent] = await db
.select({
id: copilotChats.id,
userId: copilotChats.userId,
type: copilotChats.type,
workspaceId: copilotChats.workspaceId,
title: copilotChats.title,
model: copilotChats.model,
resources: copilotChats.resources,
previewYaml: copilotChats.previewYaml,
planArtifact: copilotChats.planArtifact,
config: copilotChats.config,
})
.from(copilotChats)
.where(eq(copilotChats.id, chatId))
.limit(1)
if (!parent || parent.userId !== userId || parent.type !== 'mothership') {
return createNotFoundResponse('Chat not found')
}
if (parent.workspaceId) {
await assertActiveWorkspaceAccess(parent.workspaceId, userId)
}
const messages = await loadCopilotChatMessages(chatId)
const forkIdx = messages.findIndex((m) => m.id === upToMessageId)
if (forkIdx < 0) {
return createBadRequestResponse('Message not found in chat')
}
const forkedMessages = messages.slice(0, forkIdx + 1)
// Resources are stored as a jsonb array on the chat row — copy them directly.
const parentResources = Array.isArray(parent.resources)
? (parent.resources as MothershipResource[])
: []
const newId = generateId()
const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '')
const title = `Fork | ${baseTitle}`
const now = new Date()
const newChat = await db.transaction(async (tx) => {
const [row] = await tx
.insert(copilotChats)
.values({
id: newId,
userId,
workspaceId: parent.workspaceId,
type: parent.type,
title,
model: parent.model,
resources: parentResources,
previewYaml: parent.previewYaml,
planArtifact: parent.planArtifact,
config: parent.config,
conversationId: null,
updatedAt: now,
lastSeenAt: now,
})
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
if (!row) return null
await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx)
return row
})
if (!newChat) {
return createInternalServerErrorResponse('Failed to create forked chat')
}
// Clone copilot-service conversation state (messages, active_messages, memory files).
// Best-effort: if the copilot service doesn't have a row for the source chat yet, skip.
try {
const copilotHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
if (env.COPILOT_API_KEY) {
copilotHeaders['x-api-key'] = env.COPILOT_API_KEY
}
Object.assign(copilotHeaders, getMothershipSourceEnvHeaders())
const mothershipBaseURL = await getMothershipBaseURL({ userId })
const copilotRes = await fetchGo(`${mothershipBaseURL}/api/chats/fork`, {
method: 'POST',
headers: copilotHeaders,
body: JSON.stringify({
sourceChatId: chatId,
newChatId: newId,
upToMessageId,
userId,
}),
spanName: 'sim → go /api/chats/fork',
operation: 'fork_chat',
})
if (!copilotRes.ok) {
const text = await copilotRes.text().catch(() => '')
logger.warn('Copilot fork returned non-OK', { status: copilotRes.status, body: text })
}
} catch (err) {
// The copilot service may not have a row for this chat if no messages
// have been sent yet, or if it's unreachable. Log and continue.
logger.warn('Failed to fork copilot-service conversation, skipping', { err })
}
if (newChat.workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId: newChat.workspaceId,
chatId: newId,
type: 'created',
})
}
captureServerEvent(
userId,
'task_forked',
{ workspace_id: parent.workspaceId ?? '', source_chat_id: chatId },
{ groups: { workspace: parent.workspaceId ?? '' } }
)
return NextResponse.json({ success: true, id: newId })
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
logger.error('Error forking chat:', error)
return createInternalServerErrorResponse('Failed to fork chat')
}
}
)
@@ -0,0 +1,246 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetAccessibleCopilotChat,
mockReconcileChatStreamMarkers,
mockReadEvents,
mockReadFilePreviewSessions,
mockGetLatestRunForStream,
} = vi.hoisted(() => ({
mockGetAccessibleCopilotChat: vi.fn(),
mockReconcileChatStreamMarkers: vi.fn(),
mockReadEvents: vi.fn(),
mockReadFilePreviewSessions: vi.fn(),
mockGetLatestRunForStream: vi.fn(),
}))
vi.mock('@sim/db', () => ({ db: {} }))
vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'copilotChats.id',
userId: 'copilotChats.userId',
type: 'copilotChats.type',
updatedAt: 'copilotChats.updatedAt',
lastSeenAt: 'copilotChats.lastSeenAt',
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
sql: Object.assign(
vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
type: 'sql',
strings,
values,
})),
{ raw: vi.fn() }
),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/copilot/chat/lifecycle', () => ({
getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat,
getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChat,
}))
vi.mock('@/lib/copilot/chat/stream-liveness', () => ({
reconcileChatStreamMarkers: mockReconcileChatStreamMarkers,
}))
vi.mock('@/lib/copilot/request/session/buffer', () => ({
readEvents: mockReadEvents,
}))
vi.mock('@/lib/copilot/request/session/file-preview-session', () => ({
readFilePreviewSessions: mockReadFilePreviewSessions,
}))
vi.mock('@/lib/copilot/async-runs/repository', () => ({
getLatestRunForStream: mockGetLatestRunForStream,
}))
vi.mock('@/lib/copilot/request/session/types', () => ({
toStreamBatchEvent: (e: unknown) => e,
}))
vi.mock('@/lib/copilot/chat/effective-transcript', () => ({
buildEffectiveChatTranscript: ({ messages }: { messages: unknown[] }) => messages,
}))
vi.mock('@/lib/copilot/chat/persisted-message', () => ({
normalizeMessage: (m: unknown) => m,
}))
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: vi.fn() },
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: vi.fn(),
}))
import { GET } from '@/app/api/mothership/chats/[chatId]/route'
function makeContext(chatId: string) {
return { params: Promise.resolve({ chatId }) }
}
function createRequest(chatId: string) {
return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}`, {
method: 'GET',
})
}
describe('GET /api/mothership/chats/[chatId]', () => {
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
mockReconcileChatStreamMarkers.mockImplementation(
async (candidates: Array<{ chatId: string; streamId: string | null }>) =>
new Map(
candidates.map((candidate) => [
candidate.chatId,
{
chatId: candidate.chatId,
streamId: candidate.streamId,
status: candidate.streamId ? 'active' : 'inactive',
},
])
)
)
mockReadEvents.mockResolvedValue([])
mockReadFilePreviewSessions.mockResolvedValue([])
mockGetLatestRunForStream.mockResolvedValue(null)
})
it('clears activeStreamId when the redis lock has expired (stuck-yellow bug)', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-stuck',
type: 'mothership',
title: 'Stuck',
messages: [],
resources: [],
conversationId: 'stream-orphaned',
createdAt: new Date('2026-05-11T12:00:00Z'),
updatedAt: new Date('2026-05-11T12:00:00Z'),
})
mockReconcileChatStreamMarkers.mockResolvedValueOnce(
new Map([['chat-stuck', { chatId: 'chat-stuck', streamId: null, status: 'inactive' }]])
)
const response = await GET(createRequest('chat-stuck'), makeContext('chat-stuck'))
expect(response.status).toBe(200)
const body = await response.json()
expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith(
[{ chatId: 'chat-stuck', streamId: 'stream-orphaned' }],
{ repairVerifiedStaleMarkers: true }
)
expect(body.success).toBe(true)
expect(body.chat.activeStreamId).toBeNull()
expect(body.chat.streamSnapshot).toBeUndefined()
expect(mockReadEvents).not.toHaveBeenCalled()
})
it('returns the live activeStreamId when redis confirms the lock', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-live',
type: 'mothership',
title: 'Live',
messages: [],
resources: [],
conversationId: 'stream-live',
createdAt: new Date('2026-05-11T12:00:00Z'),
updatedAt: new Date('2026-05-11T12:00:00Z'),
})
mockGetLatestRunForStream.mockResolvedValueOnce({ status: 'active' })
const response = await GET(createRequest('chat-live'), makeContext('chat-live'))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.chat.activeStreamId).toBe('stream-live')
expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0')
expect(body.chat.streamSnapshot).toBeDefined()
expect(body.chat.streamSnapshot.status).toBe('active')
})
it('uses the Redis lock owner when it differs from a stale persisted streamId', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-mismatch',
type: 'mothership',
title: 'Mismatch',
messages: [],
resources: [],
conversationId: 'stream-stale',
createdAt: new Date('2026-05-11T12:00:00Z'),
updatedAt: new Date('2026-05-11T12:00:00Z'),
})
mockReconcileChatStreamMarkers.mockResolvedValueOnce(
new Map([
['chat-mismatch', { chatId: 'chat-mismatch', streamId: 'stream-live', status: 'active' }],
])
)
const response = await GET(createRequest('chat-mismatch'), makeContext('chat-mismatch'))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.chat.activeStreamId).toBe('stream-live')
expect(mockReadEvents).toHaveBeenCalledWith('stream-live', '0')
})
it('returns null when the persisted stream marker is already null', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce({
id: 'chat-idle',
type: 'mothership',
title: 'Idle',
messages: [],
resources: [],
conversationId: null,
createdAt: new Date('2026-05-11T12:00:00Z'),
updatedAt: new Date('2026-05-11T12:00:00Z'),
})
const response = await GET(createRequest('chat-idle'), makeContext('chat-idle'))
expect(response.status).toBe(200)
expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith(
[{ chatId: 'chat-idle', streamId: null }],
{ repairVerifiedStaleMarkers: true }
)
const body = await response.json()
expect(body.chat.activeStreamId).toBeNull()
})
it('returns 404 when the chat does not exist', async () => {
mockGetAccessibleCopilotChat.mockResolvedValueOnce(null)
const response = await GET(createRequest('chat-missing'), makeContext('chat-missing'))
expect(response.status).toBe(404)
expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled()
})
it('returns 401 when unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const response = await GET(createRequest('chat-x'), makeContext('chat-x'))
expect(response.status).toBe(401)
expect(mockGetAccessibleCopilotChat).not.toHaveBeenCalled()
expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,288 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteMothershipChatContract,
getMothershipChatContract,
updateMothershipChatContract,
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
import {
getAccessibleCopilotChatAuth,
getAccessibleCopilotChatWithMessages,
} from '@/lib/copilot/chat/lifecycle'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { readEvents } from '@/lib/copilot/request/session/buffer'
import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session'
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('MothershipChatAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const paramsResult = await parseRequest(getMothershipChatContract, request, context)
if (!paramsResult.success) return paramsResult.response
const { chatId } = paramsResult.data.params
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: StreamBatchEvent[]
previewSessions: FilePreviewSession[]
status: string
} | null = null
const reconciledMarkers = await reconcileChatStreamMarkers(
[{ chatId: chat.id, streamId: chat.conversationId }],
{ repairVerifiedStaleMarkers: true }
)
const liveStreamId = reconciledMarkers.get(chat.id)?.streamId ?? null
if (liveStreamId) {
try {
const [events, previewSessions] = await Promise.all([
readEvents(liveStreamId, '0'),
readFilePreviewSessions(liveStreamId).catch((error) => {
logger.warn('Failed to read preview sessions for mothership chat', {
chatId,
streamId: liveStreamId,
error: toError(error).message,
})
return []
}),
])
const run = await getLatestRunForStream(liveStreamId, userId).catch((error) => {
logger.warn('Failed to fetch latest run for mothership chat snapshot', {
chatId,
streamId: liveStreamId,
error: toError(error).message,
})
return null
})
streamSnapshot = {
events: events.map(toStreamBatchEvent),
previewSessions,
status:
typeof run?.status === 'string'
? run.status
: events.length > 0
? 'active'
: 'unknown',
}
} catch (error) {
logger.warn('Failed to read stream snapshot for mothership chat', {
chatId,
streamId: liveStreamId,
error: toError(error).message,
})
}
}
const normalizedMessages = Array.isArray(chat.messages)
? chat.messages
.filter((message): message is Record<string, unknown> => Boolean(message))
.map(normalizeMessage)
: []
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: liveStreamId,
...(streamSnapshot ? { streamSnapshot } : {}),
})
return NextResponse.json({
success: true,
chat: {
id: chat.id,
title: chat.title,
messages: effectiveMessages,
activeStreamId: liveStreamId,
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
},
})
} catch (error) {
logger.error('Error fetching mothership chat:', error)
return createInternalServerErrorResponse('Failed to fetch chat')
}
}
)
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(updateMothershipChatContract, request, context)
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
const { title, isUnread, pinned } = parsed.data.body
const updates: Record<string, unknown> = {}
if (title !== undefined) {
const now = new Date()
updates.title = title
updates.updatedAt = now
if (isUnread === undefined) {
updates.lastSeenAt = now
}
}
if (isUnread !== undefined) {
updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
}
if (pinned !== undefined) {
updates.pinned = pinned
}
const [updatedChat] = await db
.update(copilotChats)
.set(updates)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
id: copilotChats.id,
workspaceId: copilotChats.workspaceId,
})
if (!updatedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (updatedChat.workspaceId) {
if (title !== undefined) {
chatPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
})
captureServerEvent(
userId,
'task_renamed',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
if (isUnread === true) {
captureServerEvent(
userId,
'task_marked_unread',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
if (pinned !== undefined) {
captureServerEvent(
userId,
pinned ? 'task_pinned' : 'task_unpinned',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error updating mothership chat:', error)
return createInternalServerErrorResponse('Failed to update chat')
}
}
)
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(deleteMothershipChatContract, request, context)
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: true })
}
const [deletedChat] = await db
.delete(copilotChats)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
workspaceId: copilotChats.workspaceId,
})
if (!deletedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (deletedChat.workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId: deletedChat.workspaceId,
chatId,
type: 'deleted',
})
captureServerEvent(
userId,
'task_deleted',
{ workspace_id: deletedChat.workspaceId },
{
groups: { workspace: deletedChat.workspaceId },
}
)
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting mothership chat:', error)
return createInternalServerErrorResponse('Failed to delete chat')
}
}
)
@@ -0,0 +1,93 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockUpdate, mockSet, mockWhere, mockParseRequest } = vi.hoisted(() => ({
mockUpdate: vi.fn(),
mockSet: vi.fn(),
mockWhere: vi.fn(),
mockParseRequest: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: { update: mockUpdate },
}))
vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'copilotChats.id',
userId: 'copilotChats.userId',
updatedAt: 'copilotChats.updatedAt',
lastSeenAt: 'copilotChats.lastSeenAt',
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
lt: vi.fn((field: unknown, value: unknown) => ({ type: 'lt', field, value })),
sql: vi.fn(() => ({ type: 'sql' })),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest }))
vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} }))
import { POST } from '@/app/api/mothership/chats/read/route'
function createRequest() {
return new NextRequest('http://localhost:3000/api/mothership/chats/read', {
method: 'POST',
body: JSON.stringify({ chatId: 'chat-1' }),
})
}
describe('POST /api/mothership/chats/read', () => {
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } })
mockWhere.mockResolvedValue(undefined)
mockSet.mockReturnValue({ where: mockWhere })
mockUpdate.mockReturnValue({ set: mockSet })
})
it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => {
const res = await POST(createRequest())
expect(res.status).toBe(200)
expect(mockUpdate).toHaveBeenCalledTimes(1)
const whereArg = mockWhere.mock.calls[0][0] as {
type: string
conditions: Array<{ type: string; conditions?: unknown[] }>
}
expect(whereArg.type).toBe('and')
const orClause = whereArg.conditions.find((c) => c.type === 'or')
expect(orClause).toBeDefined()
expect(orClause?.conditions).toEqual(
expect.arrayContaining([
{ type: 'isNull', field: 'copilotChats.lastSeenAt' },
{ type: 'lt', field: 'copilotChats.lastSeenAt', value: 'copilotChats.updatedAt' },
])
)
})
it('does not touch the database when unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: null,
isAuthenticated: false,
})
const res = await POST(createRequest())
expect(res.status).toBe(401)
expect(mockUpdate).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,44 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('MarkTaskReadAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(markMothershipChatReadContract, request, {})
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.body
await db
.update(copilotChats)
.set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` })
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
or(isNull(copilotChats.lastSeenAt), lt(copilotChats.lastSeenAt, copilotChats.updatedAt))
)
)
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error marking task as read:', error)
return createInternalServerErrorResponse('Failed to mark task as read')
}
})
@@ -0,0 +1,225 @@
/**
* @vitest-environment node
*/
import { copilotHttpMock, copilotHttpMockFns, permissionsMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSelect, mockFrom, mockWhere, mockOrderBy, mockReconcileChatStreamMarkers } = vi.hoisted(
() => ({
mockSelect: vi.fn(),
mockFrom: vi.fn(),
mockWhere: vi.fn(),
mockOrderBy: vi.fn(),
mockReconcileChatStreamMarkers: vi.fn(),
})
)
vi.mock('@sim/db', () => ({
db: {
select: mockSelect,
},
}))
vi.mock('@sim/db/schema', () => ({
copilotChats: {
id: 'copilotChats.id',
title: 'copilotChats.title',
userId: 'copilotChats.userId',
workspaceId: 'copilotChats.workspaceId',
type: 'copilotChats.type',
updatedAt: 'copilotChats.updatedAt',
conversationId: 'copilotChats.conversationId',
lastSeenAt: 'copilotChats.lastSeenAt',
},
}))
vi.mock('drizzle-orm', () => ({
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
desc: vi.fn((field: unknown) => ({ type: 'desc', field })),
eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
}))
vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/lib/copilot/chat/stream-liveness', () => ({
reconcileChatStreamMarkers: mockReconcileChatStreamMarkers,
}))
vi.mock('@/lib/copilot/chat-status', () => ({
chatPubSub: { publishStatusChanged: vi.fn() },
}))
vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: vi.fn(),
}))
import { GET } from '@/app/api/mothership/chats/route'
function createRequest(workspaceId: string) {
return new NextRequest(`http://localhost:3000/api/mothership/chats?workspaceId=${workspaceId}`, {
method: 'GET',
})
}
describe('GET /api/mothership/chats', () => {
beforeEach(() => {
vi.clearAllMocks()
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
userId: 'user-1',
isAuthenticated: true,
})
mockOrderBy.mockResolvedValue([])
mockWhere.mockReturnValue({ orderBy: mockOrderBy })
mockFrom.mockReturnValue({ where: mockWhere })
mockSelect.mockReturnValue({ from: mockFrom })
mockReconcileChatStreamMarkers.mockImplementation(
async (candidates: Array<{ chatId: string; streamId: string | null }>) =>
new Map(
candidates.map((candidate) => [
candidate.chatId,
{
chatId: candidate.chatId,
streamId: candidate.streamId,
status: candidate.streamId ? 'active' : 'inactive',
},
])
)
)
})
it('clears activeStreamId on chats whose redis lock has expired (stuck-yellow bug)', async () => {
const now = new Date('2026-05-11T12:00:00Z')
mockOrderBy.mockResolvedValueOnce([
{
id: 'chat-stuck',
title: 'Stuck chat',
updatedAt: now,
activeStreamId: 'stream-orphaned',
lastSeenAt: null,
},
{
id: 'chat-live',
title: 'Live chat',
updatedAt: now,
activeStreamId: 'stream-live',
lastSeenAt: null,
},
{
id: 'chat-idle',
title: 'Idle chat',
updatedAt: now,
activeStreamId: null,
lastSeenAt: null,
},
])
mockReconcileChatStreamMarkers.mockResolvedValueOnce(
new Map([
['chat-stuck', { chatId: 'chat-stuck', streamId: null, status: 'inactive' }],
['chat-live', { chatId: 'chat-live', streamId: 'stream-live', status: 'active' }],
['chat-idle', { chatId: 'chat-idle', streamId: null, status: 'inactive' }],
])
)
const response = await GET(createRequest('ws-1'))
expect(response.status).toBe(200)
const body = await response.json()
expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith(
[
{ chatId: 'chat-stuck', streamId: 'stream-orphaned' },
{ chatId: 'chat-live', streamId: 'stream-live' },
{ chatId: 'chat-idle', streamId: null },
],
{ repairVerifiedStaleMarkers: true }
)
expect(body.success).toBe(true)
expect(body.data).toEqual([
expect.objectContaining({ id: 'chat-stuck', activeStreamId: null }),
expect.objectContaining({ id: 'chat-live', activeStreamId: 'stream-live' }),
expect.objectContaining({ id: 'chat-idle', activeStreamId: null }),
])
})
it('preserves chats when no chat has a stream marker set', async () => {
const now = new Date('2026-05-11T12:00:00Z')
mockOrderBy.mockResolvedValueOnce([
{ id: 'chat-1', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null },
{ id: 'chat-2', title: null, updatedAt: now, activeStreamId: null, lastSeenAt: null },
])
const response = await GET(createRequest('ws-1'))
expect(response.status).toBe(200)
expect(mockReconcileChatStreamMarkers).toHaveBeenCalledWith(
[
{ chatId: 'chat-1', streamId: null },
{ chatId: 'chat-2', streamId: null },
],
{ repairVerifiedStaleMarkers: true }
)
const body = await response.json()
expect(body.data).toEqual([
expect.objectContaining({ id: 'chat-1', activeStreamId: null }),
expect.objectContaining({ id: 'chat-2', activeStreamId: null }),
])
})
it('leaves activeStreamId untouched when redis confirms every lock is live', async () => {
const now = new Date('2026-05-11T12:00:00Z')
mockOrderBy.mockResolvedValueOnce([
{ id: 'chat-a', title: null, updatedAt: now, activeStreamId: 'stream-a', lastSeenAt: null },
{ id: 'chat-b', title: null, updatedAt: now, activeStreamId: 'stream-b', lastSeenAt: null },
])
const response = await GET(createRequest('ws-1'))
const body = await response.json()
expect(body.data).toEqual([
expect.objectContaining({ id: 'chat-a', activeStreamId: 'stream-a' }),
expect.objectContaining({ id: 'chat-b', activeStreamId: 'stream-b' }),
])
})
it('uses Redis lock owner when it differs from a stale activeStreamId', async () => {
const now = new Date('2026-05-11T12:00:00Z')
mockOrderBy.mockResolvedValueOnce([
{
id: 'chat-mismatch',
title: null,
updatedAt: now,
activeStreamId: 'stream-stale',
lastSeenAt: null,
},
])
mockReconcileChatStreamMarkers.mockResolvedValueOnce(
new Map([
['chat-mismatch', { chatId: 'chat-mismatch', streamId: 'stream-live', status: 'active' }],
])
)
const response = await GET(createRequest('ws-1'))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.data).toEqual([
expect.objectContaining({ id: 'chat-mismatch', activeStreamId: 'stream-live' }),
])
})
it('returns 401 when unauthenticated', async () => {
copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValueOnce({
userId: null,
isAuthenticated: false,
})
const response = await GET(createRequest('ws-1'))
expect(response.status).toBe(401)
expect(mockSelect).not.toHaveBeenCalled()
expect(mockReconcileChatStreamMarkers).not.toHaveBeenCalled()
})
})
+106
View File
@@ -0,0 +1,106 @@
import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
createMothershipChatContract,
listMothershipChatsContract,
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createForbiddenResponse,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
assertActiveWorkspaceAccess,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MothershipChatsAPI')
/**
* GET /api/mothership/chats?workspaceId=xxx
* Returns mothership (home) chats for the authenticated user in the given workspace.
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const queryResult = await parseRequest(listMothershipChatsContract, request, {})
if (!queryResult.success) return queryResult.response
const { workspaceId } = queryResult.data.query
await assertActiveWorkspaceAccess(workspaceId, userId)
const data = await listMothershipChats(userId, workspaceId)
return NextResponse.json({ success: true, data })
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
logger.error('Error fetching mothership chats:', error)
return createInternalServerErrorResponse('Failed to fetch chats')
}
})
/**
* POST /api/mothership/chats
* Creates an empty mothership chat and returns its ID.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const validation = await parseRequest(createMothershipChatContract, request, {})
if (!validation.success) return validation.response
const { workspaceId } = validation.data.body
await assertActiveWorkspaceAccess(workspaceId, userId)
const now = new Date()
const [chat] = await db
.insert(copilotChats)
.values({
userId,
workspaceId,
type: 'mothership',
title: null,
model: 'claude-opus-4-8',
updatedAt: now,
lastSeenAt: now,
})
.returning({ id: copilotChats.id })
chatPubSub?.publishStatusChanged({ workspaceId, chatId: chat.id, type: 'created' })
captureServerEvent(
userId,
'task_created',
{ workspace_id: workspaceId },
{
groups: { workspace: workspaceId },
}
)
return NextResponse.json({ success: true, id: chat.id })
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
}
logger.error('Error creating mothership chat:', error)
return createInternalServerErrorResponse('Failed to create chat')
}
})