Skip to content

Commit d00860a

Browse files
improvement(mothership): bundle copilot VFS static-component cache into one resettable object
The static component files and their integration/trigger path-owner maps were three separate module globals, with the owner maps populated as a side effect of the file build and never reset. Bundle all three into one StaticComponents object built and cached atomically, thread it through isStaticFileHidden and the per-viewer overview builder, and add resetStaticComponents (test/hot-reload seam, mirrors resetExposedIntegrationToolsCache) so a rebuild clears files and owner maps together — no stale or double-populated owner maps.
1 parent 9e6f93e commit d00860a

1 file changed

Lines changed: 70 additions & 39 deletions

File tree

apps/sim/lib/copilot/vfs/workspace-vfs.ts

Lines changed: 70 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -143,50 +143,70 @@ const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon
143143
const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024
144144

145145
/**
146-
* Static component files, computed once and shared across all VFS instances.
147-
* Built from the UNGATED registry universe (preview blocks included) so this
148-
* process-global cache can never be poisoned by one viewer's gated projection;
149-
* per-viewer gating is applied when the map is stamped into each fresh VFS
146+
* The process-global static-component cache: the ungated component-file universe
147+
* plus the path→owner lookups the stamp-time filter needs. Bundled into one
148+
* object (not three separate module-level variables) so that build and
149+
* {@link resetStaticComponents} are ATOMIC — there is never a window where the
150+
* file map is present but an owner map is stale, half-populated, or double-
151+
* populated. Built from the UNGATED registry universe (preview blocks included)
152+
* so it can never be poisoned by one viewer's gated projection; per-viewer
153+
* gating is applied downstream when the files are stamped into each fresh VFS
150154
* (see {@link isStaticFileHidden}).
151155
*/
152-
let staticComponentFiles: Map<string, string> | null = null
156+
interface StaticComponents {
157+
/** VFS path → serialized content — the ungated universe. */
158+
files: Map<string, string>
159+
/**
160+
* `components/integrations/**` path → owning block. Block and trigger schema
161+
* files carry their owning type as the path basename, but integration paths
162+
* use the version-stripped service name, so their owners need this lookup.
163+
*/
164+
integrationPathOwners: Map<string, Pick<BlockConfig, 'type' | 'preview'>>
165+
/**
166+
* `components/triggers/{provider}/{id}.json` path → owning block(s), recorded
167+
* by inverting each block's `triggers.available`. External-trigger paths key
168+
* on the trigger id + provider (not a block type). A trigger reachable from
169+
* more than one block (e.g. a GA block and its preview successor) is hidden
170+
* only when EVERY owning block is hidden, so this holds an array.
171+
*/
172+
triggerPathOwners: Map<string, Array<Pick<BlockConfig, 'type' | 'preview'>>>
173+
}
153174

154-
/**
155-
* Owning block for each `components/integrations/**` file, recorded at build
156-
* time. Block/trigger schema files carry their owning type as the path
157-
* basename, but integration paths use the version-stripped service name — so
158-
* their owners need this lookup for the stamp-time visibility filter.
159-
*/
160-
const integrationPathOwners = new Map<string, Pick<BlockConfig, 'type' | 'preview'>>()
175+
let staticComponents: StaticComponents | null = null
161176

162177
/**
163-
* Owning block(s) for each `components/triggers/{provider}/{id}.json` file,
164-
* recorded at build time by inverting each block's `triggers.available`.
165-
* External-trigger paths are keyed on the trigger id + provider (not a block
166-
* type), so — like integration paths — they need this lookup for the stamp-time
167-
* visibility filter. A trigger can be reachable from more than one block (e.g. a
168-
* GA block and its preview successor), so this holds an array and the trigger is
169-
* hidden only when EVERY owning block is hidden.
178+
* Clear the process-global static-component cache. The next
179+
* {@link getStaticComponents} rebuilds files AND owner maps together from the
180+
* current registries. Registries are compile-time static, so production never
181+
* needs this — it exists for tests and hot-reload, where a stale or (given the
182+
* non-idempotent owner-map inversion) double-populated cache would otherwise
183+
* survive a registry swap. Mirrors `resetExposedIntegrationToolsCache`.
170184
*/
171-
const triggerPathOwners = new Map<string, Array<Pick<BlockConfig, 'type' | 'preview'>>>()
185+
export function resetStaticComponents(): void {
186+
staticComponents = null
187+
}
172188

