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
@@ -0,0 +1,556 @@
import { db } from '@sim/db'
import {
customBlock,
workflow,
workflowBlocks,
workflowDeploymentVersion,
workspace,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId, generateShortId } from '@sim/utils/id'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import type { CustomBlockOutput, CustomBlockRow } from '@/blocks/custom/build-config'
import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config'
const logger = createLogger('CustomBlocksOperations')
/**
* Resolve a workspace's organization ONLY when custom blocks are enabled for it —
* the same gate the REST list/publish routes apply (`deploy-as-block` flag +
* enterprise plan). Applying it in every org-scoped resolver keeps execution, the
* copilot VFS, and workspace context from surfacing blocks the API withholds (e.g.
* after an org drops off the enterprise plan). Returns `null` when ineligible.
*
* Pass `userId` when the caller acts for a specific user so per-user flag
* targeting matches the REST routes; workspace-scoped resolvers (VFS, context,
* executor overlay) omit it and evaluate at org level.
*/
async function eligibleOrgForWorkspace(
workspaceId: string,
userId?: string
): Promise<string | null> {
const ws = await getWorkspaceWithOwner(workspaceId, { includeArchived: true })
if (!ws?.organizationId) return null
if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ws.organizationId }))) {
return null
}
if (!(await isOrganizationOnEnterprisePlan(ws.organizationId))) return null
return ws.organizationId
}
/**
* Whether the workspace's org may use custom blocks (`deploy-as-block` flag +
* enterprise plan). Feeds the 'custom-blocks' entitlement in
* `@/lib/copilot/entitlements` and matches the REST route gates.
*/
export async function isCustomBlocksEligible(
workspaceId: string,
userId?: string
): Promise<boolean> {
return (await eligibleOrgForWorkspace(workspaceId, userId)) !== null
}
/** A persisted custom block plus its live-derived Start input fields. */
export interface CustomBlockWithInputs {
id: string
organizationId: string
workflowId: string
workflowName: string
/** Source workflow's home workspace id — used client-side to gate manage affordances. */
workspaceId: string | null
workspaceName: string | null
type: string
name: string
description: string
iconUrl: string | null
enabled: boolean
inputFields: WorkflowInputField[]
exposedOutputs: CustomBlockOutput[]
}
/**
* Derive a bound workflow's Start input fields from its LATEST DEPLOYMENT — the
* exact state execution runs. Deriving from the draft/editor tables would let the
* block advertise inputs the deployed child doesn't accept (or miss ones it still
* expects) whenever the publisher edits after deploying. Returns `[]` if the
* workflow has no active deployment.
*/
async function deriveInputFields(workflowId: string): Promise<WorkflowInputField[]> {
try {
const deployed = await loadDeployedWorkflowState(workflowId)
return extractInputFieldsFromBlocks(deployed.blocks)
} catch {
return []
}
}
/** A stored per-input override (placeholder + required), keyed by the Start field's stable id. */
type InputPlaceholder = { id: string; placeholder?: string; required?: boolean }
/**
* The block's input fields: the LIVE deployed Start fields (authoritative for which
* inputs exist and their name/type — so an input removed from the source and
* redeployed simply disappears) with the stored per-id `placeholder`/`required`
* overrides merged in. When the source is undeployed there are no live fields, so
* there are no inputs — the block can't run undeployed anyway.
*/
function applyInputPlaceholders(
placeholders: InputPlaceholder[] | null,
deployed: WorkflowInputField[]
): WorkflowInputField[] {
if (deployed.length === 0) return []
if (!placeholders?.length) return deployed
const byId = new Map(placeholders.map((p) => [p.id, p]))
// Overrides are stored under `field.id ?? field.name` (the form's key), so a
// legacy field with no stable id is keyed by name — look it up the same way.
return deployed.map((field) => {
const override = byId.get(field.id ?? field.name)
if (!override) return field
return {
...field,
...(override.placeholder ? { placeholder: override.placeholder } : {}),
...(override.required ? { required: true } : {}),
}
})
}
/**
* The org's custom blocks for the server overlay (`withCustomBlockOverlay`).
* Includes DISABLED rows (carrying `enabled`) so a still-placed disabled block
* stays resolvable — it survives serialization and fails loudly at run via
* `getCustomBlockAuthority` instead of being silently dropped from the graph; the
* overlay marks it `hideFromToolbar` so no new instance can be placed. No input
* fields: the server's `inputMapping` is schema-agnostic and the handler's remap
* filters every value against the child's live deployed Start.
*/
export async function getCustomBlockRowsForOrg(
organizationId: string
): Promise<Array<CustomBlockRow & { enabled: boolean }>> {
const rows = await db
.select({
type: customBlock.type,
name: customBlock.name,
description: customBlock.description,
workflowId: customBlock.workflowId,
outputs: customBlock.outputs,
enabled: customBlock.enabled,
})
.from(customBlock)
.where(eq(customBlock.organizationId, organizationId))
return rows.map(({ outputs, ...r }) => ({ ...r, exposedOutputs: outputs ?? [] }))
}
/**
* The custom-block rows in scope for a workspace's organization, for wrapping an
* execution in `withCustomBlockOverlay`. Returns `[]` when the workspace has no
* organization (nothing to resolve).
*/
export async function getCustomBlockRowsForWorkspace(
workspaceId: string
): Promise<CustomBlockRow[]> {
const organizationId = await eligibleOrgForWorkspace(workspaceId)
if (!organizationId) return []
return getCustomBlockRowsForOrg(organizationId)
}
/**
* The custom blocks (with live-derived input fields) for a workspace's org. Used
* by the copilot VFS to expose custom blocks to the agent. Returns `[]` when the
* workspace has no organization.
*/
export async function listCustomBlocksWithInputsForWorkspace(
workspaceId: string
): Promise<CustomBlockWithInputs[]> {
const organizationId = await eligibleOrgForWorkspace(workspaceId)
if (!organizationId) return []
return listCustomBlocksWithInputs(organizationId)
}
/**
* Lightweight enabled-custom-block summaries for a workspace's org (type + name +
* description, no input derivation). Used by the copilot workspace-context markdown.
*/
export async function listCustomBlockSummariesForWorkspace(
workspaceId: string
): Promise<Array<{ type: string; name: string; description: string }>> {
const organizationId = await eligibleOrgForWorkspace(workspaceId)
if (!organizationId) return []
return db
.select({
type: customBlock.type,
name: customBlock.name,
description: customBlock.description,
})
.from(customBlock)
.where(and(eq(customBlock.organizationId, organizationId), eq(customBlock.enabled, true)))
}
/**
* Hydrate a joined custom-block row into the wire shape. Field set derived live
* from the deployed Start; stored placeholders merged in. Derive even for a
* disabled block — the source workflow's deployment is independent of the block's
* enabled flag, and the edit form needs the real fields so a save doesn't
* overwrite the block's stored placeholders.
*/
async function hydrateCustomBlockRow(joined: {
block: typeof customBlock.$inferSelect
workflowName: string
workspaceId: string | null
workspaceName: string | null
}): Promise<CustomBlockWithInputs> {
const { block: row, workflowName, workspaceId, workspaceName } = joined
return {
id: row.id,
organizationId: row.organizationId,
workflowId: row.workflowId,
workflowName,
workspaceId,
workspaceName,
type: row.type,
name: row.name,
description: row.description,
iconUrl: row.iconUrl,
enabled: row.enabled,
inputFields: applyInputPlaceholders(row.inputs, await deriveInputFields(row.workflowId)),
exposedOutputs: row.outputs ?? [],
}
}
/** The org's custom blocks with live-derived input fields (client overlay + list API). */
export async function listCustomBlocksWithInputs(
organizationId: string
): Promise<CustomBlockWithInputs[]> {
const rows = await db
.select({
block: customBlock,
workflowName: workflow.name,
workspaceId: workflow.workspaceId,
workspaceName: workspace.name,
})
.from(customBlock)
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
.leftJoin(workspace, eq(workspace.id, workflow.workspaceId))
.where(eq(customBlock.organizationId, organizationId))
return Promise.all(rows.map(hydrateCustomBlockRow))
}
/**
* The custom block bound to a workflow (with live-derived input fields), or `null`
* when the workflow isn't published as a block. One block per workflow is enforced
* at publish time. Used by the copilot deploy_custom_block tool.
*/
export async function getCustomBlockWithInputsByWorkflowId(
workflowId: string
): Promise<CustomBlockWithInputs | null> {
const [row] = await db
.select({
block: customBlock,
workflowName: workflow.name,
workspaceId: workflow.workspaceId,
workspaceName: workspace.name,
})
.from(customBlock)
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
.leftJoin(workspace, eq(workspace.id, workflow.workspaceId))
.where(eq(customBlock.workflowId, workflowId))
.limit(1)
return row ? hydrateCustomBlockRow(row) : null
}
/** Fetch a single custom block row by id. */
export async function getCustomBlockById(id: string) {
const [row] = await db.select().from(customBlock).where(eq(customBlock.id, id)).limit(1)
return row ?? null
}
/**
* Org + source-workspace context for manage (edit/delete) authorization. Managing
* a block is gated on admin of its SOURCE workflow's workspace — the same workspace
* publishing required — so only an admin of the workspace that owns the workflow
* (or an org admin, who holds admin on every org workspace) can change its outputs.
* `null` when no block matches.
*/
export async function getCustomBlockManageContext(id: string): Promise<{
organizationId: string
sourceWorkspaceId: string | null
type: string
name: string
} | null> {
const [row] = await db
.select({
organizationId: customBlock.organizationId,
sourceWorkspaceId: workflow.workspaceId,
type: customBlock.type,
name: customBlock.name,
})
.from(customBlock)
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
.where(eq(customBlock.id, id))
.limit(1)
return row ?? null
}
/**
* Execution authority for a custom block, resolved by its block type. Used by the
* executor to run the bound workflow under the invocation-boundary model: the
* consumer needs no permission on the source workflow. Returns the authoritative
* `workflowId` from the DB (never trust a serialized value) plus the source
* workflow's **owner** (`workflow.userId`) — the same identity a normal deployed
* API/schedule/webhook run executes as. Using the owner (not the publisher) means
* the owner always has read on their own workflow, and owner deletion cascade-
* deletes the workflow → the custom_block row, so there is never an orphaned block.
* `null` when no enabled block matches the type.
*/
export async function getCustomBlockAuthority(
type: string,
consumerWorkspaceId: string | undefined
): Promise<{
workflowId: string
organizationId: string
ownerUserId: string
exposedOutputs: CustomBlockOutput[]
/** Start-field ids (form keys) the publisher marked required. May reference removed fields. */
requiredInputIds: string[]
} | null> {
// Scope resolution to the consumer's org: `(organizationId, type)` is the unique
// key, so without the org filter a `custom_block_*` type smuggled in from another
// org's serialized workflow could resolve and run that org's block.
if (!consumerWorkspaceId) return null
// Match `getCustomBlockRowsForWorkspace` (which builds the overlay) — include
// archived so a workspace that can serialize a custom block can also execute it,
// instead of failing mid-run with "no longer available".
const consumerWs = await getWorkspaceWithOwner(consumerWorkspaceId, { includeArchived: true })
if (!consumerWs?.organizationId) return null
const [row] = await db
.select({
workflowId: customBlock.workflowId,
organizationId: customBlock.organizationId,
enabled: customBlock.enabled,
outputs: customBlock.outputs,
inputs: customBlock.inputs,
ownerUserId: workflow.userId,
})
.from(customBlock)
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
.where(
and(eq(customBlock.type, type), eq(customBlock.organizationId, consumerWs.organizationId))
)
.limit(1)
if (!row || !row.enabled) return null
return {
workflowId: row.workflowId,
organizationId: row.organizationId,
ownerUserId: row.ownerUserId,
exposedOutputs: row.outputs ?? [],
requiredInputIds: (row.inputs ?? []).filter((i) => i.required).map((i) => i.id),
}
}
export class CustomBlockValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'CustomBlockValidationError'
}
}
/**
* Publish a deployed workflow as an org-wide custom block. The source workflow
* must live in `workspaceId` — the workspace the caller was verified to admin —
* so a caller cannot publish another workspace's workflow (which then runs under
* that workspace owner's credentials and returns caller-chosen outputs). Also
* validates the workspace belongs to `organizationId` and the workflow is
* deployed, then inserts the row.
*/
export async function publishCustomBlock(params: {
organizationId: string
workspaceId: string
workflowId: string
userId: string
name: string
description: string
iconUrl?: string
inputs?: InputPlaceholder[]
exposedOutputs?: CustomBlockOutput[]
}): Promise<CustomBlockWithInputs> {
const {
organizationId,
workspaceId,
workflowId,
userId,
name,
description,
iconUrl,
inputs,
exposedOutputs,
} = params
const [wf] = await db
.select({
id: workflow.id,
name: workflow.name,
workspaceId: workflow.workspaceId,
isDeployed: workflow.isDeployed,
})
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!wf) throw new CustomBlockValidationError('Workflow not found')
if (!wf.isDeployed) {
throw new CustomBlockValidationError('Workflow must be deployed before publishing as a block')
}
// Authorization boundary: the caller proved admin on `workspaceId` (route), so
// the source workflow must actually live there. Without this a workspace admin
// could publish a different workspace's workflow in the same org.
if (wf.workspaceId !== workspaceId) {
throw new CustomBlockValidationError('You can only publish a workflow from its own workspace')
}
const ws = wf.workspaceId ? await getWorkspaceWithOwner(wf.workspaceId) : null
if (!ws?.organizationId || ws.organizationId !== organizationId) {
throw new CustomBlockValidationError('Workflow does not belong to this organization')
}
// One block per workflow: the (org, type) unique index doesn't prevent the same
// workflow being published under a fresh `custom_block_*` type, so guard here.
const [existing] = await db
.select({ id: customBlock.id })
.from(customBlock)
.where(eq(customBlock.workflowId, workflowId))
.limit(1)
if (existing) {
throw new CustomBlockValidationError('This workflow is already published as a block')
}
const id = generateId()
const type = `${CUSTOM_BLOCK_TYPE_PREFIX}${generateShortId(10).toLowerCase()}`
const now = new Date()
await db.insert(customBlock).values({
id,
organizationId,
workflowId,
type,
name,
description,
iconUrl: iconUrl ?? null,
inputs: inputs ?? [],
outputs: exposedOutputs ?? [],
enabled: true,
createdBy: userId,
createdAt: now,
updatedAt: now,
})
logger.info('Published custom block', { id, type, organizationId, workflowId })
return {
id,
organizationId,
workflowId,
workflowName: wf.name,
workspaceId: wf.workspaceId,
workspaceName: ws?.name ?? null,
type,
name,
description,
iconUrl: iconUrl ?? null,
enabled: true,
inputFields: applyInputPlaceholders(inputs ?? null, await deriveInputFields(workflowId)),
exposedOutputs: exposedOutputs ?? [],
}
}
/**
* Update a custom block's presentation/enabled state. `iconUrl`: a URL
* sets/replaces the icon, `null` clears it (default icon), `undefined` leaves it
* unchanged.
*/
export async function updateCustomBlock(
id: string,
updates: {
name?: string
description?: string
enabled?: boolean
iconUrl?: string | null
inputs?: InputPlaceholder[]
exposedOutputs?: CustomBlockOutput[]
}
): Promise<void> {
const patch: Partial<typeof customBlock.$inferInsert> = { updatedAt: new Date() }
if (updates.name !== undefined) patch.name = updates.name
if (updates.description !== undefined) patch.description = updates.description
if (updates.enabled !== undefined) patch.enabled = updates.enabled
if (updates.inputs !== undefined) patch.inputs = updates.inputs
if (updates.exposedOutputs !== undefined) patch.outputs = updates.exposedOutputs
if (updates.iconUrl !== undefined) patch.iconUrl = updates.iconUrl
await db.update(customBlock).set(patch).where(eq(customBlock.id, id))
}
/** Unpublish (hard-delete) a custom block. */
export async function deleteCustomBlock(id: string): Promise<void> {
await db.delete(customBlock).where(eq(customBlock.id, id))
}
/**
* How many non-archived workflows in the org place the block, in their live
* editor state and/or their ACTIVE deployment snapshot. The two are scanned
* independently — a block removed in the editor can still ship in the active
* deployment (and vice versa), and the deployed placement is the one that
* actually runs. The deployment scan pre-filters with a raw-text match on the
* unique type slug so only near-exact matches pay the jsonb parse.
*/
export async function getCustomBlockUsageCounts(
organizationId: string,
blockType: string
): Promise<{ usageCount: number; deployedUsageCount: number }> {
const orgActiveWorkflow = and(
eq(workspace.organizationId, organizationId),
isNull(workflow.archivedAt)
)
// Escape LIKE wildcards — the `_`s in `custom_block_<id>` would otherwise match
// any character and let unrelated states through to the jsonb parse.
const likePattern = `%${blockType.replace(/[\\%_]/g, '\\$&')}%`
const [liveRows, deployedRows] = await Promise.all([
db
.selectDistinct({ workflowId: workflow.id })
.from(workflowBlocks)
.innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId))
.innerJoin(workspace, eq(workspace.id, workflow.workspaceId))
.where(and(eq(workflowBlocks.type, blockType), orgActiveWorkflow)),
db
.select({ workflowId: workflow.id })
.from(workflowDeploymentVersion)
.innerJoin(workflow, eq(workflow.id, workflowDeploymentVersion.workflowId))
.innerJoin(workspace, eq(workspace.id, workflow.workspaceId))
.where(
and(
eq(workflowDeploymentVersion.isActive, true),
eq(workflow.isDeployed, true),
orgActiveWorkflow,
sql`${workflowDeploymentVersion.state}::text LIKE ${likePattern} ESCAPE '\\'`,
sql`EXISTS (
SELECT 1 FROM jsonb_each((${workflowDeploymentVersion.state})::jsonb -> 'blocks') AS b
WHERE b.value ->> 'type' = ${blockType}
)`
)
),
])
const usingWorkflowIds = new Set(liveRows.map((r) => r.workflowId))
for (const row of deployedRows) usingWorkflowIds.add(row.workflowId)
return { usageCount: usingWorkflowIds.size, deployedUsageCount: deployedRows.length }
}