Files
simstudioai--sim/apps/sim/lib/copilot/async-runs/repository.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

499 lines
14 KiB
TypeScript

import { trace } from '@opentelemetry/api'
import { db } from '@sim/db'
import {
type CopilotAsyncToolStatus,
type CopilotRunStatus,
copilotAsyncToolCalls,
copilotRunCheckpoints,
copilotRuns,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { filterUndefined } from '@sim/utils/object'
import { and, desc, eq, inArray, isNull } from 'drizzle-orm'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { markSpanForError } from '@/lib/copilot/request/otel'
import {
ASYNC_TOOL_STATUS,
type AsyncCompletionData,
isDeliveredAsyncStatus,
isTerminalAsyncStatus,
} from './lifecycle'
const logger = createLogger('CopilotAsyncRunsRepo')
// Resolve the tracer lazily per-call to avoid capturing the NoOp tracer
// before NodeSDK installs the global TracerProvider (Next.js 16/Turbopack
// can evaluate modules before instrumentation-node.ts finishes).
const getAsyncRunsTracer = () => trace.getTracer('sim-copilot-async-runs', '1.0.0')
// Wrap an async DB op in a client-kind span with canonical `db.*` attrs.
// Cancellation is routed through `markSpanForError` so aborts record the
// exception event but don't paint spans red.
async function withDbSpan<T>(
name: string,
op: string,
table: string,
attrs: Record<string, string | number | boolean | undefined>,
fn: () => Promise<T>
): Promise<T> {
const span = getAsyncRunsTracer().startSpan(name, {
attributes: {
[TraceAttr.DbSystem]: 'postgresql',
[TraceAttr.DbOperation]: op,
[TraceAttr.DbSqlTable]: table,
...filterUndefined(attrs),
},
})
try {
return await fn()
} catch (error) {
markSpanForError(span, error)
throw error
} finally {
span.end()
}
}
export interface CreateRunSegmentInput {
id?: string
executionId: string
parentRunId?: string | null
chatId: string
userId: string
workflowId?: string | null
workspaceId?: string | null
streamId: string
agent?: string | null
model?: string | null
provider?: string | null
requestContext?: Record<string, unknown>
status?: CopilotRunStatus
}
export async function createRunSegment(input: CreateRunSegmentInput) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsCreateRunSegment,
'INSERT',
'copilot_runs',
{
[TraceAttr.CopilotExecutionId]: input.executionId,
[TraceAttr.ChatId]: input.chatId,
[TraceAttr.StreamId]: input.streamId,
[TraceAttr.UserId]: input.userId,
[TraceAttr.CopilotRunParentId]: input.parentRunId ?? undefined,
[TraceAttr.CopilotRunAgent]: input.agent ?? undefined,
[TraceAttr.CopilotRunModel]: input.model ?? undefined,
[TraceAttr.CopilotRunProvider]: input.provider ?? undefined,
[TraceAttr.CopilotRunStatus]: input.status ?? 'active',
},
async () => {
const [run] = await db
.insert(copilotRuns)
.values({
...(input.id ? { id: input.id } : {}),
executionId: input.executionId,
parentRunId: input.parentRunId ?? null,
chatId: input.chatId,
userId: input.userId,
workflowId: input.workflowId ?? null,
workspaceId: input.workspaceId ?? null,
streamId: input.streamId,
agent: input.agent ?? null,
model: input.model ?? null,
provider: input.provider ?? null,
requestContext: input.requestContext ?? {},
status: input.status ?? 'active',
})
.returning()
return run
}
)
}
export async function updateRunStatus(
runId: string,
status: CopilotRunStatus,
updates: {
completedAt?: Date | null
error?: string | null
requestContext?: Record<string, unknown>
} = {}
) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsUpdateRunStatus,
'UPDATE',
'copilot_runs',
{
[TraceAttr.RunId]: runId,
[TraceAttr.CopilotRunStatus]: status,
[TraceAttr.CopilotRunHasError]: !!updates.error,
[TraceAttr.CopilotRunHasCompletedAt]: !!updates.completedAt,
},
async () => {
const [run] = await db
.update(copilotRuns)
.set({
status,
completedAt: updates.completedAt,
error: updates.error,
requestContext: updates.requestContext,
updatedAt: new Date(),
})
.where(eq(copilotRuns.id, runId))
.returning()
return run ?? null
}
)
}
async function getLatestRunForExecution(executionId: string) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsGetLatestForExecution,
'SELECT',
'copilot_runs',
{ [TraceAttr.CopilotExecutionId]: executionId },
async () => {
const [run] = await db
.select()
.from(copilotRuns)
.where(eq(copilotRuns.executionId, executionId))
.orderBy(desc(copilotRuns.startedAt))
.limit(1)
return run ?? null
}
)
}
// Un-instrumented: called from a 4 Hz resume poll; per-call spans
// swamped traces. Use Prom histograms if latency visibility is needed.
export async function getLatestRunForStream(streamId: string, userId?: string) {
const conditions = userId
? and(eq(copilotRuns.streamId, streamId), eq(copilotRuns.userId, userId))
: eq(copilotRuns.streamId, streamId)
const [run] = await db
.select()
.from(copilotRuns)
.where(conditions)
.orderBy(desc(copilotRuns.startedAt))
.limit(1)
return run ?? null
}
export async function getRunSegment(runId: string) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsGetRunSegment,
'SELECT',
'copilot_runs',
{ [TraceAttr.RunId]: runId },
async () => {
const [run] = await db
.select({ id: copilotRuns.id, userId: copilotRuns.userId })
.from(copilotRuns)
.where(eq(copilotRuns.id, runId))
.limit(1)
return run ?? null
}
)
}
async function createRunCheckpoint(input: {
runId: string
pendingToolCallId: string
conversationSnapshot: Record<string, unknown>
agentState: Record<string, unknown>
providerRequest: Record<string, unknown>
}) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsCreateRunCheckpoint,
'INSERT',
'copilot_run_checkpoints',
{
[TraceAttr.RunId]: input.runId,
[TraceAttr.CopilotCheckpointPendingToolCallId]: input.pendingToolCallId,
},
async () => {
const [checkpoint] = await db
.insert(copilotRunCheckpoints)
.values({
runId: input.runId,
pendingToolCallId: input.pendingToolCallId,
conversationSnapshot: input.conversationSnapshot,
agentState: input.agentState,
providerRequest: input.providerRequest,
})
.returning()
return checkpoint
}
)
}
export async function upsertAsyncToolCall(input: {
runId?: string | null
checkpointId?: string | null
toolCallId: string
toolName: string
args?: Record<string, unknown>
status?: CopilotAsyncToolStatus
}) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsUpsertAsyncToolCall,
'UPSERT',
'copilot_async_tool_calls',
{
[TraceAttr.ToolCallId]: input.toolCallId,
[TraceAttr.ToolName]: input.toolName,
[TraceAttr.CopilotAsyncToolStatus]: input.status ?? 'pending',
[TraceAttr.RunId]: input.runId ?? undefined,
},
async () => {
const existing = await getAsyncToolCall(input.toolCallId)
const incomingStatus = input.status ?? 'pending'
if (
existing &&
(isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) &&
!isTerminalAsyncStatus(incomingStatus) &&
!isDeliveredAsyncStatus(incomingStatus)
) {
logger.info('Ignoring async tool upsert that would downgrade terminal state', {
toolCallId: input.toolCallId,
existingStatus: existing.status,
incomingStatus,
})
return existing
}
const effectiveRunId = input.runId ?? existing?.runId ?? null
if (!effectiveRunId) {
logger.warn('upsertAsyncToolCall missing runId and no existing row', {
toolCallId: input.toolCallId,
toolName: input.toolName,
status: input.status ?? 'pending',
})
return null
}
const now = new Date()
const [row] = await db
.insert(copilotAsyncToolCalls)
.values({
runId: effectiveRunId,
checkpointId: input.checkpointId ?? null,
toolCallId: input.toolCallId,
toolName: input.toolName,
args: input.args ?? {},
status: incomingStatus,
updatedAt: now,
})
.onConflictDoUpdate({
target: copilotAsyncToolCalls.toolCallId,
set: {
runId: effectiveRunId,
checkpointId: input.checkpointId ?? null,
toolName: input.toolName,
args: input.args ?? {},
status: incomingStatus,
updatedAt: now,
},
})
.returning()
return row
}
)
}
export async function getAsyncToolCall(toolCallId: string) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsGetAsyncToolCall,
'SELECT',
'copilot_async_tool_calls',
{ [TraceAttr.ToolCallId]: toolCallId },
async () => {
const [row] = await db
.select()
.from(copilotAsyncToolCalls)
.where(eq(copilotAsyncToolCalls.toolCallId, toolCallId))
.limit(1)
return row ?? null
}
)
}
async function markAsyncToolStatus(
toolCallId: string,
status: CopilotAsyncToolStatus,
updates: {
claimedBy?: string | null
claimedAt?: Date | null
result?: AsyncCompletionData | null
error?: string | null
completedAt?: Date | null
} = {}
) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsMarkAsyncToolStatus,
'UPDATE',
'copilot_async_tool_calls',
{
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.CopilotAsyncToolStatus]: status,
[TraceAttr.CopilotAsyncToolHasError]: !!updates.error,
[TraceAttr.CopilotAsyncToolClaimedBy]: updates.claimedBy ?? undefined,
},
async () => {
const claimedAt =
updates.claimedAt !== undefined
? updates.claimedAt
: status === 'running' && updates.claimedBy
? new Date()
: undefined
const [row] = await db
.update(copilotAsyncToolCalls)
.set({
status,
claimedBy: updates.claimedBy,
claimedAt,
result: updates.result,
error: updates.error,
completedAt: updates.completedAt,
updatedAt: new Date(),
})
.where(eq(copilotAsyncToolCalls.toolCallId, toolCallId))
.returning()
return row ?? null
}
)
}
export async function markAsyncToolRunning(toolCallId: string, claimedBy: string) {
return markAsyncToolStatus(toolCallId, 'running', { claimedBy })
}
export async function completeAsyncToolCall(input: {
toolCallId: string
status: Extract<CopilotAsyncToolStatus, 'completed' | 'failed' | 'cancelled'>
result?: AsyncCompletionData | null
error?: string | null
}) {
const existing = await getAsyncToolCall(input.toolCallId)
if (!existing) {
logger.warn('completeAsyncToolCall called before pending row existed', {
toolCallId: input.toolCallId,
status: input.status,
})
return null
}
if (isTerminalAsyncStatus(existing.status) || isDeliveredAsyncStatus(existing.status)) {
return existing
}
return markAsyncToolStatus(input.toolCallId, input.status, {
claimedBy: null,
claimedAt: null,
result: input.result ?? null,
error: input.error ?? null,
completedAt: new Date(),
})
}
export async function markAsyncToolDelivered(toolCallId: string) {
return markAsyncToolStatus(toolCallId, ASYNC_TOOL_STATUS.delivered, {
claimedBy: null,
claimedAt: null,
})
}
async function listAsyncToolCallsForRun(runId: string) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsListForRun,
'SELECT',
'copilot_async_tool_calls',
{ [TraceAttr.RunId]: runId },
async () =>
db
.select()
.from(copilotAsyncToolCalls)
.where(eq(copilotAsyncToolCalls.runId, runId))
.orderBy(desc(copilotAsyncToolCalls.createdAt))
)
}
export async function getAsyncToolCalls(toolCallIds: string[]) {
if (toolCallIds.length === 0) return []
return withDbSpan(
TraceSpan.CopilotAsyncRunsGetMany,
'SELECT',
'copilot_async_tool_calls',
{ [TraceAttr.CopilotAsyncToolIdsCount]: toolCallIds.length },
async () =>
db
.select()
.from(copilotAsyncToolCalls)
.where(inArray(copilotAsyncToolCalls.toolCallId, toolCallIds))
)
}
export async function claimCompletedAsyncToolCall(toolCallId: string, workerId: string) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsClaimCompleted,
'UPDATE',
'copilot_async_tool_calls',
{
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.CopilotAsyncToolWorkerId]: workerId,
},
async () => {
const [row] = await db
.update(copilotAsyncToolCalls)
.set({
claimedBy: workerId,
claimedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(copilotAsyncToolCalls.toolCallId, toolCallId),
inArray(copilotAsyncToolCalls.status, ['completed', 'failed', 'cancelled']),
isNull(copilotAsyncToolCalls.claimedBy)
)
)
.returning()
return row ?? null
}
)
}
async function releaseCompletedAsyncToolClaim(toolCallId: string, workerId: string) {
return withDbSpan(
TraceSpan.CopilotAsyncRunsReleaseClaim,
'UPDATE',
'copilot_async_tool_calls',
{
[TraceAttr.ToolCallId]: toolCallId,
[TraceAttr.CopilotAsyncToolWorkerId]: workerId,
},
async () => {
const [row] = await db
.update(copilotAsyncToolCalls)
.set({
claimedBy: null,
claimedAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(copilotAsyncToolCalls.toolCallId, toolCallId),
inArray(copilotAsyncToolCalls.status, ['completed', 'failed', 'cancelled']),
eq(copilotAsyncToolCalls.claimedBy, workerId)
)
)
.returning()
return row ?? null
}
)
}