Files
simstudioai--sim/apps/sim/app/api/copilot/confirm/route.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

218 lines
7.5 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { copilotConfirmContract } from '@/lib/api/contracts/copilot'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
import {
ASYNC_TOOL_CONFIRMATION_STATUS,
ASYNC_TOOL_STATUS,
type AsyncCompletionData,
type AsyncConfirmationStatus,
} from '@/lib/copilot/async-runs/lifecycle'
import {
completeAsyncToolCall,
getAsyncToolCall,
getRunSegment,
upsertAsyncToolCall,
} from '@/lib/copilot/async-runs/repository'
import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createNotFoundResponse,
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('CopilotConfirmAPI')
/**
* Persist terminal durable tool status, then publish a wakeup event.
*
* `background` remains a live detach signal in the current browser workflow
* runtime, so it should not rewrite the durable async row.
*/
async function updateToolCallStatus(
existing: NonNullable<Awaited<ReturnType<typeof getAsyncToolCall>>>,
status: AsyncConfirmationStatus,
message?: string,
data?: AsyncCompletionData
): Promise<boolean> {
const toolCallId = existing.toolCallId
if (status === ASYNC_TOOL_CONFIRMATION_STATUS.background) {
publishToolConfirmation({
toolCallId,
status,
message: message || undefined,
timestamp: new Date().toISOString(),
data,
})
return true
}
const durableStatus =
status === 'success'
? ASYNC_TOOL_STATUS.completed
: status === 'cancelled'
? ASYNC_TOOL_STATUS.cancelled
: status === 'error'
? ASYNC_TOOL_STATUS.failed
: ASYNC_TOOL_STATUS.pending
try {
if (
durableStatus === ASYNC_TOOL_STATUS.completed ||
durableStatus === ASYNC_TOOL_STATUS.failed ||
durableStatus === ASYNC_TOOL_STATUS.cancelled
) {
await completeAsyncToolCall({
toolCallId,
status: durableStatus,
result: data ?? null,
error: status === 'success' ? null : message || status,
})
} else if (existing.runId) {
await upsertAsyncToolCall({
runId: existing.runId,
checkpointId: existing.checkpointId ?? null,
toolCallId,
toolName: existing.toolName || 'client_tool',
args: (existing.args as Record<string, unknown> | null) ?? {},
status: durableStatus,
})
}
publishToolConfirmation({
toolCallId,
status,
message: message || undefined,
timestamp: new Date().toISOString(),
data,
})
return true
} catch (error) {
logger.error('Failed to update tool call status', {
toolCallId,
status,
error: toError(error).message,
})
return false
}
}
// POST /api/copilot/confirm — delivery path for client-executed tool
// results. Correlate via `toolCallId` when the awaiting chat stream
// stalls.
export const POST = withRouteHandler((req: NextRequest) => {
const tracker = createRequestTracker()
return withIncomingGoSpan(
req.headers,
TraceSpan.CopilotConfirmToolResult,
{ [TraceAttr.RequestId]: tracker.requestId },
async (span) => {
try {
const { userId: authenticatedUserId, isAuthenticated } =
await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !authenticatedUserId) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Unauthorized)
return createUnauthorizedResponse()
}
const parsed = await parseRequest(
copilotConfirmContract,
req,
{},
{
validationErrorResponse: (error) => {
span.setAttribute(
TraceAttr.CopilotConfirmOutcome,
CopilotConfirmOutcome.ValidationError
)
return validationErrorResponse(
error,
`Invalid request data: ${error.issues.map((e) => e.message).join(', ')}`
)
},
}
)
if (!parsed.success) return parsed.response
const { toolCallId, status, message, data } = parsed.data.body
span.setAttributes({
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.ToolConfirmationStatus]: status,
[TraceAttr.UserId]: authenticatedUserId,
})
const existing = await getAsyncToolCall(toolCallId).catch((err) => {
logger.warn('Failed to fetch async tool call', {
toolCallId,
error: getErrorMessage(err),
})
return null
})
if (!existing) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound)
return createNotFoundResponse('Tool call not found')
}
if (existing.toolName) span.setAttribute(TraceAttr.ToolName, existing.toolName)
if (existing.runId) span.setAttribute(TraceAttr.RunId, existing.runId)
const run = await getRunSegment(existing.runId).catch((err) => {
logger.warn('Failed to fetch run segment', {
runId: existing.runId,
error: getErrorMessage(err),
})
return null
})
if (!run) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.RunNotFound)
return createNotFoundResponse('Tool call run not found')
}
if (run.userId !== authenticatedUserId) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Forbidden)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const updated = await updateToolCallStatus(existing, status, message, data)
if (!updated) {
logger.error(`[${tracker.requestId}] Failed to update tool call status`, {
userId: authenticatedUserId,
toolCallId,
status,
internalStatus: status,
message,
})
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.UpdateFailed)
// DB write failed — 500, not 400. 400 is a client-shape error.
return createInternalServerErrorResponse('Failed to update tool call status')
}
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Delivered)
return NextResponse.json({
success: true,
message: message || `Tool call ${toolCallId} has been ${status.toLowerCase()}`,
toolCallId,
status,
})
} catch (error) {
const duration = tracker.getDuration()
logger.error(`[${tracker.requestId}] Unexpected error:`, {
duration,
error: getErrorMessage(error, 'Unknown error'),
stack: error instanceof Error ? error.stack : undefined,
})
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.InternalError)
return createInternalServerErrorResponse(getErrorMessage(error, 'Internal server error'))
}
}
)
})