fix(copilot): don't surface custom blocks whose definition was deleted (#5498)

* fix(copilot): keep custom blocks out of the VFS static component cache so a deleted definition doesn't linger

* fix(copilot): drop deleted-definition custom blocks from a workflow's state so the copilot can't see unrenderable blocks

* fix(copilot): don't strip placed custom blocks when the definition load fails (null vs empty set)

* 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.
This commit is contained in:
Theodore Li
2026-07-07 20:51:17 -04:00
committed by GitHub
parent 858f7d2c2c
commit aad254464b
+51 -2
View File
@@ -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<string, Promise<DeploymentData | null>>()
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<string> | null = null
get workspaceId(): string {
return this._workspaceId
@@ -425,12 +437,45 @@ export class WorkspaceVFS {
): Promise<Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>> {
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<ReturnType<typeof loadWorkflowFromNormalizedTables>>
): Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>> {
// `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<string>()
const blocks: Record<string, unknown> = {}
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<NonNullable<WorkspaceMdData['customBlocks']>> {
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<WorkspaceMdData['customBlocks']> = []
for (const cb of blocks) {