chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { WorkflowInputField } from '@/lib/workflows/input-format'
import {
buildCustomBlockConfig,
CUSTOM_BLOCK_TILE_COLOR,
type CustomBlockRow,
isCustomBlockType,
} from '@/blocks/custom/build-config'
import type { BlockIcon } from '@/blocks/types'
const icon: BlockIcon = () => null as never
const row: CustomBlockRow = {
type: 'custom_block_abc123',
name: 'Invoice Parser',
description: 'Extracts fields from an invoice',
workflowId: 'wf-1',
}
function findSub(config: ReturnType<typeof buildCustomBlockConfig>, id: string) {
return config.subBlocks.find((s) => s.id === id)
}
describe('isCustomBlockType', () => {
it('matches only the custom_block_ prefix', () => {
expect(isCustomBlockType('custom_block_abc')).toBe(true)
expect(isCustomBlockType('agent')).toBe(false)
expect(isCustomBlockType(undefined)).toBe(false)
expect(isCustomBlockType(null)).toBe(false)
})
})
describe('buildCustomBlockConfig', () => {
const fields: WorkflowInputField[] = [
{ name: 'title', type: 'string' },
{ name: 'count', type: 'number' },
{ name: 'flag', type: 'boolean' },
{ name: 'payload', type: 'object' },
{ name: 'items', type: 'array' },
{ name: 'docs', type: 'file[]' },
]
it('carries the row identity and always wires the workflow_executor tool', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(config.type).toBe('custom_block_abc123')
expect(config.name).toBe('Invoice Parser')
expect(config.category).toBe('tools')
expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
expect(config.hideFromToolbar).toBeUndefined()
expect(config.tools.access).toEqual(['workflow_executor'])
expect(config.tools.config?.tool({})).toBe('workflow_executor')
})
it('hides a disabled block from the toolbar while keeping it resolvable', () => {
expect(buildCustomBlockConfig(row, fields, { icon }).hideFromToolbar).toBeUndefined()
expect(
buildCustomBlockConfig(row, fields, { icon, hideFromToolbar: true }).hideFromToolbar
).toBe(true)
})
it('bakes the bound workflowId as a hidden sub-block', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
const wf = findSub(config, 'workflowId')
expect(wf?.hidden).toBe(true)
expect(wf?.value?.({})).toBe('wf-1')
})
it('maps each input field type to the right sub-block', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(findSub(config, 'title')?.type).toBe('short-input')
expect(findSub(config, 'count')?.type).toBe('short-input')
expect(findSub(config, 'flag')?.type).toBe('switch')
expect(findSub(config, 'payload')?.type).toBe('code')
expect(findSub(config, 'payload')?.language).toBe('json')
expect(findSub(config, 'items')?.type).toBe('code')
expect(findSub(config, 'docs')?.type).toBe('file-upload')
expect(findSub(config, 'docs')?.multiple).toBe(true)
})
it('exposes the full result and hides plumbing when no outputs are curated', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(Object.keys(config.outputs).sort()).toEqual(['error', 'result', 'success'])
expect(config.outputs.childWorkflowId).toBeUndefined()
expect(config.outputs.childTraceSpans).toBeUndefined()
})
it('exposes only curated outputs as named fields', () => {
const config = buildCustomBlockConfig(
{ ...row, exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'email' }] },
fields,
{ icon }
)
expect(config.outputs.email).toEqual({ type: 'json', description: 'Output: content' })
expect(config.outputs.result).toBeUndefined()
expect(config.outputs.success).toBeDefined()
expect(config.outputs.childWorkflowId).toBeUndefined()
})
it('anchors the sub-block on the stable field id, showing the name as title', () => {
const config = buildCustomBlockConfig(row, [{ id: 'fld-1', name: 'title', type: 'string' }], {
icon,
})
const sub = findSub(config, 'fld-1')
expect(sub).toBeDefined()
expect(sub?.title).toBe('title')
expect(findSub(config, 'title')).toBeUndefined()
})
it('falls back to the field name as id when a field has no stable id', () => {
const config = buildCustomBlockConfig(row, [{ name: 'legacy', type: 'string' }], { icon })
expect(findSub(config, 'legacy')?.title).toBe('legacy')
})
it('assembles inputMapping from non-reserved, non-empty params', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
const mappingFn = findSub(config, 'inputMapping')?.value
const json = mappingFn?.({
workflowId: 'wf-1',
inputMapping: 'ignored',
triggerMode: true,
title: 'Acme',
count: 3,
empty: '',
})
expect(JSON.parse(json as string)).toEqual({ title: 'Acme', count: 3 })
})
})
+197
View File
@@ -0,0 +1,197 @@
import type { SubBlockType } from '@sim/workflow-types/blocks'
import type { WorkflowInputField } from '@/lib/workflows/input-format'
import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
/**
* The block-type prefix that identifies a custom (deploy-as-block) block. Shared
* by the registry overlay, the executor handler dispatch, and access control.
*/
export const CUSTOM_BLOCK_TYPE_PREFIX = 'custom_block_'
/** Whether a block type is a published custom block. */
export function isCustomBlockType(type: string | undefined | null): type is string {
return typeof type === 'string' && type.startsWith(CUSTOM_BLOCK_TYPE_PREFIX)
}
/** Tile background for custom-block icons (the uploaded image renders on top). */
export const CUSTOM_BLOCK_TILE_COLOR = '#6F6F6F'
/** A curated output exposed on the block, mapped from a child block output. */
export interface CustomBlockOutput {
blockId: string
path: string
name: string
}
/**
* A curated input the admin chose to expose on the block, keyed by the source
* Start field's stable `id`, with optional consumer-facing hints.
*/
export interface CustomBlockInput {
id: string
name: string
type: string
placeholder?: string
description?: string
required?: boolean
}
/**
* The DB-backed identity + presentation of a custom block. `workflowId` is the
* bound source workflow whose LATEST deployment this block always executes.
*/
export interface CustomBlockRow {
type: string
name: string
description: string
workflowId: string
/** Curated exposed outputs; empty/absent exposes the child's whole `result`. */
exposedOutputs?: CustomBlockOutput[]
}
/**
* Params that carry the block's own wiring rather than a mapped Start input.
* Everything else on the block is collected into the child `inputMapping`. Shared
* with the serializer so "does this custom block declare input sub-blocks?" reads
* from one source instead of re-listing the structural ids.
*/
export const RESERVED_PARAMS = new Set([
'workflowId',
'inputMapping',
'triggerMode',
'advancedMode',
])
/** Map a Start input field type to the editor sub-block type used to collect it. */
function subBlockTypeForField(fieldType: string): SubBlockType {
switch (fieldType) {
case 'boolean':
return 'switch'
case 'object':
case 'array':
return 'code'
case 'file[]':
return 'file-upload'
default:
return 'short-input'
}
}
/**
* Synthesize a `BlockConfig` for a published custom block from its DB row and the
* live-derived Start input fields. Shared by the client (real icon + per-field
* editors) and the server (placeholder icon + `inputFields: []`, since the
* `inputMapping` wiring is schema-agnostic).
*
* Execution reuses the `workflow_executor` tool: the bound `workflowId` and the
* assembled `inputMapping` are hidden, baked sub-blocks; each Start input becomes
* its own editable sub-block whose value is collected into `inputMapping`.
* `<refs>` inside those values resolve at execution exactly like the
* `workflow_input` block.
*
* The sub-block id is the field's stable id (`field.id`), NOT its display name, so
* renaming a Start input in the source workflow and redeploying never orphans a
* consumer's placed value. The name is shown as the sub-block title and is what
* the child workflow ultimately receives — the id→name remap happens at execution
* in `WorkflowBlockHandler` against the loaded child's current field names. Legacy
* fields without an id fall back to keying on the name.
*/
export function buildCustomBlockConfig(
row: CustomBlockRow,
inputFields: WorkflowInputField[],
opts: { icon: BlockIcon; bgColor?: string; hideFromToolbar?: boolean }
): BlockConfig {
const fieldSubBlocks: SubBlockConfig[] = inputFields.map((field) => {
const type = subBlockTypeForField(field.type)
const sub: SubBlockConfig = {
id: field.id ?? field.name,
title: field.name,
type,
description: field.description,
placeholder: field.placeholder,
// Serializer Loop-B (required subBlocks not covered by tool params) and the
// editor asterisk both read this — same enforcement path as regular blocks.
required: field.required === true,
}
if (field.type === 'object' || field.type === 'array') sub.language = 'json'
if (field.type === 'file[]') sub.multiple = true
return sub
})
return {
type: row.type,
name: row.name,
description: row.description,
category: 'tools',
longDescription:
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
'fields; it runs the underlying workflow and returns the outputs below. The bound ' +
'workflow is baked in — no workflow id or input mapping to configure.',
bgColor: opts.bgColor ?? CUSTOM_BLOCK_TILE_COLOR,
icon: opts.icon,
// A disabled block stays resolvable (so a still-placed instance survives
// serialization and fails loudly at run via `getCustomBlockAuthority`, instead
// of silently vanishing from the graph) but is hidden from the palette so no
// new instance can be placed.
hideFromToolbar: opts.hideFromToolbar,
subBlocks: [
{
id: 'workflowId',
type: 'short-input',
hidden: true,
value: () => row.workflowId,
},
{
id: 'inputMapping',
type: 'code',
language: 'json',
hidden: true,
value: (params) => {
const mapping: Record<string, unknown> = {}
for (const [key, val] of Object.entries(params)) {
if (RESERVED_PARAMS.has(key)) continue
if (val === undefined || val === '') continue
mapping[key] = val
}
return JSON.stringify(mapping)
},
},
...fieldSubBlocks,
],
tools: {
access: ['workflow_executor'],
config: {
tool: () => 'workflow_executor',
params: (params) => ({
workflowId: params.workflowId,
inputMapping: params.inputMapping,
}),
},
},
inputs: {
workflowId: { type: 'string', description: 'Bound source workflow id' },
inputMapping: { type: 'json', description: 'Mapping of input fields to values' },
},
outputs: buildOutputs(row.exposedOutputs),
}
}
/**
* The block's declared outputs. Internal plumbing (child workflow id/name, trace
* spans) is never exposed. With curated `exposedOutputs`, each becomes its own
* named output; otherwise the whole child `result` is exposed.
*/
function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['outputs'] {
const outputs: BlockConfig['outputs'] = {
success: { type: 'boolean', description: 'Execution success status' },
error: { type: 'string', description: 'Error message' },
}
if (exposed && exposed.length > 0) {
for (const out of exposed) {
outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
}
} else {
outputs.result = { type: 'json', description: 'Workflow execution result' }
}
return outputs
}
+61
View File
@@ -0,0 +1,61 @@
'use client'
import { useSyncExternalStore } from 'react'
import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
import type { BlockConfig } from '@/blocks/types'
/**
* Client-side custom-block overlay: a mutable Map, hydrated from
* `useCustomBlocks` by `CustomBlocksProvider`, that the `@/blocks/registry`
* accessors fall back to. Scoped to the active workspace's org; re-hydrated on
* workspace switch.
*
* Because many consumers snapshot `getAllBlocks()` (the cmd+K search, the Access
* Control block list), the overlay is also an external store: `version` bumps on
* every hydrate and listeners are notified, so those consumers can re-read via
* {@link useCustomBlockOverlayVersion} instead of going stale until a refresh.
*/
let map = new Map<string, BlockConfig>()
let version = 0
const listeners = new Set<() => void>()
registerBlockOverlayResolver({
get: (type) => map.get(type),
all: () => [...map.values()],
})
/**
* Bump the overlay version and notify subscribers that block-registry-derived
* data changed. Shared signal for BOTH custom-block hydration and the
* block-visibility hydrate path — subscribers treat the version as an opaque
* "re-read `getAllBlocks()`" token, never as "custom blocks specifically
* changed".
*/
export function notifyBlockOverlayChanged(): void {
version += 1
for (const listener of listeners) listener()
}
/** Replace the in-scope custom blocks and notify subscribers. */
export function hydrateClientCustomBlocks(configs: BlockConfig[]): void {
map = new Map(configs.map((config) => [config.type, config]))
notifyBlockOverlayChanged()
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
/**
* Subscribe a component to overlay changes. Returns a monotonic version that
* changes on every hydrate — include it in a `useMemo`/`useEffect` dep list to
* recompute anything derived from `getAllBlocks()` when custom blocks load.
*/
export function useCustomBlockOverlayVersion(): number {
return useSyncExternalStore(
subscribe,
() => version,
() => 0
)
}
@@ -0,0 +1,55 @@
'use client'
import { memo, type SVGProps } from 'react'
import { cn } from '@sim/emcn'
import { Box } from 'lucide-react'
import type { BlockIcon } from '@/blocks/types'
/**
* Build a `BlockIcon` from an uploaded icon image URL. Rendered as an `<img>` so
* any uploaded PNG/JPEG/SVG works; `className` (size) is forwarded like every
* other block icon. Cached by URL so the component reference stays stable across
* the many tiles/nodes that render a custom block.
*/
const cache = new Map<string, BlockIcon>()
export function makeImageIcon(url: string): BlockIcon {
const cached = cache.get(url)
if (cached) return cached
// Fill the tile so an uploaded image/logo reads at the same footprint as other
// blocks' colored tiles, instead of a small glyph floating in a transparent
// square. Trailing `size-full` beats a consumer size *class* (twMerge keeps the
// last of a conflict group) so a tiled surface (canvas/toolbar/palette) fills;
// it loses to a consumer inline `style` (specificity) so a tile-less inline
// surface that sizes via `style={{ width, height }}` still renders at its px.
const ImageComponent = memo(({ className, style }: SVGProps<SVGSVGElement>) => (
<img
src={url}
alt=''
style={style}
className={cn('rounded-[4px] object-contain', className, 'size-full')}
/>
))
// double-cast-allowed: an <img> renderer must satisfy the SVG-typed BlockIcon slot
const Icon = ImageComponent as unknown as BlockIcon
cache.set(url, Icon)
return Icon
}
/** Fallback icon for custom blocks published without an uploaded image. */
// double-cast-allowed: a lucide icon component fills the SVG-typed BlockIcon slot
export const DefaultCustomBlockIcon: BlockIcon = Box as unknown as BlockIcon
/**
* Resolve a custom block's icon: the uploaded image, else the org's whitelabel logo
* (`fallbackUrl`), else the default glyph.
*/
export function getCustomBlockIcon(
iconUrl: string | null | undefined,
fallbackUrl?: string | null
): BlockIcon {
const url = iconUrl || fallbackUrl
return url ? makeImageIcon(url) : DefaultCustomBlockIcon
}
+41
View File
@@ -0,0 +1,41 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it } from 'vitest'
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
import {
overlayBlocks,
registerBlockOverlayResolver,
resolveOverlayBlock,
} from '@/blocks/custom/overlay'
import type { BlockIcon } from '@/blocks/types'
const icon: BlockIcon = () => null as never
const config = buildCustomBlockConfig(
{ type: 'custom_block_xyz', name: 'X', description: '', workflowId: 'wf-9' },
[],
{ icon }
)
afterEach(() => registerBlockOverlayResolver(null))
describe('block overlay resolver', () => {
it('returns undefined/empty with no resolver registered', () => {
expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined()
expect(overlayBlocks()).toEqual([])
})
it('resolves through a registered resolver and clears on null', () => {
const map = new Map([[config.type, config]])
registerBlockOverlayResolver({ get: (t) => map.get(t), all: () => [...map.values()] })
expect(resolveOverlayBlock('custom_block_xyz')).toBe(config)
expect(resolveOverlayBlock('nope')).toBeUndefined()
expect(overlayBlocks()).toEqual([config])
registerBlockOverlayResolver(null)
expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined()
expect(overlayBlocks()).toEqual([])
})
})
+36
View File
@@ -0,0 +1,36 @@
import type { BlockConfig } from '@/blocks/types'
/**
* Resolver for dynamic (DB-driven) custom blocks that live outside the static
* `BLOCK_REGISTRY`. The four core accessors in `@/blocks/registry` fall back to
* the registered resolver so custom block types resolve everywhere without
* rewriting the many synchronous `getBlock` call sites.
*
* Two environment-specific resolvers register here:
* - client: a Map hydrated from `useCustomBlocks` (see `client-overlay.ts`)
* - server: an AsyncLocalStorage map scoped per request/org (see `server-overlay.ts`)
*
* This module is isomorphic (no `'use client'`, no `node:` imports) so
* `registry.ts` stays importable on both sides.
*/
export interface BlockOverlayResolver {
get(type: string): BlockConfig | undefined
all(): BlockConfig[]
}
let resolver: BlockOverlayResolver | null = null
/** Register (or clear with `null`) the active overlay resolver for this environment. */
export function registerBlockOverlayResolver(next: BlockOverlayResolver | null): void {
resolver = next
}
/** Resolve a single custom block config by type, or `undefined` when none applies. */
export function resolveOverlayBlock(type: string): BlockConfig | undefined {
return resolver?.get(type)
}
/** All custom block configs currently in scope (empty when no resolver is active). */
export function overlayBlocks(): BlockConfig[] {
return resolver?.all() ?? []
}
+57
View File
@@ -0,0 +1,57 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { WorkflowInputField } from '@/lib/workflows/input-format'
import { buildCustomBlockConfig, type CustomBlockRow } from '@/blocks/custom/build-config'
import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
import type { BlockConfig, BlockIcon } from '@/blocks/types'
/** A row for the overlay, optionally carrying live-derived Start input fields. */
type CustomBlockOverlayRow = CustomBlockRow & {
inputFields?: WorkflowInputField[]
/** When `false`, the block resolves but is hidden from the palette (disabled). */
enabled?: boolean
}
/**
* Server-side custom-block overlay. Resolves `custom_block_*` types during
* serialization + execution from a per-request, per-org map held in
* AsyncLocalStorage — keeping the `@/blocks/registry` accessors synchronous while
* isolating concurrent requests across different organizations.
*/
/** Icon is never rendered server-side (the serializer ignores it). */
const PLACEHOLDER_ICON: BlockIcon = () => null as never
const store = new AsyncLocalStorage<Map<string, BlockConfig>>()
registerBlockOverlayResolver({
get: (type) => store.getStore()?.get(type),
all: () => [...(store.getStore()?.values() ?? [])],
})
/**
* Run `fn` with the given org's custom blocks resolvable via `getBlock`/
* `getAllBlocks`. Wrap every execution serializer entry point (execute route,
* trigger.dev task, scheduled/webhook runs) at the org-context boundary so a
* workflow containing a custom block can serialize and execute.
*
* Execution passes bare rows: `inputMapping` is schema-agnostic, so no per-field
* editors are needed. Agent-facing callers (`get_blocks_metadata`, `edit_workflow`)
* pass rows carrying `inputFields` so `getBlock` exposes the real input sub-blocks —
* matching what the VFS block files show — instead of an empty schema.
*/
export function withCustomBlockOverlay<T>(
rows: CustomBlockOverlayRow[],
fn: () => Promise<T>
): Promise<T> {
const map = new Map<string, BlockConfig>()
for (const row of rows) {
map.set(
row.type,
buildCustomBlockConfig(row, row.inputFields ?? [], {
icon: PLACEHOLDER_ICON,
hideFromToolbar: row.enabled === false,
})
)
}
return store.run(map, fn)
}