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
2082 lines
81 KiB
TypeScript
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}` }
|
|
}
|
|
},
|
|
}
|