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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,48 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { serializeBlockSchema } from '@/lib/copilot/vfs/serializers'
import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
import type { BlockIcon } from '@/blocks/types'
const icon: BlockIcon = () => null as never
/**
* The agent must see a custom block as a self-contained block — never as a
* `workflow_executor` needing a workflowId/inputMapping (the plumbing is baked).
*/
describe('serializeBlockSchema for custom blocks', () => {
const config = buildCustomBlockConfig(
{
type: 'custom_block_abc',
name: 'Invoice Parser',
description: 'Parses invoices',
workflowId: 'wf-1',
exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }],
},
[
{ name: 'file', type: 'string' },
{ name: 'locale', type: 'string' },
],
{ icon }
)
it('hides workflow_executor and the baked workflowId/inputMapping', () => {
const schema = JSON.parse(serializeBlockSchema(config))
expect(schema.tools).toEqual([])
expect(schema.inputs ?? {}).not.toHaveProperty('workflowId')
expect(schema.inputs ?? {}).not.toHaveProperty('inputMapping')
const subBlockIds = (schema.subBlocks ?? []).map((s: { id: string }) => s.id)
expect(subBlockIds).not.toContain('workflowId')
expect(subBlockIds).not.toContain('inputMapping')
})
it('exposes the input fields and curated outputs', () => {
const schema = JSON.parse(serializeBlockSchema(config))
const subBlockIds = (schema.subBlocks ?? []).map((s: { id: string }) => s.id)
expect(subBlockIds).toEqual(expect.arrayContaining(['file', 'locale']))
expect(Object.keys(schema.outputs)).toEqual(expect.arrayContaining(['summary', 'success']))
expect(schema.outputs).not.toHaveProperty('childWorkflowId')
})
})
+447
View File
@@ -0,0 +1,447 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
const logger = createLogger('DocumentStyle')
// ZIP magic bytes: PK\x03\x04
const ZIP_MAGIC = [0x50, 0x4b, 0x03, 0x04]
interface ThemeColors {
dk1: string
lt1: string
dk2: string
lt2: string
accent1: string
accent2: string
accent3: string
accent4: string
accent5: string
accent6: string
}
export interface DocumentStyleSummary {
format: 'docx' | 'pptx' | 'pdf'
/** OOXML theme — present for pptx; present for docx when theme1.xml exists; absent for pdf */
theme?: {
colors: Partial<ThemeColors>
fonts: { major: string; minor: string }
}
/** Named paragraph/character styles — docx only */
styles?: Array<{
id: string
name: string
type: string
fontSize?: number
bold?: boolean
color?: string
font?: string
}>
/** Document-wide default run properties (body text baseline) — docx only */
defaults?: {
fontSize?: number
font?: string
}
/** Page dimensions — pdf only. widthPt/heightPt present only when preset is 'custom' */
pageSize?: {
preset: 'A4' | 'letter' | 'custom'
widthPt?: number
heightPt?: number
}
/** Embedded font names extracted from page resource dictionaries — pdf only */
fonts?: string[]
/** Number of slides — pptx only */
slideCount?: number
/** Slide aspect ratio — pptx only */
aspectRatio?: '16:9' | '4:3' | 'custom'
/** Slide master background hex color (no #) — pptx only, absent when background is transparent/image */
background?: string
}
function attr(xml: string, name: string): string {
const rx = new RegExp(`${name}="([^"]*)"`)
return rx.exec(xml)?.[1] ?? ''
}
function between(xml: string, open: string, close: string): string {
const start = xml.indexOf(open)
if (start < 0) return ''
const end = xml.indexOf(close, start + open.length)
if (end < 0) return ''
return xml.slice(start + open.length, end)
}
function parseColorSlot(xml: string, slot: string): string {
const inner = between(xml, `<a:${slot}>`, `</a:${slot}>`)
if (!inner) return ''
// srgbClr uses val=; sysClr has val="windowText" but lastClr holds the fallback hex
const srgb = attr(inner, 'val')
if (srgb && inner.includes('<a:srgbClr')) return srgb.toUpperCase()
const lastClr = attr(inner, 'lastClr')
if (lastClr) return lastClr.toUpperCase()
return ''
}
function parseFontScheme(xml: string): { major: string; minor: string } {
const major = between(xml, '<a:majorFont>', '</a:majorFont>')
const minor = between(xml, '<a:minorFont>', '</a:minorFont>')
return { major: attr(major, 'typeface') || '', minor: attr(minor, 'typeface') || '' }
}
function parseThemeXml(xml: string): NonNullable<DocumentStyleSummary['theme']> {
const slots: Array<keyof ThemeColors> = [
'dk1',
'lt1',
'dk2',
'lt2',
'accent1',
'accent2',
'accent3',
'accent4',
'accent5',
'accent6',
]
const colors: Partial<ThemeColors> = {}
for (const slot of slots) {
const hex = parseColorSlot(xml, slot)
if (hex) colors[slot] = hex
}
return { colors, fonts: parseFontScheme(xml) }
}
type StyleRaw = {
id: string
name: string
type: string
basedOn?: string
fontSize?: number
bold?: boolean
color?: string
font?: string
/** Raw w:asciiTheme value — resolved to a font name after parsing */
themeFont?: string
}
function resolveThemeFont(themeFont: string, themeFonts: { major: string; minor: string }): string {
const ref = themeFont.toLowerCase()
return ref.includes('major') ? themeFonts.major : themeFonts.minor
}
function parseFontAttrs(
fontAttrsXml: string,
themeFonts?: { major: string; minor: string }
): { font?: string; themeFont?: string } {
const asciiLit = /\bw:ascii="([^"]+)"/.exec(fontAttrsXml)
// LibreOffice DOCX files may put a semicolon-separated fallback list in w:ascii — take the first
if (asciiLit) return { font: asciiLit[1].split(';')[0].trim() || asciiLit[1] }
const themeRef = /\bw:asciiTheme="([^"]+)"/.exec(fontAttrsXml)
if (!themeRef) return {}
// Resolve immediately if theme fonts are available, otherwise defer
if (themeFonts) return { font: resolveThemeFont(themeRef[1], themeFonts) }
return { themeFont: themeRef[1] }
}
function parseDocxStyles(
xml: string,
themeFonts?: { major: string; minor: string }
): {
styles: NonNullable<DocumentStyleSummary['styles']>
defaults?: DocumentStyleSummary['defaults']
} {
// Extract document-default run properties (the baseline for body text)
const defaults: DocumentStyleSummary['defaults'] = {}
const docDefaultsBlock = between(xml, '<w:docDefaults>', '</w:docDefaults>')
if (docDefaultsBlock) {
const rPrBlock = between(docDefaultsBlock, '<w:rPrDefault>', '</w:rPrDefault>')
if (rPrBlock) {
const szMatch = /<w:sz w:val="(\d+)"/.exec(rPrBlock)
if (szMatch) defaults.fontSize = Math.round(Number.parseInt(szMatch[1]) / 2)
const fontAttrMatch = /<w:rFonts([^>]*)>/.exec(rPrBlock)
if (fontAttrMatch) {
const { font } = parseFontAttrs(fontAttrMatch[1], themeFonts)
if (font) defaults.font = font
}
}
}
// Build a full style map for basedOn inheritance resolution
const styleMap = new Map<string, StyleRaw>()
for (const block of xml.split('<w:style ').slice(1)) {
const id = attr(block, 'w:styleId')
if (!id) continue
const type = attr(block, 'w:type')
const nameMatch = /<w:name w:val="([^"]*)"/.exec(block)
const basedOnMatch = /<w:basedOn w:val="([^"]*)"/.exec(block)
const szMatch = /<w:sz w:val="(\d+)"/.exec(block)
const colorMatch = /<w:color w:val="([A-Fa-f0-9]{6})"/.exec(block)
const fontAttrMatch = /<w:rFonts([^>]*)>/.exec(block)
const { font, themeFont } = fontAttrMatch ? parseFontAttrs(fontAttrMatch[1], themeFonts) : {}
styleMap.set(id, {
id,
name: nameMatch?.[1] ?? id,
type,
...(basedOnMatch && { basedOn: basedOnMatch[1] }),
...(szMatch && { fontSize: Math.round(Number.parseInt(szMatch[1]) / 2) }),
...(/<w:b\b(?:\s[^/]*)?\/?>/.test(block) && {
bold: !/<w:b\b[^>]*\bw:val=["'](0|false)["']/.test(block),
}),
...(colorMatch && { color: colorMatch[1].toUpperCase() }),
...(font && { font }),
...(themeFont && { themeFont }),
})
}
function resolveInheritance(id: string, visited = new Set<string>()): StyleRaw | undefined {
if (visited.has(id)) return undefined
visited.add(id)
const s = styleMap.get(id)
if (!s) return undefined
if (!s.basedOn) return s
const parent = resolveInheritance(s.basedOn, visited)
if (!parent) return s
// Own properties override parent; undefined falls through to parent
return {
...parent,
...s,
fontSize: s.fontSize ?? parent.fontSize,
bold: s.bold ?? parent.bold,
color: s.color ?? parent.color,
font: s.font ?? parent.font,
themeFont: s.themeFont ?? parent.themeFont,
}
}
// Target paragraph styles (character styles excluded — generation works at paragraph level)
const targetIds: string[] = ['Normal', 'BodyText', 'Body Text', 'Title', 'Subtitle']
for (const id of styleMap.keys()) {
// Match both 'Heading1' (Office) and 'heading1' (LibreOffice) style IDs
if (/^[Hh]eading\d/.test(id) && !targetIds.includes(id)) targetIds.push(id)
}
const styles: NonNullable<DocumentStyleSummary['styles']> = []
const seen = new Set<string>()
for (const id of targetIds) {
if (seen.has(id)) continue
seen.add(id)
const resolved = resolveInheritance(id)
if (!resolved || resolved.type !== 'paragraph') continue
// Deferred theme font resolution (only reached when themeFonts was unavailable during parse)
let resolvedFont = resolved.font
if (!resolvedFont && resolved.themeFont && themeFonts) {
resolvedFont = resolveThemeFont(resolved.themeFont, themeFonts)
}
styles.push({
id: resolved.id,
name: resolved.name,
type: resolved.type,
...(resolved.fontSize !== undefined && { fontSize: resolved.fontSize }),
...(resolved.bold !== undefined && { bold: resolved.bold }),
...(resolved.color && { color: resolved.color }),
...(resolvedFont && { font: resolvedFont }),
})
}
return {
styles,
...(Object.keys(defaults).length > 0 && { defaults }),
}
}
async function extractPdfStyle(buffer: Buffer): Promise<DocumentStyleSummary | null> {
try {
const { PDFDocument, PDFName, PDFDict } = await import('pdf-lib')
let doc: Awaited<ReturnType<typeof PDFDocument.load>>
try {
doc = await PDFDocument.load(buffer, { updateMetadata: false })
} catch {
// Encrypted or corrupt
return null
}
const pages = doc.getPages()
if (pages.length === 0) return null
// Page dimensions (first page is canonical for preset detection)
const { width: widthPt, height: heightPt } = pages[0].getSize()
let preset: 'A4' | 'letter' | 'custom' = 'custom'
if (Math.abs(widthPt - 595.28) < 5 && Math.abs(heightPt - 841.89) < 5) preset = 'A4'
else if (Math.abs(widthPt - 612) < 5 && Math.abs(heightPt - 792) < 5) preset = 'letter'
// Font names from page resource dictionaries (first 10 pages to bound cost)
const rawFontNames = new Set<string>()
const pagesToScan = Math.min(pages.length, 10)
for (let i = 0; i < pagesToScan; i++) {
try {
const resourcesRef = pages[i].node.get(PDFName.of('Resources'))
if (!resourcesRef) continue
const resources = doc.context.lookup(resourcesRef, PDFDict)
if (!resources) continue
const fontDictRef = resources.get(PDFName.of('Font'))
if (!fontDictRef) continue
const fontDict = doc.context.lookup(fontDictRef, PDFDict)
if (!fontDict) continue
for (const key of fontDict.keys()) {
try {
const fontRef = fontDict.get(key)
if (!fontRef) continue
const fontObj = doc.context.lookup(fontRef, PDFDict)
if (!fontObj) continue
const baseFontRef = fontObj.get(PDFName.of('BaseFont'))
if (!baseFontRef) continue
// Format: "/ABCDEF+FontName" (subset) or "/FontName" (full embed)
const raw = baseFontRef
.toString()
.replace(/^\//, '')
.replace(/^[A-Z]{6}\+/, '')
if (raw) rawFontNames.add(raw)
} catch {}
}
} catch {}
}
// Normalize to unique font family names by stripping PostScript weight/style suffixes.
// Apply the strip in a loop to handle compound suffixes (e.g. SemiBoldItalic, LightOblique).
// BoldMT must precede Bold, Oblique must precede the simple form, etc.
const SUFFIX_RX =
/[-]?(BoldMT|BoldOblique|BoldItalic|SemiBoldItalic|ExtraBoldItalic|LightItalic|LightOblique|MediumItalic|Regular|ExtraBold|SemiBold|Medium|Black|Light|Bold|Italic|Oblique|Condensed|Expanded|MT)$/i
const familyNames = [
...new Set(
[...rawFontNames].map((name) => {
let n = name
// Strip up to 3 suffix components to handle compound PostScript names
for (let i = 0; i < 3; i++) {
const stripped = n.replace(SUFFIX_RX, '').trim()
if (stripped === n) break
n = stripped
}
return n
})
),
].filter(Boolean)
// Omit exact dimensions when the preset already encodes the page size
const pageSize: DocumentStyleSummary['pageSize'] =
preset === 'custom'
? { widthPt: Math.round(widthPt), heightPt: Math.round(heightPt), preset }
: { preset }
return {
format: 'pdf',
pageSize,
...(familyNames.length > 0 && { fonts: familyNames }),
}
} catch (err) {
logger.warn('Failed to extract PDF style', { error: toError(err).message })
return null
}
}
function parsePptxPresentation(xml: string): {
slideCount: number
aspectRatio: '16:9' | '4:3' | 'custom'
} {
// Count sldId elements inside sldIdLst
const sldIdLst = between(xml, '<p:sldIdLst>', '</p:sldIdLst>')
const slideCount = (sldIdLst.match(/<p:sldId\b/g) ?? []).length
// Slide size in EMU — 1 inch = 914400 EMU. Capture cx/cy independently so
// attribute order (LibreOffice/Google Slides may write cy before cx) doesn't matter.
const cxMatch = /<p:sldSz\b[^>]*\bcx="(\d+)"/.exec(xml)
const cyMatch = /<p:sldSz\b[^>]*\bcy="(\d+)"/.exec(xml)
let aspectRatio: '16:9' | '4:3' | 'custom' = 'custom'
if (cxMatch && cyMatch) {
const cx = Number.parseInt(cxMatch[1])
const cy = Number.parseInt(cyMatch[1])
const ratio = cx / cy
if (Math.abs(ratio - 16 / 9) < 0.01) aspectRatio = '16:9'
else if (Math.abs(ratio - 4 / 3) < 0.01) aspectRatio = '4:3'
}
return { slideCount, aspectRatio }
}
function parseSlideMasterBackground(xml: string): string | undefined {
// Look for a solid fill color in the slide master background
const bgBlock = between(xml, '<p:bg>', '</p:bg>')
if (!bgBlock) return undefined
// solidFill with srgbClr
const srgbMatch = /<a:srgbClr\b[^>]*\bval="([A-Fa-f0-9]{6})"/.exec(bgBlock)
if (srgbMatch) return srgbMatch[1].toUpperCase()
// solidFill with sysClr fallback
const sysMatch = /<a:sysClr\b[^>]*\blastClr="([A-Fa-f0-9]{6})"/.exec(bgBlock)
if (sysMatch) return sysMatch[1].toUpperCase()
return undefined
}
/**
* Extract a compact style summary from a binary document buffer.
* Supports .docx and .pptx (OOXML/ZIP) and .pdf.
* Returns null if the buffer cannot be parsed or yields no useful data.
*/
export async function extractDocumentStyle(
buffer: Buffer,
ext: 'docx' | 'pptx' | 'pdf'
): Promise<DocumentStyleSummary | null> {
if (ext === 'pdf') {
return extractPdfStyle(buffer)
}
if (buffer.length < 4) return null
for (let i = 0; i < 4; i++) {
if (buffer[i] !== ZIP_MAGIC[i]) return null
}
try {
const JSZip = (await import('jszip')).default
const zip = await JSZip.loadAsync(buffer)
const themePath = ext === 'docx' ? 'word/theme/theme1.xml' : 'ppt/theme/theme1.xml'
const themeFile = zip.file(themePath)
let theme: DocumentStyleSummary['theme']
if (themeFile) {
theme = parseThemeXml(await themeFile.async('string'))
} else if (ext === 'pptx') {
// PPTX without a theme is malformed — nothing useful to return
return null
}
// DOCX without a theme is valid (e.g. LibreOffice-generated); continue with styles only
const summary: DocumentStyleSummary = { format: ext, ...(theme && { theme }) }
if (ext === 'docx') {
const stylesFile = zip.file('word/styles.xml')
if (stylesFile) {
const { styles, defaults } = parseDocxStyles(await stylesFile.async('string'), theme?.fonts)
if (styles.length > 0) summary.styles = styles
if (defaults) summary.defaults = defaults
}
// If there's neither a theme nor any styles, there's nothing useful to return
if (!theme && !summary.styles?.length) return null
}
if (ext === 'pptx') {
const presFile = zip.file('ppt/presentation.xml')
if (presFile) {
const { slideCount, aspectRatio } = parsePptxPresentation(await presFile.async('string'))
if (slideCount > 0) summary.slideCount = slideCount
summary.aspectRatio = aspectRatio
}
const masterFile =
zip.file('ppt/slideMasters/slideMaster1.xml') ??
zip.file('ppt/slidemaster/slidemaster1.xml')
if (masterFile) {
const bg = parseSlideMasterBackground(await masterFile.async('string'))
if (bg) summary.background = bg
}
}
return summary
} catch (err) {
logger.warn('Failed to extract document style from buffer', { error: toError(err).message })
return null
}
}
@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import { randomFillSync } from 'node:crypto'
import { loggerMock } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
const { fetchWorkspaceFileBuffer } = vi.hoisted(() => ({
fetchWorkspaceFileBuffer: vi.fn(),
}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
fetchWorkspaceFileBuffer,
}))
import { readFileRecord } from '@/lib/copilot/vfs/file-reader'
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024
async function makeNoisePng(width: number, height: number): Promise<Buffer> {
const sharp = (await import('sharp')).default
const raw = Buffer.alloc(width * height * 3)
randomFillSync(raw)
return sharp(raw, { raw: { width, height, channels: 3 } })
.png()
.toBuffer()
}
const SHARP_TEST_TIMEOUT_MS = 30_000
describe('readFileRecord', () => {
it(
'downscales oversized images into attachments that fit the read limit',
async () => {
const largePng = await makeNoisePng(1800, 1800)
expect(largePng.length).toBeGreaterThan(MAX_IMAGE_READ_BYTES)
fetchWorkspaceFileBuffer.mockResolvedValue(largePng)
const result = await readFileRecord({
id: 'wf_large',
workspaceId: 'ws_1',
name: 'chesspng.png',
key: 'uploads/chesspng.png',
path: '/api/files/serve/uploads%2Fchesspng.png?context=mothership',
size: largePng.length,
type: 'image/png',
uploadedBy: 'user_1',
uploadedAt: new Date(),
deletedAt: null,
storageContext: 'mothership',
})
expect(result?.attachment?.type).toBe('image')
expect(result?.content).toContain('resized for vision')
const decoded = Buffer.from(result?.attachment?.source.data ?? '', 'base64')
expect(decoded.length).toBeLessThanOrEqual(MAX_IMAGE_READ_BYTES)
expect(result?.attachment?.source.media_type).toMatch(/^image\/(jpeg|webp|png)$/)
},
SHARP_TEST_TIMEOUT_MS
)
})
+424
View File
@@ -0,0 +1,424 @@
import { type Span, trace } from '@opentelemetry/api'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import {
CopilotVfsOutcome,
CopilotVfsReadOutcome,
CopilotVfsReadPath,
} from '@/lib/copilot/generated/trace-attribute-values-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { recordFileRead } from '@/lib/copilot/request/metrics'
import { markSpanForError } from '@/lib/copilot/request/otel'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
// Lazy tracer (same pattern as lib/copilot/request/otel.ts).
function getVfsTracer() {
return trace.getTracer('sim-copilot-vfs', '1.0.0')
}
function recordSpanError(span: Span, err: unknown) {
markSpanForError(span, err)
}
const logger = createLogger('FileReader')
const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
// Parseable-document byte cap. Large office/PDF files can still
// produce huge extracted text; reject up front to avoid wasting a
// download + parse only to blow past the tool-result budget.
const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
const MAX_IMAGE_DIMENSION = 1568
const IMAGE_RESIZE_DIMENSIONS = [1568, 1280, 1024, 768]
const IMAGE_QUALITY_STEPS = [85, 70, 55, 40]
const TEXT_TYPES = new Set([
'text/plain',
'text/csv',
'text/markdown',
'text/html',
'text/xml',
'text/x-pptxgenjs',
'text/x-docxjs',
'text/x-python-pdf',
'text/x-python-xlsx',
'application/json',
'application/xml',
'application/javascript',
])
const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx', 'ppt'])
function isReadableType(contentType: string): boolean {
return TEXT_TYPES.has(contentType) || contentType.startsWith('text/')
}
function getExtension(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : ''
}
function detectImageMime(buf: Buffer, claimed: string): string {
if (buf.length < 12) return claimed
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png'
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif'
if (buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50)
return 'image/webp'
return claimed
}
interface PreparedVisionImage {
buffer: Buffer
mediaType: string
resized: boolean
}
/**
* Prepare an image for vision models: detect media type, optionally
* resize/compress with sharp, and return the prepared buffer.
*
* Wrapped in a `copilot.vfs.prepare_image` span so the external trace
* shows exactly when an image read blocked the request on CPU-heavy
* encode attempts. Attributes record input dimensions, whether a resize
* was needed, how many encode attempts it took, and the final
* dimension/quality chosen.
*/
async function prepareImageForVision(
buffer: Buffer,
claimedType: string
): Promise<PreparedVisionImage | null> {
return getVfsTracer().startActiveSpan(
TraceSpan.CopilotVfsPrepareImage,
{
attributes: {
[TraceAttr.CopilotVfsInputBytes]: buffer.length,
[TraceAttr.CopilotVfsInputMediaTypeClaimed]: claimedType,
},
},
async (span) => {
try {
const mediaType = detectImageMime(buffer, claimedType)
span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, mediaType)
let sharpModule: typeof import('sharp')
try {
sharpModule = (await import('sharp')).default
} catch (err) {
logger.warn('Failed to load sharp for image preparation', {
mediaType,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotVfsSharpLoadFailed, true)
const fitsWithoutSharp = buffer.length <= MAX_IMAGE_READ_BYTES
span.setAttribute(
TraceAttr.CopilotVfsOutcome,
fitsWithoutSharp ? 'passthrough_no_sharp' : 'rejected_no_sharp'
)
return fitsWithoutSharp ? { buffer, mediaType, resized: false } : null
}
let metadata: Awaited<ReturnType<ReturnType<typeof sharpModule>['metadata']>>
try {
metadata = await sharpModule(buffer, { limitInputPixels: false }).metadata()
} catch (err) {
logger.warn('Failed to read image metadata for VFS read', {
mediaType,
error: toError(err).message,
})
span.setAttribute(TraceAttr.CopilotVfsMetadataFailed, true)
const fitsWithoutSharp = buffer.length <= MAX_IMAGE_READ_BYTES
span.setAttribute(
TraceAttr.CopilotVfsOutcome,
fitsWithoutSharp ? 'passthrough_no_metadata' : 'rejected_no_metadata'
)
return fitsWithoutSharp ? { buffer, mediaType, resized: false } : null
}
const width = metadata.width ?? 0
const height = metadata.height ?? 0
span.setAttributes({
[TraceAttr.CopilotVfsInputWidth]: width,
[TraceAttr.CopilotVfsInputHeight]: height,
})
const needsResize =
buffer.length > MAX_IMAGE_READ_BYTES ||
width > MAX_IMAGE_DIMENSION ||
height > MAX_IMAGE_DIMENSION
if (!needsResize) {
span.setAttributes({
[TraceAttr.CopilotVfsResized]: false,
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.PassthroughFitsBudget,
[TraceAttr.CopilotVfsOutputBytes]: buffer.length,
[TraceAttr.CopilotVfsOutputMediaType]: mediaType,
})
return { buffer, mediaType, resized: false }
}
const hasAlpha = Boolean(
metadata.hasAlpha ||
mediaType === 'image/png' ||
mediaType === 'image/webp' ||
mediaType === 'image/gif'
)
span.setAttribute(TraceAttr.CopilotVfsHasAlpha, hasAlpha)
let attempts = 0
for (const dimension of IMAGE_RESIZE_DIMENSIONS) {
for (const quality of IMAGE_QUALITY_STEPS) {
attempts += 1
try {
const pipeline = sharpModule(buffer, { limitInputPixels: false }).rotate().resize({
width: dimension,
height: dimension,
fit: 'inside',
withoutEnlargement: true,
})
const transformed = hasAlpha
? {
buffer: await pipeline
.webp({ quality, alphaQuality: quality, effort: 4 })
.toBuffer(),
mediaType: 'image/webp',
}
: {
buffer: await pipeline
.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4' })
.toBuffer(),
mediaType: 'image/jpeg',
}
span.addEvent(TraceEvent.CopilotVfsResizeAttempt, {
[TraceAttr.CopilotVfsResizeDimension]: dimension,
[TraceAttr.CopilotVfsResizeQuality]: quality,
[TraceAttr.CopilotVfsResizeOutputBytes]: transformed.buffer.length,
[TraceAttr.CopilotVfsResizeFitsBudget]:
transformed.buffer.length <= MAX_IMAGE_READ_BYTES,
})
if (transformed.buffer.length <= MAX_IMAGE_READ_BYTES) {
logger.info('Resized image for VFS read', {
originalBytes: buffer.length,
outputBytes: transformed.buffer.length,
originalWidth: width || undefined,
originalHeight: height || undefined,
maxDimension: dimension,
quality,
originalMediaType: mediaType,
outputMediaType: transformed.mediaType,
})
span.setAttributes({
[TraceAttr.CopilotVfsResized]: true,
[TraceAttr.CopilotVfsResizeAttempts]: attempts,
[TraceAttr.CopilotVfsResizeChosenDimension]: dimension,
[TraceAttr.CopilotVfsResizeChosenQuality]: quality,
[TraceAttr.CopilotVfsOutputBytes]: transformed.buffer.length,
[TraceAttr.CopilotVfsOutputMediaType]: transformed.mediaType,
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.Resized,
})
return {
buffer: transformed.buffer,
mediaType: transformed.mediaType,
resized: true,
}
}
} catch (err) {
logger.warn('Failed image resize attempt for VFS read', {
mediaType,
dimension,
quality,
error: toError(err).message,
})
span.addEvent(TraceEvent.CopilotVfsResizeAttemptFailed, {
[TraceAttr.CopilotVfsResizeDimension]: dimension,
[TraceAttr.CopilotVfsResizeQuality]: quality,
[TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500),
})
}
}
}
span.setAttributes({
[TraceAttr.CopilotVfsResized]: false,
[TraceAttr.CopilotVfsResizeAttempts]: attempts,
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.RejectedTooLargeAfterResize,
})
return null
} catch (err) {
recordSpanError(span, err)
throw err
} finally {
span.end()
}
}
)
}
export interface FileReadResult {
content: string
totalLines: number
attachment?: {
type: string
name?: string
source: {
type: 'base64'
media_type: string
data: string
}
}
}
/**
* Read and return the content of a workspace file record.
* Handles images (base64 attachment), parseable documents (PDF, DOCX, etc.),
* binary files, and plain text with size guards.
*
* Wrapped in `copilot.vfs.read_file` so the parent mothership trace shows
* per-file read latency, the path taken (image / text / parseable /
* binary), and any size rejection. The `prepareImageForVision` span
* nests underneath for the image-resize path.
*/
export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileReadResult | null> {
const startedAt = Date.now()
const result = await getVfsTracer().startActiveSpan(
TraceSpan.CopilotVfsReadFile,
{
attributes: {
[TraceAttr.CopilotVfsFileName]: record.name,
[TraceAttr.CopilotVfsFileMediaType]: record.type,
[TraceAttr.CopilotVfsFileSizeBytes]: record.size,
[TraceAttr.CopilotVfsFileExtension]: getExtension(record.name),
},
},
async (span) => {
try {
if (isImageFileType(record.type)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
const originalBuffer = await fetchWorkspaceFileBuffer(record)
const prepared = await prepareImageForVision(originalBuffer, record.type)
if (!prepared) {
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
return {
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB after resize/compression)]`,
totalLines: 1,
}
}
const sizeKb = (prepared.buffer.length / 1024).toFixed(1)
const resizeNote = prepared.resized ? ', resized for vision' : ''
span.setAttributes({
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.ImagePrepared,
[TraceAttr.CopilotVfsReadOutputBytes]: prepared.buffer.length,
[TraceAttr.CopilotVfsReadOutputMediaType]: prepared.mediaType,
[TraceAttr.CopilotVfsReadImageResized]: prepared.resized,
})
return {
content: `Image: ${record.name} (${sizeKb}KB, ${prepared.mediaType}${resizeNote})`,
totalLines: 1,
attachment: {
type: 'image',
name: record.name,
source: {
type: 'base64' as const,
media_type: prepared.mediaType,
data: prepared.buffer.toString('base64'),
},
},
}
}
if (isReadableType(record.type)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Text)
if (record.size > MAX_TEXT_READ_BYTES) {
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge)
return {
content: `[File too large to display inline: ${record.name} (${record.size} bytes, limit ${MAX_TEXT_READ_BYTES})]`,
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
const content = buffer.toString('utf-8')
const lines = content.split('\n').length
span.setAttributes({
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.TextRead,
[TraceAttr.CopilotVfsReadOutputBytes]: buffer.length,
[TraceAttr.CopilotVfsReadOutputLines]: lines,
})
return { content, totalLines: lines }
}
const ext = getExtension(record.name)
if (PARSEABLE_EXTENSIONS.has(ext)) {
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.ParseableDocument)
if (record.size > MAX_PARSEABLE_READ_BYTES) {
span.setAttribute(
TraceAttr.CopilotVfsReadOutcome,
CopilotVfsReadOutcome.DocumentTooLarge
)
return {
content: `[Document too large to parse inline: ${record.name} (${record.size} bytes, limit ${MAX_PARSEABLE_READ_BYTES})]`,
totalLines: 1,
}
}
const buffer = await fetchWorkspaceFileBuffer(record)
try {
const { parseBuffer } = await import('@/lib/file-parsers')
const result = await parseBuffer(buffer, ext)
const content = result.content || ''
const lines = content.split('\n').length
span.setAttributes({
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.DocumentParsed,
[TraceAttr.CopilotVfsReadOutputBytes]: content.length,
[TraceAttr.CopilotVfsReadOutputLines]: lines,
})
return { content, totalLines: lines }
} catch (parseErr) {
logger.warn('Failed to parse document', {
fileName: record.name,
ext,
error: toError(parseErr).message,
})
span.addEvent(TraceEvent.CopilotVfsParseFailed, {
[TraceAttr.ErrorMessage]: toError(parseErr).message.slice(0, 500),
})
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ParseFailed)
return {
content: `[Could not parse ${record.name} (${record.type}, ${record.size} bytes)]`,
totalLines: 1,
}
}
}
span.setAttributes({
[TraceAttr.CopilotVfsReadPath]: CopilotVfsReadPath.Binary,
[TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.BinaryPlaceholder,
})
return {
content: `[Binary file: ${record.name} (${record.type}, ${record.size} bytes). Cannot display as text.]`,
totalLines: 1,
}
} catch (err) {
logger.warn('Failed to read workspace file', {
fileName: record.name,
error: toError(err).message,
})
recordSpanError(span, err)
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ReadFailed)
return null
} finally {
span.end()
}
}
)
// Durable read duration + size by coarse outcome (the fine-grained outcome —
// ImageTooLarge / ParseFailed / etc. — stays on the Tempo span). readFileRecord
// returns null on failure rather than throwing.
recordFileRead(result ? 'success' : 'read_failed', Date.now() - startedAt, record.size ?? 0)
return result
}
+1
View File
@@ -0,0 +1 @@
export { getOrMaterializeVFS } from '@/lib/copilot/vfs/workspace-vfs'
@@ -0,0 +1,11 @@
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
/**
* Normalize and encode a string for use as one canonical VFS path segment.
*
* Uses the platform URL encoder for escaping rather than hand-written character maps.
* Slashes are encoded inside a segment, so callers must still join path segments with `/`.
*/
export function normalizeVfsSegment(name: string): string {
return encodeVfsSegment(name)
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { glob, grep } from '@/lib/copilot/vfs/operations'
function vfsFromEntries(entries: [string, string][]): Map<string, string> {
return new Map(entries)
}
describe('glob', () => {
it('matches nested file metadata paths with a single-star segment', () => {
const files = vfsFromEntries([
['files/Reports/q1.csv/meta.json', '{}'],
['files/data.csv/meta.json', '{}'],
])
const hits = glob(files, 'files/Reports/*/meta.json')
expect(hits).toContain('files/Reports/q1.csv/meta.json')
expect(hits).not.toContain('files/data.csv/meta.json')
})
it('matches one path segment for single star (files listing pattern)', () => {
const files = vfsFromEntries([
['files/a/meta.json', '{}'],
['files/a/b/meta.json', '{}'],
['uploads/x.png', ''],
])
const hits = glob(files, 'files/*/meta.json')
expect(hits).toContain('files/a/meta.json')
expect(hits).not.toContain('files/a/b/meta.json')
})
it('matches nested paths with double star', () => {
const files = vfsFromEntries([
['workflows/W/state.json', ''],
['workflows/W/sub/state.json', ''],
])
const hits = glob(files, 'workflows/**/state.json')
expect(hits.sort()).toEqual(['workflows/W/state.json', 'workflows/W/sub/state.json'].sort())
})
it('includes virtual directory prefixes when pattern matches descendants', () => {
const files = vfsFromEntries([['files/a/meta.json', '{}']])
const hits = glob(files, 'files/**')
expect(hits).toContain('files')
expect(hits).toContain('files/a')
expect(hits).toContain('files/a/meta.json')
})
it('treats braces literally when nobrace is set (matches old builder)', () => {
const files = vfsFromEntries([
['weird{brace}/x', ''],
['weirdA/x', ''],
])
const hits = glob(files, 'weird{brace}/*')
expect(hits).toContain('weird{brace}/x')
expect(hits).not.toContain('weirdA/x')
})
})
describe('grep', () => {
it('returns content matches per line in default mode', () => {
const files = vfsFromEntries([['a.txt', 'hello\nworld\nhello']])
const matches = grep(files, 'hello', undefined, { outputMode: 'content' })
expect(matches).toHaveLength(2)
expect(matches[0]).toMatchObject({ path: 'a.txt', line: 1, content: 'hello' })
expect(matches[1]).toMatchObject({ path: 'a.txt', line: 3, content: 'hello' })
})
it('strips CR before end-of-line matching on CRLF content', () => {
const files = vfsFromEntries([['x.txt', 'foo\r\n']])
const matches = grep(files, 'foo$', undefined, { outputMode: 'content' })
expect(matches).toHaveLength(1)
expect(matches[0]?.content).toBe('foo')
})
it('counts matching lines', () => {
const files = vfsFromEntries([['a.txt', 'a\nb\na']])
const counts = grep(files, 'a', undefined, { outputMode: 'count' })
expect(counts).toEqual([{ path: 'a.txt', count: 2 }])
})
it('files_with_matches scans whole file (can match across newlines with dot-all style pattern)', () => {
const files = vfsFromEntries([['a.txt', 'foo\nbar']])
const multiline = grep(files, 'foo[\\s\\S]*bar', undefined, {
outputMode: 'files_with_matches',
})
expect(multiline).toContain('a.txt')
const lineOnly = grep(files, 'foo[\\s\\S]*bar', undefined, { outputMode: 'content' })
expect(lineOnly).toHaveLength(0)
})
it('treats trailing slash on directory scope like grep (files/ matches files/foo)', () => {
const files = vfsFromEntries([
['files/TEST BOY.md/meta.json', '"name": "TEST BOY.md"'],
['workflows/x', 'TEST BOY'],
])
const hits = grep(files, 'TEST BOY', 'files/', { outputMode: 'files_with_matches' })
expect(hits).toContain('files/TEST BOY.md/meta.json')
expect(hits).not.toContain('workflows/x')
})
it('scopes to directory prefix without matching unrelated prefixes', () => {
const files = vfsFromEntries([
['workflows/a/x', 'needle'],
['workflowsManual/x', 'needle'],
])
const hits = grep(files, 'needle', 'workflows', { outputMode: 'files_with_matches' })
expect(hits).toContain('workflows/a/x')
expect(hits).not.toContain('workflowsManual/x')
})
it('treats scope with literal brackets as directory prefix, not a glob character class', () => {
const files = vfsFromEntries([['weird[bracket]/x.txt', 'needle']])
const hits = grep(files, 'needle', 'weird[bracket]', { outputMode: 'files_with_matches' })
expect(hits).toContain('weird[bracket]/x.txt')
})
it('scopes with glob pattern when path contains metacharacters', () => {
const files = vfsFromEntries([
['workflows/A/state.json', '{"x":1}'],
['workflows/B/sub/state.json', '{"x":1}'],
['workflows/C/other.json', '{"x":1}'],
])
const hits = grep(files, '1', 'workflows/*/state.json', { outputMode: 'files_with_matches' })
expect(hits).toEqual(['workflows/A/state.json'])
})
it('returns empty array for invalid regex pattern', () => {
const files = vfsFromEntries([['a.txt', 'x']])
expect(grep(files, '(unclosed', undefined, { outputMode: 'content' })).toEqual([])
})
it('respects ignoreCase', () => {
const files = vfsFromEntries([['a.txt', 'Hello']])
const hits = grep(files, 'hello', undefined, { outputMode: 'content', ignoreCase: true })
expect(hits).toHaveLength(1)
})
})
+355
View File
@@ -0,0 +1,355 @@
import micromatch from 'micromatch'
export interface GrepMatch {
path: string
line: number
content: string
}
export type GrepOutputMode = 'content' | 'files_with_matches' | 'count'
export interface GrepOptions {
maxResults?: number
outputMode?: GrepOutputMode
ignoreCase?: boolean
lineNumbers?: boolean
context?: number
}
export interface GrepCountEntry {
path: string
count: number
}
/**
* Thrown when a single-file content grep (see `WorkspaceVFS.grepFile`) hits an
* expected, user-facing condition: the path is not a single workspace file, the
* file has no searchable text (image/binary), or it exceeds the inline read cap.
* The grep handler surfaces the message verbatim instead of treating it as an
* internal failure. Defined here (rather than in `workspace-vfs.ts`) so the
* handler can reference it without pulling in the VFS module's heavy deps.
*/
export class WorkspaceFileGrepError extends Error {
readonly code = 'WORKSPACE_FILE_GREP' as const
constructor(message: string) {
super(message)
this.name = 'WorkspaceFileGrepError'
}
}
/**
* True when file content is one of `readFileRecord`'s non-text placeholders
* (binary, unparseable, or over the inline read cap) — these carry no searchable
* content, so grepping them should report the placeholder instead.
*/
function isNonGreppablePlaceholder(content: string, totalLines: number): boolean {
if (totalLines !== 1) return false
return /^\[(File too large|Image too large|Document too large|Could not parse|Binary file|Compiled artifact too large)/.test(
content.trim()
)
}
/**
* Run a single-file content grep over an already-resolved file read result,
* shared by workspace-file grep (`WorkspaceVFS.grepFile`) and chat-upload grep.
* Throws {@link WorkspaceFileGrepError} when the file has no searchable text
* (image/binary attachment) or is a size/parse placeholder; otherwise greps the
* text with the standard {@link grep} engine over a one-entry map keyed by
* `path`. `readHint` is the path to suggest in the "use read(...)" message.
*/
export function grepReadResult(
path: string,
result: { content: string; totalLines: number; attachment?: unknown },
pattern: string,
readHint: string,
options?: GrepOptions
): GrepMatch[] | string[] | GrepCountEntry[] {
if (result.attachment) {
throw new WorkspaceFileGrepError(
`Cannot grep "${path}" — it has no searchable text (image/binary). Use read("${readHint}") to view it.`
)
}
if (isNonGreppablePlaceholder(result.content, result.totalLines)) {
throw new WorkspaceFileGrepError(result.content)
}
return grep(new Map([[path, result.content]]), pattern, undefined, options)
}
export interface ReadResult {
content: string
totalLines: number
}
/**
* Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*`
* never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace`
* and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for
* well-tested `**` and edge cases instead of a custom `RegExp`.
*/
const VFS_GLOB_OPTIONS: micromatch.Options = {
bash: false,
dot: false,
windows: false,
nobrace: true,
noext: true,
}
/**
* Splits VFS text into lines for line-oriented grep. Strips a trailing CR so Windows-style
* CRLF payloads still match patterns anchored at line end (`$`).
*/
function splitLinesForGrep(content: string): string[] {
return content.split('\n').map((line) => line.replace(/\r$/, ''))
}
/**
* Returns true when `filePath` is `scope` or a descendant path (`scope/...`). If `scope` contains
* `*` or `?`, filters with micromatch `isMatch` and {@link VFS_GLOB_OPTIONS}. Other characters
* (including `[`, `{`, spaces) use directory-prefix logic so literal VFS path segments are not
* parsed as glob syntax. Trailing slashes are stripped so `files/` and `files` both scope under
* `files/...`.
*
* Exported so the lazy VFS can resolve exactly the lazy artifacts a scoped grep will consider,
* keeping "what we materialize" identical to "what grep filters in".
*/
export function pathWithinGrepScope(filePath: string, scope: string): boolean {
const scopeUsesStarOrQuestionGlob = /[*?]/.test(scope)
if (scopeUsesStarOrQuestionGlob) {
return micromatch.isMatch(filePath, scope, VFS_GLOB_OPTIONS)
}
const base = scope.replace(/\/+$/, '')
if (base === '') {
return true
}
return filePath === base || filePath.startsWith(`${base}/`)
}
/**
* Regex search over VFS file contents using ECMAScript `RegExp` syntax.
* `content` and `count` are line-oriented (split on newline, CR stripped per line).
* `files_with_matches` tests the entire file string once, so multiline patterns can match there
* but not in line modes.
*/
export function grep(
files: Map<string, string>,
pattern: string,
path?: string,
opts?: GrepOptions
): GrepMatch[] | string[] | GrepCountEntry[] {
const maxResults = opts?.maxResults ?? 100
const outputMode = opts?.outputMode ?? 'content'
const ignoreCase = opts?.ignoreCase ?? false
const showLineNumbers = opts?.lineNumbers ?? true
const contextLines = opts?.context ?? 0
const flags = ignoreCase ? 'gi' : 'g'
let regex: RegExp
try {
regex = new RegExp(pattern, flags)
} catch {
return []
}
if (outputMode === 'files_with_matches') {
const matchingFiles: string[] = []
for (const [filePath, content] of files) {
if (path && !pathWithinGrepScope(filePath, path)) continue
regex.lastIndex = 0
if (regex.test(content)) {
matchingFiles.push(filePath)
if (matchingFiles.length >= maxResults) break
}
}
return matchingFiles
}
if (outputMode === 'count') {
const counts: GrepCountEntry[] = []
for (const [filePath, content] of files) {
if (path && !pathWithinGrepScope(filePath, path)) continue
const lines = splitLinesForGrep(content)
let count = 0
for (const line of lines) {
regex.lastIndex = 0
if (regex.test(line)) count++
}
if (count > 0) {
counts.push({ path: filePath, count })
if (counts.length >= maxResults) break
}
}
return counts
}
// Default: 'content' mode
const matches: GrepMatch[] = []
for (const [filePath, content] of files) {
if (path && !pathWithinGrepScope(filePath, path)) continue
const lines = splitLinesForGrep(content)
for (let i = 0; i < lines.length; i++) {
regex.lastIndex = 0
if (regex.test(lines[i])) {
if (contextLines > 0) {
const start = Math.max(0, i - contextLines)
const end = Math.min(lines.length - 1, i + contextLines)
for (let j = start; j <= end; j++) {
matches.push({
path: filePath,
line: showLineNumbers ? j + 1 : 0,
content: lines[j],
})
}
} else {
matches.push({
path: filePath,
line: showLineNumbers ? i + 1 : 0,
content: lines[i],
})
}
if (matches.length >= maxResults) return matches
}
}
}
return matches
}
/**
* Glob pattern matching against VFS file paths and virtual directories using `micromatch`
* with {@link VFS_GLOB_OPTIONS} (path-aware `*` and `?`, `**`, no brace or extglob expansion).
* Returns matching file keys and virtual directory prefixes.
*/
export function glob(files: Map<string, string>, pattern: string): string[] {
const result = new Set<string>()
const directories = new Set<string>()
for (const filePath of files.keys()) {
if (filePath.endsWith('/.folder')) {
directories.add(filePath.slice(0, -'/.folder'.length))
continue
}
const parts = filePath.split('/')
for (let i = 1; i < parts.length; i++) {
directories.add(parts.slice(0, i).join('/'))
}
}
for (const filePath of files.keys()) {
if (filePath.endsWith('/.folder')) continue
if (micromatch.isMatch(filePath, pattern, VFS_GLOB_OPTIONS)) {
result.add(filePath)
}
}
for (const dir of directories) {
if (micromatch.isMatch(dir, pattern, VFS_GLOB_OPTIONS)) {
result.add(dir)
}
}
return Array.from(result).sort()
}
/**
* Read a VFS file's content, optionally with offset and limit.
* Returns null if the file does not exist.
*/
export function read(
files: Map<string, string>,
path: string,
offset?: number,
limit?: number
): ReadResult | null {
let content = files.get(path)
// Fallback: normalize Unicode and retry for encoding mismatches
if (content === undefined) {
const normalized = path.normalize('NFC')
content = files.get(normalized)
if (content === undefined) {
for (const [key, value] of files) {
if (key.normalize('NFC') === normalized) {
content = value
break
}
}
}
}
if (content === undefined) return null
const lines = content.split('\n')
const totalLines = lines.length
if (offset !== undefined || limit !== undefined) {
const rawStart = Number.isFinite(offset) ? (offset as number) : 0
const start = Math.max(0, Math.min(totalLines, rawStart))
const rawEnd = limit !== undefined ? start + Math.max(0, limit) : totalLines
const end = Math.max(start, Math.min(totalLines, rawEnd))
return {
content: lines.slice(start, end).join('\n'),
totalLines,
}
}
return { content, totalLines }
}
/**
* Find VFS paths similar to a missing path.
*
* Handles two cases:
* 1. Wrong filename: `components/blocks/gmail.json` → `gmail_v2.json`
* Matches by filename stem similarity within the same directory.
* 2. Wrong directory: `workflows/Untitled/state.json` → `Untitled Workflow`
* Matches by parent directory name similarity with the same filename.
*/
export function suggestSimilar(files: Map<string, string>, missingPath: string, max = 5): string[] {
const segments = missingPath.split('/')
const filename = segments[segments.length - 1].toLowerCase()
const fileStem = filename.replace(/\.[^.]+$/, '')
const parentDir = segments.length >= 2 ? segments[segments.length - 2].toLowerCase() : ''
const topDir = segments.length >= 1 ? `${segments[0]}/` : ''
const scored: Array<{ path: string; score: number }> = []
for (const vfsPath of files.keys()) {
const vfsSegments = vfsPath.split('/')
const vfsFilename = vfsSegments[vfsSegments.length - 1].toLowerCase()
const vfsStem = vfsFilename.replace(/\.[^.]+$/, '')
const vfsParentDir =
vfsSegments.length >= 2 ? vfsSegments[vfsSegments.length - 2].toLowerCase() : ''
const sameTopDir = topDir && vfsPath.startsWith(topDir)
// Same filename, different directory — the directory name is wrong.
// e.g. workflows/Untitled/state.json vs workflows/Untitled Workflow/state.json
if (vfsFilename === filename && vfsParentDir !== parentDir && sameTopDir) {
if (vfsParentDir.includes(parentDir) || parentDir.includes(vfsParentDir)) {
scored.push({ path: vfsPath, score: 95 })
continue
}
}
// Same directory, different filename — the filename is wrong.
const sameDir =
segments.length === vfsSegments.length &&
segments.slice(0, -1).join('/') === vfsSegments.slice(0, -1).join('/')
if (sameDir) {
if (vfsStem === fileStem) {
scored.push({ path: vfsPath, score: 100 })
} else if (vfsStem.includes(fileStem) || fileStem.includes(vfsStem)) {
scored.push({ path: vfsPath, score: 80 })
} else if (vfsFilename.includes(fileStem.replace(/[_-]/g, ''))) {
scored.push({ path: vfsPath, score: 60 })
}
} else if (sameTopDir && vfsStem === fileStem) {
// Same top-level directory and matching stem but different depth/parent
scored.push({ path: vfsPath, score: 50 })
}
}
scored.sort((a, b) => b.score - a.score)
return scored.slice(0, max).map((s) => s.path)
}
@@ -0,0 +1,58 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildVfsFolderPathMap,
canonicalBlockVfsPath,
canonicalKnowledgeBaseVfsDir,
canonicalTableVfsPath,
canonicalWorkflowVfsDir,
canonicalWorkspaceFilePath,
decodeVfsPathSegments,
encodeVfsPathSegments,
} from '@/lib/copilot/vfs/path-utils'
describe('VFS path utilities', () => {
it('round trips encoded nested path segments', () => {
const segments = ['Reports', 'Q4 Report (Final)', 'sales/east.csv']
const encoded = encodeVfsPathSegments(segments)
expect(encoded).toBe('Reports/Q4%20Report%20(Final)/sales%2Feast.csv')
expect(decodeVfsPathSegments(encoded)).toEqual(segments)
})
it('builds canonical workspace file leaf paths', () => {
expect(
canonicalWorkspaceFilePath({
folderPath: 'Reports/Q4 Report (Final)',
name: 'sales/east.csv',
})
).toBe('files/Reports/Q4%20Report%20(Final)/sales%2Feast.csv')
})
})
describe('canonical resource VFS paths', () => {
it('builds nested + encoded folder path map', () => {
const map = buildVfsFolderPathMap([
{ folderId: 'root', folderName: 'My Folder', parentId: null },
{ folderId: 'child', folderName: 'Sub Folder', parentId: 'root' },
])
expect(map.get('root')).toBe('My%20Folder')
expect(map.get('child')).toBe('My%20Folder/Sub%20Folder')
})
it('builds workflow dirs at root and nested in folders', () => {
expect(canonicalWorkflowVfsDir({ name: 'My Flow' })).toBe('workflows/My%20Flow')
expect(
canonicalWorkflowVfsDir({ name: 'My Flow', folderPath: 'My%20Folder/Sub%20Folder' })
).toBe('workflows/My%20Folder/Sub%20Folder/My%20Flow')
})
it('builds table, knowledge base, and block pointers', () => {
expect(canonicalTableVfsPath('Sales Data')).toBe('tables/Sales%20Data/meta.json')
expect(canonicalKnowledgeBaseVfsDir('Docs — KB')).toBe('knowledgebases/Docs%20%E2%80%94%20KB')
expect(canonicalBlockVfsPath('agent')).toBe('components/blocks/agent.json')
})
})
+125
View File
@@ -0,0 +1,125 @@
const CONTROL_CHARS = /[\x00-\x1f\x7f]/g
const WHITESPACE = /\s+/g
export class VfsPathError extends Error {
constructor(message: string) {
super(message)
this.name = 'VfsPathError'
}
}
function normalizeDisplaySegment(segment: string): string {
return segment.normalize('NFC').trim().replace(CONTROL_CHARS, '').replace(WHITESPACE, ' ')
}
export function encodeVfsSegment(segment: string): string {
const normalized = normalizeDisplaySegment(segment)
if (!normalized || normalized === '.' || normalized === '..') {
throw new VfsPathError('VFS path segment cannot be empty or a dot segment')
}
return encodeURIComponent(normalized)
}
export function decodeVfsSegment(segment: string): string {
try {
const decoded = decodeURIComponent(segment)
const normalized = normalizeDisplaySegment(decoded)
if (!normalized || normalized === '.' || normalized === '..') {
throw new VfsPathError('VFS path segment cannot be empty or a dot segment')
}
return normalized
} catch (error) {
if (error instanceof VfsPathError) throw error
throw new VfsPathError(`Invalid encoded VFS path segment: ${segment}`)
}
}
export function encodeVfsPathSegments(segments: string[]): string {
return segments.map(encodeVfsSegment).join('/')
}
export function decodeVfsPathSegments(path: string): string[] {
const trimmed = path.trim().replace(/^\/+|\/+$/g, '')
if (!trimmed) return []
return trimmed.split('/').map(decodeVfsSegment)
}
export function canonicalizeVfsPath(path: string): string {
return encodeVfsPathSegments(decodeVfsPathSegments(path))
}
export function canonicalWorkspaceFilePath(parts: {
folderPath?: string | null
name: string
prefix?: 'files' | 'recently-deleted/files'
}): string {
const prefix = parts.prefix ?? 'files'
const folderSegments = parts.folderPath ? parts.folderPath.split('/').filter(Boolean) : []
const encoded = encodeVfsPathSegments([...folderSegments, parts.name])
return `${prefix}/${encoded}`
}
/**
* Build a map from folderId to its canonical, per-segment-encoded VFS folder
* path (e.g. `My%20Folder/Sub`), resolving nested folders via `parentId`.
*
* Shared by the workspace VFS materializer (`workspace-vfs.ts`) and the chat
* context resolver (`process-contents.ts`) so workflow/folder pointer paths
* cannot drift from what the VFS actually serves. Works for any folder
* hierarchy that exposes `{ folderId, folderName, parentId }` rows (workflow
* folders and file folders both qualify).
*/
export function buildVfsFolderPathMap(
folders: Array<{ folderId: string; folderName: string; parentId: string | null }>
): Map<string, string> {
const folderMap = new Map<string, { name: string; parentId: string | null }>()
for (const f of folders) {
folderMap.set(f.folderId, { name: f.folderName, parentId: f.parentId })
}
const cache = new Map<string, string>()
const resolve = (id: string): string => {
const cached = cache.get(id)
if (cached !== undefined) return cached
const folder = folderMap.get(id)
if (!folder) return ''
const parentPath = folder.parentId ? resolve(folder.parentId) : ''
const path = parentPath
? `${parentPath}/${encodeVfsSegment(folder.name)}`
: encodeVfsSegment(folder.name)
cache.set(id, path)
return path
}
for (const id of folderMap.keys()) resolve(id)
return cache
}
/**
* Canonical VFS directory for a workflow. `folderPath` is the already
* per-segment-encoded folder path (from {@link buildVfsFolderPathMap}) or
* null/empty for a root-level workflow. Mirrors the prefix built by
* `workspace-vfs.ts` (`workflows/{folder}/{name}` or `workflows/{name}`).
*/
export function canonicalWorkflowVfsDir(parts: {
name: string
folderPath?: string | null
}): string {
const safeName = encodeVfsSegment(parts.name)
return parts.folderPath ? `workflows/${parts.folderPath}/${safeName}` : `workflows/${safeName}`
}
/** Canonical VFS path for a table's metadata file (`tables/{name}/meta.json`). */
export function canonicalTableVfsPath(name: string): string {
return `tables/${encodeVfsSegment(name)}/meta.json`
}
/** Canonical VFS directory for a knowledge base (`knowledgebases/{name}`). */
export function canonicalKnowledgeBaseVfsDir(name: string): string {
return `knowledgebases/${encodeVfsSegment(name)}`
}
/** Canonical VFS path for a block catalog entry (`components/blocks/{type}.json`). */
export function canonicalBlockVfsPath(blockType: string): string {
return `components/blocks/${blockType}.json`
}
@@ -0,0 +1,314 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
class FileConflictError extends Error {
readonly code = 'FILE_EXISTS' as const
}
return {
FileConflictError,
ensureWorkflowAliasBacking: vi.fn(),
ensureWorkspacePlanBacking: vi.fn(),
resolveWorkflowAliasForWorkspace: vi.fn(),
ensureWorkspaceFileFolderPath: vi.fn(),
findWorkspaceFileFolderIdByPath: vi.fn(),
normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()),
getWorkspaceFileByName: vi.fn(),
resolveWorkspaceFileReference: vi.fn(),
updateWorkspaceFileContent: vi.fn(),
uploadWorkspaceFile: vi.fn(),
}
})
vi.mock('@/lib/copilot/vfs/workflow-alias-backing', () => ({
ensureWorkflowAliasBacking: mocks.ensureWorkflowAliasBacking,
ensureWorkspacePlanBacking: mocks.ensureWorkspacePlanBacking,
}))
vi.mock('@/lib/copilot/vfs/workflow-alias-resolver', () => ({
resolveWorkflowAliasForWorkspace: mocks.resolveWorkflowAliasForWorkspace,
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath,
findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath,
normalizeWorkspaceFileItemName: mocks.normalizeWorkspaceFileItemName,
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
FileConflictError: mocks.FileConflictError,
getWorkspaceFileByName: mocks.getWorkspaceFileByName,
resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference,
updateWorkspaceFileContent: mocks.updateWorkspaceFileContent,
uploadWorkspaceFile: mocks.uploadWorkspaceFile,
}))
import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer'
describe('resource writer workflow aliases', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ensureWorkflowAliasBacking.mockResolvedValue({})
mocks.ensureWorkspacePlanBacking.mockResolvedValue({})
mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-id')
})
it('creates workflow plan aliases through backing workspace files', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({
kind: 'plan_file',
scope: 'workflow',
workflowId: 'wf_1',
workflowName: 'My Workflow',
workflowPath: 'workflows/My%20Workflow',
aliasPath: 'workflows/My%20Workflow/.plans/launch.md',
backingPath: 'files/.plans/wf_1/launch.md',
backingFolderPath: 'files/.plans/wf_1',
planRelativePath: 'launch.md',
})
mocks.getWorkspaceFileByName.mockResolvedValue(null)
mocks.uploadWorkspaceFile.mockResolvedValue({
id: 'file-plan',
name: 'launch.md',
size: 7,
type: 'text/markdown',
url: '/download',
})
const result = await writeWorkspaceFileByPath({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: 'workflows/My%20Workflow/.plans/launch.md',
mode: 'create',
},
buffer: Buffer.from('content'),
inferredMimeType: 'text/markdown',
})
expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith(
'workspace-1',
'user-1',
Buffer.from('content'),
'launch.md',
'text/markdown',
{ folderId: 'folder-id', exactName: true }
)
expect(result).toMatchObject({
id: 'file-plan',
vfsPath: 'workflows/My%20Workflow/.plans/launch.md',
backingVfsPath: 'files/.plans/wf_1/launch.md',
mode: 'create',
})
})
it('overwrites workflow changelog aliases through backing workspace files', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({
kind: 'changelog',
scope: 'workflow',
workflowId: 'wf_1',
workflowName: 'My Workflow',
workflowPath: 'workflows/My%20Workflow',
aliasPath: 'workflows/My%20Workflow/changelog.md',
backingPath: 'files/.changelogs/wf_1.md',
backingFolderPath: 'files/.changelogs',
})
mocks.getWorkspaceFileByName.mockResolvedValue({
id: 'file-changelog',
name: 'wf_1.md',
type: 'text/markdown',
folderPath: '.changelogs',
})
mocks.updateWorkspaceFileContent.mockResolvedValue({
id: 'file-changelog',
name: 'wf_1.md',
size: 7,
type: 'text/markdown',
url: '/download',
folderPath: '.changelogs',
})
const result = await writeWorkspaceFileByPath({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: 'workflows/My%20Workflow/changelog.md',
mode: 'overwrite',
},
buffer: Buffer.from('updated'),
inferredMimeType: 'text/markdown',
})
expect(mocks.updateWorkspaceFileContent).toHaveBeenCalledWith(
'workspace-1',
'file-changelog',
'user-1',
Buffer.from('updated'),
'text/markdown'
)
expect(result).toMatchObject({
id: 'file-changelog',
vfsPath: 'workflows/My%20Workflow/changelog.md',
backingVfsPath: 'files/.changelogs/wf_1.md',
mode: 'overwrite',
})
})
it('creates root workspace plan aliases through workspace backing files', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({
kind: 'plan_file',
scope: 'workspace',
aliasPath: '.plans/root.md',
backingPath: 'files/.plans/workspace/root.md',
backingFolderPath: 'files/.plans/workspace',
planRelativePath: 'root.md',
})
mocks.getWorkspaceFileByName.mockResolvedValue(null)
mocks.uploadWorkspaceFile.mockResolvedValue({
id: 'file-root-plan',
name: 'root.md',
size: 7,
type: 'text/markdown',
url: '/download',
})
const result = await writeWorkspaceFileByPath({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: '.plans/root.md',
mode: 'create',
},
buffer: Buffer.from('content'),
inferredMimeType: 'text/markdown',
})
expect(mocks.ensureWorkspacePlanBacking).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
})
expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
pathSegments: ['.plans', 'workspace'],
})
expect(result).toMatchObject({
id: 'file-root-plan',
vfsPath: '.plans/root.md',
backingVfsPath: 'files/.plans/workspace/root.md',
mode: 'create',
})
})
it('rejects direct writes to reserved workflow alias backing paths', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null)
await expect(
writeWorkspaceFileByPath({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: 'files/.plans/wf_1/launch.md',
mode: 'create',
},
buffer: Buffer.from('content'),
inferredMimeType: 'text/markdown',
})
).rejects.toThrow(
'Reserved workflow alias backing paths must be accessed through their alias path'
)
expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled()
})
it('rejects validation of reserved workflow alias backing paths', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue(null)
await expect(
validateWorkspaceFileWriteTarget({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: 'files/.changelogs/wf_1.md',
mode: 'overwrite',
},
})
).rejects.toThrow(
'Reserved workflow alias backing paths must be accessed through their alias path'
)
expect(mocks.resolveWorkspaceFileReference).not.toHaveBeenCalled()
})
it('uses exact-name creates for alias backing files', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({
kind: 'plan_file',
scope: 'workflow',
workflowId: 'wf_1',
workflowName: 'My Workflow',
workflowPath: 'workflows/My%20Workflow',
aliasPath: 'workflows/My%20Workflow/.plans/launch.md',
backingPath: 'files/.plans/wf_1/launch.md',
backingFolderPath: 'files/.plans/wf_1',
planRelativePath: 'launch.md',
})
mocks.getWorkspaceFileByName.mockResolvedValue(null)
mocks.uploadWorkspaceFile.mockResolvedValue({
id: 'file-plan',
name: 'launch.md',
size: 7,
type: 'text/markdown',
url: '/download',
})
await writeWorkspaceFileByPath({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: 'workflows/My%20Workflow/.plans/launch.md',
mode: 'create',
},
buffer: Buffer.from('content'),
inferredMimeType: 'text/markdown',
})
expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith(
'workspace-1',
'user-1',
Buffer.from('content'),
'launch.md',
'text/markdown',
{ folderId: 'folder-id', exactName: true }
)
})
it('reports alias path when exact-name alias backing creation conflicts', async () => {
mocks.resolveWorkflowAliasForWorkspace.mockResolvedValue({
kind: 'plan_file',
scope: 'workflow',
workflowId: 'wf_1',
workflowName: 'My Workflow',
workflowPath: 'workflows/My%20Workflow',
aliasPath: 'workflows/My%20Workflow/.plans/launch.md',
backingPath: 'files/.plans/wf_1/launch.md',
backingFolderPath: 'files/.plans/wf_1',
planRelativePath: 'launch.md',
})
mocks.getWorkspaceFileByName.mockResolvedValue(null)
mocks.uploadWorkspaceFile.mockRejectedValue(new mocks.FileConflictError('launch.md'))
await expect(
writeWorkspaceFileByPath({
workspaceId: 'workspace-1',
userId: 'user-1',
target: {
path: 'workflows/My%20Workflow/.plans/launch.md',
mode: 'create',
},
buffer: Buffer.from('content'),
inferredMimeType: 'text/markdown',
})
).rejects.toThrow(
'File already exists at workflows/My%20Workflow/.plans/launch.md. Use mode "overwrite" to update it.'
)
})
})
+410
View File
@@ -0,0 +1,410 @@
import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import {
ensureWorkflowAliasBacking,
ensureWorkspacePlanBacking,
} from '@/lib/copilot/vfs/workflow-alias-backing'
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
import {
isPlanAliasPath,
isWorkflowAliasBackingPath,
WORKFLOW_CHANGELOG_BACKING_FOLDER,
WORKFLOW_PLANS_BACKING_FOLDER,
WORKSPACE_PLANS_BACKING_FOLDER,
type WorkflowAliasTarget,
} from '@/lib/copilot/vfs/workflow-aliases'
import {
ensureWorkspaceFileFolderPath,
findWorkspaceFileFolderIdByPath,
normalizeWorkspaceFileItemName,
} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
FileConflictError,
getWorkspaceFileByName,
resolveWorkspaceFileReference,
updateWorkspaceFileContent,
uploadWorkspaceFile,
type WorkspaceFileRecord,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
export type WorkspaceFileWriteMode = 'create' | 'overwrite'
export interface WorkspaceFileWriteTarget {
path: string
mode: WorkspaceFileWriteMode
mimeType?: string
}
export interface WorkspaceFileWriteResult {
id: string
name: string
size: number
contentType: string
downloadUrl?: string
vfsPath: string
backingVfsPath?: string
mode: WorkspaceFileWriteMode
}
interface ResolvedCreateTarget {
fileName: string
folderId: string | null
vfsPath: string
}
export type WorkspaceFileWriteValidation =
| {
mode: 'create'
vfsPath: string
backingVfsPath?: string
fileName: string
folderId: string | null
}
| {
mode: 'overwrite'
vfsPath: string
backingVfsPath?: string
existingFileId: string
}
function displayFolderPath(segments: string[]): string {
return segments.length > 0 ? `files/${segments.join('/')}` : 'files/'
}
export function parseWorkspaceFileCreatePath(path: string): {
folderSegments: string[]
fileName: string
vfsPath: string
} {
const trimmed = path.trim().replace(/^\/+/, '')
if (!trimmed.startsWith('files/')) {
throw new Error('Workspace file paths must start with "files/"')
}
const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length))
if (decoded.length === 0) {
throw new Error('Workspace file path must include a file name')
}
const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File')
const folderSegments = decoded
.slice(0, -1)
.map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder'))
return {
folderSegments,
fileName,
vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }),
}
}
async function resolveCreateTarget(
workspaceId: string,
path: string
): Promise<ResolvedCreateTarget> {
const parsed = parseWorkspaceFileCreatePath(path)
const folderId =
parsed.folderSegments.length > 0
? await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments, {
includeReservedSystemFolders: true,
})
: null
if (parsed.folderSegments.length > 0 && !folderId) {
throw new Error(
`Directory not yet created: ${displayFolderPath(parsed.folderSegments)}. Create the directory first, then retry the file write.`
)
}
const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId })
if (existing) {
throw new Error(`File already exists at ${parsed.vfsPath}. Use mode "overwrite" to update it.`)
}
return {
fileName: parsed.fileName,
folderId,
vfsPath: parsed.vfsPath,
}
}
function vfsPathForRecord(record: WorkspaceFileRecord): string {
return canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name })
}
function assertNotReservedWorkflowAliasBackingPath(path: string): void {
if (isWorkflowAliasBackingPath(path)) {
throw new Error(
`Reserved workflow alias backing paths must be accessed through their alias path: ${path}`
)
}
}
async function resolveWorkflowAliasFileTarget(args: {
workspaceId: string
userId?: string
alias: WorkflowAliasTarget
}): Promise<ResolvedCreateTarget & { existingFile?: WorkspaceFileRecord | null }> {
if (args.alias.kind === 'plans_dir') {
throw new Error(`Cannot write file content to plan alias directory: ${args.alias.aliasPath}`)
}
if (args.userId && args.alias.scope === 'workflow') {
await ensureWorkflowAliasBacking({
workspaceId: args.workspaceId,
userId: args.userId,
workflowId: args.alias.workflowId,
workflowName: args.alias.workflowName,
})
} else if (args.userId && args.alias.scope === 'workspace') {
await ensureWorkspacePlanBacking({
workspaceId: args.workspaceId,
userId: args.userId,
})
}
if (args.alias.kind === 'changelog') {
const folderSegments = [WORKFLOW_CHANGELOG_BACKING_FOLDER]
const folderId = args.userId
? await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: folderSegments,
})
: await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, {
includeReservedSystemFolders: true,
})
if (!folderId) {
throw new Error(
`Workflow changelog backing folder is not provisioned for ${args.alias.aliasPath}`
)
}
const fileName = `${args.alias.workflowId}.md`
return {
fileName,
folderId,
vfsPath: args.alias.aliasPath,
existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }),
}
}
const relativeSegments = decodeVfsPathSegments(args.alias.planRelativePath ?? '')
if (relativeSegments.length === 0) {
throw new Error(`Workflow plan alias must include a file path: ${args.alias.aliasPath}`)
}
const fileName = normalizeWorkspaceFileItemName(relativeSegments.at(-1) ?? '', 'File')
const folderSegments = [
WORKFLOW_PLANS_BACKING_FOLDER,
args.alias.scope === 'workflow' ? args.alias.workflowId : WORKSPACE_PLANS_BACKING_FOLDER,
...relativeSegments.slice(0, -1),
].map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder'))
const folderId = args.userId
? await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: folderSegments,
})
: await findWorkspaceFileFolderIdByPath(args.workspaceId, folderSegments, {
includeReservedSystemFolders: true,
})
if (!folderId) {
throw new Error(`Plan backing directory is not provisioned for ${args.alias.aliasPath}.`)
}
return {
fileName,
folderId,
vfsPath: args.alias.aliasPath,
existingFile: await getWorkspaceFileByName(args.workspaceId, fileName, { folderId }),
}
}
export async function validateWorkspaceFileWriteTarget(args: {
workspaceId: string
userId?: string
target: WorkspaceFileWriteTarget
}): Promise<WorkspaceFileWriteValidation> {
const alias = await resolveWorkflowAliasForWorkspace({
workspaceId: args.workspaceId,
path: args.target.path,
})
if (!alias && isPlanAliasPath(args.target.path)) {
throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`)
}
if (alias) {
const resolved = await resolveWorkflowAliasFileTarget({
workspaceId: args.workspaceId,
userId: args.userId,
alias,
})
if (args.target.mode === 'overwrite') {
if (!resolved.existingFile) {
throw new Error(`File not found for overwrite: ${alias.aliasPath}`)
}
return {
mode: 'overwrite',
vfsPath: alias.aliasPath,
backingVfsPath: alias.backingPath,
existingFileId: resolved.existingFile.id,
}
}
if (resolved.existingFile) {
throw new Error(
`File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.`
)
}
return {
mode: 'create',
vfsPath: alias.aliasPath,
backingVfsPath: alias.backingPath,
fileName: resolved.fileName,
folderId: resolved.folderId,
}
}
assertNotReservedWorkflowAliasBackingPath(args.target.path)
if (args.target.mode === 'overwrite') {
const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path)
if (!existing) {
throw new Error(`File not found for overwrite: ${args.target.path}`)
}
return {
mode: 'overwrite',
vfsPath: vfsPathForRecord(existing),
existingFileId: existing.id,
}
}
const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path)
return {
mode: 'create',
vfsPath: createTarget.vfsPath,
fileName: createTarget.fileName,
folderId: createTarget.folderId,
}
}
export async function writeWorkspaceFileByPath(args: {
workspaceId: string
userId: string
target: WorkspaceFileWriteTarget
buffer: Buffer
inferredMimeType: string
}): Promise<WorkspaceFileWriteResult> {
const contentType = args.target.mimeType || args.inferredMimeType
const alias = await resolveWorkflowAliasForWorkspace({
workspaceId: args.workspaceId,
path: args.target.path,
})
if (!alias && isPlanAliasPath(args.target.path)) {
throw new Error(`Unsupported plan alias path or missing workflow: ${args.target.path}`)
}
if (alias) {
const resolved = await resolveWorkflowAliasFileTarget({
workspaceId: args.workspaceId,
userId: args.userId,
alias,
})
if (args.target.mode === 'overwrite') {
if (!resolved.existingFile) {
throw new Error(`File not found for overwrite: ${alias.aliasPath}`)
}
const updated = await updateWorkspaceFileContent(
args.workspaceId,
resolved.existingFile.id,
args.userId,
args.buffer,
contentType || resolved.existingFile.type
)
return {
id: updated.id,
name: updated.name,
size: updated.size,
contentType: updated.type,
downloadUrl: updated.url,
vfsPath: alias.aliasPath,
backingVfsPath: vfsPathForRecord(updated),
mode: 'overwrite',
}
}
if (resolved.existingFile) {
throw new Error(
`File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.`
)
}
const uploaded = await uploadWorkspaceFile(
args.workspaceId,
args.userId,
args.buffer,
resolved.fileName,
contentType,
{ folderId: resolved.folderId, exactName: true }
).catch((error: unknown) => {
if (error instanceof FileConflictError) {
throw new Error(
`File already exists at ${alias.aliasPath}. Use mode "overwrite" to update it.`
)
}
throw error
})
return {
id: uploaded.id,
name: uploaded.name,
size: uploaded.size,
contentType: uploaded.type,
downloadUrl: uploaded.url,
vfsPath: alias.aliasPath,
backingVfsPath: alias.backingPath,
mode: 'create',
}
}
assertNotReservedWorkflowAliasBackingPath(args.target.path)
if (args.target.mode === 'overwrite') {
const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path)
if (!existing) {
throw new Error(`File not found for overwrite: ${args.target.path}`)
}
const updated = await updateWorkspaceFileContent(
args.workspaceId,
existing.id,
args.userId,
args.buffer,
contentType || existing.type
)
return {
id: updated.id,
name: updated.name,
size: updated.size,
contentType: updated.type,
downloadUrl: updated.url,
vfsPath: vfsPathForRecord(updated),
mode: 'overwrite',
}
}
const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path)
const uploaded = await uploadWorkspaceFile(
args.workspaceId,
args.userId,
args.buffer,
createTarget.fileName,
contentType,
{ folderId: createTarget.folderId }
)
return {
id: uploaded.id,
name: uploaded.name,
size: uploaded.size,
contentType: uploaded.type,
downloadUrl: uploaded.url,
vfsPath: createTarget.vfsPath,
mode: 'create',
}
}
+963
View File
@@ -0,0 +1,963 @@
import { truncate } from '@sim/utils/string'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { isHosted } from '@/lib/core/config/env-flags'
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models'
import type { ToolConfig } from '@/tools/types'
/**
* Serialize workflow metadata for VFS meta.json.
*
* `locked` is the EFFECTIVE lock — true when the workflow is locked directly or
* sits inside a locked folder. A locked workflow cannot be edited, moved,
* renamed, or deleted (mutations are rejected server-side with a 423). The
* mothership should read this before attempting any workflow mutation.
* `inheritedFolderLock` carries the resolved containing-folder lock (the
* caller computes folder inheritance; see workspace-vfs materializeWorkflows).
*/
export function serializeWorkflowMeta(
wf: {
id: string
name: string
description?: string | null
folderId?: string | null
isDeployed: boolean
deployedAt?: Date | null
runCount: number
lastRunAt?: Date | null
createdAt: Date
updatedAt: Date
locked?: boolean
},
options?: { inheritedFolderLock?: boolean }
): string {
const directLock = wf.locked ?? false
const locked = directLock || (options?.inheritedFolderLock ?? false)
return JSON.stringify(
{
id: wf.id,
name: wf.name,
description: wf.description || undefined,
folderId: wf.folderId || undefined,
locked,
lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined,
isDeployed: wf.isDeployed,
deployedAt: wf.deployedAt?.toISOString(),
runCount: wf.runCount,
lastRunAt: wf.lastRunAt?.toISOString(),
createdAt: wf.createdAt.toISOString(),
updatedAt: wf.updatedAt.toISOString(),
},
null,
2
)
}
/**
* Serialize execution logs for VFS executions.json.
* Takes recent execution log rows and produces a summary.
*/
export function serializeRecentExecutions(
executions: Array<{
id: string
executionId: string
status: string
trigger: string
startedAt: Date
endedAt?: Date | null
totalDurationMs?: number | null
}>
): string {
return JSON.stringify(
executions.map((e) => ({
executionId: e.executionId,
status: e.status,
trigger: e.trigger,
startedAt: e.startedAt.toISOString(),
endedAt: e.endedAt?.toISOString(),
durationMs: e.totalDurationMs,
})),
null,
2
)
}
/**
* Serialize knowledge base metadata for VFS meta.json
*/
export function serializeKBMeta(kb: {
id: string
name: string
description?: string | null
embeddingModel: string
embeddingDimension: number
tokenCount: number
createdAt: Date
updatedAt: Date
documentCount: number
connectorTypes?: string[]
}): string {
return JSON.stringify(
{
id: kb.id,
name: kb.name,
description: kb.description || undefined,
embeddingModel: kb.embeddingModel,
embeddingDimension: kb.embeddingDimension,
tokenCount: kb.tokenCount,
documentCount: kb.documentCount,
connectorTypes:
kb.connectorTypes && kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined,
createdAt: kb.createdAt.toISOString(),
updatedAt: kb.updatedAt.toISOString(),
},
null,
2
)
}
/**
* Serialize documents list for VFS documents.json (metadata only, no content)
*/
export function serializeDocuments(
docs: Array<{
id: string
filename: string
fileSize: number
mimeType: string
chunkCount: number
tokenCount: number
processingStatus: string
enabled: boolean
uploadedAt: Date
}>
): string {
return JSON.stringify(
docs.map((d) => ({
id: d.id,
filename: d.filename,
fileSize: d.fileSize,
mimeType: d.mimeType,
chunkCount: d.chunkCount,
tokenCount: d.tokenCount,
processingStatus: d.processingStatus,
enabled: d.enabled,
uploadedAt: d.uploadedAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize KB connectors for VFS knowledgebases/{name}/connectors.json.
* Shows connector type, sync status, and schedule — NOT credentials or source config.
*/
export function serializeConnectors(
connectors: Array<{
id: string
connectorType: string
status: string
syncMode: string
syncIntervalMinutes: number
lastSyncAt: Date | null
lastSyncError: string | null
lastSyncDocCount: number | null
nextSyncAt: Date | null
consecutiveFailures: number
createdAt: Date
}>
): string {
return JSON.stringify(
connectors.map((c) => ({
id: c.id,
connectorType: c.connectorType,
status: c.status,
syncMode: c.syncMode,
syncIntervalMinutes: c.syncIntervalMinutes,
lastSyncAt: c.lastSyncAt?.toISOString(),
lastSyncError: c.lastSyncError || undefined,
lastSyncDocCount: c.lastSyncDocCount ?? undefined,
nextSyncAt: c.nextSyncAt?.toISOString(),
consecutiveFailures: c.consecutiveFailures,
createdAt: c.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Connector config field shape (mirrors ConnectorConfigField from connectors/types.ts
* but avoids importing React-dependent code into serializers).
*/
interface SerializableConfigField {
id: string
title: string
type: string
placeholder?: string
required?: boolean
description?: string
options?: Array<{ label: string; id: string }>
}
interface SerializableTagDef {
id: string
displayName: string
fieldType: string
}
interface SerializableConnectorConfig {
id: string
name: string
description: string
version: string
auth: { mode: string; provider?: string; requiredScopes?: string[] }
configFields: SerializableConfigField[]
tagDefinitions?: SerializableTagDef[]
supportsIncrementalSync?: boolean
}
/**
* Serialize a single connector type's schema for VFS knowledgebases/connectors/{type}.json.
* Contains everything the LLM needs to build a valid sourceConfig.
*/
export function serializeConnectorSchema(connector: SerializableConnectorConfig): string {
return JSON.stringify(
{
id: connector.id,
name: connector.name,
description: connector.description,
version: connector.version,
auth: connector.auth,
configFields: connector.configFields.map((f) => {
const field: Record<string, unknown> = {
id: f.id,
title: f.title,
type: f.type,
}
if (f.required) field.required = true
if (f.placeholder) field.placeholder = f.placeholder
if (f.description) field.description = f.description
if (f.options) field.options = f.options
return field
}),
tagDefinitions: connector.tagDefinitions ?? [],
supportsIncrementalSync: connector.supportsIncrementalSync ?? false,
},
null,
2
)
}
/**
* Generate the knowledgebases/connectors/connectors.md overview file.
* Lists all available connector types with their OAuth providers — enough
* for the LLM to identify the right type and credential, then read the
* per-connector schema file for full config details.
*/
export function serializeConnectorOverview(connectors: SerializableConnectorConfig[]): string {
const rows = connectors.map((c) => {
const provider = c.auth.provider ?? c.auth.mode
const scopes = c.auth.requiredScopes?.length ? c.auth.requiredScopes.join(', ') : '(none)'
return `| ${c.id} | ${c.name} | ${provider} | ${scopes} |`
})
return [
'# Available KB Connectors',
'',
'Use `read("knowledgebases/connectors/{type}.json")` to get the full config schema before calling `add_connector`.',
'',
'| Type | Name | OAuth Provider | Required Scopes |',
'|------|------|---------------|-----------------|',
...rows,
'',
'To add a connector, the user must have an OAuth credential for that provider.',
'Check `environment/credentials.json` for available credential IDs.',
].join('\n')
}
/**
* Serialize workspace file metadata for VFS files/{path}/{name}/meta.json.
*/
export function serializeFileMeta(file: {
id: string
name: string
folderId?: string | null
folderPath?: string | null
vfsPath?: string
contentType: string
size: number
uploadedAt: Date
}): string {
return JSON.stringify(
{
id: file.id,
name: file.name,
folderId: file.folderId || undefined,
folderPath: file.folderPath || undefined,
vfsPath: file.vfsPath,
contentType: file.contentType,
size: file.size,
uploadedAt: file.uploadedAt.toISOString(),
readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined,
note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).',
},
null,
2
)
}
/**
* Serialize table metadata for VFS tables/{name}/meta.json
*/
export function serializeTableMeta(table: {
id: string
name: string
description?: string | null
schema: unknown
rowCount: number
maxRows: number
createdAt: Date | string
updatedAt: Date | string
}): string {
return JSON.stringify(
{
id: table.id,
name: table.name,
description: table.description || undefined,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() : table.createdAt,
updatedAt: table.updatedAt instanceof Date ? table.updatedAt.toISOString() : table.updatedAt,
},
null,
2
)
}
/**
* Returns the static model list from PROVIDER_DEFINITIONS for VFS serialization.
* Excludes dynamic providers (ollama, vllm, openrouter) whose models are user-configured.
* Includes provider ID and whether the model is hosted by Sim (no API key required).
*/
interface StaticModelOption {
id: string
provider: string
hosted: boolean
recommended?: boolean
speedOptimized?: boolean
deprecated?: boolean
}
const DYNAMIC_PROVIDERS_NOTE = {
note: 'The options array above lists Sim\'s static provider catalog. These providers also accept user-configured models that are NOT enumerated here: the user may have additional ids available at runtime (e.g. local Ollama tags). To reference one, prefix the model id with the provider slash below — for example "ollama/llama3.1:8b" instead of the bare "llama3.1:8b". The server rejects bare ids that are not in the catalog; always use the prefix for user-configured models.',
prefixes: DYNAMIC_MODEL_PROVIDERS.map((p) => `${p}/`),
} as const
function getStaticModelOptionsForVFS(): StaticModelOption[] {
const hostedProviders = new Set(['openai', 'anthropic', 'google'])
const dynamicProviders = new Set<string>(DYNAMIC_MODEL_PROVIDERS)
const models: StaticModelOption[] = []
for (const [providerId, def] of Object.entries(PROVIDER_DEFINITIONS)) {
if (dynamicProviders.has(providerId)) continue
for (const model of def.models) {
const option: StaticModelOption = {
id: model.id,
provider: providerId,
hosted: hostedProviders.has(providerId),
}
if (model.recommended) option.recommended = true
if (model.speedOptimized) option.speedOptimized = true
if (model.deprecated) option.deprecated = true
models.push(option)
}
}
return models
}
/**
* Serialize a SubBlockConfig for the VFS component schema.
* Strips functions and UI-only fields. Includes static options arrays.
*/
function serializeSubBlock(sb: SubBlockConfig): Record<string, unknown> {
const result: Record<string, unknown> = {
id: sb.id,
type: sb.type,
}
if (sb.title) result.title = sb.title
if (sb.required === true) result.required = true
if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue
if (sb.mode) result.mode = sb.mode
if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId
// Include static options arrays for dropdowns
if (Array.isArray(sb.options)) {
result.options = sb.options
}
return result
}
/**
* Serialize a block schema for VFS components/blocks/{type}.json
*/
export function serializeBlockSchema(block: BlockConfig): string {
// Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks;
// treat `hidden` as hidden for them so those never reach the agent's schema.
const customBlock = isCustomBlockType(block.type)
const hiddenIds = new Set(
block.subBlocks
.filter((sb) => isSubBlockHidden(sb) || (customBlock && sb.hidden))
.map((sb) => sb.id)
)
const subBlocks = block.subBlocks
.filter((sb) => !hiddenIds.has(sb.id))
.map((sb) => {
const serialized = serializeSubBlock(sb)
if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') {
serialized.options = getStaticModelOptionsForVFS()
serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE
}
return serialized
})
const inputs =
block.inputs && hiddenIds.size > 0
? Object.fromEntries(Object.entries(block.inputs).filter(([key]) => !hiddenIds.has(key)))
: block.inputs
return JSON.stringify(
{
type: block.type,
name: block.name,
description: block.description,
category: block.category,
longDescription: block.longDescription || undefined,
bestPractices: block.bestPractices || undefined,
triggerAllowed: block.triggerAllowed || undefined,
singleInstance: block.singleInstance || undefined,
// Custom (deploy-as-block) blocks execute via a baked `workflow_executor`
// internally; that's implementation plumbing, not something the agent
// configures. Hiding it keeps the block self-contained (fields in, outputs
// out) so the agent doesn't treat it like the generic workflow block and
// ask for a workflowId/inputMapping.
tools: isCustomBlockType(block.type) ? [] : block.tools.access,
subBlocks,
inputs,
outputs: Object.fromEntries(
Object.entries(block.outputs)
.filter(([key, val]) => key !== 'visualization' && val != null)
.map(([key, val]) => [
key,
typeof val === 'string'
? { type: val }
: { type: val.type, description: (val as { description?: string }).description },
])
),
},
null,
2
)
}
/**
* Serialize OAuth credentials for VFS environment/credentials.json.
* Shows which integrations are connected — IDs, roles, and scopes, NOT tokens.
*/
export function serializeCredentials(
accounts: Array<{
id?: string
providerId: string
displayName?: string | null
role?: string | null
scope: string | null
createdAt: Date
}>
): string {
return JSON.stringify(
accounts.map((a) => ({
id: a.id || undefined,
provider: a.providerId,
displayName: a.displayName || undefined,
role: a.role || undefined,
scope: a.scope || undefined,
connectedAt: a.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize API keys for VFS environment/api-keys.json.
* Shows key names and types — NOT the actual key values.
*/
export function serializeApiKeys(
keys: Array<{
id: string
name: string
type: string
lastUsed: Date | null
createdAt: Date
expiresAt: Date | null
}>
): string {
return JSON.stringify(
keys.map((k) => ({
id: k.id,
name: k.name,
type: k.type,
lastUsed: k.lastUsed?.toISOString(),
createdAt: k.createdAt.toISOString(),
expiresAt: k.expiresAt?.toISOString(),
})),
null,
2
)
}
/**
* Serialize environment variables for VFS environment/variables.json.
* Shows variable NAMES only — NOT values.
*/
export function serializeEnvironmentVariables(
personalVarNames: string[],
workspaceVarNames: string[]
): string {
return JSON.stringify(
{
personal: personalVarNames,
workspace: workspaceVarNames,
},
null,
2
)
}
/** Input types for deployment serialization. */
export interface DeploymentData {
workflowId: string
isDeployed: boolean
deployedAt?: Date | null
needsRedeployment?: boolean
api?: {
version: number
createdAt: Date
} | null
chat?: {
id: string
identifier: string
title: string
description?: string | null
authType: string
customizations: unknown
isActive: boolean
} | null
mcp: Array<{
serverId: string
serverName: string
toolId: string
toolName: string
toolDescription?: string | null
}>
versions?: Array<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
}>
}
/**
* Serialize all deployment configurations for VFS deployment.json.
* Only includes keys for active deployment types.
*/
export function serializeDeployments(data: DeploymentData): string {
const result: Record<string, unknown> = {}
if (data.needsRedeployment !== undefined) {
result.needsRedeployment = data.needsRedeployment
}
if (data.isDeployed) {
result.api = {
isDeployed: true,
deployedAt: data.deployedAt?.toISOString(),
apiEndpoint: `/api/workflows/${data.workflowId}/execute`,
...(data.api ? { version: data.api.version } : {}),
}
}
if (data.chat) {
result.chat = {
id: data.chat.id,
identifier: data.chat.identifier,
chatUrl: `/chat/${data.chat.identifier}`,
title: data.chat.title,
description: data.chat.description || undefined,
authType: data.chat.authType,
customizations: data.chat.customizations,
isActive: data.chat.isActive,
}
}
if (data.mcp.length > 0) {
result.mcp = data.mcp.map((m) => ({
serverId: m.serverId,
serverName: m.serverName,
toolId: m.toolId,
toolName: m.toolName,
toolDescription: m.toolDescription || undefined,
}))
}
return JSON.stringify(result, null, 2)
}
/**
* Serialize deployment version history for VFS workflows/{name}/versions.json.
* Lists all versions without full state — use the diff_workflows tool to compare a version,
* or load_deployment to restore one into the draft.
*/
export function serializeVersions(
versions: Array<{
id: string
version: number
name: string | null
description: string | null
isActive: boolean
createdAt: Date
}>
): string {
return JSON.stringify(
versions.map((v) => ({
id: v.id,
version: v.version,
name: v.name || undefined,
description: v.description || undefined,
isActive: v.isActive,
createdAt: v.createdAt.toISOString(),
})),
null,
2
)
}
/**
* Serialize a custom tool for VFS custom-tools/{name}.json
*/
export function serializeCustomTool(tool: {
id: string
title: string
schema: unknown
code: string
}): string {
return JSON.stringify(
{
id: tool.id,
title: tool.title,
schema: tool.schema,
codePreview: truncate(tool.code, 500),
},
null,
2
)
}
/**
* Serialize an MCP server for VFS agent/mcp-servers/{name}.json
*/
export function serializeMcpServer(server: {
id: string
name: string
url: string | null
transport: string | null
enabled: boolean
connectionStatus: string | null
}): string {
return JSON.stringify(
{
id: server.id,
name: server.name,
url: server.url,
transport: server.transport,
enabled: server.enabled,
connectionStatus: server.connectionStatus,
},
null,
2
)
}
/**
* Serialize a skill for VFS agent/skills/{name}.json
*/
export function serializeSkill(s: {
id: string
name: string
description: string
content: string
createdAt: Date
}): string {
return JSON.stringify(
{
id: s.id,
name: s.name,
description: s.description,
contentPreview: truncate(s.content, 500),
createdAt: s.createdAt.toISOString(),
},
null,
2
)
}
/**
* Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json
*/
export function serializeIntegrationSchema(tool: ToolConfig): string {
const hostedApiKeyParam = isHosted && tool.hosting ? tool.hosting.apiKeyParam : null
return JSON.stringify(
{
// The full registry id is the agent-callable id (deferred tools are sent
// with this exact id; no stripping). Surface it verbatim so "copy the id
// field and load it" matches the callable tool and the block's tools.access.
id: tool.id,
name: tool.name,
description: getCopilotToolDescription(tool, { isHosted }),
version: tool.version,
oauth: tool.oauth
? { required: tool.oauth.required, provider: tool.oauth.provider }
: undefined,
params: tool.params
? {
...Object.fromEntries(
Object.entries(tool.params)
.filter(([key, val]) => val != null && key !== hostedApiKeyParam)
.map(([key, val]) => [
key,
{
type: val.type,
required: val.required,
description: val.description,
default: val.default,
},
])
),
...(tool.oauth?.required && {
credentialId: {
type: 'string',
required: false,
description:
'Credential ID to use for this OAuth tool call. For Copilot/Superagent execution, pass this explicitly. Get valid IDs from environment/credentials.json.',
},
}),
}
: undefined,
outputs: tool.outputs
? Object.fromEntries(
Object.entries(tool.outputs)
.filter(([, val]) => val != null)
.map(([key, val]) => [key, { type: val.type, description: val.description }])
)
: undefined,
},
null,
2
)
}
/**
* Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json
*/
export function serializeTriggerSchema(trigger: {
id: string
name: string
provider: string
description: string
version: string
subBlocks: SubBlockConfig[]
outputs: Record<string, unknown>
webhook?: { method?: string; headers?: Record<string, string> }
}): string {
return JSON.stringify(
{
id: trigger.id,
name: trigger.name,
provider: trigger.provider,
description: trigger.description,
version: trigger.version,
webhook: trigger.webhook || undefined,
subBlocks: trigger.subBlocks.map(serializeSubBlock),
outputs: trigger.outputs,
},
null,
2
)
}
/**
* Serialize a built-in trigger block for VFS components/triggers/sim/{type}.json
*/
export function serializeBuiltinTriggerSchema(block: BlockConfig): string {
return JSON.stringify(
{
type: block.type,
name: block.name,
description: block.description,
longDescription: block.longDescription || undefined,
category: 'builtin',
triggers: block.triggers || undefined,
subBlocks: block.subBlocks.map(serializeSubBlock),
inputs: block.inputs,
outputs: block.outputs,
},
null,
2
)
}
interface TriggerOverviewEntry {
id: string
name: string
provider: string
description: string
}
/**
* Serialize a triggers.md overview for VFS components/triggers/triggers.md
*/
export function serializeTriggerOverview(
builtinTriggers: TriggerOverviewEntry[],
externalTriggers: TriggerOverviewEntry[]
): string {
const lines: string[] = ['# Triggers', '']
lines.push('## Built-in Triggers', '')
lines.push('| ID | Name | Description |')
lines.push('|----|------|-------------|')
for (const t of builtinTriggers) {
lines.push(`| ${t.id} | ${t.name} | ${t.description} |`)
}
lines.push('')
lines.push('## External Triggers', '')
lines.push('| Provider | ID | Name | Description |')
lines.push('|----------|----|------|-------------|')
for (const t of externalTriggers) {
lines.push(`| ${t.provider} | ${t.id} | ${t.name} | ${t.description} |`)
}
lines.push('')
return lines.join('\n')
}
/**
* Serialize job metadata for VFS jobs/{id}/meta.json
*/
export function serializeJobMeta(job: {
id: string
title: string | null
prompt: string
cronExpression: string | null
timezone: string | null
status: string
lifecycle: string
successCondition: string | null
maxRuns: number | null
runCount: number
nextRunAt: Date | null
lastRanAt: Date | null
sourceTaskName: string | null
sourceChatId: string | null
createdAt: Date
}): string {
return JSON.stringify(
{
id: job.id,
title: job.title || undefined,
prompt: job.prompt,
cronExpression: job.cronExpression || undefined,
timezone: job.timezone || 'UTC',
status: job.status,
lifecycle: job.lifecycle,
successCondition: job.successCondition || undefined,
maxRuns: job.maxRuns ?? undefined,
runCount: job.runCount,
nextRunAt: job.nextRunAt?.toISOString(),
lastRanAt: job.lastRanAt?.toISOString(),
sourceTaskName: job.sourceTaskName || undefined,
sourceChatId: job.sourceChatId || undefined,
createdAt: job.createdAt.toISOString(),
},
null,
2
)
}
export function serializeTaskSession(task: {
id: string
title: string
messageCount: number
createdAt: Date
updatedAt: Date
}): string {
return [
`# ${task.title}`,
'',
`- **Chat ID:** ${task.id}`,
`- **Created:** ${task.createdAt.toISOString()}`,
`- **Updated:** ${task.updatedAt.toISOString()}`,
`- **Messages:** ${task.messageCount}`,
'',
].join('\n')
}
export function serializeTaskChat(rawMessages: unknown[]): string {
const filtered: { role: string; content: string }[] = []
for (const msg of rawMessages) {
if (!msg || typeof msg !== 'object') continue
const m = msg as Record<string, unknown>
const role = m.role as string | undefined
if (role !== 'user' && role !== 'assistant') continue
let content = ''
if (role === 'assistant' && Array.isArray(m.contentBlocks)) {
const textParts: string[] = []
for (const block of m.contentBlocks) {
if (
block &&
typeof block === 'object' &&
(block as any).type === 'text' &&
(block as any).content
) {
textParts.push((block as any).content)
}
}
content = textParts.join('')
}
if (!content && typeof m.content === 'string') {
content = m.content
}
if (!content) continue
filtered.push({ role, content })
}
return JSON.stringify(filtered, null, 2)
}
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
ensureWorkspaceFileFolderPath: vi.fn(),
listWorkspaceFileFolders: vi.fn(),
getWorkspaceFileByName: vi.fn(),
listWorkspaceFiles: vi.fn(),
uploadWorkspaceFile: vi.fn(),
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath,
listWorkspaceFileFolders: mocks.listWorkspaceFileFolders,
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
getWorkspaceFileByName: mocks.getWorkspaceFileByName,
listWorkspaceFiles: mocks.listWorkspaceFiles,
uploadWorkspaceFile: mocks.uploadWorkspaceFile,
}))
import { ensureWorkflowAliasBacking } from './workflow-alias-backing'
describe('workflow alias backing', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.ensureWorkspaceFileFolderPath.mockImplementation(({ pathSegments }) =>
Promise.resolve(`folder:${pathSegments.join('/')}`)
)
})
it('provisions reserved folders and creates a headed changelog when missing', async () => {
mocks.getWorkspaceFileByName
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'file-1', name: 'wf_1.md' })
const result = await ensureWorkflowAliasBacking({
workspaceId: 'workspace-1',
userId: 'user-1',
workflowId: 'wf_1',
workflowName: 'My Workflow',
})
expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
pathSegments: ['.changelogs'],
})
expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
pathSegments: ['.plans', 'wf_1'],
})
expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userId: 'user-1',
pathSegments: ['.plans', 'workspace'],
})
expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith(
'workspace-1',
'user-1',
Buffer.from('# My Workflow Changelog\n', 'utf-8'),
'wf_1.md',
'text/markdown',
{ folderId: 'folder:.changelogs' }
)
expect(result.changelogFile).toMatchObject({ id: 'file-1' })
})
it('reuses an existing changelog backing file', async () => {
mocks.getWorkspaceFileByName.mockResolvedValueOnce({ id: 'file-existing', name: 'wf_2.md' })
const result = await ensureWorkflowAliasBacking({
workspaceId: 'workspace-1',
userId: 'user-1',
workflowId: 'wf_2',
})
expect(mocks.uploadWorkspaceFile).not.toHaveBeenCalled()
expect(result.changelogFile).toMatchObject({ id: 'file-existing' })
})
})
@@ -0,0 +1,182 @@
import { db } from '@sim/db'
import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import {
WORKFLOW_CHANGELOG_BACKING_FOLDER,
WORKFLOW_PLANS_BACKING_FOLDER,
WORKSPACE_PLANS_BACKING_FOLDER,
} from '@/lib/copilot/vfs/workflow-aliases'
import {
ensureWorkspaceFileFolderPath,
listWorkspaceFileFolders,
} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import {
getWorkspaceFileByName,
listWorkspaceFiles,
uploadWorkspaceFile,
type WorkspaceFileRecord,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
export interface WorkflowAliasBacking {
changelogFolderId: string
plansRootFolderId: string
workflowPlansFolderId: string
workspacePlansFolderId: string
changelogFile: WorkspaceFileRecord | null
}
function initialChangelogContent(workflowName?: string): string {
const title = workflowName?.trim() || 'Workflow'
return `# ${title} Changelog\n`
}
export async function ensureWorkflowAliasBacking(args: {
workspaceId: string
userId: string
workflowId: string
workflowName?: string
}): Promise<WorkflowAliasBacking> {
const changelogFolderId = await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: [WORKFLOW_CHANGELOG_BACKING_FOLDER],
})
const plansRootFolderId = await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER],
})
const workflowPlansFolderId = await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, args.workflowId],
})
const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER],
})
if (
!changelogFolderId ||
!plansRootFolderId ||
!workflowPlansFolderId ||
!workspacePlansFolderId
) {
throw new Error('Failed to provision workflow alias backing folders')
}
const changelogName = `${args.workflowId}.md`
let changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, {
folderId: changelogFolderId,
})
if (!changelogFile) {
await uploadWorkspaceFile(
args.workspaceId,
args.userId,
Buffer.from(initialChangelogContent(args.workflowName), 'utf-8'),
changelogName,
'text/markdown',
{ folderId: changelogFolderId }
)
changelogFile = await getWorkspaceFileByName(args.workspaceId, changelogName, {
folderId: changelogFolderId,
})
}
return {
changelogFolderId,
plansRootFolderId,
workflowPlansFolderId,
workspacePlansFolderId,
changelogFile,
}
}
export async function ensureWorkspacePlanBacking(args: {
workspaceId: string
userId: string
}): Promise<{ plansRootFolderId: string; workspacePlansFolderId: string }> {
const plansRootFolderId = await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER],
})
const workspacePlansFolderId = await ensureWorkspaceFileFolderPath({
workspaceId: args.workspaceId,
userId: args.userId,
pathSegments: [WORKFLOW_PLANS_BACKING_FOLDER, WORKSPACE_PLANS_BACKING_FOLDER],
})
if (!plansRootFolderId || !workspacePlansFolderId) {
throw new Error('Failed to provision workspace plan backing folders')
}
return { plansRootFolderId, workspacePlansFolderId }
}
export async function cleanupWorkflowAliasBacking(args: {
workspaceId: string
workflowId: string
deletedAt?: Date
}): Promise<{ files: number; folders: number }> {
const deletedAt = args.deletedAt ?? new Date()
const folders = await listWorkspaceFileFolders(args.workspaceId, {
scope: 'all',
includeReservedSystemFolders: true,
})
const files = await listWorkspaceFiles(args.workspaceId, {
scope: 'all',
folders,
includeReservedSystemFiles: true,
})
const ownedFileIds = files
.filter((file) => {
if (file.deletedAt) return false
const changelogMatch =
file.folderPath === WORKFLOW_CHANGELOG_BACKING_FOLDER &&
file.name === `${args.workflowId}.md`
const workflowPlanMatch =
file.folderPath === `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` ||
Boolean(file.folderPath?.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}/`))
return changelogMatch || workflowPlanMatch
})
.map((file) => file.id)
const ownedFolderIds = folders
.filter((folder) => {
if (folder.deletedAt) return false
return (
folder.path === `${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}` ||
folder.path.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}/`)
)
})
.map((folder) => folder.id)
if (ownedFileIds.length > 0) {
await db
.update(workspaceFiles)
.set({ deletedAt })
.where(
and(
eq(workspaceFiles.workspaceId, args.workspaceId),
inArray(workspaceFiles.id, ownedFileIds),
isNull(workspaceFiles.deletedAt)
)
)
}
if (ownedFolderIds.length > 0) {
await db
.update(workspaceFileFolder)
.set({ deletedAt })
.where(
and(
eq(workspaceFileFolder.workspaceId, args.workspaceId),
inArray(workspaceFileFolder.id, ownedFolderIds),
isNull(workspaceFileFolder.deletedAt)
)
)
}
return { files: ownedFileIds.length, folders: ownedFolderIds.length }
}
@@ -0,0 +1,57 @@
import { db } from '@sim/db'
import { workflow, workflowFolder } from '@sim/db/schema'
import { and, asc, eq, isNull } from 'drizzle-orm'
import {
buildWorkflowAliasWorkflowEntries,
isPlanAliasPath,
resolveWorkflowAliasPath,
resolveWorkspacePlanAliasPath,
type WorkflowAliasTarget,
} from '@/lib/copilot/vfs/workflow-aliases'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { canonicalizeVfsPath } from './path-utils'
export async function resolveWorkflowAliasForWorkspace(args: {
workspaceId: string
path: string
}): Promise<WorkflowAliasTarget | null> {
if (!(await isFeatureEnabled('mothership-beta'))) return null
if (!isPlanAliasPath(args.path)) return null
let canonicalPath: string
try {
canonicalPath = canonicalizeVfsPath(args.path)
} catch {
canonicalPath = args.path.trim().replace(/^\/+|\/+$/g, '')
}
const workspacePlanAlias = resolveWorkspacePlanAliasPath(canonicalPath)
if (workspacePlanAlias) return workspacePlanAlias
const [workflowRows, folderRows] = await Promise.all([
db
.select({
id: workflow.id,
name: workflow.name,
folderId: workflow.folderId,
})
.from(workflow)
.where(and(eq(workflow.workspaceId, args.workspaceId), isNull(workflow.archivedAt)))
.orderBy(asc(workflow.sortOrder), asc(workflow.createdAt)),
db
.select({
folderId: workflowFolder.id,
folderName: workflowFolder.name,
parentId: workflowFolder.parentId,
})
.from(workflowFolder)
.where(
and(eq(workflowFolder.workspaceId, args.workspaceId), isNull(workflowFolder.archivedAt))
)
.orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)),
])
return resolveWorkflowAliasPath(
canonicalPath,
buildWorkflowAliasWorkflowEntries(workflowRows, folderRows)
)
}
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
buildWorkflowAliasWorkflowEntries,
isWorkflowAliasBackingPath,
resolveWorkflowAliasPath,
resolveWorkspacePlanAliasPath,
workflowChangelogBackingPath,
workspacePlanBackingPath,
} from './workflow-aliases'
describe('workflow aliases', () => {
const folders = [
{ folderId: 'root-a', folderName: 'Folder A', parentId: null },
{ folderId: 'nested', folderName: 'Nested', parentId: 'root-a' },
{ folderId: 'root-b', folderName: 'Folder B', parentId: null },
]
it('resolves root workspace plan aliases to workspace backing files', () => {
const alias = resolveWorkspacePlanAliasPath('.plans/root.md')
expect(alias).toMatchObject({
kind: 'plan_file',
scope: 'workspace',
aliasPath: '.plans/root.md',
planRelativePath: 'root.md',
backingPath: workspacePlanBackingPath('root.md'),
})
})
it('preserves nested root workspace plan paths in backing storage', () => {
const alias = resolveWorkspacePlanAliasPath('.plans/nested/phase-1.md')
expect(alias).toMatchObject({
kind: 'plan_file',
scope: 'workspace',
planRelativePath: 'nested/phase-1.md',
backingPath: 'files/.plans/workspace/nested/phase-1.md',
})
})
it('rejects root plan directory paths as file aliases', () => {
expect(resolveWorkspacePlanAliasPath('.plans')).toMatchObject({
kind: 'plans_dir',
scope: 'workspace',
})
expect(resolveWorkspacePlanAliasPath('.plans/.folder')).toMatchObject({
kind: 'plans_dir',
scope: 'workspace',
})
expect(resolveWorkspacePlanAliasPath('.plans/links.json')).toBeNull()
})
it('resolves root workflow changelog aliases to workflow-id keyed backing files', () => {
const workflows = buildWorkflowAliasWorkflowEntries(
[{ id: 'wf_123', name: 'Root Flow', folderId: null }],
[]
)
const alias = resolveWorkflowAliasPath('workflows/Root%20Flow/changelog.md', workflows)
expect(alias).toMatchObject({
kind: 'changelog',
workflowId: 'wf_123',
aliasPath: 'workflows/Root%20Flow/changelog.md',
backingPath: workflowChangelogBackingPath('wf_123'),
})
})
it('resolves nested plan aliases using the workflow folder path', () => {
const workflows = buildWorkflowAliasWorkflowEntries(
[{ id: 'wf_nested', name: 'Planner', folderId: 'nested' }],
folders
)
const alias = resolveWorkflowAliasPath(
'workflows/Folder%20A/Nested/Planner/.plans/launch.md',
workflows
)
expect(alias).toMatchObject({
kind: 'plan_file',
workflowId: 'wf_nested',
planRelativePath: 'launch.md',
backingPath: 'files/.plans/wf_nested/launch.md',
})
})
it('keeps same-name workflows in different folders distinct', () => {
const workflows = buildWorkflowAliasWorkflowEntries(
[
{ id: 'wf_a', name: 'Duplicate', folderId: 'root-a' },
{ id: 'wf_b', name: 'Duplicate', folderId: 'root-b' },
],
folders
)
expect(
resolveWorkflowAliasPath('workflows/Folder%20A/Duplicate/changelog.md', workflows)
).toMatchObject({ workflowId: 'wf_a', backingPath: 'files/.changelogs/wf_a.md' })
expect(
resolveWorkflowAliasPath('workflows/Folder%20B/Duplicate/changelog.md', workflows)
).toMatchObject({ workflowId: 'wf_b', backingPath: 'files/.changelogs/wf_b.md' })
})
it('keeps backing paths stable across workflow rename', () => {
const before = buildWorkflowAliasWorkflowEntries(
[{ id: 'wf_stable', name: 'Old Name', folderId: null }],
[]
)
const after = buildWorkflowAliasWorkflowEntries(
[{ id: 'wf_stable', name: 'New Name', folderId: null }],
[]
)
expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', before)?.backingPath).toBe(
'files/.changelogs/wf_stable.md'
)
expect(resolveWorkflowAliasPath('workflows/New%20Name/changelog.md', after)?.backingPath).toBe(
'files/.changelogs/wf_stable.md'
)
expect(resolveWorkflowAliasPath('workflows/Old%20Name/changelog.md', after)).toBeNull()
})
it('rejects arbitrary workflow-local files and missing workflows', () => {
const workflows = buildWorkflowAliasWorkflowEntries(
[{ id: 'wf_123', name: 'Root Flow', folderId: null }],
[]
)
expect(resolveWorkflowAliasPath('workflows/Root%20Flow/random.md', workflows)).toBeNull()
expect(resolveWorkflowAliasPath('workflows/Missing/changelog.md', workflows)).toBeNull()
})
it('recognizes reserved backing paths after VFS segment canonicalization', () => {
expect(isWorkflowAliasBackingPath('files/.plans/wf_1/launch.md')).toBe(true)
expect(isWorkflowAliasBackingPath('files/%2Eplans/wf_1/launch.md')).toBe(true)
expect(isWorkflowAliasBackingPath('files/ordinary/launch.md')).toBe(false)
})
})
@@ -0,0 +1,360 @@
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
import {
canonicalWorkspaceFilePath,
decodeVfsPathSegments,
encodeVfsPathSegments,
} from '@/lib/copilot/vfs/path-utils'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
export const WORKFLOW_CHANGELOG_ALIAS_NAME = 'changelog.md'
export const WORKFLOW_PLANS_ALIAS_DIR = '.plans'
export const WORKFLOW_ALIAS_LINKS_NAME = 'links.json'
export const WORKFLOW_CHANGELOG_BACKING_FOLDER = '.changelogs'
export const WORKFLOW_PLANS_BACKING_FOLDER = '.plans'
export const WORKSPACE_PLANS_BACKING_FOLDER = 'workspace'
export type WorkflowAliasKind = 'changelog' | 'plan_file' | 'plans_dir'
export type WorkflowAliasScope = 'workspace' | 'workflow'
export interface WorkflowAliasWorkflow {
id: string
name: string
folderPath?: string | null
}
export interface WorkflowAliasWorkflowRow {
id: string
name: string
folderId?: string | null
}
export interface WorkflowAliasFolderRow {
folderId: string
folderName: string
parentId: string | null
}
interface BaseWorkflowAliasTarget {
kind: WorkflowAliasKind
scope: WorkflowAliasScope
aliasPath: string
backingPath: string
backingFolderPath: string
planRelativePath?: string
}
export type WorkflowAliasTarget =
| (BaseWorkflowAliasTarget & {
kind: 'changelog'
scope: 'workflow'
workflowId: string
workflowName: string
workflowPath: string
})
| (BaseWorkflowAliasTarget & {
kind: 'plans_dir'
scope: 'workflow'
workflowId: string
workflowName: string
workflowPath: string
})
| (BaseWorkflowAliasTarget & {
kind: 'plan_file'
scope: 'workflow'
workflowId: string
workflowName: string
workflowPath: string
planRelativePath: string
})
| (BaseWorkflowAliasTarget & {
kind: 'plans_dir'
scope: 'workspace'
})
| (BaseWorkflowAliasTarget & {
kind: 'plan_file'
scope: 'workspace'
planRelativePath: string
})
export interface WorkflowAliasLink {
kind: WorkflowAliasKind
aliasPath: string
backingPath: string
backingFileId?: string
}
export function workflowVfsPath(workflow: WorkflowAliasWorkflow): string {
const safeName = normalizeVfsSegment(workflow.name)
return workflow.folderPath
? `workflows/${workflow.folderPath}/${safeName}`
: `workflows/${safeName}`
}
export function buildWorkflowAliasWorkflowEntries(
workflows: WorkflowAliasWorkflowRow[],
folders: WorkflowAliasFolderRow[]
): WorkflowAliasWorkflow[] {
const folderMap = new Map<string, { name: string; parentId: string | null }>()
for (const folder of folders) {
folderMap.set(folder.folderId, { name: folder.folderName, parentId: folder.parentId })
}
const folderPathCache = new Map<string, string>()
const folderPath = (folderId: string): string => {
const cached = folderPathCache.get(folderId)
if (cached) return cached
const folder = folderMap.get(folderId)
if (!folder) return ''
const safeName = normalizeVfsSegment(folder.name)
const path = folder.parentId ? `${folderPath(folder.parentId)}/${safeName}` : safeName
folderPathCache.set(folderId, path)
return path
}
return workflows.map((workflow) => ({
id: workflow.id,
name: workflow.name,
folderPath: workflow.folderId ? folderPath(workflow.folderId) : null,
}))
}
export function workflowChangelogBackingPath(workflowId: string): string {
return canonicalWorkspaceFilePath({
folderPath: WORKFLOW_CHANGELOG_BACKING_FOLDER,
name: `${workflowId}.md`,
})
}
export function workflowPlansBackingFolderPath(workflowId: string): string {
return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(workflowId)}`
}
export function workspacePlansBackingFolderPath(): string {
return `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/${normalizeVfsSegment(WORKSPACE_PLANS_BACKING_FOLDER)}`
}
export function workspacePlanBackingPath(planRelativePath: string): string {
const segments = decodeVfsPathSegments(planRelativePath)
if (segments.length === 0) {
throw new Error('Workspace plan alias must include a plan file path')
}
return canonicalWorkspaceFilePath({
folderPath: [
WORKFLOW_PLANS_BACKING_FOLDER,
WORKSPACE_PLANS_BACKING_FOLDER,
...segments.slice(0, -1),
].join('/'),
name: segments[segments.length - 1],
})
}
export function workflowPlanBackingPath(workflowId: string, planRelativePath: string): string {
const segments = decodeVfsPathSegments(planRelativePath)
if (segments.length === 0) {
throw new Error('Workflow plan alias must include a plan file path')
}
return canonicalWorkspaceFilePath({
folderPath: [WORKFLOW_PLANS_BACKING_FOLDER, workflowId, ...segments.slice(0, -1)].join('/'),
name: segments[segments.length - 1],
})
}
function workflowAliasTargetForPath(workflow: WorkflowAliasWorkflow, rawPath: string) {
const workflowPath = workflowVfsPath(workflow)
const changelogPath = `${workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}`
if (rawPath === changelogPath) {
return {
kind: 'changelog' as const,
scope: 'workflow' as const,
workflowId: workflow.id,
workflowName: workflow.name,
workflowPath,
aliasPath: changelogPath,
backingPath: workflowChangelogBackingPath(workflow.id),
backingFolderPath: `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}`,
}
}
const plansDirPath = `${workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}`
if (rawPath === plansDirPath || rawPath === `${plansDirPath}/.folder`) {
return {
kind: 'plans_dir' as const,
scope: 'workflow' as const,
workflowId: workflow.id,
workflowName: workflow.name,
workflowPath,
aliasPath: plansDirPath,
backingPath: workflowPlansBackingFolderPath(workflow.id),
backingFolderPath: workflowPlansBackingFolderPath(workflow.id),
}
}
const plansPrefix = `${plansDirPath}/`
if (rawPath.startsWith(plansPrefix)) {
const planRelativePath = rawPath.slice(plansPrefix.length)
if (!planRelativePath || planRelativePath === '.folder') return null
return {
kind: 'plan_file' as const,
scope: 'workflow' as const,
workflowId: workflow.id,
workflowName: workflow.name,
workflowPath,
aliasPath: rawPath,
backingPath: workflowPlanBackingPath(workflow.id, planRelativePath),
backingFolderPath: workflowPlansBackingFolderPath(workflow.id),
planRelativePath,
}
}
return null
}
export function resolveWorkspacePlanAliasPath(path: string): WorkflowAliasTarget | null {
const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '')
if (
normalizedPath === WORKFLOW_PLANS_ALIAS_DIR ||
normalizedPath === `${WORKFLOW_PLANS_ALIAS_DIR}/.folder`
) {
return {
kind: 'plans_dir',
scope: 'workspace',
aliasPath: WORKFLOW_PLANS_ALIAS_DIR,
backingPath: workspacePlansBackingFolderPath(),
backingFolderPath: workspacePlansBackingFolderPath(),
}
}
const plansPrefix = `${WORKFLOW_PLANS_ALIAS_DIR}/`
if (!normalizedPath.startsWith(plansPrefix)) return null
const planRelativePath = normalizedPath.slice(plansPrefix.length)
if (
!planRelativePath ||
planRelativePath === '.folder' ||
planRelativePath === WORKFLOW_ALIAS_LINKS_NAME
) {
return null
}
return {
kind: 'plan_file',
scope: 'workspace',
aliasPath: normalizedPath,
backingPath: workspacePlanBackingPath(planRelativePath),
backingFolderPath: workspacePlansBackingFolderPath(),
planRelativePath,
}
}
export function resolveWorkflowAliasPath(
path: string,
workflows: WorkflowAliasWorkflow[]
): WorkflowAliasTarget | null {
const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '')
if (!normalizedPath.startsWith('workflows/')) return null
const bySpecificity = [...workflows].sort(
(a, b) => workflowVfsPath(b).length - workflowVfsPath(a).length
)
for (const workflow of bySpecificity) {
const target = workflowAliasTargetForPath(workflow, normalizedPath)
if (target) return target
}
return null
}
export function isWorkflowAliasPath(path: string): boolean {
const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '')
return (
normalizedPath.startsWith('workflows/') &&
(normalizedPath.endsWith(`/${WORKFLOW_CHANGELOG_ALIAS_NAME}`) ||
normalizedPath.includes(`/${WORKFLOW_PLANS_ALIAS_DIR}/`) ||
normalizedPath.endsWith(`/${WORKFLOW_PLANS_ALIAS_DIR}`))
)
}
export function isWorkspacePlanAliasPath(path: string): boolean {
const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '')
return (
normalizedPath === WORKFLOW_PLANS_ALIAS_DIR ||
normalizedPath.startsWith(`${WORKFLOW_PLANS_ALIAS_DIR}/`)
)
}
export function isPlanAliasPath(path: string): boolean {
return isWorkspacePlanAliasPath(path) || isWorkflowAliasPath(path)
}
export function isWorkflowAliasBackingPath(path: string): boolean {
const trimmedPath = path.trim().replace(/^\/+|\/+$/g, '')
let normalizedPath = trimmedPath
if (trimmedPath.startsWith('files/')) {
try {
normalizedPath = `files/${decodeVfsPathSegments(trimmedPath.slice('files/'.length))
.map((segment) => normalizeVfsSegment(segment))
.join('/')}`
} catch {
normalizedPath = trimmedPath
}
}
return (
normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}` ||
normalizedPath === `files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}` ||
normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_CHANGELOG_BACKING_FOLDER)}/`) ||
normalizedPath.startsWith(`files/${normalizeVfsSegment(WORKFLOW_PLANS_BACKING_FOLDER)}/`)
)
}
export function isReservedWorkflowAliasBackingDisplayPath(path?: string | null): boolean {
if (!path) return false
const normalizedPath = path.trim().replace(/^\/+|\/+$/g, '')
return (
normalizedPath === WORKFLOW_CHANGELOG_BACKING_FOLDER ||
normalizedPath === WORKFLOW_PLANS_BACKING_FOLDER ||
normalizedPath.startsWith(`${WORKFLOW_CHANGELOG_BACKING_FOLDER}/`) ||
normalizedPath.startsWith(`${WORKFLOW_PLANS_BACKING_FOLDER}/`)
)
}
export function workflowAliasSandboxPath(aliasPath: string): string {
return `/home/user/${aliasPath.trim().replace(/^\/+/, '')}`
}
export function buildWorkflowAliasLinks(args: {
workflowPath: string
workflowId: string
changelog?: WorkspaceFileRecord | null
planFiles?: WorkspaceFileRecord[]
}): WorkflowAliasLink[] {
const links: WorkflowAliasLink[] = [
{
kind: 'changelog',
aliasPath: `${args.workflowPath}/${WORKFLOW_CHANGELOG_ALIAS_NAME}`,
backingPath: workflowChangelogBackingPath(args.workflowId),
backingFileId: args.changelog?.id,
},
{
kind: 'plans_dir',
aliasPath: `${args.workflowPath}/${WORKFLOW_PLANS_ALIAS_DIR}`,
backingPath: workflowPlansBackingFolderPath(args.workflowId),
},
]
for (const file of args.planFiles ?? []) {
const relativePath = file.folderPath
?.replace(`${WORKFLOW_PLANS_BACKING_FOLDER}/${args.workflowId}`, '')
.replace(/^\/+/, '')
const aliasRelativePath = encodeVfsPathSegments(
[relativePath, file.name].filter(Boolean).join('/').split('/')
)
const aliasPath = [args.workflowPath, WORKFLOW_PLANS_ALIAS_DIR, aliasRelativePath].join('/')
links.push({
kind: 'plan_file',
aliasPath,
backingPath: canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }),
backingFileId: file.id,
})
}
return links
}
File diff suppressed because it is too large Load Diff