chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
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`
|
||||
}
|
||||
Reference in New Issue
Block a user