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,38 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ASYNC_TOOL_CONFIRMATION_STATUS,
|
||||
ASYNC_TOOL_STATUS,
|
||||
isAsyncEphemeralConfirmationStatus,
|
||||
isAsyncTerminalConfirmationStatus,
|
||||
isDeliveredAsyncStatus,
|
||||
isTerminalAsyncStatus,
|
||||
} from './lifecycle'
|
||||
|
||||
describe('async tool lifecycle helpers', () => {
|
||||
it('treats only completed, failed, and cancelled as terminal execution states', () => {
|
||||
expect(isTerminalAsyncStatus(ASYNC_TOOL_STATUS.pending)).toBe(false)
|
||||
expect(isTerminalAsyncStatus(ASYNC_TOOL_STATUS.running)).toBe(false)
|
||||
expect(isTerminalAsyncStatus(ASYNC_TOOL_STATUS.completed)).toBe(true)
|
||||
expect(isTerminalAsyncStatus(ASYNC_TOOL_STATUS.failed)).toBe(true)
|
||||
expect(isTerminalAsyncStatus(ASYNC_TOOL_STATUS.cancelled)).toBe(true)
|
||||
expect(isTerminalAsyncStatus(ASYNC_TOOL_STATUS.delivered)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats delivered rows as distinct from terminal execution states', () => {
|
||||
expect(isDeliveredAsyncStatus(ASYNC_TOOL_STATUS.delivered)).toBe(true)
|
||||
})
|
||||
|
||||
it('distinguishes background from terminal completion statuses', () => {
|
||||
expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.background)).toBe(true)
|
||||
expect(isAsyncEphemeralConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.success)).toBe(false)
|
||||
|
||||
expect(isAsyncTerminalConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.success)).toBe(true)
|
||||
expect(isAsyncTerminalConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.error)).toBe(true)
|
||||
expect(isAsyncTerminalConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.cancelled)).toBe(true)
|
||||
expect(isAsyncTerminalConfirmationStatus(ASYNC_TOOL_CONFIRMATION_STATUS.background)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { CopilotAsyncToolStatus } from '@sim/db/schema'
|
||||
import {
|
||||
MothershipStreamV1AsyncToolRecordStatus,
|
||||
MothershipStreamV1ToolOutcome,
|
||||
} from '@/lib/copilot/generated/mothership-stream-v1'
|
||||
|
||||
export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus
|
||||
|
||||
export type AsyncLifecycleStatus =
|
||||
| typeof ASYNC_TOOL_STATUS.pending
|
||||
| typeof ASYNC_TOOL_STATUS.running
|
||||
| typeof ASYNC_TOOL_STATUS.completed
|
||||
| typeof ASYNC_TOOL_STATUS.failed
|
||||
| typeof ASYNC_TOOL_STATUS.cancelled
|
||||
|
||||
export type AsyncTerminalStatus =
|
||||
| typeof ASYNC_TOOL_STATUS.completed
|
||||
| typeof ASYNC_TOOL_STATUS.failed
|
||||
| typeof ASYNC_TOOL_STATUS.cancelled
|
||||
|
||||
/**
|
||||
* Confirmation statuses sent on the request-local async tool confirmation channel.
|
||||
*
|
||||
* `background` is still emitted by the current browser workflow runtime on `pagehide`.
|
||||
*/
|
||||
export const ASYNC_TOOL_CONFIRMATION_STATUS = {
|
||||
background: 'background',
|
||||
success: MothershipStreamV1ToolOutcome.success,
|
||||
error: MothershipStreamV1ToolOutcome.error,
|
||||
cancelled: MothershipStreamV1ToolOutcome.cancelled,
|
||||
} as const
|
||||
|
||||
export type AsyncConfirmationStatus =
|
||||
(typeof ASYNC_TOOL_CONFIRMATION_STATUS)[keyof typeof ASYNC_TOOL_CONFIRMATION_STATUS]
|
||||
|
||||
export type AsyncTerminalConfirmationStatus = AsyncConfirmationStatus
|
||||
|
||||
export type AsyncConfirmationProgressStatus =
|
||||
| typeof ASYNC_TOOL_STATUS.pending
|
||||
| typeof ASYNC_TOOL_STATUS.running
|
||||
|
||||
export type AsyncEphemeralConfirmationStatus = typeof ASYNC_TOOL_CONFIRMATION_STATUS.background
|
||||
|
||||
export type AsyncConfirmationStateStatus = AsyncConfirmationProgressStatus | AsyncConfirmationStatus
|
||||
|
||||
export type AsyncPromiseStatus = typeof ASYNC_TOOL_STATUS.running | AsyncTerminalConfirmationStatus
|
||||
|
||||
export type AsyncCompletionData = unknown
|
||||
|
||||
export interface AsyncCompletionEnvelope {
|
||||
toolCallId: string
|
||||
status: AsyncConfirmationStatus
|
||||
message?: string
|
||||
data?: AsyncCompletionData
|
||||
runId?: string
|
||||
checkpointId?: string
|
||||
executionId?: string
|
||||
chatId?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export type AsyncCompletionSnapshot = Pick<
|
||||
AsyncCompletionEnvelope,
|
||||
'status' | 'message' | 'data' | 'timestamp'
|
||||
>
|
||||
|
||||
export interface AsyncTerminalCompletionSnapshot extends AsyncCompletionSnapshot {
|
||||
status: AsyncTerminalConfirmationStatus
|
||||
}
|
||||
|
||||
export interface AsyncConfirmationState {
|
||||
status: AsyncConfirmationStateStatus
|
||||
message?: string
|
||||
data?: AsyncCompletionData
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export interface AsyncCompletionSignal {
|
||||
status: AsyncPromiseStatus
|
||||
message?: string
|
||||
data?: AsyncCompletionData
|
||||
}
|
||||
|
||||
export function isTerminalAsyncStatus(
|
||||
status: CopilotAsyncToolStatus | AsyncLifecycleStatus | string | null | undefined
|
||||
): status is AsyncTerminalStatus {
|
||||
return (
|
||||
status === ASYNC_TOOL_STATUS.completed ||
|
||||
status === ASYNC_TOOL_STATUS.failed ||
|
||||
status === ASYNC_TOOL_STATUS.cancelled
|
||||
)
|
||||
}
|
||||
|
||||
export function isDeliveredAsyncStatus(
|
||||
status: CopilotAsyncToolStatus | string | null | undefined
|
||||
): status is typeof ASYNC_TOOL_STATUS.delivered {
|
||||
return status === ASYNC_TOOL_STATUS.delivered
|
||||
}
|
||||
|
||||
export function isAsyncTerminalConfirmationStatus(
|
||||
status: string | null | undefined
|
||||
): status is AsyncTerminalConfirmationStatus {
|
||||
return (
|
||||
status === ASYNC_TOOL_CONFIRMATION_STATUS.background ||
|
||||
status === ASYNC_TOOL_CONFIRMATION_STATUS.success ||
|
||||
status === ASYNC_TOOL_CONFIRMATION_STATUS.error ||
|
||||
status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled
|
||||
)
|
||||
}
|
||||
|
||||
export function isAsyncEphemeralConfirmationStatus(
|
||||
status: string | null | undefined
|
||||
): status is AsyncEphemeralConfirmationStatus {
|
||||
return status === ASYNC_TOOL_CONFIRMATION_STATUS.background
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/db', () => dbChainMock)
|
||||
|
||||
import {
|
||||
claimCompletedAsyncToolCall,
|
||||
completeAsyncToolCall,
|
||||
markAsyncToolDelivered,
|
||||
} from './repository'
|
||||
|
||||
describe('async tool repository single-row semantics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
})
|
||||
|
||||
it('does not overwrite a delivered row on late completion', async () => {
|
||||
const deliveredRow = {
|
||||
toolCallId: 'tool-1',
|
||||
status: 'delivered',
|
||||
result: { ok: true },
|
||||
error: null,
|
||||
}
|
||||
dbChainMockFns.limit.mockResolvedValueOnce([deliveredRow])
|
||||
|
||||
const result = await completeAsyncToolCall({
|
||||
toolCallId: 'tool-1',
|
||||
status: 'completed',
|
||||
result: { ok: false },
|
||||
error: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual(deliveredRow)
|
||||
expect(dbChainMockFns.returning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks a row delivered and clears the claim fields', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
status: 'delivered',
|
||||
},
|
||||
])
|
||||
|
||||
await markAsyncToolDelivered('tool-1')
|
||||
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'delivered',
|
||||
claimedBy: null,
|
||||
claimedAt: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('claims only completed rows for delivery handoff', async () => {
|
||||
dbChainMockFns.returning.mockResolvedValueOnce([
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
status: 'completed',
|
||||
claimedBy: 'worker-1',
|
||||
},
|
||||
])
|
||||
|
||||
const result = await claimCompletedAsyncToolCall('tool-1', 'worker-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
toolCallId: 'tool-1',
|
||||
status: 'completed',
|
||||
claimedBy: 'worker-1',
|
||||
})
|
||||
expect(dbChainMockFns.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claimedBy: 'worker-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,498 @@
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user