import { db } from '@sim/db' import { knowledgeBase, knowledgeConnector, mcpServers, userTableDefinitions, workflow, workflowFolder, workflowSchedule, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { VfsSnapshotV1, VfsSnapshotV1Job, VfsSnapshotV1Workflow, } from '@/lib/copilot/generated/vfs-snapshot-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkills } from '@/lib/workflows/skills/operations' import { assertActiveWorkspaceAccess, getUsersWithPermissions, getWorkspaceWithOwner, } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceContext') const PROVIDER_SERVICES: Record = { google: ['Gmail', 'Sheets', 'Calendar', 'Drive'], 'google-service-account': ['Gmail', 'Sheets', 'Calendar', 'Drive'], slack: ['Slack'], github: ['GitHub'], microsoft: ['Outlook', 'OneDrive'], linear: ['Linear'], notion: ['Notion'], stripe: ['Stripe'], airtable: ['Airtable'], jira: ['Jira'], confluence: ['Confluence'], } export interface WorkspaceMdData { workspace: { id: string; name: string; ownerId: string } | null members: Array<{ name: string; email: string; permissionType: string }> workflows: Array<{ id: string name: string description?: string | null isDeployed: boolean lastRunAt?: Date | null folderPath?: string | null }> knowledgeBases: Array<{ id: string name: string description?: string | null connectorTypes?: string[] }> // rowCount is no longer rendered (it is volatile and would bust the cached // prompt prefix); kept optional so callers that still have it cheaply (the VFS // materializer via listTables) need not change, while generateWorkspaceContext // skips the per-table COUNT query entirely. tables: Array<{ id: string; name: string; description?: string | null; rowCount?: number }> files: Array<{ id: string; name: string; type: string; size: number; folderPath?: string | null }> oauthIntegrations: Array<{ id: string providerId: string displayName?: string | null role?: string | null }> envVariables: string[] tasks?: Array<{ id: string; title: string; updatedAt: Date }> customTools?: Array<{ id: string; name: string }> customBlocks?: Array<{ type: string; name: string; description?: string }> mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }> skills?: Array<{ id: string; name: string; description: string }> jobs?: Array<{ id: string title: string | null prompt: string cronExpression: string | null status: string lifecycle: string sourceTaskName: string | null }> } /** * Deterministic string ordering. The workspace inventory is placed in the * prompt-cache prefix (mothership), so its bytes must be identical for identical * workspace state regardless of DB row order — otherwise the cache silently * busts every turn. `localeCompare` with a pinned locale gives stable, readable * ordering across Sim instances (all run the same Node/ICU build). */ function stableCompare(a: string, b: string): number { return a.localeCompare(b, 'en') } /** Stable order by display name, tie-broken by id, for inventory listings. */ function byNameThenId(a: { name: string; id: string }, b: { name: string; id: string }): number { return stableCompare(a.name, b.name) || stableCompare(a.id, b.id) } /** * Pure formatting: build WORKSPACE.md content from pre-fetched data. * No DB access — callers are responsible for providing the data. * * Output is deterministic: every collection is sorted by a stable key and * volatile fields (run timestamps, mutable row counts) are omitted, so the * rendered inventory only changes when the workspace structurally changes. This * is what lets the mothership cache it in the prompt prefix across turns. */ export function buildWorkspaceMd(data: WorkspaceMdData): string { const sections: string[] = [] if (data.workspace) { sections.push( `## Workspace\n- **Name**: ${data.workspace.name}\n- **ID**: ${data.workspace.id}\n- **Owner**: ${data.workspace.ownerId}` ) } if (data.members.length > 0) { const lines = [...data.members] .sort((a, b) => stableCompare(a.email, b.email)) .map((m) => { const display = m.name ? `${m.name} (${m.email})` : m.email return `- ${display} — ${m.permissionType}` }) sections.push(`## Members\n${lines.join('\n')}`) } if (data.workflows.length > 0) { const rootWorkflows: typeof data.workflows = [] const folderWorkflows = new Map() for (const wf of data.workflows) { if (wf.folderPath) { const existing = folderWorkflows.get(wf.folderPath) ?? [] existing.push(wf) folderWorkflows.set(wf.folderPath, existing) } else { rootWorkflows.push(wf) } } const formatWf = (wf: (typeof data.workflows)[0], indent: string) => { const parts = [`${indent}- **${wf.name}** (${wf.id})`] const workflowDir = canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }) parts.push(`${indent} VFS dir: \`${workflowDir}\``) parts.push(`${indent} VFS state path: \`${workflowDir}/state.json\``) if (wf.description) parts.push(`${indent} ${wf.description}`) // `deployed` is a structural flag (kept); `lastRunAt` is intentionally // omitted — it changes on every run and would bust the cached prompt // prefix that carries this inventory. Current run data lives in // workflows/{name}/executions.json. if (wf.isDeployed) parts[0] += ' — deployed' return parts.join('\n') } const lines: string[] = [] lines.push( 'Use the canonical VFS dir/state path shown under each workflow. Paths are percent-encoded per segment; copy them verbatim and do not infer paths from display names.' ) for (const wf of [...rootWorkflows].sort(byNameThenId)) { lines.push(formatWf(wf, '')) } const sortedFolders = [...folderWorkflows.entries()].sort((a, b) => stableCompare(a[0], b[0])) for (const [folder, wfs] of sortedFolders) { lines.push(`- 📁 **${folder}/**`) for (const wf of [...wfs].sort(byNameThenId)) { lines.push(formatWf(wf, ' ')) } } sections.push(`## Workflows (${data.workflows.length})\n${lines.join('\n')}`) } else { sections.push('## Workflows (0)\n(none)') } if (data.knowledgeBases.length > 0) { const lines = [...data.knowledgeBases].sort(byNameThenId).map((kb) => { let line = `- **${kb.name}** (${kb.id})` if (kb.description) line += ` — ${kb.description}` if (kb.connectorTypes && kb.connectorTypes.length > 0) { line += ` | connectors: ${[...kb.connectorTypes].sort(stableCompare).join(', ')}` } return line }) sections.push(`## Knowledge Bases (${data.knowledgeBases.length})\n${lines.join('\n')}`) } else { sections.push('## Knowledge Bases (0)\n(none)') } if (data.tables.length > 0) { // rowCount is omitted: it changes on every row write and would bust the // cached prompt prefix. Live counts are in tables/{name}/meta.json. const lines = [...data.tables].sort(byNameThenId).map((t) => { let line = `- **${t.name}** (${t.id})` if (t.description) line += ` — ${t.description}` return line }) sections.push(`## Tables (${data.tables.length})\n${lines.join('\n')}`) } else { sections.push('## Tables (0)\n(none)') } if (data.files.length > 0) { const rootFiles: typeof data.files = [] const folderFiles = new Map() for (const f of data.files) { if (f.folderPath) { const existing = folderFiles.get(f.folderPath) ?? [] existing.push(f) folderFiles.set(f.folderPath, existing) } else { rootFiles.push(f) } } const fileLine = (f: (typeof data.files)[0], indent: string) => { const vfsPath = canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) return `${indent}- **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)} — \`${vfsPath}\`` } const lines: string[] = [ 'Read or edit a file by the exact VFS path shown in backticks below — copy it verbatim (it is already percent-encoded) and append `/content` to read the contents. Do not retype the display name or re-encode the path.', ] for (const f of [...rootFiles].sort(byNameThenId)) { lines.push(fileLine(f, '')) } const sortedFolders = [...folderFiles.entries()].sort((a, b) => stableCompare(a[0], b[0])) for (const [folder, folderFileList] of sortedFolders) { lines.push(`- 📁 **${folder}/**`) for (const f of [...folderFileList].sort(byNameThenId)) { lines.push(fileLine(f, ' ')) } } sections.push(`## Files (${data.files.length})\n${lines.join('\n')}`) } else { sections.push('## Files (0)\n(none)') } if (data.oauthIntegrations.length > 0) { const lines = [...data.oauthIntegrations] .sort((a, b) => stableCompare(a.providerId, b.providerId) || stableCompare(a.id, b.id)) .map((c) => { const services = PROVIDER_SERVICES[c.providerId] const svc = services ? ` (${services.join(', ')})` : '' const who = c.displayName ? ` — ${c.displayName}` : '' const role = c.role ? `, ${c.role}` : '' return `- ${c.providerId}${svc}${who}${role} — credentialId: \`${c.id}\`` }) sections.push( `## Connected Integrations\nPass these credentialId values directly on OAuth tool calls — no need to read environment/credentials.json for them.\n${lines.join('\n')}` ) } else { sections.push('## Connected Integrations\n(none)') } if (data.envVariables.length > 0) { const lines = [...data.envVariables].sort(stableCompare).map((v) => `- ${v}`) sections.push(`## Environment Variables (${data.envVariables.length})\n${lines.join('\n')}`) } if (data.customTools && data.customTools.length > 0) { const lines = [...data.customTools].sort(byNameThenId).map((t) => `- **${t.name}** (${t.id})`) sections.push(`## Custom Tools (${data.customTools.length})\n${lines.join('\n')}`) } if (data.customBlocks && data.customBlocks.length > 0) { const lines = [...data.customBlocks] .sort((a, b) => a.name.localeCompare(b.name)) .map((b) => `- **${b.name}** (${b.type})${b.description ? ` — ${b.description}` : ''}`) sections.push(`## Custom Blocks (${data.customBlocks.length})\n${lines.join('\n')}`) } if (data.mcpServers && data.mcpServers.length > 0) { const lines = [...data.mcpServers].sort(byNameThenId).map((s) => { const status = s.enabled ? 'enabled' : 'disabled' return `- **${s.name}** (${s.id}) — ${status}${s.url ? `, ${s.url}` : ''}` }) sections.push(`## MCP Servers (${data.mcpServers.length})\n${lines.join('\n')}`) } if (data.skills && data.skills.length > 0) { const lines = [...data.skills] .sort(byNameThenId) .map((s) => `- **${s.name}** (${s.id}) — ${s.description}`) sections.push( `## Skills (${data.skills.length})\n` + 'To use a skill, call the load_user_skill tool with its name to load the full instructions, then follow them. The descriptions below only say when each skill applies — they are not the instructions.\n' + lines.join('\n') ) } if (data.jobs && data.jobs.length > 0) { const lines = [...data.jobs] .sort((a, b) => stableCompare(a.title || a.id, b.title || b.id) || stableCompare(a.id, b.id)) .map((j) => { const displayName = j.title || j.id let line = `- **${displayName}** (${j.id}) — ${j.status}` if (j.lifecycle !== 'persistent') line += ` [${j.lifecycle}]` if (j.cronExpression) line += `, cron: ${j.cronExpression}` if (j.sourceTaskName) line += `, task: ${j.sourceTaskName}` const promptPreview = j.prompt.length > 80 ? truncate(j.prompt, 77) : j.prompt line += `\n ${promptPreview}` return line }) sections.push(`## Jobs (${data.jobs.length})\n${lines.join('\n')}`) } return sections.join('\n\n') } export function buildWorkspaceContextMd(data: WorkspaceMdData): string { return ['# Workspace Context', '', buildWorkspaceMd(data)].join('\n\n') } /** * Generate WORKSPACE.md content from actual database state. * Served as a top-level VFS file. The Go system prompt keeps only stable * discovery rules; the LLM reads dynamic workspace state from VFS files. * The LLM never writes this file directly. */ // Fetch + assemble the workspace inventory data once, from the PRIMARY db // (read-your-writes: a just-edited workflow is visible immediately, so the // injected snapshot can't lag behind a `glob`). Both the markdown inventory and // the typed VFS snapshot are built from this single fetch. Returns null when the // workspace is unavailable or a fetch fails. async function buildWorkspaceMdData( workspaceId: string, userId: string ): Promise { try { await assertActiveWorkspaceAccess(workspaceId, userId) const wsRow = await getWorkspaceWithOwner(workspaceId) if (!wsRow) { return null } const [ members, workflows, folderRows, kbs, tables, files, credentials, customTools, mcpServerRows, skillRows, jobRows, customBlockSummaries, ] = await Promise.all([ getUsersWithPermissions(workspaceId), db .select({ id: workflow.id, name: workflow.name, description: workflow.description, isDeployed: workflow.isDeployed, lastRunAt: workflow.lastRunAt, folderId: workflow.folderId, }) .from(workflow) .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), db .select({ id: workflowFolder.id, name: workflowFolder.name, parentId: workflowFolder.parentId, }) .from(workflowFolder) .where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))), db .select({ id: knowledgeBase.id, name: knowledgeBase.name, description: knowledgeBase.description, }) .from(knowledgeBase) .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))), db .select({ id: userTableDefinitions.id, name: userTableDefinitions.name, description: userTableDefinitions.description, }) .from(userTableDefinitions) .where( and( eq(userTableDefinitions.workspaceId, workspaceId), isNull(userTableDefinitions.archivedAt) ) ), listWorkspaceFiles(workspaceId), getAccessibleOAuthCredentials(workspaceId, userId), listCustomTools({ userId, workspaceId }), db .select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url, enabled: mcpServers.enabled, }) .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))), listSkills({ workspaceId, includeBuiltins: false }), db .select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle, prompt: workflowSchedule.prompt, cronExpression: workflowSchedule.cronExpression, status: workflowSchedule.status, lifecycle: workflowSchedule.lifecycle, sourceTaskName: workflowSchedule.sourceTaskName, }) .from(workflowSchedule) .where( and( eq(workflowSchedule.sourceWorkspaceId, workspaceId), eq(workflowSchedule.sourceType, 'job'), isNull(workflowSchedule.archivedAt) ) ), listCustomBlockSummariesForWorkspace(workspaceId), ]) const kbIds = kbs.map((kb) => kb.id) const connectorRows = kbIds.length > 0 ? await db .select({ knowledgeBaseId: knowledgeConnector.knowledgeBaseId, connectorType: knowledgeConnector.connectorType, }) .from(knowledgeConnector) .where( and( inArray(knowledgeConnector.knowledgeBaseId, kbIds), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt) ) ) : [] const connectorTypesByKb = new Map() for (const row of connectorRows) { const types = connectorTypesByKb.get(row.knowledgeBaseId) ?? [] if (!types.includes(row.connectorType)) { types.push(row.connectorType) } connectorTypesByKb.set(row.knowledgeBaseId, types) } const folderPathMap = new Map() const folderById = new Map(folderRows.map((f) => [f.id, f])) function resolveFolderPath(id: string): string { const cached = folderPathMap.get(id) if (cached !== undefined) return cached const folder = folderById.get(id) if (!folder) return id const parentPath = folder.parentId ? resolveFolderPath(folder.parentId) : '' const normalizedName = normalizeVfsSegment(folder.name) const path = parentPath ? `${parentPath}/${normalizedName}` : normalizedName folderPathMap.set(id, path) return path } return { workspace: wsRow, members, workflows: workflows.map((wf) => ({ ...wf, folderPath: wf.folderId ? resolveFolderPath(wf.folderId) : null, })), knowledgeBases: kbs.map((kb) => ({ ...kb, // Sort connector types so the snapshot is order-stable: the DB query has // no ORDER BY, and the Go delta engine compares item JSON byte-wise, so // an unsorted (but unchanged) list would emit a spurious "modified" // delta and needlessly bust the prompt cache. connectorTypes: connectorTypesByKb.get(kb.id)?.sort(stableCompare), })), tables: tables.map((t) => ({ id: t.id, name: t.name, description: t.description })), files: files.map((f) => ({ id: f.id, name: f.name, type: f.type, size: f.size, folderPath: f.folderPath ?? null, })), oauthIntegrations: credentials.map((c) => ({ id: c.id, providerId: c.providerId, displayName: c.displayName, role: c.role, })), envVariables: [], customTools: customTools.map((t) => ({ id: t.id, name: t.title })), customBlocks: customBlockSummaries, mcpServers: mcpServerRows, skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })), jobs: 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.error('Failed to build workspace data', { workspaceId, error: toError(err).message, }) return null } } const WORKSPACE_CONTEXT_UNAVAILABLE_MD = '## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Connected Integrations\n(unavailable)' /** * Generate WORKSPACE.md markdown from current DB state (primary db). The LLM * reads dynamic workspace state from VFS files; it never writes this file. */ export async function generateWorkspaceContext( workspaceId: string, userId: string ): Promise { const data = await buildWorkspaceMdData(workspaceId, userId) return data ? buildWorkspaceMd(data) : WORKSPACE_CONTEXT_UNAVAILABLE_MD } /** * Build BOTH the markdown inventory and the typed VFS snapshot from a single * primary-db fetch. The snapshot is the structured form Go diffs into * baseline+delta messages; the markdown is the transition fallback. Returns null * when the workspace is unavailable. */ export async function generateWorkspaceSnapshot( workspaceId: string, userId: string ): Promise<{ markdown: string; snapshot: VfsSnapshotV1 } | null> { const data = await buildWorkspaceMdData(workspaceId, userId) if (!data) return null return { markdown: buildWorkspaceMd(data), snapshot: buildVfsSnapshot(data) } } /** * Map the workspace inventory data to the typed VFS snapshot contract. Pure; * mirrors buildWorkspaceMd's field selection. Resource order is irrelevant — Go * diffs by stable id, not position. */ export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { const workflows: VfsSnapshotV1Workflow[] = data.workflows.map((wf) => ({ id: wf.id, name: wf.name, path: canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }), ...(wf.description ? { description: wf.description } : {}), ...(wf.isDeployed ? { isDeployed: true } : {}), ...(wf.folderPath ? { folderPath: wf.folderPath } : {}), })) const jobs: VfsSnapshotV1Job[] = (data.jobs ?? []) .filter((j) => j.status !== 'completed') .map((j) => ({ id: j.id, ...(j.title ? { title: j.title } : {}), ...(j.prompt ? { prompt: j.prompt } : {}), ...(j.cronExpression ? { cronExpression: j.cronExpression } : {}), ...(j.status ? { status: j.status } : {}), ...(j.lifecycle ? { lifecycle: j.lifecycle } : {}), ...(j.sourceTaskName ? { sourceTaskName: j.sourceTaskName } : {}), })) return { ...(data.workspace ? { workspace: { id: data.workspace.id, name: data.workspace.name, ...(data.workspace.ownerId ? { ownerId: data.workspace.ownerId } : {}), }, } : {}), members: data.members.map((m) => ({ ...(m.name ? { name: m.name } : {}), email: m.email, ...(m.permissionType ? { permissionType: m.permissionType } : {}), })), workflows, knowledgeBases: data.knowledgeBases.map((kb) => ({ id: kb.id, name: kb.name, ...(kb.description ? { description: kb.description } : {}), ...(kb.connectorTypes && kb.connectorTypes.length > 0 ? { connectorTypes: kb.connectorTypes } : {}), })), tables: data.tables.map((t) => ({ id: t.id, name: t.name, ...(t.description ? { description: t.description } : {}), })), files: data.files.map((f) => ({ id: f.id, name: f.name, path: canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }), ...(f.type ? { type: f.type } : {}), ...(f.size ? { size: f.size } : {}), ...(f.folderPath ? { folderPath: f.folderPath } : {}), })), integrations: data.oauthIntegrations.map((c) => ({ id: c.id, providerId: c.providerId, ...(c.displayName ? { displayName: c.displayName } : {}), ...(c.role ? { role: c.role } : {}), })), envVars: data.envVariables, customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })), mcpServers: (data.mcpServers ?? []).map((s) => ({ id: s.id, name: s.name, ...(s.url ? { url: s.url } : {}), ...(s.enabled ? { enabled: true } : {}), })), skills: (data.skills ?? []).map((s) => ({ id: s.id, name: s.name, ...(s.description ? { description: s.description } : {}), })), jobs, } } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` return `${(bytes / (1024 * 1024)).toFixed(1)}MB` }