Files
simstudioai--sim/apps/sim/lib/copilot/chat/process-contents.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

844 lines
28 KiB
TypeScript

import { db, dbReplica } from '@sim/db'
import { knowledgeBase, workflowSchedule } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import {
authorizeWorkflowByWorkspacePermission,
getActiveWorkflowRecord,
} from '@sim/platform-authz/workflow'
import { and, eq, isNull, ne } from 'drizzle-orm'
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import {
buildVfsFolderPathMap,
canonicalBlockVfsPath,
canonicalKnowledgeBaseVfsDir,
canonicalTableVfsPath,
canonicalWorkflowVfsDir,
canonicalWorkspaceFilePath,
encodeVfsPathSegments,
encodeVfsSegment,
} from '@/lib/copilot/vfs/path-utils'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { toOverview } from '@/lib/logs/log-views'
import type { TraceSpan } from '@/lib/logs/types'
import { getTableById } from '@/lib/table/service'
import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getSkillById } from '@/lib/workflows/skills/operations'
import { listFolders } from '@/lib/workflows/utils'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { escapeRegExp } from '@/executor/constants'
import type { ChatContext } from '@/stores/panel'
type AgentContextType =
| 'past_chat'
| 'workflow'
| 'current_workflow'
| 'blocks'
| 'logs'
| 'knowledge'
| 'table'
| 'file'
| 'workflow_block'
| 'docs'
| 'folder'
| 'filefolder'
| 'active_resource'
| 'skill'
interface AgentContext {
type: AgentContextType
tag: string
content: string
/**
* Canonical, URL-encoded VFS path for the tagged resource (e.g.
* `agent/skills/My%20Skill.json`). Tagged resources are sent as path
* pointers so the model reads them on demand via VFS tools instead of the
* full body bloating the request. Skills are the exception: they carry both
* `path` and the full `content` so the skill is autoloaded.
*/
path?: string
}
const logger = createLogger('ProcessContents')
// Server-side variant (recommended for use in API routes)
export async function processContextsServer(
contexts: ChatContext[] | undefined,
userId: string,
userMessage?: string,
currentWorkspaceId?: string,
chatId?: string
): Promise<AgentContext[]> {
if (!Array.isArray(contexts) || contexts.length === 0) return []
const tasks = contexts.map(async (ctx) => {
try {
if (ctx.kind === 'skill' && ctx.skillId && currentWorkspaceId) {
return await processSkillFromDb(
ctx.skillId,
currentWorkspaceId,
ctx.label ? `@${ctx.label}` : '@'
)
}
if (ctx.kind === 'past_chat' && ctx.chatId) {
return await processPastChatFromDb(
ctx.chatId,
userId,
ctx.label ? `@${ctx.label}` : '@',
currentWorkspaceId
)
}
if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) {
return await processWorkflowFromDb(
ctx.workflowId,
userId,
ctx.label ? `@${ctx.label}` : '@',
ctx.kind,
currentWorkspaceId,
chatId
)
}
if (ctx.kind === 'knowledge' && ctx.knowledgeId) {
return await processKnowledgeFromDb(
ctx.knowledgeId,
userId,
ctx.label ? `@${ctx.label}` : '@',
currentWorkspaceId
)
}
if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) {
return await processBlockMetadata(
ctx.blockIds[0],
ctx.label ? `@${ctx.label}` : '@',
userId,
currentWorkspaceId
)
}
if (ctx.kind === 'logs' && ctx.executionId) {
return await processExecutionLogFromDb(
ctx.executionId,
userId,
ctx.label ? `@${ctx.label}` : '@',
currentWorkspaceId
)
}
if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) {
return await processWorkflowBlockFromDb(
ctx.workflowId,
userId,
ctx.blockId,
ctx.label,
currentWorkspaceId
)
}
if (ctx.kind === 'table' && ctx.tableId && currentWorkspaceId) {
const result = await resolveTableResource(ctx.tableId, currentWorkspaceId)
if (!result) return null
return {
type: 'table',
tag: ctx.label ? `@${ctx.label}` : '@',
content: result.content,
path: result.path,
}
}
if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) {
const result = await resolveFileResource(ctx.fileId, currentWorkspaceId)
if (!result) return null
return {
type: 'file',
tag: ctx.label ? `@${ctx.label}` : '@',
content: result.content,
path: result.path,
}
}
if (ctx.kind === 'folder' && 'folderId' in ctx && ctx.folderId && currentWorkspaceId) {
const result = await resolveFolderResource(ctx.folderId, currentWorkspaceId)
if (!result) return null
return {
type: 'folder',
tag: ctx.label ? `@${ctx.label}` : '@',
content: result.content,
path: result.path,
}
}
if (ctx.kind === 'filefolder' && ctx.fileFolderId && currentWorkspaceId) {
const result = await resolveFileFolderResource(ctx.fileFolderId, currentWorkspaceId)
if (!result) return null
return {
type: 'filefolder',
tag: ctx.label ? `@${ctx.label}` : '@',
content: result.content,
path: result.path,
}
}
if (ctx.kind === 'scheduledtask' && ctx.scheduleId && currentWorkspaceId) {
const result = await resolveScheduledTaskResource(ctx.scheduleId, currentWorkspaceId)
if (!result) return null
return {
type: 'active_resource',
tag: ctx.label ? `@${ctx.label}` : '@',
content: result.content,
path: result.path,
}
}
if (ctx.kind === 'docs') {
try {
const { searchDocumentationServerTool } = await import(
'@/lib/copilot/tools/server/docs/search-documentation'
)
const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation'
const query = sanitizeMessageForDocs(rawQuery, contexts)
const res = await searchDocumentationServerTool.execute({ query, topK: 10 })
const content = JSON.stringify(res?.results || [])
return { type: 'docs', tag: ctx.label ? `@${ctx.label}` : '@', content }
} catch (e) {
logger.error('Failed to process docs context', e)
return null
}
}
return null
} catch (error) {
logger.error('Failed processing context (server)', { ctx, error })
return null
}
})
const results = await Promise.all(tasks)
const filtered = results.filter(
(r): r is AgentContext =>
!!r &&
((typeof r.content === 'string' && r.content.trim().length > 0) ||
(typeof r.path === 'string' && r.path.length > 0))
)
logger.info('Processed contexts (server)', {
totalRequested: contexts.length,
totalProcessed: filtered.length,
kinds: Array.from(filtered.reduce((s, r) => s.add(r.type), new Set<string>())),
})
return filtered
}
function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string {
if (!rawMessage) return ''
if (!Array.isArray(contexts) || contexts.length === 0) {
// No context mapping; conservatively strip all @mentions-like tokens
const stripped = rawMessage
.replace(/(^|\s)@([^\s]+)/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
return stripped
}
// Gather labels by kind
const blockLabels = new Set(
contexts
.filter((c) => c.kind === 'blocks')
.map((c) => c.label)
.filter((l): l is string => typeof l === 'string' && l.length > 0)
)
const nonBlockLabels = new Set(
contexts
.filter((c) => c.kind !== 'blocks')
.map((c) => c.label)
.filter((l): l is string => typeof l === 'string' && l.length > 0)
)
let result = rawMessage
// 1) Remove all non-block mentions entirely
for (const label of nonBlockLabels) {
const pattern = new RegExp(`(^|\\s)@${escapeRegExp(label)}(?!\\S)`, 'g')
result = result.replace(pattern, ' ')
}
// 2) For block mentions, strip the '@' but keep the block name
for (const label of blockLabels) {
const pattern = new RegExp(`@${escapeRegExp(label)}(?!\\S)`, 'g')
result = result.replace(pattern, label)
}
// 3) Remove any remaining @mentions (unknown or not in contexts)
result = result.replace(/(^|\s)@([^\s]+)/g, ' ')
// Normalize whitespace
result = result.replace(/\s{2,}/g, ' ').trim()
return result
}
async function processSkillFromDb(
skillId: string,
workspaceId: string,
tag: string
): Promise<AgentContext | null> {
try {
const s = await getSkillById({ skillId, workspaceId })
if (!s) return null
// Skills are autoloaded: carry the full SKILL.md body so the Go side can
// inject it into the dynamic system message for the turn. The path lets the
// model re-read the canonical VFS file if it needs to.
const path = `agent/skills/${encodeVfsSegment(s.name)}.json`
return { type: 'skill', tag, content: s.content, path }
} catch (error) {
logger.error('Error processing skill context (db)', { skillId, error })
return null
}
}
async function processPastChatFromDb(
chatId: string,
userId: string,
tag: string,
currentWorkspaceId?: string
): Promise<AgentContext | null> {
try {
const { getAccessibleCopilotChatWithMessages } = await import('./lifecycle')
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
if (!chat) {
return null
}
if (currentWorkspaceId) {
if (chat.workspaceId && chat.workspaceId !== currentWorkspaceId) {
return null
}
if (chat.workflowId) {
const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId)
if (!activeWorkflow || activeWorkflow.workspaceId !== currentWorkspaceId) {
return null
}
}
}
const messages = Array.isArray(chat.messages) ? (chat as any).messages : []
const content = messages
.map((m: any) => {
const role = m.role || 'user'
let text = ''
if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) {
text = m.contentBlocks
.filter((b: any) => b?.type === 'text')
.map((b: any) => String(b.content || ''))
.join('')
.trim()
}
if (!text && typeof m.content === 'string') text = m.content
return `${role}: ${text}`.trim()
})
.filter((s: string) => s.length > 0)
.join('\n')
logger.info('Processed past_chat context from DB', {
chatId,
length: content.length,
lines: content ? content.split('\n').length : 0,
})
return { type: 'past_chat', tag, content }
} catch (error) {
logger.error('Error processing past chat from db', { chatId, error })
return null
}
}
/**
* Resolve a workflow folder id to its canonical, per-segment-encoded VFS folder
* path. Returns null for root-level workflows or when the folder can't be
* resolved. Uses the shared {@link buildVfsFolderPathMap} so the pointer path
* matches what the workspace VFS serves.
*/
async function resolveWorkflowFolderPath(
workspaceId: string | null | undefined,
folderId: string | null | undefined
): Promise<string | null> {
if (!folderId || !workspaceId) return null
try {
const folders = await listFolders(workspaceId)
return buildVfsFolderPathMap(folders).get(folderId) ?? null
} catch (error) {
logger.warn('Failed to resolve workflow folder path', { workspaceId, folderId, error })
return null
}
}
async function processWorkflowFromDb(
workflowId: string,
userId: string | undefined,
tag: string,
kind: 'workflow' | 'current_workflow' = 'workflow',
currentWorkspaceId?: string,
_chatId?: string
): Promise<AgentContext | null> {
try {
let workflowRecord: Awaited<ReturnType<typeof getActiveWorkflowRecord>> = null
if (userId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed) {
return null
}
if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) {
return null
}
workflowRecord = authorization.workflow ?? null
}
if (!workflowRecord) {
workflowRecord = await getActiveWorkflowRecord(workflowId)
}
if (!workflowRecord) return null
// Emit a VFS-path pointer instead of the full (potentially huge) workflow
// state/meta. `current_workflow` points at the live state; a plain
// `workflow` mention points at the lighter metadata file.
const folderPath = await resolveWorkflowFolderPath(
workflowRecord.workspaceId ?? currentWorkspaceId,
workflowRecord.folderId
)
const dir = canonicalWorkflowVfsDir({ name: workflowRecord.name, folderPath })
const path = kind === 'current_workflow' ? `${dir}/state.json` : `${dir}/meta.json`
return { type: kind, tag, content: '', path }
} catch (error) {
logger.error('Error processing workflow context', { workflowId, error })
return null
}
}
async function processPastChat(chatId: string, tagOverride?: string): Promise<AgentContext | null> {
try {
// boundary-raw-fetch: GET /api/mothership/chat?chatId=... has no defineRouteContract;
// the route forwards to the copilot chat handler and emits a free-form chat envelope
// that isn't covered by mothershipChatGetQuerySchema or copilotChatGetContract.
const resp = await fetch(`/api/mothership/chat?chatId=${encodeURIComponent(chatId)}`)
if (!resp.ok) {
logger.error('Failed to fetch past chat', { chatId, status: resp.status })
return null
}
const data = await resp.json()
const messages = Array.isArray(data?.chat?.messages) ? data.chat.messages : []
const content = messages
.map((m: any) => {
const role = m.role || 'user'
// Prefer contentBlocks text if present (joins text blocks), else use content
let text = ''
if (Array.isArray(m.contentBlocks) && m.contentBlocks.length > 0) {
text = m.contentBlocks
.filter((b: any) => b?.type === 'text')
.map((b: any) => String(b.content || ''))
.join('')
.trim()
}
if (!text && typeof m.content === 'string') text = m.content
return `${role}: ${text}`.trim()
})
.filter((s: string) => s.length > 0)
.join('\n')
logger.info('Processed past_chat context via API', { chatId, length: content.length })
return { type: 'past_chat', tag: tagOverride || '@', content }
} catch (error) {
logger.error('Error processing past chat', { chatId, error })
return null
}
}
// Back-compat alias; used by processContexts above
async function processPastChatViaApi(chatId: string, tag?: string) {
return processPastChat(chatId, tag)
}
async function processKnowledgeFromDb(
knowledgeBaseId: string,
userId: string | undefined,
tag: string,
currentWorkspaceId?: string
): Promise<AgentContext | null> {
try {
if (userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, userId)
if (!accessCheck.hasAccess) {
return null
}
if (currentWorkspaceId && accessCheck.knowledgeBase?.workspaceId !== currentWorkspaceId) {
return null
}
}
const conditions = [eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)]
if (currentWorkspaceId) {
conditions.push(eq(knowledgeBase.workspaceId, currentWorkspaceId))
}
const kbRows = await dbReplica
.select({
id: knowledgeBase.id,
name: knowledgeBase.name,
})
.from(knowledgeBase)
.where(and(...conditions))
.limit(1)
const kb = kbRows?.[0]
if (!kb) return null
return {
type: 'knowledge',
tag,
content: '',
path: `${canonicalKnowledgeBaseVfsDir(kb.name)}/meta.json`,
}
} catch (error) {
logger.error('Error processing knowledge context (db)', { knowledgeBaseId, error })
return null
}
}
async function processBlockMetadata(
blockId: string,
tag: string,
userId?: string,
workspaceId?: string
): Promise<AgentContext | null> {
try {
const permissionConfig =
userId && workspaceId ? await getUserPermissionConfig(userId, workspaceId) : null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
logger.debug('Block not allowed by integration allowlist', { blockId, userId })
return null
}
const { registry: blockRegistry } = await import('@/blocks/registry')
if (!(blockRegistry as any)[blockId]) {
return null
}
return { type: 'blocks', tag, content: '', path: canonicalBlockVfsPath(blockId) }
} catch (error) {
logger.error('Error processing block metadata', { blockId, error })
return null
}
}
async function processWorkflowBlockFromDb(
workflowId: string,
userId: string | undefined,
blockId: string,
label?: string,
currentWorkspaceId?: string
): Promise<AgentContext | null> {
try {
let workflowRecord: Awaited<ReturnType<typeof getActiveWorkflowRecord>> = null
if (userId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId,
action: 'read',
})
if (!authorization.allowed) {
return null
}
if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) {
return null
}
workflowRecord = authorization.workflow ?? null
}
if (!workflowRecord) {
workflowRecord = await getActiveWorkflowRecord(workflowId)
}
if (!workflowRecord) return null
const folderPath = await resolveWorkflowFolderPath(
workflowRecord.workspaceId ?? currentWorkspaceId,
workflowRecord.folderId
)
const dir = canonicalWorkflowVfsDir({ name: workflowRecord.name, folderPath })
const tag = label ? `@${label} in Workflow` : `@${blockId} in Workflow`
// Point at the workflow state; the block id tells the model which node to
// look up inside state.json without inlining the full block definition.
return {
type: 'workflow_block',
tag,
content: `Block id: ${blockId}`,
path: `${dir}/state.json`,
}
} catch (error) {
logger.error('Error processing workflow_block context', { workflowId, blockId, error })
return null
}
}
/**
* Cap on the serialized summary (including the block overview tree) sent for
* a tagged run. `toOverview` already excludes every block's input/output, so
* this is a safety net against pathological span counts, not the primary
* defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down
* since this lands in the prompt unconditionally rather than behind an
* explicit tool call.
*/
const MAX_LOG_SUMMARY_BYTES = 64 * 1024
/**
* Resolve a tagged run to a compact summary instead of its full execution
* trace. A run's trace can carry every block's input/output plus nested
* tool-call spans, which is unbounded and would repeatedly blow the context
* window if inlined directly. The summary includes the block-level overview
* tree (name/type/status/timing/cost, no input or output — the same
* projection `query_logs`'s `overview` view returns) so the model can see
* which block failed without a round trip, and points it at `query_logs` for
* that block's actual input/output/error, or to grep the trace.
*
* `materializeExecutionData` only unwraps a top-level object-storage pointer,
* for runs whose whole trace was offloaded as one blob — a no-op for the
* common inline case. Individual span input/output stay as large-value refs;
* `toOverview` never resolves those.
*/
async function processExecutionLogFromDb(
executionId: string,
userId: string | undefined,
tag: string,
currentWorkspaceId?: string
): Promise<AgentContext | null> {
try {
const { workflowExecutionLogs, workflow } = await import('@sim/db/schema')
const rows = await db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
workspaceId: workflowExecutionLogs.workspaceId,
executionId: workflowExecutionLogs.executionId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
costTotal: workflowExecutionLogs.costTotal,
workflowName: workflow.name,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)
const log = rows?.[0] as any
if (!log) return null
if (userId) {
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: log.workflowId,
userId,
action: 'read',
})
if (!authorization.allowed) {
return null
}
if (currentWorkspaceId && authorization.workflow?.workspaceId !== currentWorkspaceId) {
return null
}
}
const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store')
const executionData = (await materializeExecutionData(
log.executionData as Record<string, unknown> | null,
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
)) as { traceSpans?: TraceSpan[] } | undefined
const overview = executionData?.traceSpans?.length
? toOverview(executionData.traceSpans)
: undefined
const summary = {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
level: log.level,
trigger: log.trigger,
startedAt: log.startedAt?.toISOString?.() || String(log.startedAt),
endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null),
totalDurationMs: log.totalDurationMs ?? null,
workflowName: log.workflowName || '',
cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined,
overview,
note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`,
}
if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) {
summary.overview = undefined
}
const content = JSON.stringify(summary)
return { type: 'logs', tag, content }
} catch (error) {
logger.error('Error processing execution log context (db)', { executionId, error })
return null
}
}
// ---------------------------------------------------------------------------
// Active resource context resolution (direct DB lookups, workspace-scoped)
// ---------------------------------------------------------------------------
/**
* Resolves the content of the currently active resource tab via direct DB
* queries. Each resource type has a dedicated handler that fetches only the
* single resource needed — avoiding the full VFS materialisation overhead.
*/
export async function resolveActiveResourceContext(
resourceType: string,
resourceId: string,
workspaceId: string,
userId: string,
chatId?: string
): Promise<AgentContext | null> {
try {
switch (resourceType) {
case 'workflow': {
const ctx = await processWorkflowFromDb(
resourceId,
userId,
'@active_resource',
'current_workflow',
workspaceId,
chatId
)
if (!ctx) return null
return {
type: 'active_resource',
tag: '@active_resource',
content: ctx.content,
path: ctx.path,
}
}
case 'knowledgebase': {
const ctx = await processKnowledgeFromDb(
resourceId,
userId,
'@active_resource',
workspaceId
)
if (!ctx) return null
return {
type: 'active_resource',
tag: '@active_resource',
content: ctx.content,
path: ctx.path,
}
}
case 'table': {
return await resolveTableResource(resourceId, workspaceId)
}
case 'file': {
return await resolveFileResource(resourceId, workspaceId)
}
case 'folder': {
return await resolveFolderResource(resourceId, workspaceId)
}
case 'filefolder': {
return await resolveFileFolderResource(resourceId, workspaceId)
}
case 'scheduledtask': {
return await resolveScheduledTaskResource(resourceId, workspaceId)
}
default:
return null
}
} catch (error) {
logger.error('Failed to resolve active resource context', { resourceType, resourceId, error })
return null
}
}
async function resolveTableResource(
tableId: string,
workspaceId: string
): Promise<AgentContext | null> {
const table = await getTableById(tableId)
if (!table) return null
if (table.workspaceId !== workspaceId) return null
return {
type: 'active_resource',
tag: '@active_resource',
content: '',
path: canonicalTableVfsPath(table.name),
}
}
async function resolveScheduledTaskResource(
scheduleId: string,
workspaceId: string
): Promise<AgentContext | null> {
const [row] = await db
.select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle })
.from(workflowSchedule)
.where(
and(
eq(workflowSchedule.id, scheduleId),
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
eq(workflowSchedule.sourceType, 'job'),
isNull(workflowSchedule.archivedAt),
// Mirror the VFS materializer (workspace-vfs `materializeJobs`), which
// excludes completed jobs — otherwise we'd point at a meta.json it never
// wrote and the agent's read would dangle.
ne(workflowSchedule.status, 'completed')
)
)
.limit(1)
if (!row) return null
// The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see
// workspace-vfs `materializeJobs`); emit the same lightweight path pointer so
// the agent reads it via the VFS instead of us inlining the (heavy) row.
return {
type: 'active_resource',
tag: '@active_resource',
content: '',
path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`,
}
}
async function resolveFileResource(
fileId: string,
workspaceId: string
): Promise<AgentContext | null> {
const record = await getWorkspaceFile(workspaceId, fileId)
if (!record) return null
return {
type: 'active_resource',
tag: '@active_resource',
content: '',
path: canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }),
}
}
async function resolveFileFolderResource(
folderId: string,
workspaceId: string
): Promise<AgentContext | null> {
try {
const rawPath = await getWorkspaceFileFolderPath(workspaceId, folderId)
if (!rawPath) return null
const encoded = encodeVfsPathSegments(rawPath.split('/').filter(Boolean))
return {
type: 'active_resource',
tag: '@active_resource',
content: '',
path: `files/${encoded}`,
}
} catch (error) {
logger.error('Failed to resolve file folder resource', { folderId, error })
return null
}
}
async function resolveFolderResource(
folderId: string,
workspaceId: string
): Promise<AgentContext | null> {
const folderPath = await resolveWorkflowFolderPath(workspaceId, folderId)
if (!folderPath) return null
return {
type: 'active_resource',
tag: '@active_resource',
content: '',
path: `workflows/${folderPath}`,
}
}