From 17795059e03a25f2dd1c21e7229ffa30a0434967 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Jul 2026 16:58:07 -0700 Subject: [PATCH 1/4] fix(copilot): keep custom blocks out of the VFS static component cache so a deleted definition doesn't linger --- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 3e3160a67ed..ac9ad5ed816 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' @@ -171,7 +171,12 @@ function getStaticComponentFiles(): Map { const files = new Map() const allBlocks = getAllBlocks() - const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar) + // Never bake custom blocks into the per-process static cache: `getAllBlocks()` is + // overlay-aware, so if this memoized build first runs inside a custom-block + // overlay it would freeze that org's blocks here forever — and a later-deleted + // definition's `components/blocks/*.json` would linger (the copilot still "sees" + // it) even though `materializeCustomBlocks` rebuilds the live set every request. + const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar && !isCustomBlockType(b.type)) let blocksFiltered = 0 for (const block of visibleBlocks) { From a8b7c24073292a466e1e3d6d4ded4d76564ecd13 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Jul 2026 17:06:57 -0700 Subject: [PATCH 2/4] fix(copilot): drop deleted-definition custom blocks from a workflow's state so the copilot can't see unrenderable blocks --- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 43 ++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index ac9ad5ed816..f87e0e91f94 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -410,6 +410,13 @@ 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. + */ + private _customBlockTypes = new Set() get workspaceId(): string { return this._workspaceId @@ -430,12 +437,42 @@ 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> { + if (!normalized) return normalized + 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) && !this._customBlockTypes.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 @@ -528,6 +565,7 @@ export class WorkspaceVFS { this.lazy = new Map() this.normalizedCache = new Map() this.deploymentCache = new Map() + this._customBlockTypes = new Set() this._workspaceId = workspaceId this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) @@ -1829,6 +1867,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) { From 2b231a59b956d7fb4f219dd225b0583faa4800fd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Jul 2026 17:16:13 -0700 Subject: [PATCH 3/4] fix(copilot): don't strip placed custom blocks when the definition load fails (null vs empty set) --- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index f87e0e91f94..671e5f5bdb4 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -415,8 +415,13 @@ export class WorkspaceVFS { * 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 = new Set() + private _customBlockTypes: Set | null = null get workspaceId(): string { return this._workspaceId @@ -455,12 +460,15 @@ export class WorkspaceVFS { private dropDeletedCustomBlocks( normalized: Awaited> ): Awaited> { - if (!normalized) return normalized + // `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) && !this._customBlockTypes.has(type)) { + if (isCustomBlockType(type) && !validTypes.has(type)) { dropped.add(id) continue } @@ -565,7 +573,7 @@ export class WorkspaceVFS { this.lazy = new Map() this.normalizedCache = new Map() this.deploymentCache = new Map() - this._customBlockTypes = new Set() + this._customBlockTypes = null this._workspaceId = workspaceId this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) From 366e9d42254674941a6381e44f123235a0ddba9a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Jul 2026 17:39:15 -0700 Subject: [PATCH 4/4] refactor(copilot): drop unreachable static-cache custom-block filter The VFS static cache is only ever built by materialize(), which runs outside any custom-block overlay (the overlay is scoped to edit_workflow / get_blocks_metadata, neither of which materializes the VFS). So getAllBlocks() there always returns first-party blocks only and no custom block can be frozen into the cache. Removed the dead guard. --- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 671e5f5bdb4..abfc3ad9508 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -171,12 +171,7 @@ function getStaticComponentFiles(): Map { const files = new Map() const allBlocks = getAllBlocks() - // Never bake custom blocks into the per-process static cache: `getAllBlocks()` is - // overlay-aware, so if this memoized build first runs inside a custom-block - // overlay it would freeze that org's blocks here forever — and a later-deleted - // definition's `components/blocks/*.json` would linger (the copilot still "sees" - // it) even though `materializeCustomBlocks` rebuilds the live set every request. - const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar && !isCustomBlockType(b.type)) + const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar) let blocksFiltered = 0 for (const block of visibleBlocks) {