d25d482dc2
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
447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
})
|