diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 3e3160a67ed..abfc3ad9508 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -123,7 +123,7 @@ import { hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' import { computeNeedsRedeployment } from '@/app/api/workflows/utils' -import { buildCustomBlockConfig } from '@/blocks/custom/build-config' +import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' import { getAllBlocks } from '@/blocks/registry' import type { BlockIcon } from '@/blocks/types' import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' @@ -405,6 +405,18 @@ export class WorkspaceVFS { private deploymentCache = new Map>() private _workspaceId = '' private _betaEnabled = false + /** + * Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block + * still resolves/renders). Populated by {@link materializeCustomBlocks}; used to + * drop a placed custom block from a workflow's state when its definition has been + * deleted, so the copilot never sees a block it can't render. + * + * `null` means "not loaded" — either not materialized yet or the load FAILED. In + * that case {@link dropDeletedCustomBlocks} strips nothing, so a transient failure + * can't wrongly nuke every placed custom block. An empty `Set` is distinct: it + * means the org genuinely has no custom blocks, so any placed one IS deleted. + */ + private _customBlockTypes: Set | null = null get workspaceId(): string { return this._workspaceId @@ -425,12 +437,45 @@ export class WorkspaceVFS { ): Promise>> { let cached = this.normalizedCache.get(workflowId) if (!cached) { - cached = loadWorkflowFromNormalizedTables(workflowId) + cached = loadWorkflowFromNormalizedTables(workflowId).then((n) => + this.dropDeletedCustomBlocks(n) + ) this.normalizedCache.set(workflowId, cached) } return cached } + /** + * Strip placed custom blocks whose definition no longer exists from a loaded + * workflow (and any edges touching them), so the copilot never sees a block it + * can't render — mirroring how the serializer drops an unresolvable custom block. + * A live definition (enabled or disabled) is kept; only a DELETED one is removed. + * Runs lazily (after materialize), so `_customBlockTypes` is populated by then. + */ + private dropDeletedCustomBlocks( + normalized: Awaited> + ): Awaited> { + // `null` = definitions never loaded (or the load failed) — strip nothing rather + // than treat every placed custom block as deleted. + if (!normalized || this._customBlockTypes === null) return normalized + const validTypes = this._customBlockTypes + const dropped = new Set() + const blocks: Record = {} + for (const [id, block] of Object.entries(normalized.blocks)) { + const type = (block as { type?: string }).type + if (isCustomBlockType(type) && !validTypes.has(type)) { + dropped.add(id) + continue + } + blocks[id] = block + } + if (dropped.size === 0) return normalized + const edges = (normalized.edges ?? []).filter( + (e) => !dropped.has(e.source) && !dropped.has(e.target) + ) + return { ...normalized, blocks: blocks as typeof normalized.blocks, edges } + } + /** Load a workflow's deployment data once per instance (deployment.json + versions.json share it). */ private loadDeployments(wf: { id: string @@ -523,6 +568,7 @@ export class WorkspaceVFS { this.lazy = new Map() this.normalizedCache = new Map() this.deploymentCache = new Map() + this._customBlockTypes = null this._workspaceId = workspaceId this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) @@ -1824,6 +1870,9 @@ export class WorkspaceVFS { ): Promise> { try { const blocks = await listCustomBlocksWithInputsForWorkspace(workspaceId) + // Every current definition (incl. disabled) — the authoritative set used to + // drop deleted-definition instances from workflow state (see loadNormalized). + this._customBlockTypes = new Set(blocks.map((cb) => cb.type)) const summary: NonNullable = [] for (const cb of blocks) {