chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user