Files
simstudioai--sim/apps/sim/lib/copilot/vfs/workspace-vfs.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

2458 lines
92 KiB
TypeScript

import { trace } from '@opentelemetry/api'
import { db } from '@sim/db'
import {
chat as chatTable,
copilotChats,
document,
jobExecutionLogs,
knowledgeConnector,
mcpServers as mcpServersTable,
workflowDeploymentVersion,
workflowExecutionLogs,
workflowFolder,
workflowMcpServer,
workflowMcpTool,
workflowSchedule,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, desc, eq, isNotNull, isNull, ne, sql } from 'drizzle-orm'
import { listApiKeys } from '@/lib/api-key/service'
import {
buildWorkspaceContextMd,
buildWorkspaceMd,
type WorkspaceMdData,
} from '@/lib/copilot/chat/workspace-context'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools'
import { recordVfsMaterialize } from '@/lib/copilot/request/metrics'
import { markSpanForError } from '@/lib/copilot/request/otel'
import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile'
import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract'
import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc'
import { isRenderableDocExt, renderDocToGrid } from '@/lib/copilot/tools/server/files/doc-render'
import {
collectWorkflowFieldIssues,
lintEditedWorkflowState,
} from '@/lib/copilot/tools/server/workflow/edit-workflow/lint'
import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation'
import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style'
import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations'
import * as ops from '@/lib/copilot/vfs/operations'
import {
buildVfsFolderPathMap,
canonicalWorkflowVfsDir,
canonicalWorkspaceFilePath,
encodeVfsPathSegments,
} from '@/lib/copilot/vfs/path-utils'
import type { DeploymentData } from '@/lib/copilot/vfs/serializers'
import {
serializeApiKeys,
serializeBlockSchema,
serializeBuiltinTriggerSchema,
serializeConnectorOverview,
serializeConnectorSchema,
serializeConnectors,
serializeCredentials,
serializeCustomTool,
serializeDeployments,
serializeDocuments,
serializeEnvironmentVariables,
serializeFileMeta,
serializeIntegrationSchema,
serializeJobMeta,
serializeKBMeta,
serializeMcpServer,
serializeRecentExecutions,
serializeSkill,
serializeTableMeta,
serializeTaskChat,
serializeTaskSession,
serializeTriggerOverview,
serializeTriggerSchema,
serializeVersions,
serializeWorkflowMeta,
} from '@/lib/copilot/vfs/serializers'
import {
buildWorkflowAliasLinks,
isWorkflowAliasBackingPath,
WORKFLOW_ALIAS_LINKS_NAME,
WORKFLOW_CHANGELOG_ALIAS_NAME,
WORKFLOW_PLANS_ALIAS_DIR,
WORKFLOW_PLANS_BACKING_FOLDER,
WORKSPACE_PLANS_BACKING_FOLDER,
workflowChangelogBackingPath,
workspacePlanBackingPath,
workspacePlansBackingFolderPath,
} from '@/lib/copilot/vfs/workflow-aliases'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import {
getAccessibleEnvCredentials,
getAccessibleOAuthCredentials,
} from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
import { getKnowledgeBases } from '@/lib/knowledge/service'
import { validateMermaidSource } from '@/lib/mermaid/validate'
import { listTables } from '@/lib/table/service'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
fetchWorkspaceFileBuffer,
findWorkspaceFileRecord,
listWorkspaceFiles,
type WorkspaceFileRecord,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
import { listCustomTools } from '@/lib/workflows/custom-tools/operations'
import {
loadWorkflowDeploymentSnapshot,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { listSkills } from '@/lib/workflows/skills/operations'
import { listFolders, listWorkflows } from '@/lib/workflows/utils'
import {
assertActiveWorkspaceAccess,
getUsersWithPermissions,
getWorkspaceWithOwner,
hasWorkspaceAdminAccess,
} from '@/lib/workspaces/permissions/utils'
import { computeNeedsRedeployment } from '@/app/api/workflows/utils'
import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config'
import { BLOCK_REGISTRY } from '@/blocks/registry-maps'
import type { BlockConfig, BlockIcon } from '@/blocks/types'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { TRIGGER_REGISTRY } from '@/triggers/registry'
const logger = createLogger('WorkspaceVFS')
/** Placeholder icon for custom-block configs — `serializeBlockSchema` never reads it. */
// double-cast-allowed: a no-op stands in for the unused SVG-typed BlockIcon slot
const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon
const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024
/**
* Static component files, computed once and shared across all VFS instances.
* Built from the UNGATED registry universe (preview blocks included) so this
* process-global cache can never be poisoned by one viewer's gated projection;
* per-viewer gating is applied when the map is stamped into each fresh VFS
* (see {@link isStaticFileHidden}).
*/
let staticComponentFiles: Map<string, string> | null = null
/**
* Owning block for each `components/integrations/**` file, recorded at build
* time. Block/trigger schema files carry their owning type as the path
* basename, but integration paths use the version-stripped service name — so
* their owners need this lookup for the stamp-time visibility filter.
*/
const integrationPathOwners = new Map<string, Pick<BlockConfig, 'type' | 'preview'>>()
/**
* Per-request visibility filter for the shared static files: hides files whose
* owning block is gated for this viewer (unrevealed preview blocks — the
* default with no context — and kill-switched types). Non-registry paths
* (loop/parallel, connectors, overviews) are always visible.
*/
function isStaticFileHidden(path: string, vis: BlockVisibilityState | null): boolean {
const blockMatch = path.match(/^components\/(?:blocks|triggers\/sim)\/([^/]+)\.json$/)
if (blockMatch) {
const config = BLOCK_REGISTRY[blockMatch[1]!]
return config ? isHiddenUnder(vis, config) : false
}
const owner = integrationPathOwners.get(path)
return owner ? isHiddenUnder(vis, owner) : false
}
// On-the-fly doc reads (render/extract) download the binary into the Sim process
// and base64-stage it to E2B, so bound the input like the compile path's staging
// caps — otherwise an authenticated member could OOM the worker with a multi-GB
// upload (uploads are capped at 5GB).
const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024
/**
* True when the buffer is an actual compiled/uploaded binary (vs a source-backed
* generated doc). OOXML (pptx/docx/xlsx) is a ZIP (starts `PK`); PDFs may carry a
* BOM or leading whitespace before `%PDF`, so scan the head rather than offset 0.
*/
function isBinaryDocBuffer(buffer: Buffer, ext: string): boolean {
if (ext === 'pdf') return buffer.subarray(0, 1024).toString('latin1').includes('%PDF')
return buffer.subarray(0, 2).toString('latin1') === 'PK'
}
/**
* Build the static component files from block and tool registries.
* This only needs to happen once per process.
*
* Integration paths are derived deterministically from the block registry's
* `tools.access` arrays rather than splitting tool IDs on underscores.
* Each block declares which tools it owns, and the block type (minus version
* suffix) becomes the service directory name.
*/
function getStaticComponentFiles(): Map<string, string> {
if (staticComponentFiles) return staticComponentFiles
const files = new Map<string, string>()
// Raw registry, never the visibility-projected getAllBlocks: this map is a
// process-global shared cache, so it must hold the deterministic ungated
// universe. Preview blocks get schema files here (path-filterable at stamp
// time for revealed viewers) but are EXCLUDED from the shared aggregate
// files (overviews, oauth/api-key summaries) that all viewers receive.
const allBlocks = Object.values(BLOCK_REGISTRY)
const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar)
let blocksFiltered = 0
for (const block of visibleBlocks) {
const path = `components/blocks/${block.type}.json`
files.set(path, serializeBlockSchema(block))
}
blocksFiltered = allBlocks.length - visibleBlocks.length
let integrationCount = 0
const oauthServices = new Map<string, { provider: string; operations: string[] }>()
const apiKeyServices = new Map<string, { params: string[]; operations: string[] }>()
// Integration tools come from the shared exposed-tool set (latest version of
// each operation owned by a visible block), the same set used to build the
// deferred callable tools — so discovery and execution can never drift.
for (const exposedTool of getExposedIntegrationTools()) {
const { config: tool, service, operation, blockType, preview } = exposedTool
const path = `components/integrations/${service}/${operation}.json`
files.set(path, serializeIntegrationSchema(tool))
integrationPathOwners.set(path, { type: blockType, preview })
integrationCount++
// Preview-owned tools stay out of the shared oauth/api-key aggregates —
// those files are identical for every viewer.
if (preview) continue
if (tool.oauth?.required) {
const existing = oauthServices.get(service)
if (existing) {
existing.operations.push(operation)
} else {
oauthServices.set(service, { provider: tool.oauth.provider, operations: [operation] })
}
} else if (tool.hosting?.apiKeyParam) {
const existing = apiKeyServices.get(service)
if (existing) {
if (!existing.params.includes(tool.hosting.apiKeyParam)) {
existing.params.push(tool.hosting.apiKeyParam)
}
existing.operations.push(operation)
} else {
apiKeyServices.set(service, {
params: [tool.hosting.apiKeyParam],
operations: [operation],
})
}
}
}
files.set(
'environment/oauth-integrations.json',
JSON.stringify(Object.fromEntries(oauthServices), null, 2)
)
files.set(
'environment/api-key-integrations.json',
JSON.stringify(Object.fromEntries(apiKeyServices), null, 2)
)
files.set(
'components/blocks/loop.json',
JSON.stringify(
{
type: 'loop',
name: 'Loop',
description:
'Iterate over a collection or repeat a fixed number of times. Blocks inside the loop run once per iteration.',
inputs: {
loopType: {
type: 'string',
enum: ['for', 'forEach', 'while', 'doWhile'],
description: 'Loop strategy',
},
iterations: { type: 'number', description: 'Number of iterations (for loopType "for")' },
collection: {
type: 'string',
description: 'Collection expression to iterate (for loopType "forEach")',
},
condition: {
type: 'string',
description: 'Condition expression (for loopType "while" or "doWhile")',
},
},
sourceHandles: ['loop-start-source', 'loop-end-source'],
notes:
'Use "loop-start-source" to connect to blocks INSIDE the loop. Use "loop-end-source" for the edge that runs AFTER the loop completes. Do NOT use "source" for a loop block — it is rejected; the only valid source handles are "loop-start-source", "loop-end-source", and "error". Blocks inside the loop must have parentId set to the loop block ID.',
},
null,
2
)
)
files.set(
'components/blocks/parallel.json',
JSON.stringify(
{
type: 'parallel',
name: 'Parallel',
description: 'Run blocks in parallel branches. All branches execute concurrently.',
inputs: {
parallelType: {
type: 'string',
enum: ['count', 'collection'],
description: 'Parallel strategy',
},
count: {
type: 'number',
description: 'Number of parallel branches (for parallelType "count")',
},
collection: {
type: 'string',
description: 'Collection to distribute (for parallelType "collection")',
},
},
sourceHandles: ['parallel-start-source', 'parallel-end-source'],
notes:
'Use "parallel-start-source" to connect to blocks INSIDE the parallel container. Use "parallel-end-source" for the edge AFTER all branches complete. Do NOT use "source" for a parallel block — it is rejected; the only valid source handles are "parallel-start-source", "parallel-end-source", and "error". Blocks inside must have parentId set to the parallel block ID.',
},
null,
2
)
)
const connectorConfigs = Object.values(CONNECTOR_REGISTRY).map((c) => ({
id: c.id,
name: c.name,
description: c.description,
version: c.version,
auth: c.auth,
configFields: c.configFields,
tagDefinitions: c.tagDefinitions,
supportsIncrementalSync: c.supportsIncrementalSync,
}))
files.set('knowledgebases/connectors/connectors.md', serializeConnectorOverview(connectorConfigs))
for (const cc of connectorConfigs) {
files.set(`knowledgebases/connectors/${cc.id}.json`, serializeConnectorSchema(cc))
}
const builtinTriggerBlocks = allBlocks.filter((b) => b.category === 'triggers')
for (const block of builtinTriggerBlocks) {
files.set(`components/triggers/sim/${block.type}.json`, serializeBuiltinTriggerSchema(block))
}
let externalTriggerCount = 0
for (const [triggerId, trigger] of Object.entries(TRIGGER_REGISTRY)) {
const path = `components/triggers/${trigger.provider}/${triggerId}.json`
files.set(path, serializeTriggerSchema(trigger))
externalTriggerCount++
}
files.set(
'components/triggers/triggers.md',
serializeTriggerOverview(
// The overview is a shared file — preview trigger blocks stay out of it
// (their per-type schema file remains discoverable for revealed viewers).
builtinTriggerBlocks
.filter((b) => !b.preview)
.map((b) => ({
id: b.type,
name: b.name,
provider: 'sim',
description: b.description,
})),
Object.entries(TRIGGER_REGISTRY).map(([id, t]) => ({
id,
name: t.name,
provider: t.provider,
description: t.description,
}))
)
)
logger.info('Static component files built', {
blocks: visibleBlocks.length,
blocksFiltered,
integrations: integrationCount,
connectors: connectorConfigs.length,
builtinTriggers: builtinTriggerBlocks.length,
externalTriggers: externalTriggerCount,
})
staticComponentFiles = files
return staticComponentFiles
}
/**
* Virtual Filesystem that materializes workspace data into an in-memory Map.
*
* Structure:
* WORKSPACE_CONTEXT.md — full dynamic workspace/user context (auto-generated)
* WORKSPACE.md — workspace inventory summary (auto-generated)
* workflows/{name}/meta.json (root-level workflows)
* workflows/{name}/state.json (sanitized blocks with embedded connections)
* workflows/{name}/lint.json (sources/sinks, required-field, credential/resource issues)
* workflows/{name}/executions.json
* workflows/{name}/deployment.json
* workflows/{folder}/{name}/... (workflows inside folders, nested folders supported)
* knowledgebases/{name}/meta.json
* knowledgebases/{name}/documents.json
* knowledgebases/{name}/connectors.json
* tables/{name}/meta.json
* files/{name} (workspace file leaf; dynamic content on read)
* files/{path}/{name}/style (dynamic — style extraction for .docx/.pptx/.pdf)
* files/{path}/{name}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?})
* jobs/{title}/meta.json
* jobs/{title}/history.json
* jobs/{title}/executions.json
* tasks/{title}/session.md
* tasks/{title}/chat.json
* custom-tools/{name}.json
* environment/credentials.json
* environment/api-keys.json
* environment/variables.json
* knowledgebases/connectors/connectors.md (available connector types overview)
* knowledgebases/connectors/{type}.json (per-connector config schema)
* components/blocks/{type}.json
* components/integrations/{service}/{operation}.json
* components/triggers/triggers.md (overview of all built-in and external triggers)
* components/triggers/sim/{type}.json (built-in trigger blocks: start, schedule, webhook)
* components/triggers/{provider}/{id}.json (external triggers: github, slack, etc.)
*/
export class WorkspaceVFS {
// Eagerly-materialized, cheap content (structure + metadata): folder markers,
// per-resource meta.json, WORKSPACE.md/WORKSPACE_CONTEXT.md, static components.
private files: Map<string, string> = new Map()
// Lazily-materialized, expensive content keyed by VFS path. The loader runs on
// demand: a `read` resolves exactly one entry; a scoped `grep` resolves only
// the entries within its scope; an unscoped `grep` resolves all; a `glob` never
// resolves any (it matches keys only). This is why a read/glob no longer pays
// for every workflow's graph-load + lint + stringify — only grep over contents
// does, and only for what it actually scans.
private lazy: Map<string, () => Promise<string | null>> = new Map()
// Per-instance (per-tool-call) memo so state.json + lint.json for the same
// workflow share one normalized-table load, and deployment.json + versions.json
// share one deployment query.
private normalizedCache = new Map<
string,
Promise<Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>>
>()
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
}
/** Register a VFS path whose (expensive) content is produced on demand. */
private registerLazy(path: string, loader: () => Promise<string | null>): void {
this.lazy.set(path, loader)
}
/**
* Load a workflow's normalized state once per instance. state.json and lint.json
* both need it, and a grep over a workflow's dir touches both — without this they
* would each re-load the full block graph.
*/
private loadNormalized(
workflowId: string
): Promise<Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>> {
let cached = this.normalizedCache.get(workflowId)
if (!cached) {
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
isDeployed: boolean
deployedAt: Date | null
}): Promise<DeploymentData | null> {
let cached = this.deploymentCache.get(wf.id)
if (!cached) {
cached = this.getWorkflowDeployments(wf.id, this._workspaceId, wf.isDeployed, wf.deployedAt)
this.deploymentCache.set(wf.id, cached)
}
return cached
}
/**
* Resolve a single lazy artifact into {@link files}. Idempotent: once resolved
* the entry moves to `files` and the loader is dropped. A loader that returns
* null (no data) leaves nothing behind, so the path reads as "not found".
*/
private async resolveLazyPath(path: string): Promise<string | null> {
const existing = this.files.get(path)
if (existing !== undefined) return existing
const loader = this.lazy.get(path)
if (!loader) return null
this.lazy.delete(path)
let content: string | null = null
try {
content = await loader()
} catch (err) {
logger.warn('Failed to resolve lazy VFS artifact', {
workspaceId: this._workspaceId,
path,
error: toError(err).message,
})
content = null
}
if (content !== null) this.files.set(path, content)
return content
}
/**
* Resolve every lazy artifact a grep over `scope` will scan, in parallel. An
* undefined scope (unscoped grep) resolves all — the worst case, equivalent to
* the old eager full materialize, but now only paid by an unscoped grep.
* Uses the same scope matcher as {@link ops.grep} so the materialized set is
* exactly the set grep filters in.
*/
private async resolveLazyWithinScope(scope?: string): Promise<void> {
const targets: string[] = []
for (const path of this.lazy.keys()) {
if (!scope || ops.pathWithinGrepScope(path, scope)) targets.push(path)
}
if (targets.length === 0) return
await Promise.all(targets.map((path) => this.resolveLazyPath(path)))
}
/**
* `recently-deleted/` artifacts are opt-in: excluded from the active view
* unless a path/pattern explicitly scopes into them.
*/
private isRecentlyDeleted(key: string): boolean {
return key.startsWith('recently-deleted/')
}
/**
* A keys-only view (eager values plus empty placeholders for unresolved lazy
* paths) for glob/suggestSimilar, which match on keys and never read content.
*/
private keyView(includeDeleted: boolean): Map<string, string> {
const view = new Map<string, string>()
for (const [key, value] of this.files) {
if (includeDeleted || !this.isRecentlyDeleted(key)) view.set(key, value)
}
for (const key of this.lazy.keys()) {
if ((includeDeleted || !this.isRecentlyDeleted(key)) && !view.has(key)) {
view.set(key, '')
}
}
return view
}
/**
* Materialize workspace data into the VFS.
* Uses shared service functions for all data access, then generates
* WORKSPACE.md from the summaries returned by each materializer.
*/
async materialize(workspaceId: string, userId: string): Promise<void> {
const start = Date.now()
this.files = new Map()
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 })
// Per-phase wall-clock, stamped on the span so a slow materialize in a
// trace names its bottleneck instead of showing up as unattributed dead
// time inside read/glob/grep (how the v0.7 lint.json regression hid).
const phaseMs: Record<string, number> = {}
const timed = <T>(phase: string, promise: Promise<T>): Promise<T> => {
const t0 = Date.now()
return promise.finally(() => {
phaseMs[phase] = Date.now() - t0
})
}
await trace
.getTracer('sim-copilot-vfs', '1.0.0')
.startActiveSpan(
TraceSpan.CopilotVfsMaterialize,
{ attributes: { [TraceAttr.WorkspaceId]: workspaceId } },
async (span) => {
try {
const [
wfSummary,
kbSummary,
tblSummary,
fileSummary,
envSummary,
toolsSummary,
customBlocksSummary,
mcpServersSummary,
skillsSummary,
taskSummary,
jobsSummary,
wsRow,
members,
] = await Promise.all([
timed('workflows', this.materializeWorkflows(workspaceId)),
timed('knowledge_bases', this.materializeKnowledgeBases(workspaceId, userId)),
timed('tables', this.materializeTables(workspaceId)),
timed('files', this.materializeFiles(workspaceId)),
timed('environment', this.materializeEnvironment(workspaceId, userId)),
timed('custom_tools', this.materializeCustomTools(workspaceId, userId)),
timed('custom_blocks', this.materializeCustomBlocks(workspaceId)),
timed('mcp_servers', this.materializeMcpServers(workspaceId)),
timed('skills', this.materializeSkills(workspaceId)),
timed('tasks', this.materializeTasks(workspaceId, userId)),
timed('jobs', this.materializeJobs(workspaceId)),
timed('workspace_row', getWorkspaceWithOwner(workspaceId)),
timed('members', getUsersWithPermissions(workspaceId)),
])
const workspaceMdData = {
workspace: wsRow,
members,
workflows: wfSummary,
knowledgeBases: kbSummary,
tables: tblSummary,
files: fileSummary,
oauthIntegrations: envSummary.oauthIntegrations,
envVariables: envSummary.envVariables,
tasks: taskSummary,
customTools: toolsSummary,
customBlocks: customBlocksSummary,
mcpServers: mcpServersSummary,
skills: skillsSummary,
jobs: jobsSummary,
}
this.files.set('WORKSPACE.md', buildWorkspaceMd(workspaceMdData))
this.files.set('WORKSPACE_CONTEXT.md', buildWorkspaceContextMd(workspaceMdData))
await timed('recently_deleted', this.materializeRecentlyDeleted(workspaceId, userId))
// Per-viewer gating happens HERE, not in the shared builder: files
// owned by blocks hidden for this viewer are skipped at stamp time.
const blockVisibility = overlayVisibility()
for (const [path, content] of getStaticComponentFiles()) {
if (isStaticFileHidden(path, blockVisibility)) continue
this.files.set(path, content)
}
span.setAttributes({
[TraceAttr.CopilotVfsMaterializeFileCount]: this.files.size,
[TraceAttr.CopilotVfsMaterializePhaseMs]: JSON.stringify(phaseMs),
})
} catch (err) {
markSpanForError(span, err)
throw err
} finally {
// Record on success AND failure: a mid-phase failure (e.g. a DB
// timeout) still belongs in copilot.vfs.materialize.duration, else
// p50/p99 skew toward successes only. phaseMs holds whatever phases
// completed before the failure.
for (const [phase, ms] of Object.entries(phaseMs)) {
recordVfsMaterialize(phase, ms)
}
recordVfsMaterialize('total', Date.now() - start)
span.end()
}
}
)
// Durable Grafana signal for "how long does VFS materialize" — total plus
// per-phase (bounded phase set). getOrMaterializeVFS runs per VFS tool call
// with no cross-request cache, so this reveals whether materialize is the
// bottleneck (observability only; not a fix). Recorded inside the span's
// finally above so a failed materialize is captured too, not just successes.
const totalMs = Date.now() - start
logger.info('VFS materialized', {
workspaceId,
fileCount: this.files.size,
durationMs: totalMs,
phaseMs,
})
}
private activeFiles(): Map<string, string> {
const filtered = new Map<string, string>()
for (const [key, value] of this.files) {
if (!this.isRecentlyDeleted(key)) {
filtered.set(key, value)
}
}
return filtered
}
private filesForPath(path?: string): Map<string, string> {
if (path?.startsWith('recently-deleted')) return this.files
return this.activeFiles()
}
async grep(
pattern: string,
path?: string,
options?: GrepOptions
): Promise<GrepMatch[] | string[] | ops.GrepCountEntry[]> {
// grep is the only op that scans contents, so it is the only op that pays to
// materialize lazy artifacts — and only those within its scope.
await this.resolveLazyWithinScope(path)
return ops.grep(this.filesForPath(path), pattern, path, options)
}
/**
* Grep the *content* of a single workspace file (under `files/`), as opposed to
* {@link grep} which searches the in-memory VFS map (workflow JSON, metadata,
* plans, memories — workspace files appear there only as metadata).
*
* Content search applies to workspace files only and must target exactly one
* file (`files/<name>` or `files/<name>/content`, plus the `recently-deleted/`
* variants). A folder, the whole `files/` tree, or any path that does not
* resolve to a single file leaf throws — grepping multiple workspace files at
* once is intentionally unsupported.
*
* Per file type the file's text is resolved via {@link readFileContent} (the
* same extraction `read` uses): text-like files are read as UTF-8, parseable
* documents (pdf/docx/xlsx/pptx/…) are parsed to text, and the regex runs over
* that text. Images and binary files have no searchable text and throw, as do
* files too large for the inline read cap. Reading exactly one file (bounded by
* the existing per-type read caps) keeps this from loading the workspace into
* memory.
*/
async grepFile(
path: string,
pattern: string,
options?: GrepOptions
): Promise<GrepMatch[] | string[] | ops.GrepCountEntry[]> {
const normalized = path.replace(/^\/+/, '')
// Prefer the path verbatim when it is itself a file leaf (e.g. a file literally
// named "content"); otherwise drop a trailing "/content" read suffix.
const leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '')
const isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf)
if (!isWorkspaceFilePath || !this.files.has(leaf)) {
const suggestions = this.suggestSimilar(leaf)
const hint =
suggestions.length > 0
? ` Did you mean: ${suggestions.join(', ')}?`
: ' Use glob to find the exact file path, then grep that single file.'
throw new ops.WorkspaceFileGrepError(
`Grep over workspace file content must target a single workspace file (e.g. path: "files/report.csv"). "${path}" is not a single workspace file.${hint}`
)
}
const contentPath = `${leaf}/content`
const result = await this.readFileContent(contentPath)
if (!result) {
throw new ops.WorkspaceFileGrepError(`Workspace file content not found for "${path}".`)
}
return ops.grepReadResult(leaf, result, pattern, contentPath, options)
}
glob(pattern: string): string[] {
// glob matches keys only, so it resolves no lazy content — it sees the full
// path structure (eager keys + lazy placeholders) for free.
const includeDeleted = pattern.startsWith('recently-deleted')
return ops.glob(this.keyView(includeDeleted), pattern)
}
async read(path: string, offset?: number, limit?: number): Promise<ReadResult | null> {
// Resolve the one lazy artifact being read into `files`; a no-op for eager
// paths (already present) and unknown paths (no loader). Lazy keys are always
// ASCII (built via encodeURIComponent), so no Unicode-normalized lookup is
// needed here; ops.read still does its own NFC/NFD fallback over `files`.
await this.resolveLazyPath(path)
return ops.read(this.files, path, offset, limit)
}
suggestSimilar(missingPath: string, max?: number): string[] {
return ops.suggestSimilar(this.keyView(true), missingPath, max)
}
private async resolveWorkspaceFileForDynamicRead(
path: string,
suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract'
): Promise<WorkspaceFileRecord | null> {
if (!this._betaEnabled && isWorkflowAliasBackingPath(path)) {
return null
}
const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`))
if (!canonicalMatch?.[1]) return null
const files = await listWorkspaceFiles(this._workspaceId, { includeReservedSystemFiles: true })
return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`)
}
/**
* Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and
* returns it as a model readable JPEG attachment. Shared by the `/render` and
* `/compiled` reads so a binary doc is NEVER attached as a raw (non-PDF)
* `document` block — the model only reads images and application/pdf. Compiles
* the source first when needed (E2B doc sandbox, else isolated-vm); uses the
* binary directly for already-binary uploads. Throws on compile/render failure
* (the caller's try/catch reports it).
*/
private async renderDocRecordResult(
record: WorkspaceFileRecord,
ext: string,
buildMessage: (pageCount: number) => string
): Promise<FileReadResult> {
if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File is too large to render' }),
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
if (buffer.length > MAX_DOC_READ_INPUT_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File is too large to render' }),
totalLines: 1,
}
}
// Already-binary uploads render directly; source files are compiled first
// (E2B regime -> doc sandbox: Node pptx/docx, Python pdf; otherwise
// isolated-vm pptxgenjs/docx-js/pdf-lib).
let bin: Buffer
if (isBinaryDocBuffer(buffer, ext)) {
bin = buffer
} else {
const code = buffer.toString('utf-8')
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }),
totalLines: 1,
}
}
if (isE2BDocEnabled && (await getE2BDocFormat(record.name))) {
bin = (
await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId })
).buffer
} else {
const taskId = BINARY_DOC_TASKS[ext]
if (!taskId) {
return {
content: JSON.stringify({ ok: false, error: 'Cannot render this file' }),
totalLines: 1,
}
}
bin = await runSandboxTask(taskId, { code, workspaceId: this._workspaceId })
}
}
const { grid, pageCount } = await renderDocToGrid({
binary: bin,
ext,
workspaceId: this._workspaceId,
})
return {
content: buildMessage(pageCount),
totalLines: 1,
attachment: {
// The rendered contact sheet is a JPEG, so it must be an image block.
// Tagging it 'file' routes it to a provider document block, which only
// accepts application/pdf — Anthropic rejects image/jpeg there with a
// 400 that surfaces to the client as a "Stream error".
type: 'image',
name: `${record.name}.render.jpg`,
source: { type: 'base64', media_type: 'image/jpeg', data: grid.toString('base64') },
},
}
}
/**
* Attempt to read dynamic workspace file content from storage.
* Handles explicit /content reads for images, PDFs, documents, and text files.
* Also handles:
* `files/{path}/{name}/style` — style extraction (.docx / .pptx / .pdf)
* `files/{path}/{name}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams
* `files/{path}/{name}/compiled` — compile JS-source binary files and return the compiled artifact as an attachment
* Files are resolved by their sanitized canonical path only.
* Returns null if the path doesn't match a dynamic file path or the file isn't found.
*/
async readFileContent(path: string): Promise<FileReadResult | null> {
const compiledMatch = /^files\/.+\/compiled$/.test(path)
if (compiledMatch) {
let record: WorkspaceFileRecord | null = null
try {
record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled')
if (!record) return null
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null
const taskId = BINARY_DOC_TASKS[ext]
if (!e2bFmt && !taskId) return null
// Only PDF can be attached as a model-readable `document` block —
// Bedrock/Anthropic document blocks accept application/pdf ONLY. Attaching
// raw pptx/docx/xlsx binary is rejected by the provider (400). So for
// pptx/docx, render to page images (which the model CAN read) and return
// those directly — /compiled can never emit an invalid document block for
// these formats. xlsx isn't renderable; direct to /extract for its content.
if (ext !== 'pdf') {
if (isRenderableDocExt(ext)) {
const compiledName = record.name
return await this.renderDocRecordResult(
record,
ext,
(pageCount) =>
`${compiledName}: the raw ${ext.toUpperCase()} binary isn't model-readable, so it was rendered to ${pageCount} page image(s) for inspection.`
)
}
const extractPath = `${canonicalWorkspaceFilePath({
folderPath: record.folderPath,
name: record.name,
})}/extract`
return {
content: `${record.name} is a spreadsheet — read "${extractPath}" for its contents.`,
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
const code = buffer.toString('utf-8')
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }),
totalLines: 1,
}
}
const compiled = e2bFmt
? (
await compileDoc({
source: code,
fileName: record.name,
workspaceId: this._workspaceId,
})
).buffer
: await runSandboxTask(taskId, { code, workspaceId: this._workspaceId })
if (compiled.length > MAX_COMPILED_ATTACHMENT_BYTES) {
return {
content: `[Compiled artifact too large: ${record.name} (${compiled.length} bytes, limit ${MAX_COMPILED_ATTACHMENT_BYTES})]`,
totalLines: 1,
}
}
return {
content: `Compiled file: ${record.name} (${compiled.length} bytes, application/pdf)`,
totalLines: 1,
attachment: {
type: 'file',
name: record.name,
source: {
type: 'base64',
media_type: 'application/pdf',
data: compiled.toString('base64'),
},
},
}
} catch (err) {
logger.warn('Compiled artifact read failed via VFS', {
workspaceId: this._workspaceId,
path,
fileId: record?.id,
error: toError(err).message,
})
if (err instanceof SandboxUserCodeError) {
const json = JSON.stringify({
ok: false,
error: toError(err).message,
errorName: err.name,
})
return { content: json, totalLines: 1 }
}
return null
}
}
const renderMatch = /^files\/.+\/render$/.test(path)
if (renderMatch) {
let record: WorkspaceFileRecord | null = null
try {
record = await this.resolveWorkspaceFileForDynamicRead(path, 'render')
if (!record) return null
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
if (!isRenderableDocExt(ext)) {
return {
content: JSON.stringify({
ok: false,
error: 'Render supports .pptx, .docx, and .pdf only',
}),
totalLines: 1,
}
}
const renderName = record.name
return await this.renderDocRecordResult(
record,
ext,
(pageCount) =>
`Rendered ${pageCount} page(s) of ${renderName} as a contact-sheet grid for visual QA. Inspect each page for text overflow/cutoff, overlapping elements, low contrast, misalignment, and leftover placeholder text; fix and re-render until clean.`
)
} catch (err) {
logger.warn('Render read failed via VFS', {
workspaceId: this._workspaceId,
path,
fileId: record?.id,
error: toError(err).message,
})
// Return an explicit error (not null) once the file resolved — a null read
// looks like a missing path and sends the agent hunting for the "correct"
// render path instead of surfacing the real compile/render failure.
return {
content: JSON.stringify({ ok: false, error: toError(err).message }),
totalLines: 1,
}
}
}
const extractMatch = /^files\/.+\/extract$/.test(path)
if (extractMatch && isE2BDocEnabled) {
let record: WorkspaceFileRecord | null = null
try {
record = await this.resolveWorkspaceFileForDynamicRead(path, 'extract')
if (!record) return null
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
if (!isExtractableDocExt(ext)) {
return {
content: JSON.stringify({
ok: false,
error: 'Extraction supports .pdf, .pptx, .docx, and .xlsx only',
}),
totalLines: 1,
}
}
// Bound the input before downloading + base64-staging it in-process.
if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File is too large to extract' }),
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
if (buffer.length > MAX_DOC_READ_INPUT_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File is too large to extract' }),
totalLines: 1,
}
}
// Extraction reads the binary. A source-backed generated doc (text source,
// no binary magic) should be read directly instead — point the agent there.
if (!isBinaryDocBuffer(buffer, ext)) {
return {
content: JSON.stringify({
ok: false,
error: 'This is a source-backed generated file; read its content directly instead.',
}),
totalLines: 1,
}
}
const { text, truncated } = await extractDocText({ binary: buffer, ext })
const note = truncated
? '\n\n[... truncated — read the file directly for the full content]'
: ''
return {
content: `${text || '[no extractable text found]'}${note}`,
totalLines: 1,
}
} catch (err) {
logger.warn('Extract read failed via VFS', {
workspaceId: this._workspaceId,
path,
fileId: record?.id,
error: toError(err).message,
})
return {
content: JSON.stringify({ ok: false, error: toError(err).message }),
totalLines: 1,
}
}
}
const compiledCheckMatch = /^files\/.+\/compiled-check$/.test(path)
if (compiledCheckMatch) {
let record: WorkspaceFileRecord | null = null
try {
record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check')
if (!record) return null
const ext = record.name.split('.').pop()?.toLowerCase() ?? ''
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null
const taskId = BINARY_DOC_TASKS[ext]
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
if (!e2bFmt && !taskId && !isMermaidFile) return null
const buffer = await fetchWorkspaceFileBuffer(record)
const code = buffer.toString('utf-8')
if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) {
return {
content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }),
totalLines: 1,
}
}
if (isMermaidFile) {
const result = await validateMermaidSource(code)
const json = JSON.stringify(result)
return { content: json, totalLines: 1 }
}
let result: { ok: boolean; error?: string; errorName?: string }
if (e2bFmt) {
// Loads the artifact if present, else compiles once (and recalc-scans
// xlsx). Only a script error is { ok: false }; infra failures rethrow to
// the outer catch so an E2B/S3 outage isn't reported as a bad script.
result = await runE2BCompiledCheck({
source: code,
fileName: record.name,
workspaceId: this._workspaceId,
ext,
})
} else {
try {
if (!taskId) return null
await runSandboxTask(taskId, { code, workspaceId: this._workspaceId })
result = { ok: true }
} catch (err) {
if (err instanceof SandboxUserCodeError) {
result = { ok: false, error: toError(err).message, errorName: err.name }
} else {
throw err
}
}
}
const json = JSON.stringify(result)
return { content: json, totalLines: 1 }
} catch (err) {
logger.warn('Compiled check failed via VFS', {
workspaceId: this._workspaceId,
path,
fileId: record?.id,
error: toError(err).message,
})
return null
}
}
const styleMatch = /^files\/.+\/style$/.test(path)
if (styleMatch) {
let record: WorkspaceFileRecord | null = null
try {
record = await this.resolveWorkspaceFileForDynamicRead(path, 'style')
if (!record) return null
const rawExt = record.name.split('.').pop()?.toLowerCase()
if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null
const ext: 'docx' | 'pptx' | 'pdf' = rawExt
const buffer = await fetchWorkspaceFileBuffer(record)
const summary = await extractDocumentStyle(buffer, ext)
if (!summary) return null
const json = JSON.stringify(summary, null, 2)
return { content: json, totalLines: json.split('\n').length }
} catch (err) {
logger.warn('Failed to extract document style via VFS', {
workspaceId: this._workspaceId,
path,
fileId: record?.id,
error: toError(err).message,
})
return null
}
}
const deletedMatch = path.match(/^recently-deleted\/files\/(.+)\/content$/)
const activeMatch = path.match(/^files\/(.+)\/content$/)
const match = deletedMatch || activeMatch
if (!match) return null
const fileReference = path
.replace(/^recently-deleted\//, '')
.replace(/\/content$/, '')
.replace(/^\/+/, '')
if (!this._betaEnabled && isWorkflowAliasBackingPath(fileReference)) {
return null
}
if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null
const scope = deletedMatch ? 'archived' : 'active'
try {
const files = await listWorkspaceFiles(this._workspaceId, {
scope,
includeReservedSystemFiles: this._betaEnabled,
})
const record = findWorkspaceFileRecord(files, fileReference)
if (!record) return null
return readFileRecord(record)
} catch (err) {
logger.warn('Failed to list workspace files for readFileContent', {
workspaceId: this._workspaceId,
path,
error: toError(err).message,
})
return null
}
}
/**
* Build a map from folderId to its full VFS path segment (e.g. "My Folder/Sub Folder").
* Handles nested folders via parentId traversal.
*/
private buildFolderPaths(
folders: Array<{ folderId: string; folderName: string; parentId: string | null }>
): Map<string, string> {
return buildVfsFolderPathMap(folders)
}
/**
* Resolve the set of folder IDs that are effectively locked — locked directly
* or via a locked ancestor folder. A workflow inside any of these folders is
* itself immutable, so its meta.json must report `locked: true`. Mirrors the
* folder-chain walk in `@sim/platform-authz/workflow` getFolderLockStatus, but resolves
* the whole workspace in memory to avoid a per-workflow DB round trip.
*/
private computeLockedFolderIds(
folders: Array<{ folderId: string; parentId: string | null; locked: boolean }>
): Set<string> {
const byId = new Map(folders.map((f) => [f.folderId, f]))
const lockedFolderIds = new Set<string>()
for (const folder of folders) {
let current: string | null = folder.folderId
const visited = new Set<string>()
while (current && !visited.has(current)) {
visited.add(current)
const node = byId.get(current)
if (!node) break
if (node.locked) {
lockedFolderIds.add(folder.folderId)
break
}
current = node.parentId
}
}
return lockedFolderIds
}
/**
* Materialize all workflows using the shared listWorkflows function.
* Workflows are nested under their folder paths in the VFS:
* workflows/{folder}/{name}/ (if in a folder)
* workflows/{name}/ (if at workspace root)
* Returns a summary for WORKSPACE.md generation.
*/
private async materializeWorkflows(workspaceId: string): Promise<WorkspaceMdData['workflows']> {
const workflowArtifactsEnabled = this._betaEnabled
const [workflowRows, folderRows] = await Promise.all([
listWorkflows(workspaceId),
listFolders(workspaceId),
])
const folderPaths = this.buildFolderPaths(folderRows)
const lockedFolderIds = this.computeLockedFolderIds(folderRows)
// NOTE: materialization is a pure READ. Alias backing (changelog/plan
// folders + files) is ensured at write time — workflow create/rename
// (lib/workflows/utils) and alias writes (vfs/resource-writer,
// tools/server/files/workspace-file) — never here. Ensuring per workflow
// on every materialize meant N storage/DB writes per read tool call, and
// concurrent materializations contending on the same rows.
const workspaceFiles = workflowArtifactsEnabled
? await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true })
: []
// Register all folders in the VFS so empty folders are discoverable.
for (const { folderId } of folderRows) {
const folderPath = folderPaths.get(folderId)
if (folderPath) {
this.files.set(`workflows/${folderPath}/.folder`, '')
}
}
await Promise.all(
workflowRows.map(async (wf) => {
const folderPath = wf.folderId ? folderPaths.get(wf.folderId) : null
const prefix = `${canonicalWorkflowVfsDir({ name: wf.name, folderPath })}/`
const workflowPath = prefix.replace(/\/$/, '')
const inheritedFolderLock = wf.folderId ? lockedFolderIds.has(wf.folderId) : false
this.files.set(`${prefix}meta.json`, serializeWorkflowMeta(wf, { inheritedFolderLock }))
if (workflowArtifactsEnabled) {
const changelog = findWorkspaceFileRecord(
workspaceFiles,
workflowChangelogBackingPath(wf.id)
)
let changelogContent = ''
if (changelog) {
try {
changelogContent = (await readFileRecord(changelog))?.content ?? ''
} catch (err) {
logger.warn('Failed to read workflow changelog alias backing file', {
workspaceId,
workflowId: wf.id,
fileId: changelog.id,
error: toError(err).message,
})
}
}
if (changelog) {
this.files.set(`${prefix}${WORKFLOW_CHANGELOG_ALIAS_NAME}`, changelogContent)
}
this.files.set(`${prefix}${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '')
const planFiles = workspaceFiles.filter((file) => {
if (!file.folderPath) return false
return (
file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}` ||
file.folderPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}/`)
)
})
for (const planFile of planFiles) {
const relativeFolder = planFile.folderPath
?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${wf.id}`, '')
.replace(/^\/+/, '')
const aliasPlanPath = [
prefix,
`${WORKFLOW_PLANS_ALIAS_DIR}/`,
relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '',
normalizeVfsSegment(planFile.name),
].join('')
try {
this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '')
} catch (err) {
logger.warn('Failed to read workflow plan alias backing file', {
workspaceId,
workflowId: wf.id,
fileId: planFile.id,
error: toError(err).message,
})
}
}
this.files.set(
`${prefix}${WORKFLOW_ALIAS_LINKS_NAME}`,
JSON.stringify(
{
aliases: buildWorkflowAliasLinks({
workflowPath,
workflowId: wf.id,
changelog,
planFiles,
}),
},
null,
2
)
)
}
// Heavy per-workflow content is LAZY: a read/glob never loads the block
// graph, runs lint, or queries executions/deployments. Only a read of the
// specific artifact — or a grep whose scope touches it — resolves it.
// state.json + lint.json share one memoized normalized-table load;
// deployment.json + versions.json share one memoized deployment query.
// This is the change that stops every read/glob from paying O(workflows)
// graph-loads + lint + stringify (what made large-workspace reads ~40s).
this.registerLazy(`${prefix}state.json`, async () => {
const normalized = await this.loadNormalized(wf.id)
// loadWorkflowFromNormalizedTables returns null for a zero-block
// workflow; it still exists and must be readable, so emit an
// empty-but-valid state.json rather than a 404.
const sanitized = normalized
? sanitizeForCopilot({
blocks: normalized.blocks,
edges: normalized.edges,
loops: normalized.loops,
parallels: normalized.parallels,
} as any)
: sanitizeForCopilot({ blocks: {}, edges: [], loops: {}, parallels: {} } as any)
return JSON.stringify(sanitized, null, 2)
})
this.registerLazy(`${prefix}lint.json`, async () => {
const normalized = await this.loadNormalized(wf.id)
// Derived from the raw normalized state (subBlock values, advancedMode,
// canonicalModes, subflow edges). CPU-only by design: tier-2 reference
// resolution runs at edit_workflow apply time, not here. A zero-block
// workflow has no lint (reads as not-found, as before).
if (!normalized) return null
const graphLint = lintEditedWorkflowState(normalized as any)
const fieldIssues = collectWorkflowFieldIssues(normalized.blocks as any)
return JSON.stringify(
{
...graphLint,
fieldIssues,
notes: [
UNRESOLVABLE_AT_LINT_NOTE,
'Credential/resource reference resolution is validated when editing the workflow, not in this snapshot.',
],
},
null,
2
)
})
// executions.json is advertised only when the workflow has run (cheap
// signal: lastRunAt), matching the old "set iff execRows > 0" behavior
// without the per-workflow query on every tool call.
if (wf.lastRunAt) {
this.registerLazy(`${prefix}executions.json`, async () => {
const execRows = await db
.select({
id: workflowExecutionLogs.id,
executionId: workflowExecutionLogs.executionId,
status: workflowExecutionLogs.status,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
})
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.workflowId, wf.id))
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(5)
return execRows.length > 0 ? serializeRecentExecutions(execRows) : null
})
}
// deployment.json / versions.json are advertised when the workflow is
// deployed (cheap signal: isDeployed). Both share one memoized query.
if (wf.isDeployed) {
this.registerLazy(`${prefix}deployment.json`, async () => {
const deploymentData = await this.loadDeployments(wf)
return deploymentData ? serializeDeployments(deploymentData) : null
})
this.registerLazy(`${prefix}versions.json`, async () => {
const deploymentData = await this.loadDeployments(wf)
return deploymentData?.versions && deploymentData.versions.length > 0
? serializeVersions(deploymentData.versions)
: null
})
}
})
)
return workflowRows.map((wf) => ({
id: wf.id,
name: wf.name,
description: wf.description,
isDeployed: wf.isDeployed,
lastRunAt: wf.lastRunAt,
folderPath: wf.folderId ? (folderPaths.get(wf.folderId) ?? null) : null,
}))
}
/**
* Materialize knowledge bases using the shared getKnowledgeBases function.
* Returns a summary for WORKSPACE.md generation.
*/
private async materializeKnowledgeBases(
workspaceId: string,
userId: string
): Promise<WorkspaceMdData['knowledgeBases']> {
const kbs = await getKnowledgeBases(userId, workspaceId)
await Promise.all(
kbs.map(async (kb) => {
const safeName = sanitizeName(kb.name)
const prefix = `knowledgebases/${safeName}/`
this.files.set(
`${prefix}meta.json`,
serializeKBMeta({
id: kb.id,
name: kb.name,
description: kb.description,
embeddingModel: kb.embeddingModel,
embeddingDimension: kb.embeddingDimension,
tokenCount: kb.tokenCount,
createdAt: kb.createdAt,
updatedAt: kb.updatedAt,
documentCount: kb.docCount,
connectorTypes: kb.connectorTypes,
})
)
// documents.json / connectors.json are lazy, advertised only when the KB
// summary says they exist (docCount / connectorTypes) — no per-KB query on
// a read/glob, only when the artifact is read or grepped.
if (kb.docCount > 0) {
this.registerLazy(`${prefix}documents.json`, async () => {
const docRows = await db
.select({
id: document.id,
filename: document.filename,
fileSize: document.fileSize,
mimeType: document.mimeType,
chunkCount: document.chunkCount,
tokenCount: document.tokenCount,
processingStatus: document.processingStatus,
enabled: document.enabled,
uploadedAt: document.uploadedAt,
})
.from(document)
.where(
and(
eq(document.knowledgeBaseId, kb.id),
eq(document.userExcluded, false),
isNull(document.archivedAt),
isNull(document.deletedAt)
)
)
return docRows.length > 0 ? serializeDocuments(docRows) : null
})
}
if (kb.connectorTypes.length > 0) {
this.registerLazy(`${prefix}connectors.json`, async () => {
const connectorRows = await db
.select({
id: knowledgeConnector.id,
connectorType: knowledgeConnector.connectorType,
status: knowledgeConnector.status,
syncMode: knowledgeConnector.syncMode,
syncIntervalMinutes: knowledgeConnector.syncIntervalMinutes,
lastSyncAt: knowledgeConnector.lastSyncAt,
lastSyncError: knowledgeConnector.lastSyncError,
lastSyncDocCount: knowledgeConnector.lastSyncDocCount,
nextSyncAt: knowledgeConnector.nextSyncAt,
consecutiveFailures: knowledgeConnector.consecutiveFailures,
createdAt: knowledgeConnector.createdAt,
})
.from(knowledgeConnector)
.where(
and(
eq(knowledgeConnector.knowledgeBaseId, kb.id),
isNull(knowledgeConnector.archivedAt),
isNull(knowledgeConnector.deletedAt)
)
)
return connectorRows.length > 0 ? serializeConnectors(connectorRows) : null
})
}
})
)
return kbs.map((kb) => ({
id: kb.id,
name: kb.name,
description: kb.description,
connectorTypes: kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined,
}))
}
/**
* Materialize tables using the shared listTables function.
* Returns a summary for WORKSPACE.md generation.
*/
private async materializeTables(workspaceId: string): Promise<WorkspaceMdData['tables']> {
try {
const tables = await listTables(workspaceId)
for (const table of tables) {
const safeName = sanitizeName(table.name)
this.files.set(
`tables/${safeName}/meta.json`,
serializeTableMeta({
id: table.id,
name: table.name,
description: table.description,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt,
updatedAt: table.updatedAt,
})
)
}
return tables.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
rowCount: t.rowCount,
}))
} catch (err) {
logger.warn('Failed to materialize tables', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Materialize workspace files (already uses listWorkspaceFiles).
* Returns a summary for WORKSPACE.md generation.
*/
private async materializeFiles(workspaceId: string): Promise<WorkspaceMdData['files']> {
try {
const workflowArtifactsEnabled = this._betaEnabled
const folders = await listWorkspaceFileFolders(workspaceId, {
includeReservedSystemFolders: true,
})
const files = await listWorkspaceFiles(workspaceId, {
folders,
includeReservedSystemFiles: true,
})
for (const folder of folders) {
if (
!workflowArtifactsEnabled &&
isWorkflowAliasBackingPath(`files/${encodeVfsPathSegments(folder.path.split('/'))}`)
) {
continue
}
this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '')
}
for (const file of files) {
const filePath = canonicalWorkspaceFilePath({
folderPath: file.folderPath,
name: file.name,
})
if (!workflowArtifactsEnabled && isWorkflowAliasBackingPath(filePath)) {
continue
}
this.files.set(
filePath,
serializeFileMeta({
id: file.id,
name: file.name,
folderId: file.folderId,
folderPath: file.folderPath,
vfsPath: filePath,
contentType: file.type,
size: file.size,
uploadedAt: file.uploadedAt,
})
)
}
if (workflowArtifactsEnabled) {
this.files.set(`${WORKFLOW_PLANS_ALIAS_DIR}/.folder`, '')
const workspacePlanFiles = files.filter((file) => {
if (!file.folderPath) return false
return (
file.folderPath ===
`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}` ||
file.folderPath.startsWith(
`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}/`
)
)
})
const workspacePlanLinks = []
for (const planFile of workspacePlanFiles) {
const relativeFolder = planFile.folderPath
?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${WORKSPACE_PLANS_BACKING_FOLDER}`, '')
.replace(/^\/+/, '')
const aliasRelativePath = [
relativeFolder ? `${encodeVfsPathSegments(relativeFolder.split('/'))}/` : '',
normalizeVfsSegment(planFile.name),
].join('')
const aliasPlanPath = `${WORKFLOW_PLANS_ALIAS_DIR}/${aliasRelativePath}`
const relativeSegments = aliasRelativePath.split('/').slice(0, -1)
for (let index = 0; index < relativeSegments.length; index++) {
this.files.set(
`${WORKFLOW_PLANS_ALIAS_DIR}/${relativeSegments.slice(0, index + 1).join('/')}/.folder`,
''
)
}
try {
this.files.set(aliasPlanPath, (await readFileRecord(planFile))?.content ?? '')
workspacePlanLinks.push({
kind: 'plan_file',
scope: 'workspace',
aliasPath: aliasPlanPath,
backingPath: workspacePlanBackingPath(aliasRelativePath),
backingFileId: planFile.id,
})
} catch (err) {
logger.warn('Failed to read workspace plan alias backing file', {
workspaceId,
fileId: planFile.id,
error: toError(err).message,
})
}
}
this.files.set(
`${WORKFLOW_PLANS_ALIAS_DIR}/${WORKFLOW_ALIAS_LINKS_NAME}`,
JSON.stringify(
{
aliases: [
{
kind: 'plans_dir',
scope: 'workspace',
aliasPath: WORKFLOW_PLANS_ALIAS_DIR,
backingPath: workspacePlansBackingFolderPath(),
},
...workspacePlanLinks,
],
},
null,
2
)
)
}
return files
.filter(
(f) =>
!isWorkflowAliasBackingPath(
canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name })
)
)
.map((f) => ({
id: f.id,
name: f.name,
type: f.type,
size: f.size,
folderPath: f.folderPath ?? null,
}))
} catch (err) {
logger.warn('Failed to materialize files', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Query all deployment configurations for a single workflow.
* Returns null if the workflow has no deployments of any kind.
*/
private async getWorkflowDeployments(
workflowId: string,
workspaceId: string,
isDeployed: boolean,
deployedAt: Date | null
): Promise<DeploymentData | null> {
const [chatRows, mcpRows, versionRows, allVersionRows] = await Promise.all([
db
.select({
id: chatTable.id,
identifier: chatTable.identifier,
title: chatTable.title,
description: chatTable.description,
authType: chatTable.authType,
customizations: chatTable.customizations,
isActive: chatTable.isActive,
})
.from(chatTable)
.where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))),
db
.select({
serverId: workflowMcpTool.serverId,
serverName: workflowMcpServer.name,
toolId: workflowMcpTool.id,
toolName: workflowMcpTool.toolName,
toolDescription: workflowMcpTool.toolDescription,
})
.from(workflowMcpTool)
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
.where(
and(
eq(workflowMcpTool.workflowId, workflowId),
isNull(workflowMcpTool.archivedAt),
isNull(workflowMcpServer.deletedAt)
)
),
isDeployed
? db
.select({
version: workflowDeploymentVersion.version,
state: workflowDeploymentVersion.state,
createdAt: workflowDeploymentVersion.createdAt,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
: Promise.resolve([]),
db
.select({
id: workflowDeploymentVersion.id,
version: workflowDeploymentVersion.version,
name: workflowDeploymentVersion.name,
description: workflowDeploymentVersion.description,
isActive: workflowDeploymentVersion.isActive,
createdAt: workflowDeploymentVersion.createdAt,
})
.from(workflowDeploymentVersion)
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
.orderBy(desc(workflowDeploymentVersion.version)),
])
const hasAnyDeployment = isDeployed || chatRows.length > 0 || mcpRows.length > 0
if (!hasAnyDeployment && allVersionRows.length === 0) return null
let needsRedeployment: boolean | undefined
const deployedVersion = versionRows[0]
if (isDeployed && deployedVersion?.state) {
try {
// Use the canonical deployment snapshot (includes variables) so this
// matches check_deployment_status exactly. The reshaped normalized load
// dropped variables, which made any workflow with deployment variables
// permanently report needsRedeployment: true.
const currentSnapshot = await loadWorkflowDeploymentSnapshot(workflowId)
needsRedeployment = computeNeedsRedeployment(
currentSnapshot,
deployedVersion.state as WorkflowState
)
} catch (err) {
logger.warn('Failed to compute needsRedeployment', {
workflowId,
error: toError(err).message,
})
}
}
return {
workflowId,
isDeployed,
deployedAt,
needsRedeployment,
api: deployedVersion
? { version: deployedVersion.version, createdAt: deployedVersion.createdAt }
: null,
chat: chatRows[0] ?? null,
mcp: mcpRows,
versions: allVersionRows,
}
}
/**
* Materialize custom tools using the shared listCustomTools function.
*/
private async materializeCustomTools(
workspaceId: string,
userId: string
): Promise<NonNullable<WorkspaceMdData['customTools']>> {
try {
const toolRows = await listCustomTools({ userId, workspaceId })
for (const tool of toolRows) {
const safeName = sanitizeName(tool.title)
const serialized = serializeCustomTool({
id: tool.id,
title: tool.title,
schema: tool.schema,
code: tool.code,
})
this.files.set(`custom-tools/${safeName}.json`, serialized)
this.files.set(`agent/custom-tools/${safeName}.json`, serialized)
}
return toolRows.map((t) => ({ id: t.id, name: t.title }))
} catch (err) {
logger.warn('Failed to materialize custom tools', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Materialize the org's published custom (deploy-as-block) blocks as VFS
* component files — the same `components/blocks/<type>.json` path + serializer
* first-party blocks use — so the agent can grep/read them. Returns the summary
* for `WORKSPACE_CONTEXT.md`. Per-request/per-org, so it bypasses the frozen
* static component cache. Only enabled blocks are exposed.
*/
private async materializeCustomBlocks(
workspaceId: string
): 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) {
if (!cb.enabled) continue
const config = buildCustomBlockConfig(
{
type: cb.type,
name: cb.name,
description: cb.description,
workflowId: cb.workflowId,
exposedOutputs: cb.exposedOutputs,
},
cb.inputFields,
{ icon: PLACEHOLDER_BLOCK_ICON }
)
this.files.set(`components/blocks/${config.type}.json`, serializeBlockSchema(config))
summary.push({
type: cb.type,
name: cb.name,
...(cb.description ? { description: cb.description } : {}),
})
}
return summary
} catch (err) {
logger.warn('Failed to materialize custom blocks', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Materialize external MCP server connections using the mcpServers table.
*/
private async materializeMcpServers(
workspaceId: string
): Promise<NonNullable<WorkspaceMdData['mcpServers']>> {
try {
const servers = await db
.select()
.from(mcpServersTable)
.where(and(eq(mcpServersTable.workspaceId, workspaceId), isNull(mcpServersTable.deletedAt)))
for (const server of servers) {
const safeName = sanitizeName(server.name)
this.files.set(
`agent/mcp-servers/${safeName}.json`,
serializeMcpServer({
id: server.id,
name: server.name,
url: server.url,
transport: server.transport,
enabled: server.enabled,
connectionStatus: server.connectionStatus,
})
)
}
return servers.map((s) => ({ id: s.id, name: s.name, url: s.url, enabled: s.enabled }))
} catch (err) {
logger.warn('Failed to materialize MCP servers', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Materialize workspace skills using the shared listSkills function.
*/
private async materializeSkills(
workspaceId: string
): Promise<NonNullable<WorkspaceMdData['skills']>> {
try {
const skillRows = await listSkills({ workspaceId, includeBuiltins: false })
for (const s of skillRows) {
const safeName = sanitizeName(s.name)
this.files.set(
`agent/skills/${safeName}.json`,
serializeSkill({
id: s.id,
name: s.name,
description: s.description,
content: s.content,
createdAt: s.createdAt,
})
)
}
return skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description }))
} catch (err) {
logger.warn('Failed to materialize skills', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Materialize mothership task chats as browsable conversation files.
* Returns a summary for WORKSPACE.md generation.
*/
private async materializeTasks(
workspaceId: string,
userId: string
): Promise<WorkspaceMdData['tasks']> {
try {
const taskRows = await db
.select({
id: copilotChats.id,
title: copilotChats.title,
messageCount: sql<number>`COALESCE((
SELECT COUNT(*) FROM copilot_messages cm
WHERE cm.chat_id = ${copilotChats.id} AND cm.deleted_at IS NULL
), 0)`,
messages: sql<unknown[]>`COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'role', cm.content->>'role',
'content', cm.content->'content',
'contentBlocks', COALESCE((
SELECT jsonb_agg(jsonb_build_object('type', 'text', 'content', b.value->'content') ORDER BY b.ord)
FROM jsonb_array_elements(
CASE WHEN jsonb_typeof(cm.content->'contentBlocks') = 'array'
THEN cm.content->'contentBlocks'
ELSE '[]'::jsonb
END
) WITH ORDINALITY AS b(value, ord)
WHERE b.value->>'type' = 'text'
), '[]'::jsonb)
)
ORDER BY cm.seq ASC NULLS LAST, cm.created_at ASC, cm.id ASC
)
FROM copilot_messages cm
WHERE cm.chat_id = ${copilotChats.id}
AND cm.deleted_at IS NULL
AND cm.content->>'role' IN ('user', 'assistant')
), '[]'::jsonb)`,
createdAt: copilotChats.createdAt,
updatedAt: copilotChats.updatedAt,
})
.from(copilotChats)
.where(
and(
eq(copilotChats.workspaceId, workspaceId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.orderBy(desc(copilotChats.updatedAt))
.limit(5)
for (const task of taskRows) {
const title = task.title || 'Untitled task'
const safeName = sanitizeName(title)
const prefix = `tasks/${safeName}/`
const messages = Array.isArray(task.messages) ? task.messages : []
const messageCount = Number(task.messageCount) || 0
this.files.set(
`${prefix}session.md`,
serializeTaskSession({
id: task.id,
title,
messageCount,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
})
)
if (messages.length > 0) {
this.files.set(`${prefix}chat.json`, serializeTaskChat(messages))
}
}
return taskRows.map((t) => ({
id: t.id,
title: t.title || 'Untitled task',
updatedAt: t.updatedAt,
}))
} catch (err) {
logger.warn('Failed to materialize tasks', {
workspaceId,
error: toError(err).message,
})
return []
}
}
/**
* Materialize scheduled jobs using the workflowSchedule table.
* Returns a summary for WORKSPACE.md generation.
*/
private async materializeJobs(
workspaceId: string
): Promise<NonNullable<WorkspaceMdData['jobs']>> {
try {
const jobRows = await db
.select({
id: workflowSchedule.id,
jobTitle: workflowSchedule.jobTitle,
prompt: workflowSchedule.prompt,
cronExpression: workflowSchedule.cronExpression,
timezone: workflowSchedule.timezone,
status: workflowSchedule.status,
lifecycle: workflowSchedule.lifecycle,
successCondition: workflowSchedule.successCondition,
maxRuns: workflowSchedule.maxRuns,
runCount: workflowSchedule.runCount,
nextRunAt: workflowSchedule.nextRunAt,
lastRanAt: workflowSchedule.lastRanAt,
sourceTaskName: workflowSchedule.sourceTaskName,
sourceChatId: workflowSchedule.sourceChatId,
jobHistory: workflowSchedule.jobHistory,
createdAt: workflowSchedule.createdAt,
})
.from(workflowSchedule)
.where(
and(
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
eq(workflowSchedule.sourceType, 'job'),
isNull(workflowSchedule.archivedAt),
ne(workflowSchedule.status, 'completed')
)
)
for (const job of jobRows) {
const safeName = sanitizeName(job.jobTitle || job.id)
this.files.set(
`jobs/${safeName}/meta.json`,
serializeJobMeta({
id: job.id,
title: job.jobTitle,
prompt: job.prompt || '',
cronExpression: job.cronExpression,
timezone: job.timezone,
status: job.status,
lifecycle: job.lifecycle,
successCondition: job.successCondition,
maxRuns: job.maxRuns,
runCount: job.runCount,
nextRunAt: job.nextRunAt,
lastRanAt: job.lastRanAt,
sourceTaskName: job.sourceTaskName,
sourceChatId: job.sourceChatId,
createdAt: job.createdAt,
})
)
const history = job.jobHistory as Array<{ timestamp: string; summary: string }> | null
if (history && history.length > 0) {
this.files.set(`jobs/${safeName}/history.json`, JSON.stringify(history, null, 2))
}
// executions.json is lazy, advertised only when the job has run (cheap
// signal: lastRanAt) — no per-job query on a read/glob.
if (job.lastRanAt) {
this.registerLazy(`jobs/${safeName}/executions.json`, async () => {
const execRows = await db
.select({
id: jobExecutionLogs.id,
executionId: jobExecutionLogs.executionId,
status: jobExecutionLogs.status,
trigger: jobExecutionLogs.trigger,
startedAt: jobExecutionLogs.startedAt,
endedAt: jobExecutionLogs.endedAt,
totalDurationMs: jobExecutionLogs.totalDurationMs,
})
.from(jobExecutionLogs)
.where(eq(jobExecutionLogs.scheduleId, job.id))
.orderBy(desc(jobExecutionLogs.startedAt))
.limit(5)
return execRows.length > 0 ? serializeRecentExecutions(execRows) : null
})
}
}
return jobRows
.filter((j) => j.status !== 'completed')
.map((j) => ({
id: j.id,
title: j.jobTitle,
prompt: j.prompt || '',
cronExpression: j.cronExpression,
status: j.status,
lifecycle: j.lifecycle,
sourceTaskName: j.sourceTaskName,
}))
} catch (err) {
logger.warn('Failed to materialize jobs', {
workspaceId,
error: toError(err).message,
})
return []
}
}
private async materializeRecentlyDeleted(workspaceId: string, userId: string): Promise<void> {
try {
const [
archivedWorkflows,
archivedFolders,
archivedTables,
archivedFiles,
archivedFileFolders,
archivedKBs,
] = await Promise.all([
listWorkflows(workspaceId, { scope: 'archived' }),
db
.select({
id: workflowFolder.id,
name: workflowFolder.name,
archivedAt: workflowFolder.archivedAt,
})
.from(workflowFolder)
.where(
and(eq(workflowFolder.workspaceId, workspaceId), isNotNull(workflowFolder.archivedAt))
),
listTables(workspaceId, { scope: 'archived' }),
listWorkspaceFiles(workspaceId, { scope: 'archived' }),
listWorkspaceFileFolders(workspaceId, { scope: 'archived' }),
getKnowledgeBases(userId, workspaceId, 'archived'),
])
for (const wf of archivedWorkflows) {
const safeName = sanitizeName(wf.name)
this.files.set(
`recently-deleted/workflows/${safeName}/meta.json`,
serializeWorkflowMeta(wf)
)
}
for (const folder of archivedFolders) {
const safeName = sanitizeName(folder.name)
this.files.set(
`recently-deleted/folders/${safeName}/meta.json`,
JSON.stringify(
{ id: folder.id, name: folder.name, archivedAt: folder.archivedAt },
null,
2
)
)
}
for (const table of archivedTables) {
const safeName = sanitizeName(table.name)
this.files.set(
`recently-deleted/tables/${safeName}/meta.json`,
serializeTableMeta({
id: table.id,
name: table.name,
description: table.description,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt,
updatedAt: table.updatedAt,
})
)
}
for (const folder of archivedFileFolders) {
const safePath = folder.path
.split('/')
.map((segment) => sanitizeName(segment))
.join('/')
this.files.set(
`recently-deleted/file-folders/${safePath}/meta.json`,
JSON.stringify(
{
id: folder.id,
name: folder.name,
parentId: folder.parentId,
path: folder.path,
deletedAt: folder.deletedAt,
type: 'file_folder',
},
null,
2
)
)
}
for (const file of archivedFiles) {
const filePath = canonicalWorkspaceFilePath({
folderPath: file.folderPath,
name: file.name,
prefix: 'recently-deleted/files',
})
this.files.set(
filePath,
serializeFileMeta({
id: file.id,
name: file.name,
folderId: file.folderId,
folderPath: file.folderPath,
vfsPath: filePath,
contentType: file.type,
size: file.size,
uploadedAt: file.uploadedAt,
})
)
}
for (const kb of archivedKBs) {
const safeName = sanitizeName(kb.name)
this.files.set(
`recently-deleted/knowledgebases/${safeName}/meta.json`,
serializeKBMeta({
id: kb.id,
name: kb.name,
description: kb.description,
embeddingModel: kb.embeddingModel,
embeddingDimension: kb.embeddingDimension,
tokenCount: kb.tokenCount,
createdAt: kb.createdAt,
updatedAt: kb.updatedAt,
documentCount: kb.docCount,
connectorTypes: kb.connectorTypes,
})
)
}
} catch (err) {
logger.warn('Failed to materialize recently deleted resources', {
workspaceId,
error: toError(err).message,
})
}
}
/**
* Materialize environment data using shared service functions:
* - getAccessibleEnvCredentials for workspace-scoped credentials
* - listApiKeys for workspace API keys
* - getPersonalAndWorkspaceEnv for env variable names
*
* Returns a credential summary for WORKSPACE.md generation.
*/
private async materializeEnvironment(
workspaceId: string,
userId: string
): Promise<{
oauthIntegrations: WorkspaceMdData['oauthIntegrations']
envVariables: WorkspaceMdData['envVariables']
}> {
try {
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(userId, workspaceId)
const [envCredentials, oauthCredentials, apiKeyRows, envData] = await Promise.all([
getAccessibleEnvCredentials(workspaceId, userId, { isWorkspaceAdmin }),
getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }),
listApiKeys(workspaceId),
getPersonalAndWorkspaceEnv(userId, workspaceId),
])
this.files.set(
'environment/credentials.json',
serializeCredentials([
...envCredentials.map((c) => ({
providerId: c.envKey,
scope: c.type === 'env_workspace' ? 'workspace' : 'personal',
createdAt: c.updatedAt,
})),
...oauthCredentials.map((c) => ({
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
role: c.role,
scope: null,
createdAt: c.updatedAt,
})),
])
)
this.files.set('environment/api-keys.json', serializeApiKeys(apiKeyRows))
const personalVarNames = Object.keys(envData.personalEncrypted)
const workspaceVarNames = Object.keys(envData.workspaceEncrypted)
this.files.set(
'environment/variables.json',
serializeEnvironmentVariables(personalVarNames, workspaceVarNames)
)
const envKeys = [...new Set(envCredentials.map((c) => c.envKey))]
return {
oauthIntegrations: oauthCredentials.map((c) => ({
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
role: c.role,
})),
envVariables: envKeys,
}
} catch (err) {
logger.warn('Failed to materialize environment data', {
workspaceId,
error: toError(err).message,
})
return { oauthIntegrations: [], envVariables: [] }
}
}
}
/**
* Create a fresh VFS for a workspace.
* Dynamic data (workflows, KBs, env) is always fetched fresh.
* Static component files (blocks, integrations) are cached per-process.
*/
export async function getOrMaterializeVFS(
workspaceId: string,
userId: string
): Promise<WorkspaceVFS> {
await assertActiveWorkspaceAccess(workspaceId, userId)
const vfs = new WorkspaceVFS()
await vfs.materialize(workspaceId, userId)
return vfs
}
export type { FileReadResult } from '@/lib/copilot/vfs/file-reader'
/**
* Sanitize a name for use as a VFS path segment.
* Delegates to {@link normalizeVfsSegment} so workspace file paths match DB lookups.
*/
export function sanitizeName(name: string): string {
return normalizeVfsSegment(name)
}