Files
simstudioai--sim/apps/sim/lib/copilot/tools/server/table/user-table.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

2082 lines
81 KiB
TypeScript

import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1'
import {
assertServerToolNotAborted,
type BaseServerTool,
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { runDetached } from '@/lib/core/utils/background'
import { captureServerEvent } from '@/lib/posthog/server'
import {
buildAutoMapping,
COLUMN_TYPES,
CSV_ASYNC_IMPORT_THRESHOLD_BYTES,
CSV_MAX_BATCH_SIZE,
type CsvHeaderMapping,
CsvImportValidationError,
coerceRowsForTable,
getWorkspaceTableLimits,
inferSchemaFromCsv,
parseFileRows,
sanitizeName,
TABLE_LIMITS,
validateMapping,
} from '@/lib/table'
import {
buildIdByName,
buildNameById,
filterNamesToIds,
rowDataIdToName,
rowDataNameToId,
sortNamesToIds,
} from '@/lib/table/column-keys'
import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming'
import {
addTableColumn,
deleteColumn,
deleteColumns,
renameColumn,
updateColumnConstraints,
updateColumnType,
} from '@/lib/table/columns/service'
import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
import {
batchInsertRows,
batchUpdateRows,
deleteRow,
deleteRowsByFilter,
deleteRowsByIds,
getRowById,
insertRow,
queryRows,
replaceTableRows,
updateRow,
updateRowsByFilter,
} from '@/lib/table/rows/service'
import { createTable, deleteTable, getTableById, renameTable } from '@/lib/table/service'
import type {
ColumnDefinition,
Filter,
RowData,
TableDefinition,
TableDeleteJobPayload,
TableUpdateJobPayload,
WorkflowGroup,
WorkflowGroupDependencies,
WorkflowGroupDeploymentMode,
WorkflowGroupInputMapping,
WorkflowGroupOutput,
} from '@/lib/table/types'
import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner'
import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns'
import {
addWorkflowGroup,
addWorkflowGroupOutput,
deleteWorkflowGroup,
deleteWorkflowGroupOutput,
updateWorkflowGroup,
} from '@/lib/table/workflow-groups/service'
import {
fetchWorkspaceFileBuffer,
resolveWorkspaceFileReference,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import {
type FlattenedBlockOutput,
flattenWorkflowOutputs,
} from '@/lib/workflows/blocks/flatten-outputs'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
const logger = createLogger('UserTableServerTool')
type UserTableArgs = {
operation: string
args?: Record<string, any>
}
type UserTableResult = {
success: boolean
message: string
data?: any
}
const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE
async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspaceId: string) {
const record = await resolveWorkspaceFileReference(workspaceId, fileReference)
if (!record) {
throw new Error(
`File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.`
)
}
return record
}
/**
* Whether a workspace file should import as a background job instead of inline:
* CSV/TSV at or above the same byte threshold the UI uses. Other formats
* (xlsx/json) aren't supported by the streaming import worker and stay inline.
*/
function shouldImportInBackground(record: { name: string; size: number }): boolean {
const ext = record.name.split('.').pop()?.toLowerCase()
return (ext === 'csv' || ext === 'tsv') && record.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES
}
/**
* Dispatches a background import for an already-claimed job slot, mirroring the
* import-async routes: trigger.dev when enabled (survives deploys, retries),
* detached in-process worker otherwise. A failed dispatch releases the claim so
* a ghost `running` job can't hold the table's one-write-job slot.
*/
async function dispatchImportJob(payload: TableImportPayload): Promise<void> {
if (isTriggerDevEnabled) {
try {
const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-import'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof tableImportTask>('table-import', payload, {
tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`],
region: await resolveTriggerRegion(),
})
} catch (error) {
await releaseJobClaim(payload.tableId, payload.importId).catch(() => {})
throw error
}
} else {
runDetached('table-import', () => runTableImport(payload))
}
}
/**
* Dispatches a background filter-delete for an already-claimed job slot,
* mirroring the delete-async route. Same release-on-failed-dispatch guard as
* {@link dispatchImportJob}.
*/
async function dispatchDeleteJob(params: {
jobId: string
tableId: string
workspaceId: string
filter: Filter
cutoff: Date
maxRows?: number
}): Promise<void> {
const { jobId, tableId, workspaceId, filter, cutoff, maxRows } = params
if (isTriggerDevEnabled) {
try {
const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-delete'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof tableDeleteTask>(
'table-delete',
{ jobId, tableId, workspaceId, filter, cutoff: cutoff.toISOString(), maxRows },
{ tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() }
)
} catch (error) {
await releaseJobClaim(tableId, jobId).catch(() => {})
throw error
}
} else {
runDetached('table-delete', () =>
runTableDelete({ jobId, tableId, workspaceId, filter, cutoff, maxRows }).catch(
async (error) => {
await markTableDeleteFailed(tableId, jobId, error)
throw error
}
)
)
}
}
/**
* Dispatches a background bulk update for an already-claimed job slot, mirroring
* {@link dispatchDeleteJob}: trigger.dev when enabled, detached worker otherwise, releasing the
* slot on a failed dispatch.
*/
async function dispatchUpdateJob(params: {
jobId: string
tableId: string
workspaceId: string
filter: Filter
data: RowData
cutoff: Date
maxRows?: number
}): Promise<void> {
const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params
if (isTriggerDevEnabled) {
try {
const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-update'),
import('@trigger.dev/sdk'),
import('@/lib/core/async-jobs/region'),
])
await tasks.trigger<typeof tableUpdateTask>(
'table-update',
{ jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString(), maxRows },
{ tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() }
)
} catch (error) {
await releaseJobClaim(tableId, jobId).catch(() => {})
throw error
}
} else {
runDetached('table-update', () =>
runTableUpdate({ jobId, tableId, workspaceId, filter, data, cutoff, maxRows }).catch(
async (error) => {
await markTableUpdateFailed(tableId, jobId, error)
throw error
}
)
)
}
}
/**
* Loads the live workflow state and flattens it into pickable outputs. Used
* to validate `(blockId, path)` pairs the AI passes to add/update_workflow_group
* before they get stored as stale references — and to power `list_workflow_outputs`
* so the AI can discover valid picks instead of guessing.
*/
async function loadFlattenedWorkflowOutputs(
workflowId: string
): Promise<FlattenedBlockOutput[] | null> {
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) return null
const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({
id: b.id,
type: b.type,
name: b.name,
triggerMode: (b as { triggerMode?: boolean }).triggerMode,
subBlocks: b.subBlocks as Record<string, unknown> | undefined,
}))
return flattenWorkflowOutputs(blocks, normalized.edges ?? [])
}
/**
* Validates a list of `(blockId, path)` outputs against the live workflow.
* Returns `null` on success; on failure returns an error message that lists
* the valid options so the AI can retry without guessing again.
*/
function validateOutputsAgainstWorkflow(
outputs: Array<{ blockId: string; path: string }>,
flattened: FlattenedBlockOutput[],
workflowId: string
): string | null {
const valid = new Set(flattened.map((f) => `${f.blockId}::${f.path}`))
const invalid = outputs.filter((o) => !valid.has(`${o.blockId}::${o.path}`))
if (invalid.length === 0) return null
const sample = flattened
.slice(0, 12)
.map((f) => ` - ${f.blockId} (${f.blockName}) → ${f.path}`)
.join('\n')
const invalidList = invalid.map((o) => ` - ${o.blockId}${o.path}`).join('\n')
return `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${flattened.length > 12 ? ' (first 12)' : ''}:\n${sample}\n\nCall list_workflow_outputs with workflowId="${workflowId}" to see all valid (blockId, path) picks.`
}
/**
* Narrows a raw `deploymentMode` arg to the `'live' | 'deployed'` union, or
* `undefined` when absent/invalid (leaving the group's existing value — which
* itself defaults to `'live'`). Lets Mothership choose whether a group's
* per-cell runs execute the live draft or the latest active deployment.
*/
function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | undefined {
return value === 'live' || value === 'deployed' ? value : undefined
}
/**
* Validates an optional row limit. There's no upper bound the caller must respect — the model may
* ask for any number. `MAX_QUERY_LIMIT` / `MAX_BULK_OPERATION_SIZE` are applied internally instead
* (query_rows clamps the page; bulk ops above the bound run as a background job). Returns an error
* message, or `null` when the limit is acceptable.
*/
function limitError(limit: unknown): string | null {
if (limit === undefined) return null
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) {
return 'Limit must be an integer of at least 1'
}
return null
}
async function batchInsertAll(
tableId: string,
rows: RowData[],
table: TableDefinition,
workspaceId: string,
context?: ServerToolContext
): Promise<number> {
let inserted = 0
const userId = context?.userId
for (let i = 0; i < rows.length; i += MAX_BATCH_SIZE) {
assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.')
const batch = rows.slice(i, i + MAX_BATCH_SIZE)
const requestId = generateId().slice(0, 8)
const result = await batchInsertRows(
{ tableId, rows: batch, workspaceId, userId },
// Pass the running total so each batch's capacity check sees cumulative rows,
// not the same pre-loop snapshot (which would let a multi-batch insert overshoot).
{ ...table, rowCount: table.rowCount + inserted },
requestId
)
inserted += result.length
}
return inserted
}
export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult> = {
name: UserTable.id,
async execute(params: UserTableArgs, context?: ServerToolContext): Promise<UserTableResult> {
const withMessageId = (message: string) =>
context?.messageId ? `${message} [messageId:${context.messageId}]` : message
if (!context?.userId) {
logger.error('Unauthorized attempt to access user table - no authenticated user context')
throw new Error('Authentication required')
}
const { operation, args = {} } = params
const workspaceId =
context.workspaceId || ((args as Record<string, unknown>).workspaceId as string | undefined)
const assertNotAborted = () =>
assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.')
try {
switch (operation) {
case 'create': {
if (!args.name) {
return { success: false, message: 'Name is required for creating a table' }
}
if (!args.schema) {
return { success: false, message: 'Schema is required for creating a table' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const planLimits = await getWorkspaceTableLimits(workspaceId)
const table = await createTable(
{
name: args.name,
description: args.description,
schema: args.schema,
workspaceId,
userId: context.userId,
maxTables: planLimits.maxTables,
},
requestId
)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.TABLE_CREATED,
resourceType: AuditResourceType.TABLE,
resourceId: table.id,
resourceName: table.name,
description: `Created table "${table.name}"`,
metadata: { source: 'tool_input' },
})
return {
success: true,
message: `Created table "${table.name}" (${table.id})`,
data: { table },
}
}
case 'get': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
return {
success: true,
message: `Table "${table.name}" has ${table.rowCount} rows`,
data: { table },
}
}
case 'get_schema': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
return {
success: true,
message: `Schema for "${table.name}"`,
data: {
name: table.name,
columns: table.schema.columns,
workflowGroups: table.schema.workflowGroups ?? [],
},
}
}
case 'delete': {
const tableIds: string[] = args.tableIds ?? (args.tableId ? [args.tableId] : [])
if (tableIds.length === 0) {
return { success: false, message: 'tableId or tableIds is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const deleted: string[] = []
const failed: string[] = []
for (const tableId of tableIds) {
const table = await getTableById(tableId)
if (!table || table.workspaceId !== workspaceId) {
failed.push(tableId)
continue
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
await deleteTable(tableId, requestId, context.userId)
captureServerEvent(
context.userId,
'table_deleted',
{ table_id: tableId, workspace_id: workspaceId },
{ groups: { workspace: workspaceId } }
)
deleted.push(tableId)
}
return {
success: deleted.length > 0,
message: `Deleted ${deleted.length} table(s)${failed.length > 0 ? `, ${failed.length} not found` : ''}`,
}
}
case 'insert_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.data) {
return { success: false, message: 'Data is required for inserting a row' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
// The LLM authors row data by column name; storage keys by id.
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const row = await insertRow(
{
tableId: args.tableId,
data: rowDataNameToId(args.data, idByName),
workspaceId,
userId: context.userId,
position: args.position as number | undefined,
},
table,
requestId
)
return {
success: true,
message: `Inserted row ${row.id}`,
data: { row: { ...row, data: rowDataIdToName(row.data, nameById) } },
}
}
case 'batch_insert_rows': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rows || args.rows.length === 0) {
return { success: false, message: 'Rows array is required and must not be empty' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const rows = await batchInsertRows(
{
tableId: args.tableId,
rows: args.rows.map((r: RowData) => rowDataNameToId(r, idByName)),
workspaceId,
userId: context.userId,
},
table,
requestId
)
return {
success: true,
message: `Inserted ${rows.length} rows`,
data: {
rows: rows.map((r) => ({ ...r, data: rowDataIdToName(r.data, nameById) })),
insertedCount: rows.length,
},
}
}
case 'get_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rowId) {
return { success: false, message: 'Row ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const rowTable = await getTableById(args.tableId)
if (!rowTable || rowTable.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const row = await getRowById(args.tableId, args.rowId, workspaceId)
if (!row) {
return { success: false, message: `Row not found: ${args.rowId}` }
}
const nameById = buildNameById(rowTable.schema)
return {
success: true,
message: `Row ${row.id}`,
data: {
row: { ...row, data: rowDataIdToName(row.data, nameById) },
},
}
}
case 'query_rows': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const queryLimitError = limitError(args.limit)
if (queryLimitError) {
return { success: false, message: queryLimitError }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
// The model may request any number; we serve at most MAX_QUERY_LIMIT per page so a single
// tool result can't drain a whole table. `totalCount` in the response signals truncation,
// and the model pages with `offset`.
const result = await queryRows(
table,
{
filter: args.filter ? filterNamesToIds(args.filter, idByName) : undefined,
sort: args.sort ? sortNamesToIds(args.sort, idByName) : undefined,
limit:
args.limit !== undefined
? Math.min(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT)
: undefined,
offset: args.offset,
withExecutions: false,
},
requestId
)
return {
success: true,
message: `Returned ${result.rows.length} of ${result.totalCount} rows`,
data: {
...result,
rows: result.rows.map((r) => ({ ...r, data: rowDataIdToName(r.data, nameById) })),
},
}
}
case 'update_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rowId) {
return { success: false, message: 'Row ID is required' }
}
if (!args.data) {
return { success: false, message: 'Data is required for updating a row' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const nameById = buildNameById(table.schema)
const updatedRow = await updateRow(
{
tableId: args.tableId,
rowId: args.rowId,
data: rowDataNameToId(args.data, idByName),
workspaceId,
actorUserId: context.userId,
},
table,
requestId
)
if (!updatedRow) {
// Only the cell-task path passes a `cancellationGuard`; this caller
// doesn't, so the guard never trips here. Defensive narrowing.
return { success: false, message: 'Row update was skipped' }
}
// Auto-dispatch for user edits is handled inside `updateRow`
// (mode: 'new' for newly-cleared groups + cancel+rerun for in-flight
// downstream groups). Firing a second mode: 'incomplete' dispatch
// here would race with the internal one AND bulk-clear sibling-group
// outputs (mode: 'incomplete' wipes terminal-state cells in scope).
return {
success: true,
message: `Updated row ${updatedRow.id}`,
data: { row: { ...updatedRow, data: rowDataIdToName(updatedRow.data, nameById) } },
}
}
case 'delete_row': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.rowId) {
return { success: false, message: 'Row ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
await deleteRow(args.tableId, args.rowId, workspaceId, requestId)
return {
success: true,
message: `Deleted row ${args.rowId}`,
}
}
case 'update_rows_by_filter': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.filter) {
return { success: false, message: 'Filter is required for bulk update' }
}
if (!args.data) {
return { success: false, message: 'Data is required for bulk update' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const updateLimitError = limitError(args.limit)
if (updateLimitError) {
return { success: false, message: updateLimitError }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
const idByName = buildIdByName(table.schema)
const idFilter = filterNamesToIds(args.filter, idByName)
const idData = rowDataNameToId(args.data, idByName)
// Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation
// (an explicit limit above the cap, or unbounded "update everything matching") runs in the
// background worker so a broad update on a huge table doesn't load every matching row into
// this request. A small explicit limit is the fast path — no count needed. A patch
// touching a unique column always stays inline (the service rejects bulk-setting a unique
// value across multiple rows).
const patchTouchesUnique = table.schema.columns.some(
(c) => c.unique === true && (c.id ?? c.name) in idData
)
const updateInlineEligible =
args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE
if (!updateInlineEligible && !patchTouchesUnique) {
const { totalCount } = await queryRows(
table,
{ filter: idFilter, limit: 1, withExecutions: false },
requestId
)
const matchCount = totalCount ?? 0
const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount
if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
const cutoff = new Date()
const jobId = generateId()
const payload: TableUpdateJobPayload = {
filter: idFilter,
data: idData,
cutoff: cutoff.toISOString(),
affectedCount: target,
maxRows: args.limit,
}
assertNotAborted()
const claimed = await markTableJobRunning(table.id, jobId, 'update', payload)
if (!claimed) {
return { success: false, message: 'A job is already in progress for this table' }
}
await dispatchUpdateJob({
jobId,
tableId: table.id,
workspaceId,
filter: idFilter,
data: idData,
cutoff,
maxRows: args.limit,
})
return {
success: true,
message: `Started background update of ${target} matching rows (job ${jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`,
data: { jobId, affectedCount: target },
}
}
}
assertNotAborted()
const result = await updateRowsByFilter(
table,
{
filter: idFilter,
data: idData,
limit: args.limit,
actorUserId: context.userId,
},
requestId
)
return {
success: true,
message: `Updated ${result.affectedCount} rows`,
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
}
}
case 'delete_rows_by_filter': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!args.filter) {
return { success: false, message: 'Filter is required for bulk delete' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const deleteLimitError = limitError(args.limit)
if (deleteLimitError) {
return { success: false, message: deleteLimitError }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
const idByName = buildIdByName(table.schema)
const idFilter = filterNamesToIds(args.filter, idByName)
// Inline handles up to MAX_BULK_OPERATION_SIZE rows; a larger delete (an explicit limit
// above the cap, or unbounded "delete everything matching") hands off to the background
// delete worker so a broad delete on a huge table doesn't load every matching id into this
// request. A small explicit limit is the fast path.
const deleteInlineEligible =
args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE
if (!deleteInlineEligible) {
const { totalCount } = await queryRows(
table,
{ filter: idFilter, limit: 1, withExecutions: false },
requestId
)
const matchCount = totalCount ?? 0
const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount
if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
const doomedCount = Math.min(target, table.rowCount)
const cutoff = new Date()
const jobId = generateId()
// Unbounded: mask the whole matching set (instant post-delete view), so `doomedCount`
// drives the count adjustment. Bounded (maxRows): no mask — `doomedCount` is omitted so
// the count isn't double-subtracted; rows disappear progressively as they're deleted.
const bounded = args.limit !== undefined
const payload: TableDeleteJobPayload = bounded
? { filter: idFilter, cutoff: cutoff.toISOString(), maxRows: args.limit }
: { filter: idFilter, cutoff: cutoff.toISOString(), doomedCount }
assertNotAborted()
const claimed = await markTableJobRunning(table.id, jobId, 'delete', payload)
if (!claimed) {
return { success: false, message: 'A job is already in progress for this table' }
}
await dispatchDeleteJob({
jobId,
tableId: table.id,
workspaceId,
filter: idFilter,
cutoff,
maxRows: args.limit,
})
return {
success: true,
message: bounded
? `Started background delete of up to ${doomedCount} matching rows (job ${jobId}). Rows delete in the background — query_rows to check progress.`
: `Started background delete of ${doomedCount} matching rows (job ${jobId}). The rows are hidden from reads immediately — query_rows already reflects the post-delete view.`,
data: { jobId, doomedCount },
}
}
}
// Claim the table's one-write-job slot for the inline delete too, so it
// can't interleave with a running background import/delete. Mask-safe: a
// payload-less delete job is ignored by pendingDeleteMask, and the delete
// completes synchronously within this request before the slot is released.
assertNotAborted()
const inlineDeleteId = generateId()
const deleteClaimed = await markTableJobRunning(table.id, inlineDeleteId, 'delete')
if (!deleteClaimed) {
return { success: false, message: 'A job is already in progress for this table' }
}
let result: Awaited<ReturnType<typeof deleteRowsByFilter>>
try {
result = await deleteRowsByFilter(
table,
{ filter: idFilter, limit: args.limit },
requestId
)
} finally {
await releaseJobClaim(table.id, inlineDeleteId).catch(() => {})
}
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: table.id,
resourceName: table.name,
description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`,
metadata: {
op: 'bulk_delete',
rowsDeleted: result.affectedCount,
source: 'tool_input',
},
})
return {
success: true,
message: `Deleted ${result.affectedCount} rows`,
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
}
}
case 'batch_update_rows': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const rawUpdates = (args as Record<string, unknown>).updates as
| Array<{ rowId: string; data: Record<string, unknown> }>
| undefined
const columnName = (args as Record<string, unknown>).columnName as string | undefined
const valuesMap = (args as Record<string, unknown>).values as
| Record<string, unknown>
| undefined
let updates: Array<{ rowId: string; data: Record<string, unknown> }>
if (rawUpdates && rawUpdates.length > 0) {
updates = rawUpdates
} else if (columnName && valuesMap) {
updates = Object.entries(valuesMap).map(([rowId, value]) => ({
rowId,
data: { [columnName]: value },
}))
} else {
return {
success: false,
message: 'Provide either "updates" array or "columnName" + "values" map',
}
}
if (updates.length > MAX_BATCH_SIZE) {
return {
success: false,
message: `Too many updates (${updates.length}). Maximum is ${MAX_BATCH_SIZE}.`,
}
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const idByName = buildIdByName(table.schema)
const result = await batchUpdateRows(
{
tableId: args.tableId,
updates: (updates as Array<{ rowId: string; data: RowData }>).map((u) => ({
rowId: u.rowId,
data: rowDataNameToId(u.data, idByName),
})),
workspaceId,
actorUserId: context.userId,
},
table,
requestId
)
return {
success: true,
message: `Updated ${result.affectedCount} rows`,
data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds },
}
}
case 'batch_delete_rows': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const rowIds = (args as Record<string, unknown>).rowIds as string[] | undefined
if (!rowIds || rowIds.length === 0) {
return { success: false, message: 'rowIds array is required' }
}
if (rowIds.length > MAX_BATCH_SIZE) {
return {
success: false,
message: `Too many row IDs (${rowIds.length}). Maximum is ${MAX_BATCH_SIZE}.`,
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const result = await deleteRowsByIds(
{ tableId: args.tableId, rowIds, workspaceId },
requestId
)
recordAudit({
workspaceId,
actorId: context.userId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: args.tableId,
description: `Deleted ${result.deletedCount} row(s)`,
metadata: { op: 'bulk_delete', rowsDeleted: result.deletedCount, source: 'tool_input' },
})
return {
success: true,
message: `Deleted ${result.deletedCount} rows`,
data: {
deletedCount: result.deletedCount,
deletedRowIds: result.deletedRowIds,
},
}
}
case 'create_from_file': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
const filePath = (args as Record<string, unknown>).filePath as string | undefined
const fileReference = fileId || filePath
if (!fileReference) {
return {
success: false,
message:
'fileId or filePath is required for create_from_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").',
}
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId)
// Large CSV/TSV: create a placeholder table whose creation claims the
// job slot, then let the streaming import worker infer the schema and
// populate rows in the background (mirrors POST /api/table/import-async).
if (shouldImportInBackground(record)) {
const planLimits = await getWorkspaceTableLimits(workspaceId)
const tableName =
args.name ||
sanitizeName(record.name.replace(/\.[^.]+$/, ''), 'imported_table').slice(
0,
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH
)
const requestId = generateId().slice(0, 8)
const importId = generateId()
assertNotAborted()
const table = await createTable(
{
name: tableName,
description: args.description || `Imported from ${record.name}`,
schema: { columns: [{ name: 'column_1', type: 'string' }] },
workspaceId,
userId: context.userId,
maxRows: planLimits.maxRowsPerTable,
maxTables: planLimits.maxTables,
jobStatus: 'running',
jobType: 'import',
jobId: importId,
},
requestId
)
try {
await dispatchImportJob({
importId,
tableId: table.id,
workspaceId,
userId: context.userId,
fileKey: record.key,
fileName: record.name,
delimiter: record.name.toLowerCase().endsWith('.tsv') ? '\t' : ',',
mode: 'create',
deleteSourceFile: false,
})
} catch (dispatchError) {
// The user never saw the placeholder — archive it back out.
await deleteTable(table.id, generateId().slice(0, 8)).catch(() => {})
throw dispatchError
}
return {
success: true,
message: `Created table "${table.name}" (${table.id}); importing rows from "${record.name}" in the background (job ${importId}). Columns and rows appear as the import progresses — query_rows to check what has landed.`,
data: {
tableId: table.id,
tableName: table.name,
jobId: importId,
sourceFile: record.name,
},
}
}
const file = {
buffer: await fetchWorkspaceFileBuffer(record),
name: record.name,
type: record.type,
}
const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type)
if (rows.length === 0) {
return { success: false, message: 'File contains no data rows' }
}
const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows)
const tableName = args.name || file.name.replace(/\.[^.]+$/, '')
const requestId = generateId().slice(0, 8)
assertNotAborted()
const planLimits = await getWorkspaceTableLimits(workspaceId)
const droppedRows = Math.max(0, rows.length - planLimits.maxRowsPerTable)
const rowsToImport = droppedRows > 0 ? rows.slice(0, planLimits.maxRowsPerTable) : rows
const table = await createTable(
{
name: tableName,
description: args.description || `Imported from ${file.name}`,
schema: { columns },
workspaceId,
userId: context.userId,
maxTables: planLimits.maxTables,
},
requestId
)
// Coerce against the created table's schema so rows key by the ids
// `createTable` assigned (not the inferred, id-less columns).
const coerced = coerceRowsForTable(rowsToImport, table.schema, headerToColumn)
let inserted: number
try {
inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
} catch (insertError) {
const cleanupRequestId = generateId().slice(0, 8)
await deleteTable(table.id, cleanupRequestId).catch((cleanupError) => {
logger.error('Failed to roll back table after import failure', {
tableId: table.id,
error: toError(cleanupError).message,
})
})
const reason = toError(insertError).message
const cause =
insertError instanceof Error && insertError.cause
? toError(insertError.cause).message
: undefined
logger.error('Failed to import rows into new table', {
tableId: table.id,
fileName: file.name,
error: reason,
cause,
})
return {
success: false,
message: `Failed to import rows from "${file.name}" — the table was rolled back. ${cause ? `${reason} (${cause})` : reason}`,
}
}
logger.info('Table created from file', {
tableId: table.id,
fileName: file.name,
columns: columns.length,
rows: inserted,
droppedRows,
userId: context.userId,
})
const createdMessage = `Created table "${table.name}" with ${columns.length} columns and ${inserted.toLocaleString()} rows from "${file.name}"`
const message =
droppedRows > 0
? `${createdMessage}. Dropped ${droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.`
: createdMessage
return {
success: true,
message,
data: {
tableId: table.id,
tableName: table.name,
columns: columns.map((c) => ({ name: c.name, type: c.type })),
rowCount: inserted,
sourceFile: file.name,
},
}
}
case 'import_file': {
const fileId = (args as Record<string, unknown>).fileId as string | undefined
const filePath = (args as Record<string, unknown>).filePath as string | undefined
const tableId = (args as Record<string, unknown>).tableId as string | undefined
const fileReference = fileId || filePath
const rawMode = (args as Record<string, unknown>).mode as string | undefined
const rawMapping = (args as Record<string, unknown>).mapping as
| CsvHeaderMapping
| undefined
if (!fileReference) {
return {
success: false,
message:
'fileId or filePath is required for import_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").',
}
}
if (!tableId) {
return { success: false, message: 'tableId is required for import_file' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
if (rawMode && rawMode !== 'append' && rawMode !== 'replace') {
return {
success: false,
message: `Invalid mode "${rawMode}". Must be "append" or "replace".`,
}
}
const mode: 'append' | 'replace' = rawMode === 'replace' ? 'replace' : 'append'
const table = await getTableById(tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${tableId}` }
}
if (table.archivedAt) {
return { success: false, message: `Table is archived: ${tableId}` }
}
const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId)
// Large CSV/TSV: claim the table's one-write-job slot and hand the
// file to the streaming import worker (mirrors
// POST /api/table/[tableId]/import-async).
if (shouldImportInBackground(record)) {
const importId = generateId()
assertNotAborted()
const claimed = await markTableJobRunning(table.id, importId, 'import')
if (!claimed) {
return { success: false, message: 'A job is already in progress for this table' }
}
await dispatchImportJob({
importId,
tableId: table.id,
workspaceId,
userId: context.userId,
fileKey: record.key,
fileName: record.name,
delimiter: record.name.toLowerCase().endsWith('.tsv') ? '\t' : ',',
mode,
mapping: rawMapping,
deleteSourceFile: false,
})
return {
success: true,
message: `Started background ${mode} import of "${record.name}" into "${table.name}" (job ${importId}). Rows appear as the import progresses — query_rows to check what has landed.`,
data: { tableId: table.id, jobId: importId, mode },
}
}
// Claim the table's one-write-job slot up front — before the download
// and parse — so the inline import is mutually exclusive with any
// background import/delete for its whole duration, not just the write,
// and contention is detected before the parse work is spent.
const inlineImportId = generateId()
assertNotAborted()
const inlineClaimed = await markTableJobRunning(table.id, inlineImportId, 'import')
if (!inlineClaimed) {
return { success: false, message: 'A job is already in progress for this table' }
}
try {
const file = {
buffer: await fetchWorkspaceFileBuffer(record),
name: record.name,
type: record.type,
}
const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type)
if (rows.length === 0) {
return { success: false, message: 'File contains no data rows' }
}
const mapping: CsvHeaderMapping = rawMapping ?? buildAutoMapping(headers, table.schema)
let validation: ReturnType<typeof validateMapping>
try {
validation = validateMapping({
csvHeaders: headers,
mapping,
tableSchema: table.schema,
})
} catch (err) {
if (err instanceof CsvImportValidationError) {
return { success: false, message: err.message }
}
throw err
}
if (validation.mappedHeaders.length === 0) {
return {
success: false,
message: `No matching columns between file (${headers.join(', ')}) and table (${table.schema.columns.map((c) => c.name).join(', ')})`,
}
}
const coerced = coerceRowsForTable(rows, table.schema, validation.effectiveMap)
if (mode === 'replace') {
const requestId = generateId().slice(0, 8)
const result = await replaceTableRows(
{ tableId: table.id, rows: coerced, workspaceId, userId: context.userId },
table,
requestId
)
logger.info('Rows replaced from file', {
tableId: table.id,
fileName: file.name,
mode,
matchedColumns: validation.mappedHeaders.length,
deleted: result.deletedCount,
inserted: result.insertedCount,
userId: context.userId,
})
return {
success: true,
message: `Replaced rows in "${table.name}" from "${file.name}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`,
data: {
tableId: table.id,
tableName: table.name,
mode,
matchedColumns: validation.mappedHeaders,
skippedColumns: validation.skippedHeaders,
deletedCount: result.deletedCount,
insertedCount: result.insertedCount,
sourceFile: file.name,
},
}
}
const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context)
logger.info('Rows imported from file', {
tableId: table.id,
fileName: file.name,
mode,
matchedColumns: validation.mappedHeaders.length,
rows: inserted,
userId: context.userId,
})
return {
success: true,
message: `Imported ${inserted} rows into "${table.name}" from "${file.name}" (${validation.mappedHeaders.length} columns matched)`,
data: {
tableId: table.id,
tableName: table.name,
mode,
matchedColumns: validation.mappedHeaders,
skippedColumns: validation.skippedHeaders,
rowCount: inserted,
sourceFile: file.name,
},
}
} finally {
await releaseJobClaim(table.id, inlineImportId).catch(() => {})
}
}
case 'add_column': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const col = (args as Record<string, unknown>).column as
| {
name: string
type: string
unique?: boolean
position?: number
}
| undefined
if (!col?.name || !col?.type) {
return {
success: false,
message: 'column with name and type is required for add_column',
}
}
const tableForAdd = await getTableById(args.tableId)
if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await addTableColumn(args.tableId, col, requestId)
return {
success: true,
message: `Added column "${col.name}" (${col.type}) to table`,
data: { schema: updated.schema },
}
}
case 'rename_column': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const colName = (args as Record<string, unknown>).columnName as string | undefined
const newColName = (args as Record<string, unknown>).newName as string | undefined
if (!colName || !newColName) {
return { success: false, message: 'columnName and newName are required' }
}
const tableForRename = await getTableById(args.tableId)
if (!tableForRename || tableForRename.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await renameColumn(
{ tableId: args.tableId, oldName: colName, newName: newColName },
requestId
)
return {
success: true,
message: `Renamed column "${colName}" to "${newColName}"`,
data: { schema: updated.schema },
}
}
case 'delete_column': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const colName = (args as Record<string, unknown>).columnName as string | undefined
const colNames = (args as Record<string, unknown>).columnNames as string[] | undefined
const names = colNames ?? (colName ? [colName] : null)
if (!names || names.length === 0) {
return { success: false, message: 'columnName or columnNames is required' }
}
const tableForDelete = await getTableById(args.tableId)
if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
if (names.length === 1) {
assertNotAborted()
const updated = await deleteColumn(
{ tableId: args.tableId, columnName: names[0] },
requestId
)
return {
success: true,
message: `Deleted column "${names[0]}"`,
data: { schema: updated.schema },
}
}
assertNotAborted()
const updated = await deleteColumns(
{ tableId: args.tableId, columnNames: names },
requestId
)
return {
success: true,
message: `Deleted ${names.length} columns: ${names.join(', ')}`,
data: { schema: updated.schema },
}
}
case 'update_column': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const colName = (args as Record<string, unknown>).columnName as string | undefined
if (!colName) {
return { success: false, message: 'columnName is required' }
}
const newType = (args as Record<string, unknown>).newType as string | undefined
const uniqFlag = (args as Record<string, unknown>).unique as boolean | undefined
if (newType === undefined && uniqFlag === undefined) {
return {
success: false,
message: 'At least one of newType or unique must be provided',
}
}
const tableForUpdate = await getTableById(args.tableId)
if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
let result: TableDefinition | undefined
if (newType !== undefined) {
if (!(COLUMN_TYPES as readonly string[]).includes(newType)) {
return {
success: false,
message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`,
}
}
assertNotAborted()
result = await updateColumnType(
{
tableId: args.tableId,
columnName: colName,
newType: newType as (typeof COLUMN_TYPES)[number],
},
requestId
)
}
if (uniqFlag !== undefined) {
assertNotAborted()
result = await updateColumnConstraints(
{ tableId: args.tableId, columnName: colName, unique: uniqFlag },
requestId
)
}
return {
success: true,
message: `Updated column "${colName}"`,
data: { schema: result?.schema },
}
}
case 'rename': {
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
const newName = (args as Record<string, unknown>).newName as string | undefined
if (!newName) {
return { success: false, message: 'newName is required for renaming a table' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const renamed = await renameTable(args.tableId, newName, requestId, context.userId)
return {
success: true,
message: `Renamed table to "${renamed.name}"`,
data: { table: { id: renamed.id, name: renamed.name } },
}
}
case 'list_workflow_outputs': {
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const workflowId = args.workflowId as string | undefined
if (!workflowId) {
return {
success: false,
message: 'workflowId is required for list_workflow_outputs',
}
}
const flattened = await loadFlattenedWorkflowOutputs(workflowId)
if (!flattened) {
return {
success: false,
message: `Workflow not found or has no blocks: ${workflowId}`,
}
}
return {
success: true,
message: `Found ${flattened.length} output path(s) across the workflow's blocks`,
data: { workflowId, outputs: flattened },
}
}
case 'add_workflow_group': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const workflowId = args.workflowId as string | undefined
if (!workflowId) {
return { success: false, message: 'workflowId is required for add_workflow_group' }
}
const rawOutputs = args.outputs as
| Array<{
blockId: string
path: string
columnName?: string
columnType?: string
}>
| undefined
if (!rawOutputs || rawOutputs.length === 0) {
return {
success: false,
message: 'outputs array (with blockId + path entries) is required',
}
}
const tableForGroup = await getTableById(args.tableId)
if (!tableForGroup || tableForGroup.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
for (const o of rawOutputs) {
if (!o.blockId || !o.path) {
return {
success: false,
message: 'Each output entry must include both blockId and path',
}
}
}
const flattened = await loadFlattenedWorkflowOutputs(workflowId)
if (!flattened) {
return {
success: false,
message: `Workflow not found or has no blocks: ${workflowId}`,
}
}
const validationError = validateOutputsAgainstWorkflow(
rawOutputs.map((o) => ({ blockId: o.blockId, path: o.path })),
flattened,
workflowId
)
if (validationError) {
return { success: false, message: validationError }
}
const leafTypeByKey = new Map(
flattened.map((f) => [`${f.blockId}::${f.path}`, f.leafType])
)
const taken = new Set(tableForGroup.schema.columns.map((c) => c.name))
const groupId = generateId()
const outputs: WorkflowGroupOutput[] = []
const outputColumns: ColumnDefinition[] = []
for (const o of rawOutputs) {
const colName = o.columnName ?? deriveOutputColumnName(o.path, taken)
taken.add(colName)
outputs.push({ blockId: o.blockId, path: o.path, columnName: colName })
const leafType = o.columnType ?? leafTypeByKey.get(`${o.blockId}::${o.path}`)
outputColumns.push({
name: colName,
type: columnTypeForLeaf(leafType),
required: false,
unique: false,
workflowGroupId: groupId,
})
}
const dependencies = args.dependencies as WorkflowGroupDependencies | undefined
const name = args.name as string | undefined
const deploymentMode = parseDeploymentMode(args.deploymentMode)
const group: WorkflowGroup = {
id: groupId,
workflowId,
...(name ? { name } : {}),
...(dependencies ? { dependencies } : {}),
...(deploymentMode ? { deploymentMode } : {}),
outputs,
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
// Mothership stages groups silently by default — the AI may add more
// columns or update deps before the user wants rows to fire. Caller
// can opt in by passing `autoRun: true`.
const autoRun = args.autoRun === true
const updated = await addWorkflowGroup(
{ tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId },
requestId
)
return {
success: true,
message: `Added workflow group "${name ?? groupId}" with ${outputs.length} output column(s)`,
data: {
groupId,
schema: updated.schema,
},
}
}
case 'update_workflow_group': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const groupId = args.groupId as string | undefined
if (!groupId) {
return { success: false, message: 'groupId is required for update_workflow_group' }
}
const tableForUpdate = await getTableById(args.tableId)
if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined
if (updateOutputs && updateOutputs.length > 0) {
// Resolve which workflow these outputs apply to: explicit override
// wins, else the existing group's workflowId.
const existingGroup = tableForUpdate.schema.workflowGroups?.find(
(g) => g.id === groupId
)
const targetWorkflowId =
(args.workflowId as string | undefined) ?? existingGroup?.workflowId
if (!targetWorkflowId) {
return {
success: false,
message: `Cannot validate outputs — workflow group ${groupId} not found and no workflowId provided`,
}
}
const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId)
if (!flattened) {
return {
success: false,
message: `Workflow not found or has no blocks: ${targetWorkflowId}`,
}
}
const validationError = validateOutputsAgainstWorkflow(
updateOutputs.map((o) => ({ blockId: o.blockId, path: o.path })),
flattened,
targetWorkflowId
)
if (validationError) {
return { success: false, message: validationError }
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await updateWorkflowGroup(
{
tableId: args.tableId,
groupId,
actorUserId: context.userId,
workflowId: args.workflowId as string | undefined,
name: args.name as string | undefined,
dependencies: args.dependencies as WorkflowGroupDependencies | undefined,
outputs: updateOutputs,
newOutputColumns: args.newOutputColumns as ColumnDefinition[] | undefined,
mappingUpdates: args.mappingUpdates as
| Array<{ columnName: string; blockId: string; path: string }>
| undefined,
deploymentMode: parseDeploymentMode(args.deploymentMode),
autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined,
},
requestId
)
return {
success: true,
message: `Updated workflow group ${groupId}`,
data: { schema: updated.schema },
}
}
case 'delete_workflow_group': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const groupId = args.groupId as string | undefined
if (!groupId) {
return { success: false, message: 'groupId is required for delete_workflow_group' }
}
const tableForDelete = await getTableById(args.tableId)
if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await deleteWorkflowGroup({ tableId: args.tableId, groupId }, requestId)
return {
success: true,
message: `Deleted workflow group ${groupId}`,
data: { schema: updated.schema },
}
}
case 'add_workflow_group_output': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const groupId = args.groupId as string | undefined
const blockId = args.blockId as string | undefined
const path = args.path as string | undefined
const columnName = args.columnName as string | undefined
if (!groupId || !blockId || !path) {
return {
success: false,
message: 'groupId, blockId, and path are required for add_workflow_group_output',
}
}
const tableForAdd = await getTableById(args.tableId)
if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await addWorkflowGroupOutput(
{
tableId: args.tableId,
groupId,
blockId,
path,
columnName,
actorUserId: context.userId,
},
requestId
)
return {
success: true,
message: `Added output to workflow group ${groupId}`,
data: { schema: updated.schema },
}
}
case 'delete_workflow_group_output': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const groupId = args.groupId as string | undefined
const columnName = args.columnName as string | undefined
if (!groupId || !columnName) {
return {
success: false,
message: 'groupId and columnName are required for delete_workflow_group_output',
}
}
const tableForRemove = await getTableById(args.tableId)
if (!tableForRemove || tableForRemove.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await deleteWorkflowGroupOutput(
{ tableId: args.tableId, groupId, columnName },
requestId
)
return {
success: true,
message: `Removed output "${columnName}" from workflow group ${groupId}`,
data: { schema: updated.schema },
}
}
case 'run_column': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const rawGroupIds = args.groupIds as unknown
if (
!Array.isArray(rawGroupIds) ||
rawGroupIds.length === 0 ||
rawGroupIds.some((id) => typeof id !== 'string' || id.length === 0)
) {
return {
success: false,
message: 'groupIds must be a non-empty array of group id strings',
}
}
const groupIds = rawGroupIds as string[]
const runMode = (args.runMode as 'all' | 'incomplete' | undefined) ?? 'incomplete'
if (runMode !== 'all' && runMode !== 'incomplete') {
return {
success: false,
message: `Invalid runMode "${runMode}". Must be "all" or "incomplete"`,
}
}
const rawRowIds = args.rowIds as unknown
let rowIds: string[] | undefined
if (rawRowIds !== undefined) {
if (
!Array.isArray(rawRowIds) ||
rawRowIds.length === 0 ||
rawRowIds.some((id) => typeof id !== 'string' || id.length === 0)
) {
return {
success: false,
message: 'rowIds must be a non-empty array of row id strings',
}
}
rowIds = rawRowIds as string[]
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const { dispatchId } = await runWorkflowColumn({
tableId: args.tableId,
workspaceId,
groupIds,
mode: runMode,
rowIds,
requestId,
triggeredByUserId: context.userId,
})
const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode
return {
success: true,
message: `Started running ${groupIds.length} column(s) (${scopeLabel}). Cells will populate as workflows complete.`,
data: { dispatchId },
}
}
case 'cancel_table_runs': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const scope = (args.scope as 'all' | 'row' | undefined) ?? 'all'
if (scope !== 'all' && scope !== 'row') {
return {
success: false,
message: `Invalid scope "${scope}". Must be "all" or "row"`,
}
}
const rowId = args.rowId as string | undefined
if (scope === 'row' && !rowId) {
return { success: false, message: 'rowId is required when scope is "row"' }
}
const tableForCancel = await getTableById(args.tableId)
if (!tableForCancel || tableForCancel.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
assertNotAborted()
const cancelled = await cancelWorkflowGroupRuns(
args.tableId,
scope === 'row' ? rowId : undefined
)
return {
success: true,
message: `Cancelled ${cancelled} run(s)`,
data: { cancelled },
}
}
case 'list_enrichments': {
const { ALL_ENRICHMENTS } = await import('@/enrichments/registry')
const enrichments = ALL_ENRICHMENTS.map((e) => ({
id: e.id,
name: e.name,
description: e.description,
inputs: e.inputs.map((i) => ({
id: i.id,
name: i.name,
type: i.type,
required: i.required ?? false,
})),
outputs: e.outputs.map((o) => ({ id: o.id, name: o.name, type: o.type })),
}))
return {
success: true,
message: `${enrichments.length} enrichment(s) available`,
data: { enrichments },
}
}
case 'add_enrichment': {
if (!args.tableId) return { success: false, message: 'Table ID is required' }
if (!workspaceId) return { success: false, message: 'Workspace ID is required' }
const enrichmentId = args.enrichmentId as string | undefined
if (!enrichmentId) {
return { success: false, message: 'enrichmentId is required for add_enrichment' }
}
const { getEnrichment } = await import('@/enrichments/registry')
const enrichment = getEnrichment(enrichmentId)
if (!enrichment) {
return {
success: false,
message: `Unknown enrichment "${enrichmentId}". Call list_enrichments to see available ids.`,
}
}
const tableForEnrichment = await getTableById(args.tableId)
if (!tableForEnrichment || tableForEnrichment.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
// Validate the input mapping: every required input must be mapped, and
// each mapped column must already exist on the table.
const rawMappings = args.inputMappings as
| Array<{ inputName: string; columnName: string }>
| undefined
const mappingByInput = new Map(
(Array.isArray(rawMappings) ? rawMappings : []).map((m) => [m.inputName, m.columnName])
)
const existingColumns = new Set(tableForEnrichment.schema.columns.map((c) => c.name))
for (const input of enrichment.inputs) {
const mapped = mappingByInput.get(input.id)
if (input.required && !mapped) {
return {
success: false,
message: `Enrichment "${enrichment.name}" requires input "${input.id}" to be mapped to a column`,
}
}
if (mapped && !existingColumns.has(mapped)) {
return {
success: false,
message: `Mapped column "${mapped}" for input "${input.id}" does not exist on table ${args.tableId}`,
}
}
}
const inputMappings: WorkflowGroupInputMapping[] = enrichment.inputs
.filter((input) => mappingByInput.has(input.id))
.map((input) => ({
inputName: input.id,
columnName: mappingByInput.get(input.id) as string,
}))
// Each enrichment output becomes a new column. Names can be overridden
// per output id; otherwise the enrichment's default name is used.
const outputNameOverrides = (args.outputColumnNames ?? {}) as Record<string, string>
const taken = new Set(tableForEnrichment.schema.columns.map((c) => c.name))
const groupId = generateId()
const outputs: WorkflowGroupOutput[] = []
const outputColumns: ColumnDefinition[] = []
for (const out of enrichment.outputs) {
const desired = (outputNameOverrides[out.id] ?? '').trim() || out.name
const colName = deriveOutputColumnName(desired, taken)
taken.add(colName)
outputs.push({ blockId: '', path: '', outputId: out.id, columnName: colName })
outputColumns.push({
name: colName,
type: out.type,
required: false,
unique: false,
workflowGroupId: groupId,
})
}
// Default the run dependencies to the mapped input columns so a row
// fires once its inputs are filled. Mothership stages groups silently
// by default (autoRun false) — call run_column to fire rows.
const dependencies =
(args.dependencies as WorkflowGroupDependencies | undefined) ??
({
columns: inputMappings.map((m) => m.columnName),
} satisfies WorkflowGroupDependencies)
const name = (args.name as string | undefined) ?? enrichment.name
const autoRun = args.autoRun === true
const group: WorkflowGroup = {
id: groupId,
workflowId: '',
enrichmentId,
name,
type: 'enrichment',
dependencies,
outputs,
inputMappings,
autoRun,
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await addWorkflowGroup(
{ tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId },
requestId
)
return {
success: true,
message: `Added enrichment "${name}" with ${outputs.length} output column(s)${
autoRun ? ' (auto-run enabled)' : ' (staged — use run_column to fire rows)'
}`,
data: { groupId, schema: updated.schema },
}
}
default:
return { success: false, message: `Unknown operation: ${operation}` }
}
} catch (error) {
const errorMessage = toError(error).message
const cause = error instanceof Error && error.cause ? toError(error.cause).message : undefined
logger.error('Table operation failed', {
operation,
error: errorMessage,
cause,
})
const displayMessage = cause ? `${errorMessage} (${cause})` : errorMessage
return { success: false, message: `Operation failed: ${displayMessage}` }
}
},
}