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
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { parse as csvParse } from 'csv-parse/sync'
|
|
import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1'
|
|
import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1'
|
|
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
|
|
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
|
|
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
|
|
import { withCopilotSpan } from '@/lib/copilot/request/otel'
|
|
import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions'
|
|
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
|
import type { RowData, TableDefinition } from '@/lib/table'
|
|
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
|
|
import { replaceTableRows } from '@/lib/table/rows/service'
|
|
import { getTableById } from '@/lib/table/service'
|
|
|
|
const logger = createLogger('CopilotToolResultTables')
|
|
|
|
const MAX_OUTPUT_TABLE_ROWS = 10_000
|
|
|
|
/**
|
|
* Replaces a table's rows with wire rows keyed by column name. Translates the
|
|
* keys to stable column ids (unknown keys are dropped, matching every other
|
|
* name-translating boundary) and delegates to `replaceTableRows`, which owns
|
|
* locking, validation, plan row limits, batching, and rowCount maintenance.
|
|
*/
|
|
async function replaceTableRowsFromWire(
|
|
table: TableDefinition,
|
|
rows: Array<Record<string, unknown>>,
|
|
context: ExecutionContext
|
|
): Promise<{ error?: string }> {
|
|
const idByName = buildIdByName(table.schema)
|
|
const idKeyedRows = rows.map((row) => rowDataNameToId(row as RowData, idByName))
|
|
const emptyIndex = idKeyedRows.findIndex((row) => Object.keys(row).length === 0)
|
|
if (emptyIndex !== -1) {
|
|
return {
|
|
error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`,
|
|
}
|
|
}
|
|
await replaceTableRows(
|
|
{
|
|
tableId: table.id,
|
|
rows: idKeyedRows,
|
|
workspaceId: table.workspaceId,
|
|
userId: context.userId,
|
|
},
|
|
table,
|
|
generateId().slice(0, 8)
|
|
)
|
|
return {}
|
|
}
|
|
|
|
export async function maybeWriteOutputToTable(
|
|
toolName: string,
|
|
params: Record<string, unknown> | undefined,
|
|
result: ToolCallResult,
|
|
context: ExecutionContext
|
|
): Promise<ToolCallResult> {
|
|
if (toolName !== FunctionExecute.id) return result
|
|
if (!result.success || !result.output) return result
|
|
if (!context.workspaceId || !context.userId) return result
|
|
|
|
const outputTable = params?.outputTable as string | undefined
|
|
if (!outputTable) return result
|
|
|
|
const denied = denyOutputWriteWithoutWritePermission(context)
|
|
if (denied) return denied
|
|
|
|
return withCopilotSpan(
|
|
TraceSpan.CopilotToolsWriteOutputTable,
|
|
{
|
|
[TraceAttr.ToolName]: toolName,
|
|
[TraceAttr.CopilotTableId]: outputTable,
|
|
[TraceAttr.WorkspaceId]: context.workspaceId,
|
|
},
|
|
async (span) => {
|
|
try {
|
|
const table = await getTableById(outputTable)
|
|
if (!table || table.workspaceId !== context.workspaceId) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
|
|
return {
|
|
success: false,
|
|
error: `Table "${outputTable}" not found`,
|
|
}
|
|
}
|
|
|
|
const rawOutput = result.output
|
|
let rows: Array<Record<string, unknown>>
|
|
|
|
if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) {
|
|
const inner = (rawOutput as Record<string, unknown>).result
|
|
if (Array.isArray(inner)) {
|
|
rows = inner
|
|
} else {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
|
return {
|
|
success: false,
|
|
error: 'outputTable requires the code to return an array of objects',
|
|
}
|
|
}
|
|
} else if (Array.isArray(rawOutput)) {
|
|
rows = rawOutput
|
|
} else {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
|
return {
|
|
success: false,
|
|
error: 'outputTable requires the code to return an array of objects',
|
|
}
|
|
}
|
|
|
|
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
|
|
|
|
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
|
|
return {
|
|
success: false,
|
|
error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
|
|
}
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
|
|
return {
|
|
success: false,
|
|
error: 'outputTable requires at least one row — code returned an empty array',
|
|
}
|
|
}
|
|
|
|
if (context.abortSignal?.aborted) {
|
|
throw new Error('Request aborted before tool mutation could be applied')
|
|
}
|
|
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
|
|
if (replaceResult.error) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
|
return { success: false, error: replaceResult.error }
|
|
}
|
|
|
|
logger.info('Tool output written to table', {
|
|
toolName,
|
|
tableId: outputTable,
|
|
rowCount: rows.length,
|
|
})
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote)
|
|
return {
|
|
success: true,
|
|
output: {
|
|
message: `Wrote ${rows.length} rows to table ${outputTable}`,
|
|
tableId: outputTable,
|
|
rowCount: rows.length,
|
|
},
|
|
}
|
|
} catch (err) {
|
|
logger.warn('Failed to write tool output to table', {
|
|
toolName,
|
|
outputTable,
|
|
error: toError(err).message,
|
|
})
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
|
|
span.addEvent(TraceEvent.CopilotTableError, {
|
|
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
|
|
})
|
|
return {
|
|
success: false,
|
|
error: `Failed to write to table: ${toError(err).message}`,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
export async function maybeWriteReadCsvToTable(
|
|
toolName: string,
|
|
params: Record<string, unknown> | undefined,
|
|
result: ToolCallResult,
|
|
context: ExecutionContext
|
|
): Promise<ToolCallResult> {
|
|
if (toolName !== ReadTool.id) return result
|
|
if (!result.success || !result.output) return result
|
|
if (!context.workspaceId || !context.userId) return result
|
|
|
|
const outputTable = params?.outputTable as string | undefined
|
|
if (!outputTable) return result
|
|
|
|
const denied = denyOutputWriteWithoutWritePermission(context)
|
|
if (denied) return denied
|
|
|
|
return withCopilotSpan(
|
|
TraceSpan.CopilotToolsWriteCsvToTable,
|
|
{
|
|
[TraceAttr.ToolName]: toolName,
|
|
[TraceAttr.CopilotTableId]: outputTable,
|
|
[TraceAttr.WorkspaceId]: context.workspaceId,
|
|
},
|
|
async (span) => {
|
|
try {
|
|
const table = await getTableById(outputTable)
|
|
if (!table || table.workspaceId !== context.workspaceId) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.TableNotFound)
|
|
return { success: false, error: `Table "${outputTable}" not found` }
|
|
}
|
|
|
|
const output = result.output as Record<string, unknown>
|
|
const content = (output.content as string) || ''
|
|
if (!content.trim()) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyContent)
|
|
return { success: false, error: 'File has no content to import into table' }
|
|
}
|
|
|
|
const filePath = (params?.path as string) || ''
|
|
const ext = filePath.split('.').pop()?.toLowerCase()
|
|
span.setAttributes({
|
|
[TraceAttr.CopilotTableSourcePath]: filePath,
|
|
[TraceAttr.CopilotTableSourceFormat]: ext === 'json' ? 'json' : 'csv',
|
|
[TraceAttr.CopilotTableSourceContentBytes]: content.length,
|
|
})
|
|
|
|
let rows: Record<string, unknown>[]
|
|
|
|
if (ext === 'json') {
|
|
const parsed = JSON.parse(content)
|
|
if (!Array.isArray(parsed)) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidJsonShape)
|
|
return {
|
|
success: false,
|
|
error: 'JSON file must contain an array of objects for table import',
|
|
}
|
|
}
|
|
rows = parsed
|
|
} else {
|
|
rows = csvParse(content, {
|
|
columns: true,
|
|
skip_empty_lines: true,
|
|
trim: true,
|
|
relax_column_count: true,
|
|
relax_quotes: true,
|
|
skip_records_with_error: true,
|
|
cast: false,
|
|
}) as Record<string, unknown>[]
|
|
}
|
|
|
|
span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length)
|
|
|
|
if (rows.length === 0) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows)
|
|
return { success: false, error: 'File has no data rows to import' }
|
|
}
|
|
|
|
if (rows.length > MAX_OUTPUT_TABLE_ROWS) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded)
|
|
return {
|
|
success: false,
|
|
error: `Row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`,
|
|
}
|
|
}
|
|
|
|
if (context.abortSignal?.aborted) {
|
|
throw new Error('Request aborted before tool mutation could be applied')
|
|
}
|
|
const replaceResult = await replaceTableRowsFromWire(table, rows, context)
|
|
if (replaceResult.error) {
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape)
|
|
return { success: false, error: replaceResult.error }
|
|
}
|
|
|
|
logger.info('Read output written to table', {
|
|
toolName,
|
|
tableId: outputTable,
|
|
tableName: table.name,
|
|
rowCount: rows.length,
|
|
filePath,
|
|
})
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Imported)
|
|
return {
|
|
success: true,
|
|
output: {
|
|
message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`,
|
|
tableId: outputTable,
|
|
tableName: table.name,
|
|
rowCount: rows.length,
|
|
},
|
|
}
|
|
} catch (err) {
|
|
logger.warn('Failed to write read output to table', {
|
|
toolName,
|
|
outputTable,
|
|
error: toError(err).message,
|
|
})
|
|
span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Failed)
|
|
span.addEvent(TraceEvent.CopilotTableError, {
|
|
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
|
|
})
|
|
return {
|
|
success: false,
|
|
error: `Failed to import into table: ${toError(err).message}`,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|