173189
/**
174190
* Per-request visibility filter for the shared static files: hides files whose
175191
* owning block is gated for this viewer (unrevealed preview blocks — the
176192
* default with no context — and kill-switched types). Non-registry paths
177193
* (loop/parallel, connectors, overviews) are always visible.
178194
*/
179-
function isStaticFileHidden(path: string, vis: BlockVisibilityState | null): boolean {
195+
function isStaticFileHidden(
196+
path: string,
197+
vis: BlockVisibilityState | null,
198+
components: StaticComponents
199+
): boolean {
180200
const blockMatch = path.match(/^components\/(?:blocks|triggers\/sim)\/([^/]+)\.json$/)
181201
if (blockMatch) {
182202
const config = BLOCK_REGISTRY[blockMatch[1]!]
183203
return config ? isHiddenUnder(vis, config) : false
184204
}
185-
const triggerOwners = triggerPathOwners.get(path)
205+
const triggerOwners = components.triggerPathOwners.get(path)
186206
if (triggerOwners) {
187207
return triggerOwners.length > 0 && triggerOwners.every((owner) => isHiddenUnder(vis, owner))
188208
}
189-
const owner = integrationPathOwners.get(path)
209+
const owner = components.integrationPathOwners.get(path)
190210
return owner ? isHiddenUnder(vis, owner) : false
191211
}
192212

@@ -207,18 +227,21 @@ function isBinaryDocBuffer(buffer: Buffer, ext: string): boolean {
207227
}
208228

209229
/**
210-
* Build the static component files from block and tool registries.
211-
* This only needs to happen once per process.
230+
* Build the static component files + path→owner maps from the block and tool
231+
* registries, cached together as one {@link StaticComponents} object. Runs once
232+
* per process (until {@link resetStaticComponents}).
212233
*
213234
* Integration paths are derived deterministically from the block registry's
214235
* `tools.access` arrays rather than splitting tool IDs on underscores.
215236
* Each block declares which tools it owns, and the block type (minus version
216237
* suffix) becomes the service directory name.
217238
*/
218-
function getStaticComponentFiles(): Map<string, string> {
219-
if (staticComponentFiles) return staticComponentFiles
239+
function getStaticComponents(): StaticComponents {
240+
if (staticComponents) return staticComponents
220241

221242
const files = new Map<string, string>()
243+
const integrationPathOwners = new Map<string, Pick<BlockConfig, 'type' | 'preview'>>()
244+
const triggerPathOwners = new Map<string, Array<Pick<BlockConfig, 'type' | 'preview'>>>()
222245

223246
// Raw registry, never the visibility-projected getAllBlocks: this map is a
224247
// process-global shared cache, so it must hold the deterministic ungated
@@ -366,8 +389,8 @@ function getStaticComponentFiles(): Map<string, string> {
366389
externalTriggers: externalTriggerCount,
367390
})
368391

369-
staticComponentFiles = files
370-
return staticComponentFiles
392+
staticComponents = { files, integrationPathOwners, triggerPathOwners }
393+
return staticComponents
371394
}
372395

373396
/**
@@ -384,13 +407,14 @@ function getStaticComponentFiles(): Map<string, string> {
384407
* Reuses the same predicates the stamp filter uses ({@link isHiddenUnder} for
385408
* builtin trigger blocks, {@link isStaticFileHidden} for external triggers,
386409
* {@link filterExposedIntegrationTools} for integration tools), so an overview
387-
* entry appears exactly when its per-file schema does. Depends on the owner maps
388-
* populated by {@link getStaticComponentFiles}, so it forces that build first
389-
* (idempotentnormally already warm by materialize time).
410+
* entry appears exactly when its per-file schema does. Takes the built
411+
* {@link StaticComponents} so its `triggerPathOwners` resolve external-trigger
412+
* visibilitythe caller (materialize) already holds it.
390413
*/
391-
function buildViewerScopedComponentFiles(vis: BlockVisibilityState | null): Map<string, string> {
392-
getStaticComponentFiles()
393-
414+
function buildViewerScopedComponentFiles(
415+
vis: BlockVisibilityState | null,
416+
components: StaticComponents
417+
): Map<string, string> {
394418
const files = new Map<string, string>()
395419

396420
const oauthServices = new Map<string, { provider: string; operations: string[] }>()
@@ -434,7 +458,10 @@ function buildViewerScopedComponentFiles(vis: BlockVisibilityState | null): Map<
434458
.filter((b) => b.category === 'triggers' && !isHiddenUnder(vis, b))
435459
.map((b) => ({ id: b.type, name: b.name, provider: 'sim', description: b.description }))
436460
const externalTriggers = Object.entries(TRIGGER_REGISTRY)
437-
.filter(([id, t]) => !isStaticFileHidden(`components/triggers/${t.provider}/${id}.json`, vis))
461+
.filter(
462+
([id, t]) =>
463+
!isStaticFileHidden(`components/triggers/${t.provider}/${id}.json`, vis, components)
464+
)
438465
.map(([id, t]) => ({ id, name: t.name, provider: t.provider, description: t.description }))
439466
files.set(
440467
'components/triggers/triggers.md',
@@ -741,14 +768,18 @@ export class WorkspaceVFS {
741768
// Per-viewer gating happens HERE, not in the shared builder: files
742769
// owned by blocks hidden for this viewer are skipped at stamp time.
743770
const blockVisibility = overlayVisibility()
744-
for (const [path, content] of getStaticComponentFiles()) {
745-
if (isStaticFileHidden(path, blockVisibility)) continue
771+
const staticComponents = getStaticComponents()
772+
for (const [path, content] of staticComponents.files) {
773+
if (isStaticFileHidden(path, blockVisibility, staticComponents)) continue
746774
this.files.set(path, content)
747775
}
748776
// Overview files summarize a viewer-dependent set, so their CONTENT
749777
// (not just presence) is rebuilt per viewer — the stamp loop above
750778
// can only include/exclude whole files, not re-derive a list.
751-
for (const [path, content] of buildViewerScopedComponentFiles(blockVisibility)) {
779+
for (const [path, content] of buildViewerScopedComponentFiles(
780+
blockVisibility,
781+
staticComponents
782+
)) {
752783
this.files.set(path, content)
753784
}
754785

0 commit comments

Comments
 (0)