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,19 @@
import type { NextRequest } from 'next/server'
import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route'
export const POST = withRouteHandler(async (request: NextRequest) => {
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
const body = await request
.clone()
.json()
.catch(() => undefined)
if (body !== undefined) {
const validation = mothershipChatAbortEnvelopeSchema.safeParse(body)
if (!validation.success) return validationErrorResponse(validation.error)
}
return copilotAbortPost(request, undefined)
})
@@ -0,0 +1,43 @@
import type { NextRequest, NextResponse } from 'next/server'
import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
DELETE as copilotResourcesDelete,
PATCH as copilotResourcesPatch,
POST as copilotResourcesPost,
} from '@/app/api/copilot/chat/resources/route'
async function validateResourceRequestEnvelope(request: NextRequest): Promise<NextResponse | null> {
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
const body = await request
.clone()
.json()
.catch(() => undefined)
if (body !== undefined) {
const validation = mothershipChatResourceEnvelopeSchema.safeParse(body)
if (!validation.success) return validationErrorResponse(validation.error)
}
return null
}
export const POST = withRouteHandler(async (request: NextRequest) => {
const validationResponse = await validateResourceRequestEnvelope(request)
if (validationResponse) return validationResponse
return copilotResourcesPost(request, undefined)
})
export const PATCH = withRouteHandler(async (request: NextRequest) => {
const validationResponse = await validateResourceRequestEnvelope(request)
if (validationResponse) return validationResponse
return copilotResourcesPatch(request, undefined)
})
export const DELETE = withRouteHandler(async (request: NextRequest) => {
const validationResponse = await validateResourceRequestEnvelope(request)
if (validationResponse) return validationResponse
return copilotResourcesDelete(request, undefined)
})
+41
View File
@@ -0,0 +1,41 @@
import { type NextRequest, NextResponse } from 'next/server'
import {
mothershipChatGetQuerySchema,
mothershipChatPostEnvelopeSchema,
} from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries'
export { maxDuration }
// Unified chat route surface.
export const GET = withRouteHandler((request: NextRequest) => {
const validation = mothershipChatGetQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries())
)
if (!validation.success) return validationErrorResponse(validation.error)
return copilotChatGet(request)
})
export const POST = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
const body = await request
.clone()
.json()
.catch(() => undefined)
if (body !== undefined) {
const validation = mothershipChatPostEnvelopeSchema.safeParse(body)
if (!validation.success) return validationErrorResponse(validation.error)
}
return handleUnifiedChatPost(request)
})
@@ -0,0 +1,19 @@
import type { NextRequest } from 'next/server'
import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route'
export const POST = withRouteHandler(async (request: NextRequest) => {
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
const body = await request
.clone()
.json()
.catch(() => undefined)
if (body !== undefined) {
const validation = mothershipChatStopEnvelopeSchema.safeParse(body)
if (!validation.success) return validationErrorResponse(validation.error)
}
return copilotStopPost(request, undefined)
})
@@ -0,0 +1,15 @@
import type { NextRequest } from 'next/server'
import { mothershipChatStreamQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { GET as copilotStreamGet, maxDuration } from '@/app/api/copilot/chat/stream/route'
export { maxDuration }
export function GET(request: NextRequest) {
const validation = mothershipChatStreamQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries())
)
if (!validation.success) return validationErrorResponse(validation.error)
return copilotStreamGet(request, undefined)
}
@@ -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')
}
})
@@ -0,0 +1,45 @@
/**
* SSE endpoint for task status events.
*
* Pushes `task_status` events to the browser when tasks are
* started, completed, created, deleted, or renamed.
*
* Auth is handled via session cookies (EventSource sends cookies automatically).
*/
import type { NextRequest } from 'next/server'
import { mothershipEventsQuerySchema } from '@/lib/api/contracts/mothership-chats'
import { validationErrorResponse } from '@/lib/api/server'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createWorkspaceSSE } from '@/lib/events/sse-endpoint'
export const dynamic = 'force-dynamic'
const mothershipEventsHandler = createWorkspaceSSE({
label: 'mothership-events',
subscriptions: [
{
subscribe: (workspaceId, send) => {
if (!chatPubSub) return () => {}
return chatPubSub.onStatusChanged((event) => {
if (event.workspaceId !== workspaceId) return
send('task_status', {
chatId: event.chatId,
type: event.type,
...(event.streamId ? { streamId: event.streamId } : {}),
timestamp: Date.now(),
})
})
},
},
],
})
export const GET = withRouteHandler((request: NextRequest) => {
const validation = mothershipEventsQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams.entries())
)
if (!validation.success) return validationErrorResponse(validation.error)
return mothershipEventsHandler(request)
})
@@ -0,0 +1,446 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements'
import {
MothershipStreamV1EventType,
MothershipStreamV1TextChannel,
} from '@/lib/copilot/generated/mothership-stream-v1'
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort'
import type { StreamEvent } from '@/lib/copilot/request/types'
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { buildUserSkillTool } from '@/lib/mothership/skills'
import {
assertActiveWorkspaceAccess,
getUserEntityPermissions,
isWorkspaceAccessDeniedError,
} from '@/lib/workspaces/permissions/utils'
import type { ChatContext } from '@/stores/panel'
export const maxDuration = 3600
const logger = createLogger('MothershipExecuteAPI')
const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'x-mothership-execute-stream'
const MOTHERSHIP_EXECUTE_STREAM_VALUE = 'ndjson'
const MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE = 'application/x-ndjson'
const MOTHERSHIP_EXECUTE_HEARTBEAT_INTERVAL_MS = 15_000
const ndjsonEncoder = new TextEncoder()
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError'
}
function wantsStreamedExecuteResponse(req: NextRequest): boolean {
return (
req.headers.get(MOTHERSHIP_EXECUTE_STREAM_HEADER) === MOTHERSHIP_EXECUTE_STREAM_VALUE ||
req.headers.get('accept')?.includes(MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE) === true
)
}
function encodeNdjson(value: unknown): Uint8Array {
return ndjsonEncoder.encode(`${JSON.stringify(value)}\n`)
}
function buildExecuteResponsePayload(
result: Awaited<ReturnType<typeof runHeadlessCopilotLifecycle>>,
effectiveChatId: string,
integrationTools: Array<{ name: string }>
) {
const clientToolNames = new Set(integrationTools.map((t) => t.name))
const clientToolCalls = (result.toolCalls || []).filter(
(tc: { name: string }) => clientToolNames.has(tc.name) || tc.name.startsWith('mcp-')
)
return {
content: result.content,
model: 'mothership',
conversationId: effectiveChatId,
tokens: result.usage
? {
prompt: result.usage.prompt,
completion: result.usage.completion,
total: (result.usage.prompt || 0) + (result.usage.completion || 0),
}
: {},
cost: result.cost || undefined,
toolCalls: clientToolCalls,
}
}
/**
* POST /api/mothership/execute
*
* Endpoint for Mothership block execution within workflows. Called by the
* executor via internal JWT auth, not by the browser directly. JSON callers get
* a single final response; NDJSON callers get heartbeats followed by a final
* event so long-running headless requests do not look idle to HTTP stacks.
*/
export const POST = withRouteHandler(async (req: NextRequest) => {
let messageId: string | undefined
let requestId: string | undefined
try {
const auth = await checkInternalAuth(req, { requireWorkflowId: false })
if (!auth.success) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const validation = await parseRequest(mothershipExecuteContract, req, {})
if (!validation.success) return validation.response
const {
messages,
responseFormat,
workspaceId,
userId: bodyUserId,
chatId,
messageId: providedMessageId,
requestId: providedRequestId,
fileAttachments,
contexts,
workflowId,
executionId,
userMetadata,
} = validation.data.body
// Bind the billing actor to the authenticated identity. The executor mints
// the internal JWT with the workflow owner's userId, so a token issued for
// one user must never be used to attribute mothership-block cost to another
// user via a forged body.userId. When the token carries a userId we require
// the body to match it; the JWT userId is authoritative.
if (auth.userId && auth.userId !== bodyUserId) {
logger.warn('Mothership execute userId does not match authenticated identity', {
tokenUserId: auth.userId,
bodyUserId,
})
return NextResponse.json(
{ error: 'userId does not match authenticated identity' },
{ status: 403 }
)
}
const userId = auth.userId ?? bodyUserId
await assertActiveWorkspaceAccess(workspaceId, userId)
const effectiveChatId = chatId || generateId()
messageId = providedMessageId || generateId()
requestId = providedRequestId || generateId()
const reqLogger = logger.withMetadata({
messageId,
requestId,
workflowId,
executionId,
})
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
const agentMentions = contexts as unknown as ChatContext[] | undefined
const [
workspaceContext,
integrationTools,
userSkillTool,
userPermission,
entitlements,
agentContexts,
] = await Promise.all([
generateWorkspaceContext(workspaceId, userId),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
buildUserSkillTool(workspaceId),
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
agentMentions,
userId,
lastUserMessage,
workspaceId,
effectiveChatId
).catch((error) => {
reqLogger.warn('Failed to resolve agent contexts for execution', {
error: toError(error).message,
})
return []
}),
])
const requestPayload: Record<string, unknown> = {
messages,
responseFormat,
userId,
// Go's auth middleware reads workspaceId off the request body to forward
// to /api/copilot/api-keys/validate (per-member org usage gate). Omitting
// it makes that validation 400 ("API key validation failed"), which kills
// the block. The chat path sends it via buildCopilotRequestPayload; the
// block path must too.
workspaceId,
chatId: effectiveChatId,
mode: 'agent',
messageId,
isHosted: true,
workspaceContext,
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
...(userMetadata ? { userMetadata } : {}),
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
...(agentContexts.length > 0 ? { contexts: agentContexts } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
...(userPermission ? { userPermission } : {}),
...(entitlements.length > 0 ? { entitlements } : {}),
}
let allowExplicitAbort = true
let explicitAbortRequest: Promise<void> | undefined
const lifecycleAbortController = new AbortController()
const requestExplicitAbortOnce = () => {
if (!allowExplicitAbort || explicitAbortRequest || !messageId) {
return
}
explicitAbortRequest = requestExplicitStreamAbort({
streamId: messageId,
userId,
chatId: effectiveChatId,
workspaceId,
}).catch((error) => {
reqLogger.warn('Failed to send explicit abort for mothership execution', {
error: toError(error).message,
})
})
}
const abortLifecycle = (reason?: unknown) => {
if (!lifecycleAbortController.signal.aborted) {
lifecycleAbortController.abort(reason ?? 'mothership_execute_aborted')
}
requestExplicitAbortOnce()
}
const onAbort = () => {
abortLifecycle(req.signal.reason ?? 'request_aborted')
}
if (req.signal.aborted) {
onAbort()
} else {
req.signal.addEventListener('abort', onAbort, { once: true })
}
const runLifecycle = (onEvent?: (event: StreamEvent) => Promise<void>) =>
runHeadlessCopilotLifecycle(requestPayload, {
userId,
workspaceId,
chatId: effectiveChatId,
workflowId,
executionId,
simRequestId: requestId,
goRoute: '/api/mothership/execute',
autoExecuteTools: true,
interactive: false,
abortSignal: lifecycleAbortController.signal,
onEvent,
})
if (wantsStreamedExecuteResponse(req)) {
let cancelled = false
let heartbeatId: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let forwardedAssistantContent = ''
const send = (event: unknown) => {
if (!cancelled) {
controller.enqueue(encodeNdjson(event))
}
}
// Flush response headers promptly and keep long headless runs from
// looking idle to worker/proxy HTTP stacks.
send({ type: 'heartbeat', timestamp: new Date().toISOString() })
heartbeatId = setInterval(() => {
send({ type: 'heartbeat', timestamp: new Date().toISOString() })
}, MOTHERSHIP_EXECUTE_HEARTBEAT_INTERVAL_MS)
void (async () => {
try {
const result = await runLifecycle(async (event) => {
if (
event.type === MothershipStreamV1EventType.text &&
event.payload.channel === MothershipStreamV1TextChannel.assistant &&
event.payload.text
) {
const text = event.payload.text
const content = text.startsWith(forwardedAssistantContent)
? text.slice(forwardedAssistantContent.length)
: text
if (content) {
forwardedAssistantContent += content
send({ type: 'chunk', content })
}
}
})
allowExplicitAbort = false
if (lifecycleAbortController.signal.aborted) {
send({ type: 'error', error: 'Sim execution aborted' })
return
}
if (!result.success) {
logger.error(
messageId
? `Mothership execute failed [messageId:${messageId}]`
: 'Mothership execute failed',
{
requestId,
workflowId,
executionId,
error: result.error,
errors: result.errors,
}
)
send({
type: 'error',
error: result.error || 'Sim execution failed',
content: result.content || '',
})
return
}
send({
type: 'final',
data: buildExecuteResponsePayload(result, effectiveChatId, integrationTools),
})
} catch (error) {
if (
lifecycleAbortController.signal.aborted ||
req.signal.aborted ||
isAbortError(error)
) {
logger.info(
messageId
? `Mothership execute aborted [messageId:${messageId}]`
: 'Mothership execute aborted',
{ requestId }
)
send({ type: 'error', error: 'Sim execution aborted' })
return
}
logger.error(
messageId
? `Mothership execute error [messageId:${messageId}]`
: 'Mothership execute error',
{
requestId,
error: getErrorMessage(error, 'Unknown error'),
}
)
send({
type: 'error',
error: getErrorMessage(error, 'Internal server error'),
})
} finally {
allowExplicitAbort = false
if (heartbeatId) {
clearInterval(heartbeatId)
}
req.signal.removeEventListener('abort', onAbort)
await explicitAbortRequest
if (!cancelled) {
controller.close()
}
}
})()
},
cancel(reason) {
cancelled = true
if (heartbeatId) {
clearInterval(heartbeatId)
}
abortLifecycle(reason ?? 'mothership_execute_stream_cancelled')
},
})
return new Response(stream, {
headers: {
'Content-Type': `${MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE}; charset=utf-8`,
'Cache-Control': 'no-cache, no-transform',
},
})
}
try {
const result = await runLifecycle()
allowExplicitAbort = false
if (lifecycleAbortController.signal.aborted || req.signal.aborted) {
reqLogger.info('Mothership execute aborted after lifecycle completion')
return NextResponse.json({ error: 'Sim execution aborted' }, { status: 499 })
}
if (!result.success) {
logger.error(
messageId
? `Mothership execute failed [messageId:${messageId}]`
: 'Mothership execute failed',
{
requestId,
workflowId,
executionId,
error: result.error,
errors: result.errors,
}
)
return NextResponse.json(
{
error: result.error || 'Sim execution failed',
content: result.content || '',
},
{ status: 500 }
)
}
return NextResponse.json(
buildExecuteResponsePayload(result, effectiveChatId, integrationTools)
)
} finally {
allowExplicitAbort = false
req.signal.removeEventListener('abort', onAbort)
await explicitAbortRequest
}
} catch (error) {
if (req.signal.aborted || isAbortError(error)) {
logger.info(
messageId
? `Mothership execute aborted [messageId:${messageId}]`
: 'Mothership execute aborted',
{
requestId,
}
)
return NextResponse.json({ error: 'Sim execution aborted' }, { status: 499 })
}
if (isWorkspaceAccessDeniedError(error)) {
return NextResponse.json({ error: 'Workspace access denied' }, { status: 403 })
}
logger.error(
messageId ? `Mothership execute error [messageId:${messageId}]` : 'Mothership execute error',
{
requestId,
error: getErrorMessage(error, 'Unknown error'),
}
)
return NextResponse.json(
{ error: getErrorMessage(error, 'Internal server error') },
{ status: 500 }
)
}
})