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

660 lines
24 KiB
TypeScript

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<string, string[]> = {
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<string, typeof data.workflows>()
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<string, typeof data.files>()
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<WorkspaceMdData | null> {
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<string, string[]>()
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<string, string>()
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<string> {
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`
}