Files
simstudioai--sim/apps/sim/lib/workflows/input-format.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

265 lines
8.8 KiB
TypeScript

import { generateId } from '@sim/utils/id'
import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { UserFile } from '@/executor/types'
/**
* Simplified input field representation for workflow input mapping
*/
export interface WorkflowInputField {
/**
* Stable per-field id seeded at field creation (`InputFormatFieldState.id`).
* Custom blocks anchor their input sub-block on this so renaming a field
* never orphans a consumer's placed value. Absent on legacy fields.
*/
id?: string
name: string
type: string
description?: string
/**
* Consumer-facing placeholder hint for a custom block's curated input. Authored
* in the Custom Blocks settings UI; has no source on the workflow's Start block.
*/
placeholder?: string
/**
* Consumers must fill this custom-block input. Authored in the Custom Blocks
* settings UI; has no source on the workflow's Start block.
*/
required?: boolean
}
/**
* Stateful input-format field as stored in sub-block values: the editor's
* per-row shape, including the editor-only `id` and `collapsed` fields. Stricter
* than the wire-level {@link InputFormatField} (required `name`/`type`/`value`).
*/
interface InputFormatFieldState {
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
value: string
description?: string
collapsed: boolean
}
/**
* Creates a new empty input-format field with a fresh id.
*
* Single source of truth for the default field shape used when seeding
* input-format / response-format sub-blocks and when adding rows in the editor.
*/
export function createDefaultInputFormatField(): InputFormatFieldState {
return {
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
}
}
/**
* Whether an input-format field type denotes a file input. Matches the canonical
* `file[]` written by the field-type dropdown — the same literal the execution
* and webhook file paths already key off (`lib/execution/files.ts`,
* `lib/webhooks/providers/generic.ts`) — so the editor and runtime agree and no
* existing non-`file[]` field changes behavior.
*/
export function isFileFieldType(type: string | null | undefined): boolean {
return type === 'file[]'
}
/**
* Run-ready file object stored as a file field's value. Derived from the
* executor's canonical {@link UserFile} (validated by `normalizeStartFile`) so
* editor-attached files flow into a run unchanged and the shape can't drift.
*/
export type InputFormatFile = Pick<UserFile, 'id' | 'name' | 'url' | 'size' | 'type'> &
Pick<Partial<UserFile>, 'key'>
/**
* Whether a file's key is usable at run time: an explicit non-empty `key`, or an
* internal `/api/files/serve/...` URL the key can actually be parsed from. This
* mirrors `normalizeStartFile` exactly (including the parse, so a malformed
* internal URL is rejected rather than accepted on the prefix alone).
*/
function hasRecoverableFileKey(file: InputFormatFile): boolean {
if (typeof file.key === 'string' && file.key.length > 0) return true
if (typeof file.url !== 'string' || !isInternalFileUrl(file.url)) return false
try {
return parseInternalFileUrl(file.url).key.length > 0
} catch {
return false
}
}
/**
* Tolerantly parses a file field's stored value (a JSON string, or an already
* materialized array) into run-ready file objects. Returns an empty array for
* legacy free-form values (base64 placeholders, raw text) that don't describe
* uploaded files, so callers degrade gracefully instead of throwing.
*/
export function parseInputFormatFiles(value: unknown): InputFormatFile[] {
let raw: unknown = value
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) return []
try {
raw = JSON.parse(trimmed)
} catch {
return []
}
}
if (!Array.isArray(raw)) return []
return raw.filter((file): file is InputFormatFile => {
if (file === null || typeof file !== 'object') return false
const f = file as InputFormatFile
// Accept only the run-ready shape `normalizeStartFile` accepts (non-empty
// id/name/url/type + finite size + recoverable key); file normalization is
// all-or-nothing, so anything short of this falls back to the JSON editor
// rather than silently dropping every file at run time.
return (
typeof f.id === 'string' &&
f.id.length > 0 &&
typeof f.name === 'string' &&
f.name.length > 0 &&
typeof f.url === 'string' &&
f.url.length > 0 &&
typeof f.size === 'number' &&
Number.isFinite(f.size) &&
typeof f.type === 'string' &&
f.type.length > 0 &&
hasRecoverableFileKey(f)
)
})
}
/**
* Collects all editor-attached files from the file-typed fields of an
* inputFormat value. Files are already uploaded (run-ready), so callers can pass
* them straight to the executor's file channel without a re-upload.
*/
export function collectInputFormatFiles(inputFormatValue: unknown): InputFormatFile[] {
if (!Array.isArray(inputFormatValue)) return []
return inputFormatValue.flatMap((field) =>
field &&
typeof field === 'object' &&
isFileFieldType((field as { type?: unknown }).type as string)
? parseInputFormatFiles((field as { value?: unknown }).value)
: []
)
}
/**
* Extracts input fields from workflow blocks.
* Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat.
*
* @param blocks - The blocks object from workflow state
* @returns Array of input field definitions
*/
export function extractInputFieldsFromBlocks(
blocks: Record<string, unknown> | null | undefined
): WorkflowInputField[] {
if (!blocks) return []
// Find trigger block
const triggerEntry = Object.entries(blocks).find(([, block]) => {
const b = block as Record<string, unknown>
return typeof b.type === 'string' && isInputDefinitionTrigger(b.type)
})
if (!triggerEntry) return []
const triggerBlock = triggerEntry[1] as Record<string, unknown>
const subBlocks = triggerBlock.subBlocks as Record<string, { value?: unknown }> | undefined
const inputFormat = subBlocks?.inputFormat?.value
// Try primary location: subBlocks.inputFormat.value
if (Array.isArray(inputFormat)) {
return inputFormat
.filter(
(
field: unknown
): field is { id?: unknown; name: string; type?: string; description?: string } =>
typeof field === 'object' &&
field !== null &&
'name' in field &&
typeof (field as { name: unknown }).name === 'string' &&
(field as { name: string }).name.trim() !== ''
)
.map((field) => ({
...(typeof field.id === 'string' && field.id ? { id: field.id } : {}),
name: field.name,
type: field.type || 'string',
...(field.description && { description: field.description }),
}))
}
// Try legacy location: config.params.inputFormat
const config = triggerBlock.config as { params?: { inputFormat?: unknown } } | undefined
const legacyFormat = config?.params?.inputFormat
if (Array.isArray(legacyFormat)) {
return legacyFormat
.filter(
(
field: unknown
): field is { id?: unknown; name: string; type?: string; description?: string } =>
typeof field === 'object' &&
field !== null &&
'name' in field &&
typeof (field as { name: unknown }).name === 'string' &&
(field as { name: string }).name.trim() !== ''
)
.map((field) => ({
...(typeof field.id === 'string' && field.id ? { id: field.id } : {}),
name: field.name,
type: field.type || 'string',
...(field.description && { description: field.description }),
}))
}
return []
}
/**
* Normalizes an input format value into a list of valid fields.
*
* Filters out:
* - null or undefined values
* - Empty arrays
* - Non-array values
* - Fields without names
* - Fields with empty or whitespace-only names
*
* @param inputFormatValue - Raw input format value from subblock state
* @returns Array of validated input format fields
*/
export function normalizeInputFormatValue(inputFormatValue: unknown): InputFormatField[] {
// Handle null, undefined, and empty arrays
if (
inputFormatValue === null ||
inputFormatValue === undefined ||
(Array.isArray(inputFormatValue) && inputFormatValue.length === 0)
) {
return []
}
// Handle non-array values
if (!Array.isArray(inputFormatValue)) {
return []
}
// Filter valid fields
return inputFormatValue.filter(
(field): field is InputFormatField =>
field &&
typeof field === 'object' &&
typeof field.name === 'string' &&
field.name.trim() !== ''
)
}