chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user