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,97 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
|
||||
import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases'
|
||||
import { inferContentType } from './workspace-file'
|
||||
|
||||
const logger = createLogger('CreateFileServerTool')
|
||||
const CREATE_FILE_TOOL_ID = 'create_file'
|
||||
|
||||
interface CreateFileArgs {
|
||||
fileName: string
|
||||
contentType?: string
|
||||
outputs?: { files?: Array<{ path: string; mode?: 'create' | 'overwrite'; mimeType?: string }> }
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface CreateFileResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: {
|
||||
id: string
|
||||
name: string
|
||||
contentType: string
|
||||
vfsPath: string
|
||||
backingVfsPath?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const createFileServerTool: BaseServerTool<CreateFileArgs, CreateFileResult> = {
|
||||
name: CREATE_FILE_TOOL_ID,
|
||||
async execute(params: CreateFileArgs, context?: ServerToolContext): Promise<CreateFileResult> {
|
||||
if (!context?.userId) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
|
||||
|
||||
const nested = params.args
|
||||
const fileName = params.fileName || (nested?.fileName as string) || ''
|
||||
const explicitType = params.contentType || (nested?.contentType as string) || undefined
|
||||
const outputFile = params.outputs?.files?.[0]
|
||||
if (!outputFile?.path && !fileName) {
|
||||
return { success: false, message: 'create_file requires outputs.files[0].path or fileName' }
|
||||
}
|
||||
const outputPath =
|
||||
outputFile?.path ?? (fileName.startsWith('files/') ? fileName : `files/${fileName}`)
|
||||
if (isPlanAliasPath(outputPath)) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'create_file does not initialize plan aliases; changelog.md is created automatically per workflow.',
|
||||
}
|
||||
}
|
||||
const contentType = outputFile?.mimeType ?? inferContentType(outputPath, explicitType)
|
||||
const emptyBuffer = Buffer.from('', 'utf-8')
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await writeWorkspaceFileByPath({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
target: {
|
||||
path: outputPath,
|
||||
mode: outputFile?.mode ?? 'create',
|
||||
mimeType: outputFile?.mimeType,
|
||||
},
|
||||
buffer: emptyBuffer,
|
||||
inferredMimeType: contentType,
|
||||
})
|
||||
|
||||
logger.info('File created via create_file', {
|
||||
fileId: result.id,
|
||||
name: result.vfsPath,
|
||||
contentType,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${result.vfsPath}" created successfully`,
|
||||
data: {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
contentType,
|
||||
vfsPath: result.vfsPath,
|
||||
backingVfsPath: result.backingVfsPath,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { DeleteFile } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import {
|
||||
getWorkspaceFile,
|
||||
resolveWorkspaceFileReference,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration'
|
||||
|
||||
const logger = createLogger('DeleteFileServerTool')
|
||||
|
||||
interface DeleteFileArgs {
|
||||
paths?: string[]
|
||||
path?: string
|
||||
fileIds?: string[]
|
||||
fileId?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface DeleteFileResult {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export const deleteFileServerTool: BaseServerTool<DeleteFileArgs, DeleteFileResult> = {
|
||||
name: DeleteFile.id,
|
||||
async execute(params: DeleteFileArgs, context?: ServerToolContext): Promise<DeleteFileResult> {
|
||||
if (!context?.userId) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
|
||||
|
||||
const nested = params.args
|
||||
const paths: string[] =
|
||||
params.paths ??
|
||||
(nested?.paths as string[] | undefined) ??
|
||||
[params.path || (nested?.path as string) || ''].filter(Boolean)
|
||||
const legacyFileIds: string[] =
|
||||
params.fileIds ??
|
||||
(nested?.fileIds as string[] | undefined) ??
|
||||
[params.fileId || (nested?.fileId as string) || ''].filter(Boolean)
|
||||
|
||||
if (paths.length === 0 && legacyFileIds.length === 0) {
|
||||
return { success: false, message: 'paths is required' }
|
||||
}
|
||||
|
||||
const deletable: { id: string; name: string }[] = []
|
||||
const failed: string[] = []
|
||||
|
||||
for (const path of paths) {
|
||||
const existingFile = await resolveWorkspaceFileReference(workspaceId, path)
|
||||
if (!existingFile) {
|
||||
failed.push(path)
|
||||
continue
|
||||
}
|
||||
deletable.push({ id: existingFile.id, name: existingFile.name })
|
||||
}
|
||||
|
||||
for (const fileId of legacyFileIds) {
|
||||
const existingFile = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!existingFile) {
|
||||
failed.push(fileId)
|
||||
continue
|
||||
}
|
||||
deletable.push({ id: fileId, name: existingFile.name })
|
||||
}
|
||||
|
||||
if (deletable.length > 0) {
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
fileIds: deletable.map((file) => file.id),
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.error || 'Failed to delete files' }
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of deletable) {
|
||||
logger.info('File deleted via delete_file', {
|
||||
fileId: file.id,
|
||||
name: file.name,
|
||||
userId: context.userId,
|
||||
})
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (deletable.length > 0)
|
||||
parts.push(`Deleted: ${deletable.map((file) => file.name).join(', ')}`)
|
||||
if (failed.length > 0) parts.push(`Not found: ${failed.join(', ')}`)
|
||||
|
||||
return {
|
||||
success: deletable.length > 0,
|
||||
message: parts.join('. '),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: vi.fn(),
|
||||
executeShellInE2B: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/execution/languages', () => ({
|
||||
CodeLanguage: { javascript: 'javascript', python: 'python' },
|
||||
}))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
getWorkspaceFile: vi.fn(),
|
||||
fetchWorkspaceFileBuffer: vi.fn(),
|
||||
}))
|
||||
vi.mock('./doc-compiled-store', () => ({
|
||||
loadCompiledDoc: vi.fn(),
|
||||
storeCompiledDoc: vi.fn(),
|
||||
}))
|
||||
|
||||
import { collectReferencedFileIds } from './doc-compile'
|
||||
|
||||
const ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
describe('collectReferencedFileIds', () => {
|
||||
it('captures the id from getFileBase64(...) with single or double quotes', () => {
|
||||
expect(collectReferencedFileIds(`await getFileBase64('${ID}')`)).toEqual(new Set([ID]))
|
||||
expect(collectReferencedFileIds(`getFileBase64("abc_def-1")`)).toEqual(new Set(['abc_def-1']))
|
||||
})
|
||||
|
||||
it('captures the id from pptx addImage(slide, id, opts) (second arg)', () => {
|
||||
const src = `await addImage(slide, '${ID}', { x: 1, y: 1, w: 2, h: 2 })`
|
||||
expect(collectReferencedFileIds(src)).toEqual(new Set([ID]))
|
||||
})
|
||||
|
||||
it('captures the id from docx addImage(id, opts) (first arg)', () => {
|
||||
const src = `const img = await addImage('docx-img-1', { width: 200, height: 100 })`
|
||||
expect(collectReferencedFileIds(src)).toEqual(new Set(['docx-img-1']))
|
||||
})
|
||||
|
||||
it('captures the id from pdf drawImage(page, id, opts) (second arg)', () => {
|
||||
const src = `await drawImage(page, 'pdf-img-2', { x: 0, y: 0, width: 100, height: 100 })`
|
||||
expect(collectReferencedFileIds(src)).toEqual(new Set(['pdf-img-2']))
|
||||
})
|
||||
|
||||
it('still supports the legacy /home/user/inputs/<id> path form', () => {
|
||||
expect(collectReferencedFileIds(`fs.readFileSync('/home/user/inputs/legacy-1')`)).toEqual(
|
||||
new Set(['legacy-1'])
|
||||
)
|
||||
})
|
||||
|
||||
it('collects and dedupes ids across multiple call sites', () => {
|
||||
const src = `
|
||||
await addImage(slide, 'logo-1', { x: 0, y: 0, w: 1, h: 1 });
|
||||
const uri = await getFileBase64('logo-1');
|
||||
await addImage(slide, 'crest-2', { x: 2, y: 0, w: 1, h: 1 });
|
||||
`
|
||||
expect(collectReferencedFileIds(src)).toEqual(new Set(['logo-1', 'crest-2']))
|
||||
})
|
||||
|
||||
it('does not match id-like strings outside the image helpers', () => {
|
||||
const src = `slide.addText('order ${ID} shipped', { x: 1, y: 1, w: 8, h: 1 })`
|
||||
expect(collectReferencedFileIds(src)).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('does not match slide.addImage({ data }) — no fileId is present there', () => {
|
||||
const src = `slide.addImage({ data: base64Data, x: 1, y: 1, w: 2, h: 2 })`
|
||||
expect(collectReferencedFileIds(src)).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('returns an empty set when there are no image references', () => {
|
||||
expect(collectReferencedFileIds(`slide.addText('hello', { x: 1, y: 1 })`)).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,556 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
|
||||
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
|
||||
import {
|
||||
fetchWorkspaceFileBuffer,
|
||||
getWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getContentType } from '@/app/api/files/utils'
|
||||
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
|
||||
import { loadCompiledDoc, storeCompiledDoc } from './doc-compiled-store'
|
||||
|
||||
const logger = createLogger('CopilotDocCompile')
|
||||
|
||||
/**
|
||||
* Thrown when the user-authored Python script itself fails (raised an exception
|
||||
* or produced no output) — i.e. an error the agent should fix by editing the
|
||||
* script. Infra failures (E2B sandbox create/timeout, S3) propagate as plain
|
||||
* Errors so callers can return 5xx instead of telling the agent its script was
|
||||
* wrong.
|
||||
*/
|
||||
export class DocCompileUserError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'DocCompileUserError'
|
||||
}
|
||||
}
|
||||
|
||||
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
const PDF_MIME = 'application/pdf'
|
||||
|
||||
// When the E2B doc sandbox is enabled, ALL four formats compile there: pptx/docx
|
||||
// via Node (pptxgenjs/docx + react-icons/sharp icons), pdf/xlsx via Python
|
||||
// (reportlab/openpyxl). Source MIMEs for the node engines match the isolated-vm
|
||||
// JS path; the python engines have distinct markers.
|
||||
export const PPTXGENJS_SOURCE_MIME = 'text/x-pptxgenjs'
|
||||
export const DOCXJS_SOURCE_MIME = 'text/x-docxjs'
|
||||
export const PYTHON_PDF_SOURCE_MIME = 'text/x-python-pdf'
|
||||
export const PYTHON_XLSX_SOURCE_MIME = 'text/x-python-xlsx'
|
||||
|
||||
export type DocEngine = 'node' | 'python'
|
||||
|
||||
export interface E2BDocFormat {
|
||||
ext: 'pptx' | 'docx' | 'pdf' | 'xlsx'
|
||||
engine: DocEngine
|
||||
formatName: 'PPTX' | 'DOCX' | 'PDF' | 'XLSX'
|
||||
contentType: string
|
||||
sourceMime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the E2B doc format + engine for a filename, or null for non-docs.
|
||||
* pptx/docx → node, pdf/xlsx → python. Only meaningful when the E2B doc sandbox
|
||||
* is enabled; callers gate on isE2BDocEnabled before using this.
|
||||
*/
|
||||
export async function getE2BDocFormat(fileName: string): Promise<E2BDocFormat | null> {
|
||||
const l = fileName.toLowerCase()
|
||||
if (l.endsWith('.pptx'))
|
||||
return {
|
||||
ext: 'pptx',
|
||||
engine: 'node',
|
||||
formatName: 'PPTX',
|
||||
contentType: PPTX_MIME,
|
||||
sourceMime: PPTXGENJS_SOURCE_MIME,
|
||||
}
|
||||
if (l.endsWith('.docx'))
|
||||
return {
|
||||
ext: 'docx',
|
||||
engine: 'node',
|
||||
formatName: 'DOCX',
|
||||
contentType: DOCX_MIME,
|
||||
sourceMime: DOCXJS_SOURCE_MIME,
|
||||
}
|
||||
if (l.endsWith('.pdf'))
|
||||
return {
|
||||
ext: 'pdf',
|
||||
engine: 'python',
|
||||
formatName: 'PDF',
|
||||
contentType: PDF_MIME,
|
||||
sourceMime: PYTHON_PDF_SOURCE_MIME,
|
||||
}
|
||||
// xlsx is gated behind the mothership-beta feature flag (like plans/changelog): the
|
||||
// skill + prompt are gated on the Go side, and this is the single Sim chokepoint
|
||||
// that keeps the compile/serve/check/recalc paths off for xlsx when beta is off.
|
||||
if (l.endsWith('.xlsx') && (await isFeatureEnabled('mothership-beta')))
|
||||
return {
|
||||
ext: 'xlsx',
|
||||
engine: 'python',
|
||||
formatName: 'XLSX',
|
||||
contentType: XLSX_MIME,
|
||||
sourceMime: PYTHON_XLSX_SOURCE_MIME,
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The skills reference workspace images by BARE file id through the injected
|
||||
// helpers — `getFileBase64(id)`, `addImage(slide, id, ...)` (pptx),
|
||||
// `addImage(id, ...)` (docx), `drawImage(page, id, ...)` (pdf) — never as a path.
|
||||
// Capture the id from those call sites (skipping a leading slide/page argument),
|
||||
// plus the legacy `/home/user/inputs/<id>` path, so referenced files are staged
|
||||
// before the script runs. Without this the sandbox `getFileBase64` throws
|
||||
// "file not staged" and every workspace-image embed silently fails.
|
||||
const INPUT_PATH_RE = /\/home\/user\/inputs\/([A-Za-z0-9_-]+)/g
|
||||
const FILE_HELPER_RE =
|
||||
/\b(?:getFileBase64|addImage|drawImage)\(\s*(?:[A-Za-z_$][\w$]*\s*,\s*)?['"]([A-Za-z0-9_-]+)['"]/g
|
||||
|
||||
// The doc source is user/LLM-controlled, so bound how much it can pull into the
|
||||
// sandbox: each `/home/user/inputs/<id>` reference is only ~35 bytes, so the
|
||||
// source-size cap alone does not bound staging. These caps prevent an
|
||||
// authenticated member from forcing thousands of (or very large) workspace files
|
||||
// to be downloaded and base64-held in-process per compile request.
|
||||
const MAX_STAGED_INPUTS = 20
|
||||
const MAX_STAGED_FILE_BYTES = 25 * 1024 * 1024
|
||||
const MAX_STAGED_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Collects the workspace file ids a doc source references — from the injected
|
||||
* image-helper call sites and the legacy `/home/user/inputs/<id>` path. Matching
|
||||
* is scoped to the helper calls (not bare id-like strings in slide text), and the
|
||||
* caller skips any id that does not resolve to a real file, so over-matching is
|
||||
* harmless.
|
||||
*/
|
||||
export function collectReferencedFileIds(source: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const re of [INPUT_PATH_RE, FILE_HELPER_RE]) {
|
||||
for (const match of source.matchAll(re)) {
|
||||
if (match[1]) ids.add(match[1])
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function stageReferencedImages(source: string, workspaceId: string): Promise<SandboxFile[]> {
|
||||
const ids = collectReferencedFileIds(source)
|
||||
if (ids.size > MAX_STAGED_INPUTS) {
|
||||
throw new Error(
|
||||
`Too many referenced input files (${ids.size}); max ${MAX_STAGED_INPUTS}. Reference fewer files.`
|
||||
)
|
||||
}
|
||||
const files: SandboxFile[] = []
|
||||
let totalBytes = 0
|
||||
for (const fileId of ids) {
|
||||
let record: Awaited<ReturnType<typeof getWorkspaceFile>>
|
||||
try {
|
||||
record = await getWorkspaceFile(workspaceId, fileId)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to resolve referenced image for doc compile', {
|
||||
workspaceId,
|
||||
fileId,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!record) continue
|
||||
if (typeof record.size === 'number' && record.size > MAX_STAGED_FILE_BYTES) {
|
||||
logger.warn('Skipping oversized referenced image for doc compile', {
|
||||
workspaceId,
|
||||
fileId,
|
||||
size: record.size,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (totalBytes + (record.size ?? 0) > MAX_STAGED_TOTAL_BYTES) {
|
||||
throw new Error(
|
||||
`Referenced input files exceed the ${MAX_STAGED_TOTAL_BYTES} byte staging budget.`
|
||||
)
|
||||
}
|
||||
let buffer: Buffer
|
||||
try {
|
||||
buffer = await fetchWorkspaceFileBuffer(record)
|
||||
} catch (err) {
|
||||
logger.warn('Failed to stage referenced image for doc compile', {
|
||||
workspaceId,
|
||||
fileId,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Enforce the per-file cap on actual bytes too: record.size can be null/stale,
|
||||
// in which case the pre-fetch check above is skipped and a single oversized
|
||||
// file would otherwise be fully base64-held in memory.
|
||||
if (buffer.length > MAX_STAGED_FILE_BYTES) {
|
||||
logger.warn('Skipping oversized referenced image for doc compile (post-fetch)', {
|
||||
workspaceId,
|
||||
fileId,
|
||||
size: buffer.length,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Budget check after the fetch (record.size may be unset/stale) — kept
|
||||
// outside the catch above so it fails the compile rather than being skipped.
|
||||
totalBytes += buffer.length
|
||||
if (totalBytes > MAX_STAGED_TOTAL_BYTES) {
|
||||
throw new Error(
|
||||
`Referenced input files exceed the ${MAX_STAGED_TOTAL_BYTES} byte staging budget.`
|
||||
)
|
||||
}
|
||||
files.push({
|
||||
path: `/home/user/inputs/${fileId}`,
|
||||
content: buffer.toString('base64'),
|
||||
encoding: 'base64',
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
const DOC_COMPILE_TIMEOUT_MS = 120_000
|
||||
|
||||
// Appended to xlsx compile scripts: LibreOffice recalculates formulas on
|
||||
// load/convert and writes cached values, then we move the result back over
|
||||
// output.xlsx so the binary read back has computed results (openpyxl alone omits
|
||||
// them). Indented at column 0 so it concatenates cleanly after the user's script.
|
||||
const XLSX_RECALC_SNIPPET = `
|
||||
import subprocess as __sim_sp, shutil as __sim_sh, os as __sim_os
|
||||
# Best-effort: bake cached formula values via LibreOffice. If recalc fails
|
||||
# (soffice crash/timeout/unsupported), keep the openpyxl workbook as-is — it's
|
||||
# still a valid file (formulas just lack cached values). Never fail the user's
|
||||
# compile over an infra recalc failure.
|
||||
try:
|
||||
__sim_os.makedirs("/home/user/__recalc", exist_ok=True)
|
||||
__sim_sp.run(
|
||||
["soffice", "--headless", "--convert-to", "xlsx", "--outdir", "/home/user/__recalc", "/home/user/output.xlsx"],
|
||||
check=True, timeout=120, capture_output=True,
|
||||
)
|
||||
__sim_sh.move("/home/user/__recalc/output.xlsx", "/home/user/output.xlsx")
|
||||
except Exception as __sim_recalc_err:
|
||||
print("xlsx recalc skipped:", __sim_recalc_err)
|
||||
`.trim()
|
||||
|
||||
interface CompileArgs {
|
||||
source: string
|
||||
fileName: string
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a Python document script to its binary in the dedicated E2B doc
|
||||
* sandbox. The script must save to /home/user/output.<ext>; we read that back.
|
||||
* Throws with a human-readable message when the script errors or writes nothing.
|
||||
* Internal — callers use compileDoc (load-or-build + store).
|
||||
*/
|
||||
async function compileDocViaE2BPython(
|
||||
{ source, workspaceId }: CompileArgs,
|
||||
fmt: E2BDocFormat
|
||||
): Promise<Buffer> {
|
||||
const sandboxFiles = await stageReferencedImages(source, workspaceId)
|
||||
const outputSandboxPath = `/home/user/output.${fmt.ext}`
|
||||
|
||||
// openpyxl writes formula strings but no cached values, so a web viewer (SheetJS)
|
||||
// renders formula cells blank. Recalculate in place with LibreOffice (it
|
||||
// evaluates formulas on load and writes cached values on convert) so the stored
|
||||
// artifact — and everything that serves it — shows computed results. pdf is
|
||||
// unaffected. Runs only after the user's script succeeds.
|
||||
const code = fmt.ext === 'xlsx' ? `${source}\n${XLSX_RECALC_SNIPPET}` : source
|
||||
|
||||
const result = await executeInE2B({
|
||||
code,
|
||||
language: CodeLanguage.Python,
|
||||
timeoutMs: DOC_COMPILE_TIMEOUT_MS,
|
||||
sandboxFiles,
|
||||
outputSandboxPath,
|
||||
sandboxKind: 'doc',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
// The script raised — a user-code error the agent should fix.
|
||||
throw new DocCompileUserError(result.error)
|
||||
}
|
||||
if (!result.exportedFileContent) {
|
||||
throw new DocCompileUserError(
|
||||
`${fmt.formatName} generation produced no output. The script must save to ${outputSandboxPath}.`
|
||||
)
|
||||
}
|
||||
return Buffer.from(result.exportedFileContent, 'base64')
|
||||
}
|
||||
|
||||
// ── Node engine (pptxgenjs / docx) ──────────────────────────────────────────
|
||||
// Preambles replicate the isolated-vm bootstraps as node globals: the injected
|
||||
// `pptx`/`docx` instances, geometry constants, and fileId-based image helpers
|
||||
// (reading staged /home/user/inputs/<id> files). pptx also gets `iconImage`
|
||||
// (react-icons → sharp → PNG), which only works here because the E2B sandbox is
|
||||
// a full Linux VM. The agent's edit_content source runs inside an async IIFE so
|
||||
// top-level await (addImage/iconImage) works; the finalizer writes the binary.
|
||||
const PPTX_NODE_PREAMBLE = `
|
||||
const PptxGenJS = require('pptxgenjs');
|
||||
const fs = require('fs');
|
||||
globalThis.pptx = new PptxGenJS();
|
||||
globalThis.pptx.layout = 'LAYOUT_16x9';
|
||||
globalThis.SLIDE_W = 10; globalThis.SLIDE_H = 5.625;
|
||||
globalThis.MARGIN = 0.5; globalThis.CONTENT_W = 9; globalThis.CONTENT_H = 3.8;
|
||||
function __mime(b){ if(b.length>=2&&b[0]===0x89&&b[1]===0x50)return 'image/png'; if(b.length>=2&&b[0]===0xff&&b[1]===0xd8)return 'image/jpeg'; if(b.length>=3&&b[0]===0x47&&b[1]===0x49&&b[2]===0x46)return 'image/gif'; if(b.length>=12&&b.slice(0,4).toString('latin1')==='RIFF'&&b.slice(8,12).toString('latin1')==='WEBP')return 'image/webp'; return 'image/png'; }
|
||||
globalThis.getFileBase64 = async function(fileId){ const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('getFileBase64: file not staged: '+fileId); const b=fs.readFileSync(p); return __mime(b)+';base64,'+b.toString('base64'); };
|
||||
globalThis.addImage = async function(slide, fileId, opts){ if(!opts||opts.x==null||opts.y==null||opts.w==null||opts.h==null) throw new Error('addImage: opts must include x, y, w, h'); const data=await globalThis.getFileBase64(fileId); slide.addImage(Object.assign({}, opts, { data })); };
|
||||
globalThis.iconImage = async function(IconComponent, color, size){ const React=require('react'); const RDS=require('react-dom/server'); const sharp=require('sharp'); const svg=RDS.renderToStaticMarkup(React.createElement(IconComponent,{color:color||'#000000',size:String(size||256)})); const png=await sharp(Buffer.from(svg)).png().toBuffer(); return 'image/png;base64,'+png.toString('base64'); };
|
||||
`.trim()
|
||||
|
||||
const DOCX_NODE_PREAMBLE = `
|
||||
const docx = require('docx');
|
||||
const fs = require('fs');
|
||||
globalThis.docx = docx;
|
||||
globalThis.__docxSections = [];
|
||||
globalThis.__docxDocOptions = null;
|
||||
globalThis.addSection = function(s){ globalThis.__docxSections.push(s); };
|
||||
globalThis.PAGE_W = 12240; globalThis.PAGE_H = 15840; globalThis.MARGIN = 1440; globalThis.CONTENT_W = 9360;
|
||||
globalThis.getFileBase64 = async function(fileId){ const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('getFileBase64: file not staged: '+fileId); const b=fs.readFileSync(p); const m=(b[0]===0x89?'image/png':b[0]===0xff?'image/jpeg':b[0]===0x47?'image/gif':'image/png'); return 'data:'+m+';base64,'+b.toString('base64'); };
|
||||
globalThis.addImage = async function(fileId, opts){ if(!opts||opts.width==null||opts.height==null) throw new Error('addImage: opts must include width and height'); const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('addImage: file not staged: '+fileId); const b=fs.readFileSync(p); const ext=(b[0]===0x89?'png':b[0]===0xff?'jpg':b[0]===0x47?'gif':'png'); const { width, height, type:_t, data:_d, transformation:ut, ...rest } = opts; return new docx.ImageRun(Object.assign(rest, { data: b, type: ext, transformation: Object.assign({ width, height }, ut||{}) })); };
|
||||
`.trim()
|
||||
|
||||
const PPTX_NODE_FINALIZE = `await globalThis.pptx.writeFile({ fileName: '/home/user/output.pptx' });`
|
||||
const DOCX_NODE_FINALIZE = `
|
||||
let doc = globalThis.doc;
|
||||
if (!doc && globalThis.__docxSections.length > 0) doc = new docx.Document(Object.assign({}, globalThis.__docxDocOptions || {}, { sections: globalThis.__docxSections }));
|
||||
if (!doc) throw new Error('No document created. Use addSection({ children: [...] }) for chunked writes, or set globalThis.doc.');
|
||||
const __buf = await docx.Packer.toBuffer(doc);
|
||||
fs.writeFileSync('/home/user/output.docx', __buf);
|
||||
`.trim()
|
||||
|
||||
/**
|
||||
* Compiles a pptx/docx document by running the agent's pptxgenjs/docx source in
|
||||
* the E2B doc sandbox via Node. Mirrors compileDocViaE2BPython for the JS
|
||||
* engines. Throws DocCompileUserError on a script error.
|
||||
*/
|
||||
async function compileDocViaE2BNode(
|
||||
{ source, fileName, workspaceId }: CompileArgs,
|
||||
ext: 'pptx' | 'docx'
|
||||
): Promise<Buffer> {
|
||||
const sandboxFiles = await stageReferencedImages(source, workspaceId)
|
||||
const outputSandboxPath = `/home/user/output.${ext}`
|
||||
const preamble = ext === 'pptx' ? PPTX_NODE_PREAMBLE : DOCX_NODE_PREAMBLE
|
||||
const finalize = ext === 'pptx' ? PPTX_NODE_FINALIZE : DOCX_NODE_FINALIZE
|
||||
|
||||
const script = `${preamble}
|
||||
;(async () => {
|
||||
${source}
|
||||
${finalize}
|
||||
})().then(() => console.log('__DOC_OK__')).catch((e) => { console.error('__DOC_ERR__' + (e && e.message ? e.message : String(e))); process.exit(1); });
|
||||
`
|
||||
|
||||
const result = await executeShellInE2B({
|
||||
code: 'NODE_PATH=$(npm root -g) node /home/user/script.js',
|
||||
envs: {},
|
||||
timeoutMs: DOC_COMPILE_TIMEOUT_MS,
|
||||
sandboxKind: 'doc',
|
||||
sandboxFiles: [
|
||||
...sandboxFiles,
|
||||
{
|
||||
path: '/home/user/script.js',
|
||||
content: Buffer.from(script, 'utf-8').toString('base64'),
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
outputSandboxPath,
|
||||
})
|
||||
|
||||
// Success requires the script to reach the finalizer (__DOC_OK__) AND produce
|
||||
// the output file — a script that writes then throws must not persist a
|
||||
// partial/corrupt artifact (mirrors the Python path).
|
||||
const out = `${result.stdout || ''}\n${result.error || ''}`
|
||||
const errMatch = out.match(/__DOC_ERR__([\s\S]*)/)
|
||||
if (out.includes('__DOC_OK__') && result.exportedFileContent) {
|
||||
return Buffer.from(result.exportedFileContent, 'base64')
|
||||
}
|
||||
if (errMatch) {
|
||||
// The script ran and threw — a user-code error the agent should fix.
|
||||
throw new DocCompileUserError(
|
||||
`${ext.toUpperCase()} generation failed: ${errMatch[1]?.trim() || 'unknown error'}`
|
||||
)
|
||||
}
|
||||
// No __DOC_OK__ and no __DOC_ERR__ → node never completed (sandbox died, command
|
||||
// failure, or the output couldn't be read). That's a retriable system error, not
|
||||
// the agent's code — surface it as a plain Error so callers don't tell the agent
|
||||
// to "fix its code".
|
||||
throw new Error(
|
||||
`${ext.toUpperCase()} compile did not complete in the sandbox: ${result.error || 'no output produced'}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the compiled binary for a doc, building it once (via the right engine —
|
||||
* Node for pptx/docx, Python for pdf/xlsx) if the source-hash artifact is not
|
||||
* already in S3. Used by read paths (serve, render, compiled-check) so E2B runs
|
||||
* at most once per distinct source.
|
||||
*/
|
||||
export async function compileDoc(
|
||||
args: CompileArgs
|
||||
): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
const { source, fileName, workspaceId } = args
|
||||
const fmt = await getE2BDocFormat(fileName)
|
||||
if (!fmt) throw new Error(`Unsupported document format: ${fileName}`)
|
||||
|
||||
const existing = await loadCompiledDoc(workspaceId, source, fmt.ext)
|
||||
if (existing) return { buffer: existing, contentType: fmt.contentType }
|
||||
|
||||
const buffer =
|
||||
fmt.engine === 'node'
|
||||
? await compileDocViaE2BNode({ source, fileName, workspaceId }, fmt.ext as 'pptx' | 'docx')
|
||||
: await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt)
|
||||
await storeCompiledDoc(workspaceId, source, fmt.ext, fmt.contentType, buffer)
|
||||
return { buffer, contentType: fmt.contentType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a compiled doc artifact by extension when present, without compiling.
|
||||
* Used by the serve route, which has the source + ext but no file record — a hit
|
||||
* means the file is a generated doc whose binary is already built.
|
||||
*/
|
||||
export async function loadCompiledDocByExt(
|
||||
workspaceId: string,
|
||||
source: string,
|
||||
ext: string
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
const fmt = await getE2BDocFormat(`x.${ext}`)
|
||||
if (!fmt) return null
|
||||
const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext)
|
||||
return buffer ? { buffer, contentType: fmt.contentType } : null
|
||||
}
|
||||
|
||||
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04])
|
||||
const PDF_MAGIC = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]) // %PDF-
|
||||
|
||||
function bufferStartsWith(buffer: Buffer, magic: Buffer): boolean {
|
||||
return buffer.length >= magic.length && buffer.subarray(0, magic.length).equals(magic)
|
||||
}
|
||||
|
||||
/**
|
||||
* How a read-only consumer (e.g. the public share route) should serve a stored doc
|
||||
* WITHOUT compiling:
|
||||
* - `passthrough` — serve the raw stored bytes as-is (a non-doc file, or an uploaded
|
||||
* binary that already carries its format magic).
|
||||
* - `artifact` — serve this prebuilt content-addressed compiled binary.
|
||||
* - `unavailable` — a generated doc stored as source whose compiled artifact does
|
||||
* not exist yet; the raw bytes are source, so serving them under the file's binary
|
||||
* content type would be corrupt. The caller should signal "not ready" instead.
|
||||
*/
|
||||
export type ServableDoc =
|
||||
| { kind: 'passthrough' }
|
||||
| { kind: 'artifact'; buffer: Buffer; contentType: string }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
export async function resolveServableDoc(
|
||||
workspaceId: string,
|
||||
storedBytes: Buffer,
|
||||
fileName: string
|
||||
): Promise<ServableDoc> {
|
||||
const fmt = await getE2BDocFormat(fileName)
|
||||
if (!fmt) return { kind: 'passthrough' }
|
||||
const magic = fmt.ext === 'pdf' ? PDF_MAGIC : ZIP_MAGIC
|
||||
if (bufferStartsWith(storedBytes, magic)) return { kind: 'passthrough' }
|
||||
const artifact = await loadCompiledDocByExt(workspaceId, storedBytes.toString('utf-8'), fmt.ext)
|
||||
return artifact ? { kind: 'artifact', ...artifact } : { kind: 'unavailable' }
|
||||
}
|
||||
|
||||
interface CompilableFormat {
|
||||
magic: Buffer
|
||||
taskId: SandboxTaskId
|
||||
contentType: string
|
||||
}
|
||||
|
||||
const COMPILABLE_FORMATS: Record<string, CompilableFormat> = {
|
||||
'.pptx': { magic: ZIP_MAGIC, taskId: 'pptx-generate', contentType: PPTX_MIME },
|
||||
'.docx': { magic: ZIP_MAGIC, taskId: 'docx-generate', contentType: DOCX_MIME },
|
||||
'.pdf': { magic: PDF_MAGIC, taskId: 'pdf-generate', contentType: PDF_MIME },
|
||||
}
|
||||
|
||||
const MAX_COMPILED_DOC_CACHE = 10
|
||||
const compiledDocCache = new Map<string, Buffer>()
|
||||
|
||||
function compiledCacheSet(key: string, buffer: Buffer): void {
|
||||
if (compiledDocCache.size >= MAX_COMPILED_DOC_CACHE) {
|
||||
compiledDocCache.delete(compiledDocCache.keys().next().value as string)
|
||||
}
|
||||
compiledDocCache.set(key, buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the bytes a consumer should actually serve/attach for a stored file —
|
||||
* the single source of truth shared by the file-serve route and every tool that
|
||||
* downloads a workspace file (email attachments, uploads, provider file inputs).
|
||||
*
|
||||
* Generated docs (pdf/docx/pptx/xlsx) store their GENERATION SOURCE as the primary
|
||||
* file; the rendered binary lives in a separate content-addressed artifact store.
|
||||
* A naive raw-byte read therefore hands out source text under a `.pdf` name — the
|
||||
* corruption every non-serve consumer used to ship. The file-serve route and the
|
||||
* attachment download helper share this one function so they resolve identically.
|
||||
* (The public read-only share route uses the non-compiling {@link resolveServableDoc}
|
||||
* variant, which returns `unavailable` instead of throwing.) The swap:
|
||||
*
|
||||
* - Bytes already carry the format magic (`%PDF`/ZIP) → real uploaded/binary file,
|
||||
* serve as-is.
|
||||
* - Generated-doc source → load the content-addressed compiled artifact.
|
||||
* - Artifact missing in the E2B regime → the doc is still being generated; throw
|
||||
* {@link DocCompileUserError} so callers signal "not ready / retry" instead of
|
||||
* shipping source.
|
||||
* - E2B disabled → compile the committed JS source via isolated-vm (cached).
|
||||
* - Non-doc files → pass through with the extension-derived content type.
|
||||
*
|
||||
* It never falls back to attaching the raw source bytes for a generated doc.
|
||||
*/
|
||||
export async function resolveServableDocBytes(args: {
|
||||
rawBuffer: Buffer
|
||||
fileName: string
|
||||
workspaceId: string | undefined
|
||||
ownerKey?: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
const { rawBuffer, fileName, workspaceId, ownerKey, signal } = args
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
|
||||
const extNoDot = ext.replace(/^\./, '')
|
||||
const format = COMPILABLE_FORMATS[ext]
|
||||
|
||||
// xlsx isn't in COMPILABLE_FORMATS (no isolated-vm path), so match its ZIP magic
|
||||
// explicitly alongside the table-driven formats.
|
||||
const magic = format?.magic ?? (extNoDot === 'xlsx' ? ZIP_MAGIC : undefined)
|
||||
if (magic && bufferStartsWith(rawBuffer, magic)) {
|
||||
return { buffer: rawBuffer, contentType: getContentType(fileName) }
|
||||
}
|
||||
|
||||
if (!format && extNoDot !== 'xlsx') {
|
||||
return { buffer: rawBuffer, contentType: getContentType(fileName) }
|
||||
}
|
||||
|
||||
const source = rawBuffer.toString('utf-8')
|
||||
|
||||
if (workspaceId) {
|
||||
const stored = await loadCompiledDocByExt(workspaceId, source, extNoDot)
|
||||
if (stored) {
|
||||
return { buffer: stored.buffer, contentType: stored.contentType }
|
||||
}
|
||||
if (isE2BDocEnabled && (await getE2BDocFormat(fileName))) {
|
||||
throw new DocCompileUserError('Document is still being generated')
|
||||
}
|
||||
}
|
||||
|
||||
// Reaches here only for xlsx, which has no isolated-vm fallback.
|
||||
if (!format) return { buffer: rawBuffer, contentType: getContentType(fileName) }
|
||||
|
||||
const cacheKey = sha256Hex(`${ext}${source}${workspaceId ?? ''}`)
|
||||
const cached = compiledDocCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return { buffer: cached, contentType: format.contentType }
|
||||
}
|
||||
|
||||
const compiled = await runSandboxTask(
|
||||
format.taskId,
|
||||
{ code: source, workspaceId: workspaceId || '' },
|
||||
{ ownerKey, signal }
|
||||
)
|
||||
compiledCacheSet(cacheKey, compiled)
|
||||
return { buffer: compiled, contentType: format.contentType }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
|
||||
|
||||
const logger = createLogger('CopilotDocCompiledStore')
|
||||
|
||||
/**
|
||||
* Compiled-artifact store for Python-generated documents.
|
||||
*
|
||||
* The Python doc path keeps the SOURCE as the primary file (the agent reads and
|
||||
* edits it exactly like the JS path). The compiled binary is stored as its own
|
||||
* S3 object, content-addressed by (workspaceId, sha256(source), ext) — the hash
|
||||
* is in the key, so when the source changes the key changes. Every read path
|
||||
* (serve, preview, /compiled) loads the artifact for the current source hash and
|
||||
* recompiles only when it is absent. No fileId in the key means any site with
|
||||
* the source (e.g. the serve route) can find it. S3 is cheap; stale artifacts
|
||||
* are inert.
|
||||
*/
|
||||
function compiledArtifactKey(workspaceId: string, source: string, ext: string): string {
|
||||
const hash = createHash('sha256').update(source, 'utf-8').digest('hex')
|
||||
return `copilot-doc-compiled/${workspaceId}/${hash}.${ext}`
|
||||
}
|
||||
|
||||
/** Loads the compiled binary for the current source, or null if not yet built. */
|
||||
export async function loadCompiledDoc(
|
||||
workspaceId: string,
|
||||
source: string,
|
||||
ext: string
|
||||
): Promise<Buffer | null> {
|
||||
const key = compiledArtifactKey(workspaceId, source, ext)
|
||||
try {
|
||||
return await downloadFile({ key, context: 'copilot' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the compiled binary as the source's associated S3 artifact.
|
||||
*
|
||||
* Throws on failure (does not swallow): the serve route is load-only and cannot
|
||||
* self-heal a missing artifact, so a silent store failure would make a write
|
||||
* report success while leaving the document unrenderable. Propagating lets the
|
||||
* write fail honestly so the caller (and the agent) can retry.
|
||||
*/
|
||||
export async function storeCompiledDoc(
|
||||
workspaceId: string,
|
||||
source: string,
|
||||
ext: string,
|
||||
contentType: string,
|
||||
binary: Buffer
|
||||
): Promise<void> {
|
||||
const key = compiledArtifactKey(workspaceId, source, ext)
|
||||
try {
|
||||
await uploadFile({
|
||||
file: binary,
|
||||
fileName: `doc.${ext}`,
|
||||
contentType,
|
||||
context: 'copilot',
|
||||
customKey: key,
|
||||
preserveKey: true,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to store compiled doc artifact', {
|
||||
key,
|
||||
error: getErrorMessage(err),
|
||||
})
|
||||
throw toError(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
|
||||
const EXTRACT_TIMEOUT_MS = 120_000
|
||||
// Bound the text handed back to the agent so a huge document can't blow the
|
||||
// context window; the agent gets a clear truncation marker if it hits the cap.
|
||||
const MAX_EXTRACT_CHARS = 200_000
|
||||
|
||||
/** Binary document formats whose text/tables we can extract in the doc sandbox. */
|
||||
const EXTRACTABLE_EXTS = new Set(['pdf', 'pptx', 'docx', 'xlsx'])
|
||||
|
||||
export function isExtractableDocExt(ext: string): boolean {
|
||||
return EXTRACTABLE_EXTS.has(ext.toLowerCase())
|
||||
}
|
||||
|
||||
export interface DocExtract {
|
||||
text: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts readable text (and tables) from an uploaded binary document inside the
|
||||
* E2B doc sandbox so the agent can read/reason over files it cannot otherwise see
|
||||
* as source: pdf via pdfplumber, pptx via python-pptx, docx via python-docx, xlsx
|
||||
* via openpyxl. Read-only — never mutates the file. Throws on sandbox/infra
|
||||
* failure or an unparseable document.
|
||||
*/
|
||||
export async function extractDocText(args: { binary: Buffer; ext: string }): Promise<DocExtract> {
|
||||
const ext = args.ext.toLowerCase()
|
||||
if (!isExtractableDocExt(ext)) {
|
||||
throw new Error(`Cannot extract text from .${ext} (supported: pdf, pptx, docx, xlsx)`)
|
||||
}
|
||||
|
||||
const script = `
|
||||
import json
|
||||
ext = ${JSON.stringify(ext)}
|
||||
inp = f"/home/user/input.{ext}"
|
||||
out = []
|
||||
|
||||
if ext == "pdf":
|
||||
import pdfplumber
|
||||
with pdfplumber.open(inp) as pdf:
|
||||
for i, page in enumerate(pdf.pages, 1):
|
||||
out.append(f"--- Page {i} ---")
|
||||
out.append(page.extract_text() or "")
|
||||
for t in (page.extract_tables() or []):
|
||||
out.append("[table] " + json.dumps(t, ensure_ascii=False))
|
||||
elif ext == "pptx":
|
||||
from pptx import Presentation
|
||||
prs = Presentation(inp)
|
||||
for i, slide in enumerate(prs.slides, 1):
|
||||
out.append(f"--- Slide {i} ---")
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame and shape.text_frame.text.strip():
|
||||
out.append(shape.text_frame.text)
|
||||
if shape.has_table:
|
||||
for row in shape.table.rows:
|
||||
out.append(" | ".join(c.text for c in row.cells))
|
||||
nf = slide.notes_slide.notes_text_frame if slide.has_notes_slide else None
|
||||
notes = nf.text if nf is not None else ""
|
||||
if notes.strip():
|
||||
out.append("[notes] " + notes)
|
||||
elif ext == "docx":
|
||||
import docx
|
||||
d = docx.Document(inp)
|
||||
for p in d.paragraphs:
|
||||
if p.text.strip():
|
||||
out.append(p.text)
|
||||
for tbl in d.tables:
|
||||
for row in tbl.rows:
|
||||
out.append(" | ".join(c.text for c in row.cells))
|
||||
elif ext == "xlsx":
|
||||
import openpyxl
|
||||
wb = openpyxl.load_workbook(inp, data_only=True)
|
||||
for ws in wb.worksheets:
|
||||
out.append(f"--- Sheet {ws.title} ---")
|
||||
# Cap rows so an inflated used-range can't blow up memory/output.
|
||||
for ri, row in enumerate(ws.iter_rows(values_only=True)):
|
||||
if ri >= 5000:
|
||||
out.append("[... more rows truncated]")
|
||||
break
|
||||
out.append(",".join("" if v is None else str(v) for v in row))
|
||||
|
||||
# Bound the transferred text so a decompression bomb can't return gigabytes.
|
||||
# Headroom over MAX_EXTRACT_CHARS so the TS-side truncation flag can still fire.
|
||||
text = "\\n".join(out)[:${MAX_EXTRACT_CHARS + 20000}]
|
||||
print("__SIM_RESULT__=" + json.dumps({"text": text}))
|
||||
`.trim()
|
||||
|
||||
const result = await executeInE2B({
|
||||
code: script,
|
||||
language: CodeLanguage.Python,
|
||||
timeoutMs: EXTRACT_TIMEOUT_MS,
|
||||
sandboxKind: 'doc',
|
||||
sandboxFiles: [
|
||||
{
|
||||
path: `/home/user/input.${ext}`,
|
||||
content: args.binary.toString('base64'),
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Document extraction failed: ${result.error}`)
|
||||
}
|
||||
const payload = result.result as { text?: string } | null
|
||||
const full = payload?.text ?? ''
|
||||
const truncated = full.length > MAX_EXTRACT_CHARS
|
||||
// The caller (VFS read) owns the user-facing truncation note; just return the
|
||||
// bounded text + the flag here.
|
||||
return { text: full.slice(0, MAX_EXTRACT_CHARS), truncated }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
import { compileDoc, DocCompileUserError } from './doc-compile'
|
||||
|
||||
const logger = createLogger('CopilotDocRecalc')
|
||||
|
||||
const RECALC_TIMEOUT_MS = 150_000
|
||||
const MAX_REPORTED_ERRORS = 50
|
||||
|
||||
export interface XlsxCellError {
|
||||
sheet: string
|
||||
cell: string
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface XlsxRecalcResult {
|
||||
ok: boolean
|
||||
errors: XlsxCellError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans an .xlsx workbook for spilled error values (#REF!/#DIV/0!/etc.) — the
|
||||
* spreadsheet equivalent of the visual QA loop.
|
||||
*
|
||||
* Precondition: the binary must already be recalculated (cached values present).
|
||||
* compileDoc bakes a LibreOffice recalc into every xlsx artifact, so the input
|
||||
* here always has cached values — meaning we can read them with openpyxl
|
||||
* (data_only) directly and skip a second LibreOffice cold-start. Throws on
|
||||
* sandbox/infra failure.
|
||||
*/
|
||||
export async function recalcXlsx(args: {
|
||||
binary: Buffer
|
||||
workspaceId: string
|
||||
}): Promise<XlsxRecalcResult> {
|
||||
const script = `
|
||||
import json, openpyxl
|
||||
|
||||
ERR = {"#REF!", "#DIV/0!", "#VALUE!", "#NAME?", "#N/A", "#NULL!", "#NUM!"}
|
||||
|
||||
# Input is already recalculated by compileDoc, so cached values are present.
|
||||
wb = openpyxl.load_workbook("/home/user/input.xlsx", data_only=True)
|
||||
errors = []
|
||||
for ws in wb.worksheets:
|
||||
for row in ws.iter_rows():
|
||||
for c in row:
|
||||
if isinstance(c.value, str) and c.value.strip() in ERR:
|
||||
errors.append({"sheet": ws.title, "cell": c.coordinate, "error": c.value.strip()})
|
||||
|
||||
print("__SIM_RESULT__=" + json.dumps({"ok": len(errors) == 0, "errors": errors[:${MAX_REPORTED_ERRORS}]}))
|
||||
`.trim()
|
||||
|
||||
const result = await executeInE2B({
|
||||
code: script,
|
||||
language: CodeLanguage.Python,
|
||||
timeoutMs: RECALC_TIMEOUT_MS,
|
||||
sandboxKind: 'doc',
|
||||
sandboxFiles: [
|
||||
{
|
||||
path: '/home/user/input.xlsx',
|
||||
content: args.binary.toString('base64'),
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Spreadsheet recalc failed: ${result.error}`)
|
||||
}
|
||||
const payload = result.result as XlsxRecalcResult | null
|
||||
if (!payload || typeof payload.ok !== 'boolean') {
|
||||
logger.warn('Recalc returned no structured result', { workspaceId: args.workspaceId })
|
||||
return { ok: true, errors: [] }
|
||||
}
|
||||
return { ok: payload.ok, errors: Array.isArray(payload.errors) ? payload.errors : [] }
|
||||
}
|
||||
|
||||
/** Single-line summary of the first few formula errors, for the compiled-check result. */
|
||||
export function formatXlsxErrors(errors: XlsxCellError[]): string {
|
||||
return `${errors.length} formula error(s): ${errors
|
||||
.slice(0, 5)
|
||||
.map((e) => `${e.sheet}!${e.cell}=${e.error}`)
|
||||
.join(', ')}`
|
||||
}
|
||||
|
||||
export interface CompiledCheckResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
errors?: XlsxCellError[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles a generated doc (and, for xlsx, recalc-scans its formulas) to verify
|
||||
* it builds — the shared body behind the /compiled-check route and the VFS
|
||||
* compiled-check read. Returns { ok: false } only for a DocCompileUserError (the
|
||||
* agent's script is wrong); infra failures (E2B/S3) rethrow so callers surface a
|
||||
* 5xx instead of telling the agent to fix its script.
|
||||
*/
|
||||
export async function runE2BCompiledCheck(args: {
|
||||
source: string
|
||||
fileName: string
|
||||
workspaceId: string
|
||||
ext: string
|
||||
}): Promise<CompiledCheckResult> {
|
||||
try {
|
||||
const compiled = await compileDoc({
|
||||
source: args.source,
|
||||
fileName: args.fileName,
|
||||
workspaceId: args.workspaceId,
|
||||
})
|
||||
if (args.ext === 'xlsx') {
|
||||
const recalc = await recalcXlsx({ binary: compiled.buffer, workspaceId: args.workspaceId })
|
||||
return recalc.ok
|
||||
? { ok: true }
|
||||
: { ok: false, error: formatXlsxErrors(recalc.errors), errors: recalc.errors }
|
||||
}
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
if (err instanceof DocCompileUserError) return { ok: false, error: err.message }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { CodeLanguage } from '@/lib/execution/languages'
|
||||
|
||||
const RENDER_TIMEOUT_MS = 150_000
|
||||
// Bound the visual-QA cost: cap pages and rasterization DPI so the JPEGs the
|
||||
// file agent inspects stay small enough for vision input.
|
||||
const MAX_RENDER_PAGES = 20
|
||||
const RENDER_DPI = 110
|
||||
|
||||
/** Extensions LibreOffice can render to page images for the visual QA loop. */
|
||||
const RENDERABLE_EXTS = new Set(['pptx', 'docx', 'pdf'])
|
||||
|
||||
export function isRenderableDocExt(ext: string): boolean {
|
||||
return RENDERABLE_EXTS.has(ext.toLowerCase())
|
||||
}
|
||||
|
||||
export interface DocRender {
|
||||
/** A single contact-sheet grid JPEG of all pages, for the agent's visual QA. */
|
||||
grid: Buffer
|
||||
pageCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a compiled document binary to a single contact-sheet grid image inside
|
||||
* the E2B doc sandbox (LibreOffice → PDF → poppler `pdftoppm` → Pillow tile).
|
||||
* Works for any compiled binary regardless of which engine produced it
|
||||
* (isolated-vm JS or E2B Python), so the visual QA loop covers pptx/docx/pdf
|
||||
* uniformly. One grid image fits the VFS single-attachment read and gives the
|
||||
* agent the whole deck/doc at a glance (mirrors Anthropic's thumbnail grid).
|
||||
*
|
||||
* Throws on a sandbox/infra failure or when the doc renders to zero pages.
|
||||
*/
|
||||
export async function renderDocToGrid(args: {
|
||||
binary: Buffer
|
||||
ext: string
|
||||
workspaceId: string
|
||||
}): Promise<DocRender> {
|
||||
const ext = args.ext.toLowerCase()
|
||||
if (!isRenderableDocExt(ext)) {
|
||||
throw new Error(`Cannot render .${ext} to images (supported: pptx, docx, pdf)`)
|
||||
}
|
||||
|
||||
const script = `
|
||||
import subprocess, glob, base64, json
|
||||
from PIL import Image
|
||||
|
||||
ext = ${JSON.stringify(ext)}
|
||||
inp = f"/home/user/input.{ext}"
|
||||
pdf = inp if ext == "pdf" else "/home/user/input.pdf"
|
||||
|
||||
if ext != "pdf":
|
||||
subprocess.run(
|
||||
["soffice", "--headless", "--convert-to", "pdf", "--outdir", "/home/user", inp],
|
||||
check=True, timeout=120, capture_output=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["pdftoppm", "-jpeg", "-r", "${RENDER_DPI}", "-l", "${MAX_RENDER_PAGES}", pdf, "/home/user/page"],
|
||||
check=True, timeout=120, capture_output=True,
|
||||
)
|
||||
|
||||
paths = sorted(glob.glob("/home/user/page*.jpg"))[:${MAX_RENDER_PAGES}]
|
||||
imgs = [Image.open(p).convert("RGB") for p in paths]
|
||||
n = len(imgs)
|
||||
if n == 0:
|
||||
print("__SIM_RESULT__=" + json.dumps({"grid": None, "pageCount": 0}))
|
||||
else:
|
||||
cols = 1 if n == 1 else (2 if n <= 6 else 3)
|
||||
rows = (n + cols - 1) // cols
|
||||
cell_w = max(i.width for i in imgs)
|
||||
cell_h = max(i.height for i in imgs)
|
||||
pad = 12
|
||||
grid = Image.new("RGB", (cols * cell_w + (cols + 1) * pad, rows * cell_h + (rows + 1) * pad), (240, 240, 240))
|
||||
for idx, im in enumerate(imgs):
|
||||
r, c = divmod(idx, cols)
|
||||
grid.paste(im, (pad + c * (cell_w + pad), pad + r * (cell_h + pad)))
|
||||
# Cap the grid's longest edge so the JPEG stays a reasonable vision input.
|
||||
max_edge = 2200
|
||||
if max(grid.size) > max_edge:
|
||||
scale = max_edge / max(grid.size)
|
||||
grid = grid.resize((int(grid.width * scale), int(grid.height * scale)))
|
||||
grid.save("/home/user/grid.jpg", "JPEG", quality=80)
|
||||
with open("/home/user/grid.jpg", "rb") as f:
|
||||
print("__SIM_RESULT__=" + json.dumps({"grid": base64.b64encode(f.read()).decode(), "pageCount": n}))
|
||||
`.trim()
|
||||
|
||||
const result = await executeInE2B({
|
||||
code: script,
|
||||
language: CodeLanguage.Python,
|
||||
timeoutMs: RENDER_TIMEOUT_MS,
|
||||
sandboxKind: 'doc',
|
||||
sandboxFiles: [
|
||||
{
|
||||
path: `/home/user/input.${ext}`,
|
||||
content: args.binary.toString('base64'),
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Document render failed: ${result.error}`)
|
||||
}
|
||||
const payload = result.result as { grid?: string | null; pageCount?: number } | null
|
||||
if (!payload?.grid) {
|
||||
throw new Error('Document render produced no pages')
|
||||
}
|
||||
return { grid: Buffer.from(payload.grid, 'base64'), pageCount: payload.pageCount ?? 0 }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { e2bFlag, betaFlag, mockLoadCompiledDoc, mockRunSandboxTask } = vi.hoisted(() => ({
|
||||
e2bFlag: { value: true },
|
||||
betaFlag: { value: false },
|
||||
mockLoadCompiledDoc: vi.fn(),
|
||||
mockRunSandboxTask: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: vi.fn(),
|
||||
executeShellInE2B: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/execution/languages', () => ({
|
||||
CodeLanguage: { javascript: 'javascript', python: 'python' },
|
||||
}))
|
||||
vi.mock('@/lib/execution/sandbox/run-task', () => ({
|
||||
runSandboxTask: mockRunSandboxTask,
|
||||
}))
|
||||
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
|
||||
getWorkspaceFile: vi.fn(),
|
||||
fetchWorkspaceFileBuffer: vi.fn(),
|
||||
}))
|
||||
vi.mock('./doc-compiled-store', () => ({
|
||||
loadCompiledDoc: mockLoadCompiledDoc,
|
||||
storeCompiledDoc: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isFeatureEnabled: vi.fn(async () => betaFlag.value),
|
||||
}))
|
||||
vi.mock('@/lib/core/config/env-flags', () => ({
|
||||
get isE2BDocEnabled() {
|
||||
return e2bFlag.value
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/api/files/utils', () => ({
|
||||
getContentType: (name: string) =>
|
||||
name.endsWith('.pdf')
|
||||
? 'application/pdf'
|
||||
: name.endsWith('.txt')
|
||||
? 'text/plain'
|
||||
: 'application/octet-stream',
|
||||
}))
|
||||
|
||||
import { DocCompileUserError, resolveServableDocBytes } from './doc-compile'
|
||||
|
||||
const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const PDF_MAGIC = Buffer.from('%PDF-1.7\n...binary...')
|
||||
const PDF_SOURCE = Buffer.from('from reportlab.pdfgen import canvas\n# generates a PDF', 'utf-8')
|
||||
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x00, 0x01])
|
||||
const XLSX_SOURCE = Buffer.from('from openpyxl import Workbook\n# generates an xlsx', 'utf-8')
|
||||
|
||||
describe('resolveServableDocBytes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
e2bFlag.value = true
|
||||
betaFlag.value = false
|
||||
})
|
||||
|
||||
it('swaps generated-doc source for the compiled artifact + binary content type', async () => {
|
||||
const artifact = Buffer.from('%PDF-compiled-binary')
|
||||
mockLoadCompiledDoc.mockResolvedValue(artifact)
|
||||
|
||||
const result = await resolveServableDocBytes({
|
||||
rawBuffer: PDF_SOURCE,
|
||||
fileName: 'report.pdf',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
|
||||
expect(result.buffer).toBe(artifact)
|
||||
expect(result.contentType).toBe('application/pdf')
|
||||
expect(mockLoadCompiledDoc).toHaveBeenCalledWith(
|
||||
WORKSPACE_ID,
|
||||
PDF_SOURCE.toString('utf-8'),
|
||||
'pdf'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes through a real binary PDF (carries the %PDF magic) without an artifact lookup', async () => {
|
||||
const result = await resolveServableDocBytes({
|
||||
rawBuffer: PDF_MAGIC,
|
||||
fileName: 'uploaded.pdf',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
|
||||
expect(result.buffer).toBe(PDF_MAGIC)
|
||||
expect(result.contentType).toBe('application/pdf')
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws DocCompileUserError when a generated doc artifact is not ready (E2B regime)', async () => {
|
||||
mockLoadCompiledDoc.mockResolvedValue(null)
|
||||
e2bFlag.value = true
|
||||
|
||||
await expect(
|
||||
resolveServableDocBytes({
|
||||
rawBuffer: PDF_SOURCE,
|
||||
fileName: 'report.pdf',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DocCompileUserError)
|
||||
|
||||
expect(mockRunSandboxTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('compiles via the sandbox when E2B is disabled and no artifact is stored', async () => {
|
||||
mockLoadCompiledDoc.mockResolvedValue(null)
|
||||
e2bFlag.value = false
|
||||
const compiled = Buffer.from('%PDF-isolated-vm-binary')
|
||||
mockRunSandboxTask.mockResolvedValue(compiled)
|
||||
|
||||
const result = await resolveServableDocBytes({
|
||||
rawBuffer: PDF_SOURCE,
|
||||
fileName: 'report.pdf',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
|
||||
expect(result.buffer).toBe(compiled)
|
||||
expect(result.contentType).toBe('application/pdf')
|
||||
expect(mockRunSandboxTask).toHaveBeenCalledWith(
|
||||
'pdf-generate',
|
||||
{ code: PDF_SOURCE.toString('utf-8'), workspaceId: WORKSPACE_ID },
|
||||
expect.objectContaining({})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes non-doc files through untouched with their extension content type', async () => {
|
||||
const text = Buffer.from('hello world', 'utf-8')
|
||||
const result = await resolveServableDocBytes({
|
||||
rawBuffer: text,
|
||||
fileName: 'notes.txt',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
|
||||
expect(result.buffer).toBe(text)
|
||||
expect(result.contentType).toBe('text/plain')
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes through a real binary XLSX (ZIP magic) without an artifact lookup', async () => {
|
||||
const result = await resolveServableDocBytes({
|
||||
rawBuffer: ZIP_MAGIC,
|
||||
fileName: 'sheet.xlsx',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
|
||||
expect(result.buffer).toBe(ZIP_MAGIC)
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when a generated XLSX artifact is not ready (E2B + mothership-beta enabled)', async () => {
|
||||
mockLoadCompiledDoc.mockResolvedValue(null)
|
||||
e2bFlag.value = true
|
||||
betaFlag.value = true
|
||||
|
||||
await expect(
|
||||
resolveServableDocBytes({
|
||||
rawBuffer: XLSX_SOURCE,
|
||||
fileName: 'sheet.xlsx',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DocCompileUserError)
|
||||
|
||||
expect(mockRunSandboxTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns raw XLSX source when there is no workspaceId (xlsx has no isolated-vm path)', async () => {
|
||||
betaFlag.value = true
|
||||
|
||||
const result = await resolveServableDocBytes({
|
||||
rawBuffer: XLSX_SOURCE,
|
||||
fileName: 'sheet.xlsx',
|
||||
workspaceId: undefined,
|
||||
})
|
||||
|
||||
expect(result.buffer).toBe(XLSX_SOURCE)
|
||||
expect(mockLoadCompiledDoc).not.toHaveBeenCalled()
|
||||
expect(mockRunSandboxTask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { z } from 'zod'
|
||||
import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import {
|
||||
getExtensionFromMimeType,
|
||||
getFileExtension,
|
||||
getMimeTypeFromExtension,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
|
||||
const logger = createLogger('DownloadToWorkspaceFileTool')
|
||||
|
||||
const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
|
||||
const DownloadToWorkspaceFileArgsSchema = z.object({
|
||||
url: z.string().url(),
|
||||
fileName: z.string().min(1).optional(),
|
||||
outputs: z
|
||||
.object({
|
||||
files: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string().min(1),
|
||||
mode: z.enum(['create', 'overwrite']).optional(),
|
||||
mimeType: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const DownloadToWorkspaceFileResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
fileId: z.string().optional(),
|
||||
fileName: z.string().optional(),
|
||||
vfsPath: z.string().optional(),
|
||||
downloadUrl: z.string().optional(),
|
||||
})
|
||||
|
||||
type DownloadToWorkspaceFileArgs = z.infer<typeof DownloadToWorkspaceFileArgsSchema>
|
||||
type DownloadToWorkspaceFileResult = z.infer<typeof DownloadToWorkspaceFileResultSchema>
|
||||
|
||||
function sanitizeFileName(fileName: string): string {
|
||||
return fileName.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').trim()
|
||||
}
|
||||
|
||||
function stripQueryAndHash(input: string): string {
|
||||
return input.split('#')[0]?.split('?')[0] ?? input
|
||||
}
|
||||
|
||||
function extractFileNameFromUrl(url: string): string | undefined {
|
||||
try {
|
||||
const pathname = new URL(url).pathname
|
||||
const lastSegment = pathname.split('/').pop()
|
||||
if (!lastSegment) return undefined
|
||||
const decoded = decodeURIComponent(lastSegment)
|
||||
return decoded && decoded !== '/' ? decoded : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileNameFromContentDisposition(header: string | null): string | undefined {
|
||||
if (!header) return undefined
|
||||
|
||||
const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i)
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1].trim())
|
||||
} catch {
|
||||
return utf8Match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
const quotedMatch = header.match(/filename\s*=\s*"([^"]+)"/i)
|
||||
if (quotedMatch?.[1]) return quotedMatch[1].trim()
|
||||
|
||||
const bareMatch = header.match(/filename\s*=\s*([^;]+)/i)
|
||||
if (bareMatch?.[1]) return bareMatch[1].trim()
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveMimeType(
|
||||
responseContentType: string | null,
|
||||
candidateFileName?: string,
|
||||
sourceUrl?: string
|
||||
): string {
|
||||
const headerMime = responseContentType?.split(';')[0]?.trim().toLowerCase()
|
||||
if (headerMime && headerMime !== 'application/octet-stream') {
|
||||
return headerMime
|
||||
}
|
||||
|
||||
const fileName = candidateFileName || extractFileNameFromUrl(sourceUrl || '')
|
||||
const ext = fileName ? getFileExtension(stripQueryAndHash(fileName)) : ''
|
||||
return ext ? getMimeTypeFromExtension(ext) : 'application/octet-stream'
|
||||
}
|
||||
|
||||
function ensureFileExtension(fileName: string, mimeType: string): string {
|
||||
const ext = getFileExtension(stripQueryAndHash(fileName))
|
||||
if (ext) return fileName
|
||||
|
||||
const inferredExt = getExtensionFromMimeType(mimeType)
|
||||
return inferredExt ? `${fileName}.${inferredExt}` : fileName
|
||||
}
|
||||
|
||||
function inferOutputFileName(
|
||||
requestedFileName: string | undefined,
|
||||
headers: { get(name: string): string | null },
|
||||
url: string,
|
||||
mimeType: string
|
||||
): string {
|
||||
const preferredName =
|
||||
requestedFileName ||
|
||||
extractFileNameFromContentDisposition(headers.get('content-disposition')) ||
|
||||
extractFileNameFromUrl(url) ||
|
||||
'downloaded-file'
|
||||
|
||||
const sanitized = sanitizeFileName(stripQueryAndHash(preferredName)) || 'downloaded-file'
|
||||
return ensureFileExtension(sanitized, mimeType)
|
||||
}
|
||||
|
||||
export const downloadToWorkspaceFileServerTool: BaseServerTool<
|
||||
DownloadToWorkspaceFileArgs,
|
||||
DownloadToWorkspaceFileResult
|
||||
> = {
|
||||
name: DownloadToWorkspaceFile.id,
|
||||
inputSchema: DownloadToWorkspaceFileArgsSchema,
|
||||
outputSchema: DownloadToWorkspaceFileResultSchema,
|
||||
|
||||
async execute(
|
||||
params: DownloadToWorkspaceFileArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<DownloadToWorkspaceFileResult> {
|
||||
const withMessageId = (message: string) =>
|
||||
context?.messageId ? `${message} [messageId:${context.messageId}]` : message
|
||||
|
||||
if (!context?.userId) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
|
||||
|
||||
try {
|
||||
assertServerToolNotAborted(context)
|
||||
|
||||
// secureFetchWithValidation handles: DNS resolution, private IP blocking (via ipaddr.js),
|
||||
// SSRF-safe redirect following, and streaming size enforcement
|
||||
const response = await secureFetchWithValidation(params.url, {
|
||||
maxResponseBytes: MAX_DOWNLOAD_BYTES,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Download failed with status ${response.status} ${response.statusText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const mimeType = resolveMimeType(
|
||||
response.headers.get('content-type'),
|
||||
params.fileName,
|
||||
params.url
|
||||
)
|
||||
const outputFile = params.outputs?.files?.[0]
|
||||
const fileName = inferOutputFileName(params.fileName, response.headers, params.url, mimeType)
|
||||
const outputPath = outputFile?.path ?? `files/${fileName}`
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
if (fileBuffer.length === 0) {
|
||||
return { success: false, message: 'Downloaded file is empty' }
|
||||
}
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const written = await writeWorkspaceFileByPath({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
target: {
|
||||
path: outputPath,
|
||||
mode: outputFile?.mode ?? 'create',
|
||||
mimeType: outputFile?.mimeType,
|
||||
},
|
||||
buffer: fileBuffer,
|
||||
inferredMimeType: outputFile?.mimeType ?? mimeType,
|
||||
})
|
||||
|
||||
logger.info('Downloaded remote file to workspace', {
|
||||
sourceUrl: params.url,
|
||||
fileId: written.id,
|
||||
fileName: written.name,
|
||||
vfsPath: written.vfsPath,
|
||||
mimeType,
|
||||
size: fileBuffer.length,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Downloaded "${written.name}" to ${written.vfsPath} (${fileBuffer.length} bytes)`,
|
||||
fileId: written.id,
|
||||
fileName: written.name,
|
||||
vfsPath: written.vfsPath,
|
||||
downloadUrl: written.downloadUrl,
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error, 'Unknown error')
|
||||
logger.error('Failed to download file to workspace', {
|
||||
url: params.url,
|
||||
error: msg,
|
||||
})
|
||||
return { success: false, message: `Failed to download file: ${msg}` }
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
|
||||
import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { getE2BDocFormat } from './doc-compile'
|
||||
import { buildEmbeddedImageRefWarning } from './embedded-image-refs'
|
||||
import { consumeLatestFileIntent } from './file-intent-store'
|
||||
import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file'
|
||||
|
||||
const logger = createLogger('EditContentServerTool')
|
||||
|
||||
type EditContentArgs = {
|
||||
content: string
|
||||
}
|
||||
|
||||
type EditContentResult = {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentResult> = {
|
||||
name: 'edit_content',
|
||||
async execute(params: EditContentArgs, context?: ServerToolContext): Promise<EditContentResult> {
|
||||
if (!context?.userId) {
|
||||
logger.error('Unauthorized attempt to use edit_content')
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
|
||||
const raw = params as Record<string, unknown>
|
||||
const nested = raw.args as Record<string, unknown> | undefined
|
||||
const content =
|
||||
typeof params.content === 'string'
|
||||
? params.content
|
||||
: typeof nested?.content === 'string'
|
||||
? (nested.content as string)
|
||||
: undefined
|
||||
|
||||
if (content === undefined) {
|
||||
return { success: false, message: 'content is required for edit_content' }
|
||||
}
|
||||
|
||||
// Consume the intent from THIS file subagent's channel (its outer tool_use
|
||||
// id), not just the latest in the message — otherwise two file agents
|
||||
// writing concurrently would each grab whichever workspace_file landed last
|
||||
// and write their content into the wrong file. Falls back to latest-in-
|
||||
// message when no channel id is present (main-agent / legacy calls).
|
||||
const intent = await consumeLatestFileIntent(workspaceId, {
|
||||
chatId: context.chatId,
|
||||
messageId: context.messageId,
|
||||
channelId: context.parentToolCallId,
|
||||
})
|
||||
if (!intent) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'No workspace_file context found. Call workspace_file first, wait for it to succeed, then call edit_content in the next step. Do not emit edit_content in parallel or in the same batch as workspace_file.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { operation, fileRecord } = intent
|
||||
const docInfo = getDocumentFormatInfo(fileRecord.name)
|
||||
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null
|
||||
|
||||
let finalContent: string
|
||||
switch (operation) {
|
||||
case 'append': {
|
||||
const existing = intent.existingContent ?? ''
|
||||
// The JS engines (isolated-vm and E2B-node pptx/docx) use the `{ ... }`
|
||||
// block-append convention — block statements scope cleanly inside the
|
||||
// compile wrapper. Python docs (pdf/xlsx) are a single cohesive script,
|
||||
// so brace-wrapping would produce invalid Python; plain-concatenate.
|
||||
// Brace-wrap appended content for the JS engines (isolated-vm and
|
||||
// E2B-node pptx/docx); Python docs (pdf/xlsx) are one cohesive script.
|
||||
const braceWrap = e2bFmt ? e2bFmt.engine === 'node' : docInfo.isDoc
|
||||
finalContent = braceWrap
|
||||
? existing
|
||||
? `${existing}\n{\n${content}\n}`
|
||||
: content
|
||||
: existing
|
||||
? `${existing}\n${content}`
|
||||
: content
|
||||
break
|
||||
}
|
||||
case 'update': {
|
||||
finalContent = content
|
||||
break
|
||||
}
|
||||
case 'patch': {
|
||||
const existing = intent.existingContent ?? ''
|
||||
if (!intent.edit) {
|
||||
return { success: false, message: 'Patch intent missing edit metadata' }
|
||||
}
|
||||
|
||||
if (intent.edit.strategy === 'search_replace') {
|
||||
const search = intent.edit.search!
|
||||
const firstIdx = existing.indexOf(search)
|
||||
if (firstIdx === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch failed: search string not found in file "${fileRecord.name}"`,
|
||||
}
|
||||
}
|
||||
finalContent = intent.edit.replaceAll
|
||||
? existing.split(search).join(content)
|
||||
: existing.slice(0, firstIdx) + content + existing.slice(firstIdx + search.length)
|
||||
} else if (intent.edit.strategy === 'anchored') {
|
||||
const lines = existing.split('\n')
|
||||
const defaultOccurrence = intent.edit.occurrence ?? 1
|
||||
|
||||
const findAnchorLine = (
|
||||
anchor: string,
|
||||
occurrence = defaultOccurrence,
|
||||
afterIndex = -1
|
||||
): { index: number; error?: string } => {
|
||||
const trimmed = anchor.trim()
|
||||
let count = 0
|
||||
for (let i = afterIndex + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === trimmed) {
|
||||
count++
|
||||
if (count === occurrence) return { index: i }
|
||||
}
|
||||
}
|
||||
if (count === 0) {
|
||||
return {
|
||||
index: -1,
|
||||
error: `Anchor line not found in "${fileRecord.name}": "${anchor.slice(0, 100)}"`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
index: -1,
|
||||
error: `Anchor line occurrence ${occurrence} not found (only ${count} match${count > 1 ? 'es' : ''}) in "${fileRecord.name}": "${anchor.slice(0, 100)}"`,
|
||||
}
|
||||
}
|
||||
|
||||
if (intent.edit.mode === 'replace_between') {
|
||||
if (!intent.edit.before_anchor || !intent.edit.after_anchor) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'replace_between requires before_anchor and after_anchor',
|
||||
}
|
||||
}
|
||||
const before = findAnchorLine(intent.edit.before_anchor)
|
||||
if (before.error) return { success: false, message: `Patch failed: ${before.error}` }
|
||||
const after = findAnchorLine(
|
||||
intent.edit.after_anchor,
|
||||
defaultOccurrence,
|
||||
before.index
|
||||
)
|
||||
if (after.error) return { success: false, message: `Patch failed: ${after.error}` }
|
||||
if (after.index <= before.index) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Patch failed: after_anchor must appear after before_anchor in the file',
|
||||
}
|
||||
}
|
||||
const newLines = [
|
||||
...lines.slice(0, before.index + 1),
|
||||
...content.split('\n'),
|
||||
...lines.slice(after.index),
|
||||
]
|
||||
finalContent = newLines.join('\n')
|
||||
} else if (intent.edit.mode === 'insert_after') {
|
||||
if (!intent.edit.anchor) {
|
||||
return { success: false, message: 'insert_after requires anchor' }
|
||||
}
|
||||
const found = findAnchorLine(intent.edit.anchor)
|
||||
if (found.error) return { success: false, message: `Patch failed: ${found.error}` }
|
||||
const newLines = [
|
||||
...lines.slice(0, found.index + 1),
|
||||
...content.split('\n'),
|
||||
...lines.slice(found.index + 1),
|
||||
]
|
||||
finalContent = newLines.join('\n')
|
||||
} else if (intent.edit.mode === 'delete_between') {
|
||||
if (!intent.edit.start_anchor || !intent.edit.end_anchor) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'delete_between requires start_anchor and end_anchor',
|
||||
}
|
||||
}
|
||||
const start = findAnchorLine(intent.edit.start_anchor)
|
||||
if (start.error) return { success: false, message: `Patch failed: ${start.error}` }
|
||||
const end = findAnchorLine(intent.edit.end_anchor, defaultOccurrence, start.index)
|
||||
if (end.error) return { success: false, message: `Patch failed: ${end.error}` }
|
||||
if (end.index <= start.index) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Patch failed: end_anchor must appear after start_anchor in the file',
|
||||
}
|
||||
}
|
||||
const newLines = [...lines.slice(0, start.index), ...lines.slice(end.index)]
|
||||
finalContent = newLines.join('\n')
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown anchored patch mode: "${intent.edit.mode}"`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { success: false, message: `Unknown patch strategy: "${intent.edit.strategy}"` }
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
return { success: false, message: `Unsupported operation in intent: ${operation}` }
|
||||
}
|
||||
|
||||
// Compile once via the right engine (or isolated-vm fallback) and resolve
|
||||
// the source MIME to store. Shared with the create path.
|
||||
const compiled = await compileDocForWrite({
|
||||
source: finalContent,
|
||||
fileName: fileRecord.name,
|
||||
workspaceId,
|
||||
ownerKey: `user:${context.userId}`,
|
||||
signal: context.abortSignal,
|
||||
fallbackMime: inferContentType(fileRecord.name, intent.contentType),
|
||||
})
|
||||
if (!compiled.ok) {
|
||||
return { success: false, message: compiled.message }
|
||||
}
|
||||
|
||||
const fileBuffer = Buffer.from(finalContent, 'utf-8')
|
||||
assertServerToolNotAborted(context)
|
||||
await updateWorkspaceFileContent(
|
||||
workspaceId,
|
||||
intent.fileId,
|
||||
context.userId,
|
||||
fileBuffer,
|
||||
compiled.sourceMime
|
||||
)
|
||||
|
||||
const verb =
|
||||
operation === 'append' ? 'appended to' : operation === 'update' ? 'updated' : 'patched'
|
||||
logger.info(`Workspace file ${verb} via copilot (edit_content)`, {
|
||||
fileId: intent.fileId,
|
||||
name: fileRecord.name,
|
||||
operation,
|
||||
size: fileBuffer.length,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
// Flag any `/api/files/view/<id>` embeds the model just authored that won't render/export
|
||||
// (non-workspace or missing), so it can self-correct on the next step.
|
||||
const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${fileRecord.name}" ${verb} successfully (${fileBuffer.length} bytes)${embedWarning}`,
|
||||
data: {
|
||||
id: intent.fileId,
|
||||
name: fileRecord.name,
|
||||
size: fileBuffer.length,
|
||||
contentType: compiled.sourceMime,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error('Error in edit_content tool', {
|
||||
operation: intent.operation,
|
||||
fileId: intent.fileId,
|
||||
error: errorMessage,
|
||||
userId: context.userId,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to apply content: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
extractEmbeddedImageIds,
|
||||
extractEmbeddedImageKeys,
|
||||
} from '@/lib/copilot/tools/server/files/embedded-image-refs'
|
||||
|
||||
const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png'
|
||||
|
||||
describe('extractEmbeddedImageIds', () => {
|
||||
it('extracts unique ids from view-url and in-app-path embeds (wf_ and uuid)', () => {
|
||||
const a = 'wf_YwDXi8eWOkTxn0sbgChlB'
|
||||
const b = '4bdaf6c4-072e-464e-891d-b6af3b5fe2cc'
|
||||
const content = `  `
|
||||
expect(extractEmbeddedImageIds(content).sort()).toEqual([b, a].sort())
|
||||
})
|
||||
|
||||
it('ignores serve-url, external, and plain content', () => {
|
||||
expect(
|
||||
extractEmbeddedImageIds(`}) plain`)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('caps the result at 50 ids', () => {
|
||||
const content = Array.from(
|
||||
{ length: 60 },
|
||||
(_, i) => `/api/files/view/wf_${String(i).padStart(6, '0')}`
|
||||
).join(' ')
|
||||
expect(extractEmbeddedImageIds(content)).toHaveLength(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractEmbeddedImageKeys', () => {
|
||||
it('extracts decoded workspace keys from serve-url embeds (encoded + s3/blob prefixed)', () => {
|
||||
const content = `}?context=workspace) })`
|
||||
expect(extractEmbeddedImageKeys(content)).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('drops non-workspace keys (e.g. public profile pictures) and view-url embeds', () => {
|
||||
const content =
|
||||
' '
|
||||
expect(extractEmbeddedImageKeys(content)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
|
||||
import { extractEmbeddedFileRefs } from '@/lib/uploads/utils/embedded-image-ref'
|
||||
|
||||
/** View-URL embed (`/api/files/view/<id>`) — the only form the file agent writes; see {@link findUnembeddableImageRefs}. */
|
||||
const VIEW_EMBED_RE = /\/api\/files\/view\/([A-Za-z0-9_-]+)/g
|
||||
|
||||
/**
|
||||
* De-duplicated workspace file **ids** embedded in `content` (view URL or in-app workspace path).
|
||||
* Shares the {@link extractEmbeddedFileRefs} grammar with the frontend renderer so the referenced-by-doc
|
||||
* gate authorizes exactly what the client links. Resolution and access are checked by the caller.
|
||||
*/
|
||||
export function extractEmbeddedImageIds(content: string): string[] {
|
||||
return extractEmbeddedFileRefs(content).ids
|
||||
}
|
||||
|
||||
/**
|
||||
* De-duplicated workspace storage **keys** (`workspace/<wsId>/…`) embedded in `content` via the serve URL.
|
||||
* Same shared grammar as {@link extractEmbeddedImageIds}.
|
||||
*/
|
||||
export function extractEmbeddedImageKeys(content: string): string[] {
|
||||
return extractEmbeddedFileRefs(content).keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ids of `/api/files/view/<id>` image embeds in `content` that will not render or survive a
|
||||
* workspace export. An embed is valid only when its id resolves to a workspace file in this same
|
||||
* workspace — the only thing the view route serves and an export can bundle. Every other case (missing,
|
||||
* archived, a different workspace, or a non-`workspace` upload such as a chat-scoped `mothership` file)
|
||||
* is flagged by id alone, without disclosing the referenced file's real context or owning workspace, so
|
||||
* the result can't be used to probe files outside this workspace. Best-effort and never throws, so a
|
||||
* content write is never blocked by this validation.
|
||||
*/
|
||||
export async function findUnembeddableImageRefs(
|
||||
content: string,
|
||||
workspaceId: string
|
||||
): Promise<string[]> {
|
||||
const ids = new Set<string>()
|
||||
for (const match of content.matchAll(VIEW_EMBED_RE)) ids.add(match[1])
|
||||
if (ids.size === 0) return []
|
||||
|
||||
const checked = await Promise.all(
|
||||
[...ids].map(async (id): Promise<string | null> => {
|
||||
try {
|
||||
const record = await getFileMetadataById(id)
|
||||
const embeddable = record?.context === 'workspace' && record.workspaceId === workspaceId
|
||||
return embeddable ? null : id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return checked.filter((id): id is string => id !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an actionable suffix appended to a successful file-write tool result so the model can
|
||||
* self-correct: only workspace files in this workspace embed, so any other reference must be re-saved
|
||||
* into the workspace and re-referenced by the workspace file's id. Empty when there is nothing to flag.
|
||||
*/
|
||||
export async function buildEmbeddedImageRefWarning(
|
||||
content: string,
|
||||
workspaceId: string
|
||||
): Promise<string> {
|
||||
const ids = await findUnembeddableImageRefs(content, workspaceId)
|
||||
if (ids.length === 0) return ''
|
||||
const list = ids.map((id) => `/api/files/view/${id}`).join('; ')
|
||||
return ` Warning: embedded image(s) will not render or export because they are not workspace files in this workspace — ${list}. Save each image as a workspace file (under files/) and reference it via /api/files/view/<workspace-file-id>.`
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import {
|
||||
CreateFileFolder,
|
||||
DeleteFileFolder,
|
||||
ListFileFolders,
|
||||
MoveFile,
|
||||
MoveFileFolder,
|
||||
RenameFileFolder,
|
||||
} from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
||||
import {
|
||||
findWorkspaceFileFolderIdByPath,
|
||||
getWorkspaceFileFolder,
|
||||
listWorkspaceFileFolders,
|
||||
type WorkspaceFileFolderRecord,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
|
||||
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import {
|
||||
performCreateWorkspaceFileFolder,
|
||||
performDeleteWorkspaceFileItems,
|
||||
performMoveWorkspaceFileItems,
|
||||
performUpdateWorkspaceFileFolder,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
|
||||
const logger = createLogger('FileFolderServerTools')
|
||||
|
||||
interface WorkspaceScopedArgs {
|
||||
workspaceId?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type ListFileFoldersArgs = WorkspaceScopedArgs
|
||||
|
||||
interface CreateFileFolderArgs extends WorkspaceScopedArgs {
|
||||
path?: string
|
||||
name?: string
|
||||
parentId?: string | null
|
||||
parentPath?: string | null
|
||||
}
|
||||
|
||||
interface RenameFileFolderArgs extends WorkspaceScopedArgs {
|
||||
path?: string
|
||||
folderId?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface MoveFileFolderArgs extends WorkspaceScopedArgs {
|
||||
path?: string
|
||||
folderId?: string
|
||||
destinationPath?: string | null
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
interface DeleteFileFolderArgs extends WorkspaceScopedArgs {
|
||||
paths?: string[]
|
||||
path?: string
|
||||
folderIds?: string[]
|
||||
folderId?: string
|
||||
}
|
||||
|
||||
interface MoveFileArgs extends WorkspaceScopedArgs {
|
||||
paths?: string[]
|
||||
path?: string
|
||||
destinationPath?: string | null
|
||||
fileIds?: string[]
|
||||
fileId?: string
|
||||
folderId?: string | null
|
||||
}
|
||||
|
||||
interface FileFolderResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
function nested(params: WorkspaceScopedArgs): Record<string, unknown> | undefined {
|
||||
return params.args && typeof params.args === 'object' ? params.args : undefined
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function stringArrayValue(value: unknown): string[] | undefined {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === 'string')
|
||||
: undefined
|
||||
}
|
||||
|
||||
function nullableStringValue(value: unknown): string | null | undefined {
|
||||
if (value === null) return null
|
||||
if (typeof value !== 'string') return undefined
|
||||
return value.trim() ? value : null
|
||||
}
|
||||
|
||||
function stringListFromValues(...values: unknown[]): string[] {
|
||||
for (const value of values) {
|
||||
const arr = stringArrayValue(value)
|
||||
if (arr && arr.length > 0) return arr
|
||||
}
|
||||
return values
|
||||
.map((value) => stringValue(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
}
|
||||
|
||||
function decodeFileFolderPath(path: string): string[] | null {
|
||||
const trimmed = path.trim().replace(/\/+$/, '')
|
||||
if (!trimmed || trimmed === 'files') return null
|
||||
const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed
|
||||
const withoutMarker = withoutPrefix.endsWith('/.folder')
|
||||
? withoutPrefix.slice(0, -'/.folder'.length)
|
||||
: withoutPrefix
|
||||
const segments = decodeVfsPathSegments(withoutMarker).filter(Boolean)
|
||||
return segments.length > 0 ? segments : null
|
||||
}
|
||||
|
||||
async function resolveFolderIdFromPath(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
label = 'Folder'
|
||||
): Promise<string> {
|
||||
const segments = decodeFileFolderPath(path)
|
||||
if (!segments) throw new Error(`${label} path must identify a folder under files/`)
|
||||
const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments)
|
||||
if (!folderId) throw new Error(`${label} not found at files/${segments.join('/')}`)
|
||||
return folderId
|
||||
}
|
||||
|
||||
async function resolveOptionalFolderId(
|
||||
workspaceId: string,
|
||||
value: unknown
|
||||
): Promise<string | null | undefined> {
|
||||
const raw = nullableStringValue(value)
|
||||
if (raw === undefined) return undefined
|
||||
if (raw === null) return null
|
||||
const segments = decodeFileFolderPath(raw)
|
||||
if (!segments) return null
|
||||
const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments)
|
||||
if (!folderId) throw new Error(`Target folder not found at files/${segments.join('/')}`)
|
||||
return folderId
|
||||
}
|
||||
|
||||
async function resolveFileIdsFromPaths(
|
||||
workspaceId: string,
|
||||
paths: string[]
|
||||
): Promise<{
|
||||
fileIds: string[]
|
||||
failed: string[]
|
||||
}> {
|
||||
const fileIds: string[] = []
|
||||
const failed: string[] = []
|
||||
for (const path of paths) {
|
||||
const file = await resolveWorkspaceFileReference(workspaceId, path)
|
||||
if (!file) {
|
||||
failed.push(path)
|
||||
continue
|
||||
}
|
||||
fileIds.push(file.id)
|
||||
}
|
||||
return { fileIds, failed }
|
||||
}
|
||||
|
||||
async function resolveWorkspaceId(
|
||||
params: WorkspaceScopedArgs,
|
||||
context: ServerToolContext | undefined,
|
||||
permission: 'read' | 'write'
|
||||
): Promise<string | FileFolderResult> {
|
||||
if (!context?.userId) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const payload = nested(params)
|
||||
const workspaceId =
|
||||
stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, permission)
|
||||
return workspaceId
|
||||
}
|
||||
|
||||
function folderLabel(folder: WorkspaceFileFolderRecord): string {
|
||||
return folder.path || folder.name
|
||||
}
|
||||
|
||||
export const listFileFoldersServerTool: BaseServerTool<ListFileFoldersArgs, FileFolderResult> = {
|
||||
name: ListFileFolders.id,
|
||||
async execute(
|
||||
params: ListFileFoldersArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<FileFolderResult> {
|
||||
try {
|
||||
const workspaceId = await resolveWorkspaceId(params, context, 'read')
|
||||
if (typeof workspaceId !== 'string') return workspaceId
|
||||
|
||||
const folders = await listWorkspaceFileFolders(workspaceId)
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
folders.length === 1 ? 'Found 1 file folder' : `Found ${folders.length} file folders`,
|
||||
data: { workspaceId, folders },
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: toError(error).message }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const createFileFolderServerTool: BaseServerTool<CreateFileFolderArgs, FileFolderResult> = {
|
||||
name: CreateFileFolder.id,
|
||||
async execute(
|
||||
params: CreateFileFolderArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<FileFolderResult> {
|
||||
try {
|
||||
const workspaceId = await resolveWorkspaceId(params, context, 'write')
|
||||
if (typeof workspaceId !== 'string') return workspaceId
|
||||
if (!context?.userId) throw new Error('Authentication required')
|
||||
|
||||
const payload = nested(params)
|
||||
const rawPath = stringValue(params.path) || stringValue(payload?.path)
|
||||
const pathSegments = rawPath ? decodeFileFolderPath(rawPath) : undefined
|
||||
const name = (
|
||||
pathSegments?.at(-1) ||
|
||||
stringValue(params.name) ||
|
||||
stringValue(payload?.name) ||
|
||||
''
|
||||
).trim()
|
||||
if (!name) return { success: false, message: 'name is required' }
|
||||
|
||||
let parentId =
|
||||
(await resolveOptionalFolderId(workspaceId, params.parentPath ?? payload?.parentPath)) ??
|
||||
nullableStringValue(params.parentId ?? payload?.parentId) ??
|
||||
null
|
||||
if (pathSegments && pathSegments.length > 1) {
|
||||
const resolvedParentId = await findWorkspaceFileFolderIdByPath(
|
||||
workspaceId,
|
||||
pathSegments.slice(0, -1)
|
||||
)
|
||||
if (!resolvedParentId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Parent folder not found at files/${pathSegments.slice(0, -1).join('/')}`,
|
||||
}
|
||||
}
|
||||
parentId = resolvedParentId
|
||||
}
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performCreateWorkspaceFileFolder({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
name,
|
||||
parentId,
|
||||
})
|
||||
if (!result.success || !result.folder) {
|
||||
return { success: false, message: result.error || 'Failed to create file folder' }
|
||||
}
|
||||
const { folder } = result
|
||||
|
||||
logger.info('File folder created via create_file_folder', {
|
||||
workspaceId,
|
||||
folderId: folder.id,
|
||||
parentId,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Created file folder "${folderLabel(folder)}"`,
|
||||
data: { folder },
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: toError(error).message }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const renameFileFolderServerTool: BaseServerTool<RenameFileFolderArgs, FileFolderResult> = {
|
||||
name: RenameFileFolder.id,
|
||||
async execute(
|
||||
params: RenameFileFolderArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<FileFolderResult> {
|
||||
try {
|
||||
const workspaceId = await resolveWorkspaceId(params, context, 'write')
|
||||
if (typeof workspaceId !== 'string') return workspaceId
|
||||
if (!context?.userId) throw new Error('Authentication required')
|
||||
|
||||
const payload = nested(params)
|
||||
const folderPath = stringValue(params.path) || stringValue(payload?.path)
|
||||
const folderId =
|
||||
(folderPath ? await resolveFolderIdFromPath(workspaceId, folderPath) : undefined) ||
|
||||
stringValue(params.folderId) ||
|
||||
stringValue(payload?.folderId) ||
|
||||
''
|
||||
const name = (stringValue(params.name) || stringValue(payload?.name) || '').trim()
|
||||
if (!folderId) return { success: false, message: 'path is required' }
|
||||
if (!name) return { success: false, message: 'name is required' }
|
||||
|
||||
const existing = await getWorkspaceFileFolder(workspaceId, folderId)
|
||||
if (!existing) return { success: false, message: 'Folder not found' }
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performUpdateWorkspaceFileFolder({
|
||||
workspaceId,
|
||||
folderId,
|
||||
userId: context.userId,
|
||||
name,
|
||||
})
|
||||
if (!result.success || !result.folder) {
|
||||
return { success: false, message: result.error || 'Failed to rename file folder' }
|
||||
}
|
||||
const { folder } = result
|
||||
|
||||
logger.info('File folder renamed via rename_file_folder', {
|
||||
workspaceId,
|
||||
folderId,
|
||||
oldName: existing.name,
|
||||
name,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Renamed file folder "${folderLabel(existing)}" to "${folderLabel(folder)}"`,
|
||||
data: { folder },
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: toError(error).message }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const moveFileFolderServerTool: BaseServerTool<MoveFileFolderArgs, FileFolderResult> = {
|
||||
name: MoveFileFolder.id,
|
||||
async execute(
|
||||
params: MoveFileFolderArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<FileFolderResult> {
|
||||
try {
|
||||
const workspaceId = await resolveWorkspaceId(params, context, 'write')
|
||||
if (typeof workspaceId !== 'string') return workspaceId
|
||||
if (!context?.userId) throw new Error('Authentication required')
|
||||
|
||||
const payload = nested(params)
|
||||
const folderPath = stringValue(params.path) || stringValue(payload?.path)
|
||||
const folderId =
|
||||
(folderPath ? await resolveFolderIdFromPath(workspaceId, folderPath) : undefined) ||
|
||||
stringValue(params.folderId) ||
|
||||
stringValue(payload?.folderId) ||
|
||||
''
|
||||
if (!folderId) return { success: false, message: 'path is required' }
|
||||
const parentId =
|
||||
(await resolveOptionalFolderId(
|
||||
workspaceId,
|
||||
params.destinationPath ?? payload?.destinationPath
|
||||
)) ??
|
||||
nullableStringValue(params.parentId ?? payload?.parentId) ??
|
||||
null
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performUpdateWorkspaceFileFolder({
|
||||
workspaceId,
|
||||
folderId,
|
||||
userId: context.userId,
|
||||
parentId,
|
||||
})
|
||||
if (!result.success || !result.folder) {
|
||||
return { success: false, message: result.error || 'Failed to move file folder' }
|
||||
}
|
||||
const { folder } = result
|
||||
|
||||
logger.info('File folder moved via move_file_folder', {
|
||||
workspaceId,
|
||||
folderId,
|
||||
parentId,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: parentId
|
||||
? `Moved file folder "${folderLabel(folder)}"`
|
||||
: `Moved file folder "${folderLabel(folder)}" to root`,
|
||||
data: { folder },
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: toError(error).message }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const deleteFileFolderServerTool: BaseServerTool<DeleteFileFolderArgs, FileFolderResult> = {
|
||||
name: DeleteFileFolder.id,
|
||||
async execute(
|
||||
params: DeleteFileFolderArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<FileFolderResult> {
|
||||
try {
|
||||
const workspaceId = await resolveWorkspaceId(params, context, 'write')
|
||||
if (typeof workspaceId !== 'string') return workspaceId
|
||||
if (!context?.userId) throw new Error('Authentication required')
|
||||
|
||||
const payload = nested(params)
|
||||
const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path)
|
||||
const folderIds =
|
||||
paths.length > 0
|
||||
? await Promise.all(paths.map((path) => resolveFolderIdFromPath(workspaceId, path)))
|
||||
: (params.folderIds ??
|
||||
stringArrayValue(payload?.folderIds) ??
|
||||
[stringValue(params.folderId) || stringValue(payload?.folderId) || ''].filter(Boolean))
|
||||
if (folderIds.length === 0) return { success: false, message: 'paths is required' }
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
folderIds,
|
||||
})
|
||||
if (!result.success || !result.deletedItems) {
|
||||
return { success: false, message: result.error || 'Failed to delete file folders' }
|
||||
}
|
||||
|
||||
logger.info('File folders deleted via delete_file_folder', {
|
||||
workspaceId,
|
||||
folderIds,
|
||||
folders: result.deletedItems.folders,
|
||||
files: result.deletedItems.files,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: result.deletedItems.folders > 0 || result.deletedItems.files > 0,
|
||||
message: `Deleted ${result.deletedItems.folders} file folder${result.deletedItems.folders === 1 ? '' : 's'} and ${result.deletedItems.files} file${result.deletedItems.files === 1 ? '' : 's'}`,
|
||||
data: result.deletedItems,
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: toError(error).message }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const moveFileServerTool: BaseServerTool<MoveFileArgs, FileFolderResult> = {
|
||||
name: MoveFile.id,
|
||||
async execute(params: MoveFileArgs, context?: ServerToolContext): Promise<FileFolderResult> {
|
||||
try {
|
||||
const workspaceId = await resolveWorkspaceId(params, context, 'write')
|
||||
if (typeof workspaceId !== 'string') return workspaceId
|
||||
if (!context?.userId) throw new Error('Authentication required')
|
||||
|
||||
const payload = nested(params)
|
||||
const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path)
|
||||
const resolvedByPath =
|
||||
paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined
|
||||
if (resolvedByPath?.failed.length) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Files not found: ${resolvedByPath.failed.join(', ')}`,
|
||||
}
|
||||
}
|
||||
const fileIds =
|
||||
resolvedByPath?.fileIds ??
|
||||
params.fileIds ??
|
||||
stringArrayValue(payload?.fileIds) ??
|
||||
[stringValue(params.fileId) || stringValue(payload?.fileId) || ''].filter(Boolean)
|
||||
if (fileIds.length === 0) return { success: false, message: 'paths is required' }
|
||||
|
||||
const folderId =
|
||||
(await resolveOptionalFolderId(
|
||||
workspaceId,
|
||||
params.destinationPath ?? payload?.destinationPath
|
||||
)) ??
|
||||
nullableStringValue(params.folderId ?? payload?.folderId) ??
|
||||
null
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performMoveWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
fileIds,
|
||||
targetFolderId: folderId,
|
||||
})
|
||||
if (!result.success || !result.movedItems) {
|
||||
return { success: false, message: result.error || 'Failed to move files' }
|
||||
}
|
||||
|
||||
logger.info('Files moved via move_file', {
|
||||
workspaceId,
|
||||
fileIds,
|
||||
folderId,
|
||||
movedFiles: result.movedItems.files,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: result.movedItems.files > 0,
|
||||
message: folderId
|
||||
? `Moved ${result.movedItems.files} file${result.movedItems.files === 1 ? '' : 's'}`
|
||||
: `Moved ${result.movedItems.files} file${result.movedItems.files === 1 ? '' : 's'} to root`,
|
||||
data: result.movedItems,
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: toError(error).message }
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
consumeLatestFileIntent,
|
||||
type PendingFileIntent,
|
||||
storeFileIntent,
|
||||
} from './file-intent-store'
|
||||
|
||||
// Force the in-memory store path so the test is deterministic and Redis-free.
|
||||
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => null }))
|
||||
|
||||
function makeIntent(overrides: Partial<PendingFileIntent>): PendingFileIntent {
|
||||
return {
|
||||
operation: 'update',
|
||||
fileId: 'file-x',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
chatId: 'chat-1',
|
||||
messageId: 'msg-1',
|
||||
fileRecord: { id: overrides.fileId ?? 'file-x' } as unknown as PendingFileIntent['fileRecord'],
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueWorkspace(): string {
|
||||
return `ws-${generateShortId()}`
|
||||
}
|
||||
|
||||
describe('file-intent-store channel scoping', () => {
|
||||
it('consumes the intent for the requesting channel, not the latest in the message', async () => {
|
||||
const ws = uniqueWorkspace()
|
||||
const scope = { chatId: 'chat-1', messageId: 'msg-1' }
|
||||
|
||||
// Two concurrent file subagents: A declares fileA on channel F1 first, then
|
||||
// B declares fileB on channel F2 (later createdAt = the "latest" in message).
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileA',
|
||||
makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() })
|
||||
)
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileB',
|
||||
makeIntent({
|
||||
workspaceId: ws,
|
||||
fileId: 'fileB',
|
||||
channelId: 'F2',
|
||||
createdAt: Date.now() + 1000,
|
||||
})
|
||||
)
|
||||
|
||||
// edit_content from channel F1 must get fileA — NOT the latest (fileB).
|
||||
const a = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F1' })
|
||||
expect(a?.fileId).toBe('fileA')
|
||||
|
||||
// edit_content from channel F2 gets fileB.
|
||||
const b = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F2' })
|
||||
expect(b?.fileId).toBe('fileB')
|
||||
})
|
||||
|
||||
it('only consumes its own channel, leaving the sibling intent intact', async () => {
|
||||
const ws = uniqueWorkspace()
|
||||
const scope = { chatId: 'chat-1', messageId: 'msg-1' }
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileA',
|
||||
makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() })
|
||||
)
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileB',
|
||||
makeIntent({
|
||||
workspaceId: ws,
|
||||
fileId: 'fileB',
|
||||
channelId: 'F2',
|
||||
createdAt: Date.now() + 1000,
|
||||
})
|
||||
)
|
||||
|
||||
await consumeLatestFileIntent(ws, { ...scope, channelId: 'F1' })
|
||||
// The sibling (F2) is untouched and still consumable afterward.
|
||||
const b = await consumeLatestFileIntent(ws, { ...scope, channelId: 'F2' })
|
||||
expect(b?.fileId).toBe('fileB')
|
||||
})
|
||||
|
||||
it('falls back to latest-in-message when no channelId (legacy / main-agent)', async () => {
|
||||
const ws = uniqueWorkspace()
|
||||
const scope = { chatId: 'chat-1', messageId: 'msg-1' }
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileA',
|
||||
makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() })
|
||||
)
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileB',
|
||||
makeIntent({
|
||||
workspaceId: ws,
|
||||
fileId: 'fileB',
|
||||
channelId: 'F2',
|
||||
createdAt: Date.now() + 1000,
|
||||
})
|
||||
)
|
||||
const latest = await consumeLatestFileIntent(ws, scope)
|
||||
expect(latest?.fileId).toBe('fileB')
|
||||
})
|
||||
|
||||
it('returns undefined when the requesting channel has no pending intent', async () => {
|
||||
const ws = uniqueWorkspace()
|
||||
await storeFileIntent(
|
||||
ws,
|
||||
'fileA',
|
||||
makeIntent({ workspaceId: ws, fileId: 'fileA', channelId: 'F1', createdAt: Date.now() })
|
||||
)
|
||||
const none = await consumeLatestFileIntent(ws, {
|
||||
chatId: 'chat-1',
|
||||
messageId: 'msg-1',
|
||||
channelId: 'F-absent',
|
||||
})
|
||||
expect(none).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
export type PendingFileIntent = {
|
||||
operation: 'append' | 'update' | 'patch'
|
||||
fileId: string
|
||||
workspaceId: string
|
||||
userId: string
|
||||
chatId?: string
|
||||
messageId?: string
|
||||
// The invoking file subagent's channel id (its outer tool_use id). Lets
|
||||
// edit_content consume the intent for ITS OWN file subagent instead of the
|
||||
// latest in the message, so two file agents writing concurrently never cross
|
||||
// their content into each other's file.
|
||||
channelId?: string
|
||||
fileRecord: WorkspaceFileRecord
|
||||
existingContent?: string
|
||||
edit?: {
|
||||
strategy: string
|
||||
search?: string
|
||||
replaceAll?: boolean
|
||||
mode?: string
|
||||
occurrence?: number
|
||||
before_anchor?: string
|
||||
after_anchor?: string
|
||||
anchor?: string
|
||||
start_anchor?: string
|
||||
end_anchor?: string
|
||||
}
|
||||
contentType?: string
|
||||
title?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type FileIntentScope = {
|
||||
chatId?: string
|
||||
messageId?: string
|
||||
// When set, consumeLatestFileIntent only considers intents from this subagent
|
||||
// channel — the key to isolating concurrent file subagents. Omitted by callers
|
||||
// that intentionally span the whole message (e.g. clearIntentsForWorkspace).
|
||||
channelId?: string
|
||||
}
|
||||
|
||||
const logger = createLogger('FileIntentStore')
|
||||
|
||||
const INTENT_TTL_MS = 60 * 60 * 1000
|
||||
const INTENT_TTL_SECONDS = INTENT_TTL_MS / 1000
|
||||
const REDIS_KEY_PREFIX = 'mothership_file_intent:'
|
||||
const RETRY_DELAYS_MS = [0, 50, 150] as const
|
||||
const memoryStore = new Map<string, PendingFileIntent>()
|
||||
|
||||
function buildKey(workspaceId: string, fileId: string): string {
|
||||
return `${workspaceId}:${fileId}`
|
||||
}
|
||||
|
||||
function getWorkspaceRedisKey(workspaceId: string): string {
|
||||
return `${REDIS_KEY_PREFIX}${workspaceId}`
|
||||
}
|
||||
|
||||
function scopeMatches(intent: PendingFileIntent, scope?: FileIntentScope): boolean {
|
||||
return intent.chatId === scope?.chatId && intent.messageId === scope?.messageId
|
||||
}
|
||||
|
||||
// Channel filter for consume: when a scope carries a channelId, only the
|
||||
// matching file subagent's intent qualifies. No channelId => message-wide
|
||||
// (legacy / main-agent) behavior. Deliberately separate from scopeMatches so
|
||||
// clearIntentsForWorkspace keeps clearing every channel in a message.
|
||||
function channelMatches(intent: PendingFileIntent, scope?: FileIntentScope): boolean {
|
||||
return !scope?.channelId || intent.channelId === scope.channelId
|
||||
}
|
||||
|
||||
function buildScopedField(fileId: string, scope?: FileIntentScope): string {
|
||||
return `${scope?.chatId ?? ''}:${scope?.messageId ?? ''}:${fileId}`
|
||||
}
|
||||
|
||||
function cleanupStale(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, intent] of memoryStore) {
|
||||
if (now - intent.createdAt > INTENT_TTL_MS) {
|
||||
memoryStore.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withRedisRetry<T>(
|
||||
operation: string,
|
||||
workspaceId: string,
|
||||
work: (redis: NonNullable<ReturnType<typeof getRedisClient>>) => Promise<T>
|
||||
): Promise<T> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
throw new Error('Redis client unavailable')
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
||||
const delay = RETRY_DELAYS_MS[attempt]
|
||||
if (delay > 0) {
|
||||
await sleep(delay)
|
||||
}
|
||||
|
||||
try {
|
||||
return await work(redis)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
logger.warn('Redis file intent operation failed', {
|
||||
operation,
|
||||
workspaceId,
|
||||
attempt: attempt + 1,
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(`${operation} failed`)
|
||||
}
|
||||
|
||||
function isStale(intent: PendingFileIntent): boolean {
|
||||
return Date.now() - intent.createdAt > INTENT_TTL_MS
|
||||
}
|
||||
|
||||
function parseIntent(raw: string | null | undefined): PendingFileIntent | undefined {
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as PendingFileIntent
|
||||
return isStale(parsed) ? undefined : parsed
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse file intent', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeFileIntent(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
intent: PendingFileIntent
|
||||
): Promise<void> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
cleanupStale()
|
||||
memoryStore.set(buildKey(workspaceId, buildScopedField(fileId, intent)), intent)
|
||||
return
|
||||
}
|
||||
|
||||
await withRedisRetry('store_file_intent', workspaceId, async (client) => {
|
||||
const key = getWorkspaceRedisKey(workspaceId)
|
||||
const pipeline = client.pipeline()
|
||||
pipeline.hset(key, buildScopedField(fileId, intent), JSON.stringify(intent))
|
||||
pipeline.expire(key, INTENT_TTL_SECONDS)
|
||||
await pipeline.exec()
|
||||
})
|
||||
}
|
||||
|
||||
async function consumeFileIntent(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
scope?: FileIntentScope
|
||||
): Promise<PendingFileIntent | undefined> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
const key = buildKey(workspaceId, buildScopedField(fileId, scope))
|
||||
const intent = memoryStore.get(key)
|
||||
if (intent) {
|
||||
memoryStore.delete(key)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
const raw = await withRedisRetry('consume_file_intent', workspaceId, async (client) => {
|
||||
const key = getWorkspaceRedisKey(workspaceId)
|
||||
const field = buildScopedField(fileId, scope)
|
||||
const value = await client.hget(key, field)
|
||||
if (value !== null) {
|
||||
await client.hdel(key, field)
|
||||
}
|
||||
return value
|
||||
})
|
||||
return parseIntent(raw)
|
||||
}
|
||||
|
||||
export async function peekFileIntent(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
scope?: FileIntentScope
|
||||
): Promise<PendingFileIntent | undefined> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
cleanupStale()
|
||||
return memoryStore.get(buildKey(workspaceId, buildScopedField(fileId, scope)))
|
||||
}
|
||||
|
||||
const raw = await withRedisRetry('peek_file_intent', workspaceId, async (client) => {
|
||||
const key = getWorkspaceRedisKey(workspaceId)
|
||||
return client.hget(key, buildScopedField(fileId, scope))
|
||||
})
|
||||
const intent = parseIntent(raw)
|
||||
if (!intent && raw !== null) {
|
||||
await withRedisRetry('clear_stale_file_intent', workspaceId, async (client) => {
|
||||
await client.hdel(getWorkspaceRedisKey(workspaceId), buildScopedField(fileId, scope))
|
||||
})
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
export async function consumeLatestFileIntent(
|
||||
workspaceId: string,
|
||||
scope?: FileIntentScope
|
||||
): Promise<PendingFileIntent | undefined> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
cleanupStale()
|
||||
let latest: PendingFileIntent | undefined
|
||||
let latestKey: string | undefined
|
||||
for (const [key, intent] of memoryStore) {
|
||||
if (
|
||||
intent.workspaceId === workspaceId &&
|
||||
scopeMatches(intent, scope) &&
|
||||
channelMatches(intent, scope)
|
||||
) {
|
||||
if (!latest || intent.createdAt > latest.createdAt) {
|
||||
latest = intent
|
||||
latestKey = key
|
||||
}
|
||||
}
|
||||
}
|
||||
if (latestKey) {
|
||||
memoryStore.delete(latestKey)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
const entries = await withRedisRetry('read_workspace_file_intents', workspaceId, async (client) =>
|
||||
client.hgetall(getWorkspaceRedisKey(workspaceId))
|
||||
)
|
||||
let latest: PendingFileIntent | undefined
|
||||
let latestField: string | undefined
|
||||
const staleFields: string[] = []
|
||||
for (const [field, raw] of Object.entries(entries)) {
|
||||
const parsed = parseIntent(raw)
|
||||
if (!parsed) {
|
||||
staleFields.push(field)
|
||||
continue
|
||||
}
|
||||
if (!scopeMatches(parsed, scope) || !channelMatches(parsed, scope)) {
|
||||
continue
|
||||
}
|
||||
if (!latest || parsed.createdAt > latest.createdAt) {
|
||||
latest = parsed
|
||||
latestField = field
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsToDelete = latestField ? [...staleFields, latestField] : staleFields
|
||||
if (fieldsToDelete.length > 0) {
|
||||
await withRedisRetry('delete_workspace_file_intents', workspaceId, async (client) => {
|
||||
await client.hdel(getWorkspaceRedisKey(workspaceId), ...fieldsToDelete)
|
||||
})
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
export async function clearIntentsForWorkspace(
|
||||
workspaceId: string,
|
||||
scope?: FileIntentScope
|
||||
): Promise<number> {
|
||||
const redis = getRedisClient()
|
||||
if (!redis) {
|
||||
let cleared = 0
|
||||
for (const [key, intent] of memoryStore) {
|
||||
if (intent.workspaceId === workspaceId && (!scope || scopeMatches(intent, scope))) {
|
||||
memoryStore.delete(key)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
return cleared
|
||||
}
|
||||
|
||||
const key = getWorkspaceRedisKey(workspaceId)
|
||||
if (!scope) {
|
||||
const count = await withRedisRetry(
|
||||
'count_workspace_file_intents',
|
||||
workspaceId,
|
||||
async (client) => client.hlen(key)
|
||||
)
|
||||
await withRedisRetry('clear_workspace_file_intents', workspaceId, async (client) => {
|
||||
await client.del(key)
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
const entries = await withRedisRetry('read_workspace_file_intents', workspaceId, async (client) =>
|
||||
client.hgetall(key)
|
||||
)
|
||||
const fieldsToDelete: string[] = []
|
||||
for (const [field, raw] of Object.entries(entries)) {
|
||||
const parsed = parseIntent(raw)
|
||||
if (parsed && scopeMatches(parsed, scope)) {
|
||||
fieldsToDelete.push(field)
|
||||
}
|
||||
}
|
||||
if (fieldsToDelete.length > 0) {
|
||||
await withRedisRetry('clear_scoped_file_intents', workspaceId, async (client) => {
|
||||
await client.hdel(key, ...fieldsToDelete)
|
||||
})
|
||||
}
|
||||
return fieldsToDelete.length
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildFilePreviewText } from '@/lib/copilot/tools/server/files/file-preview'
|
||||
|
||||
describe('buildFilePreviewText', () => {
|
||||
it('returns the full streamed content for update previews', () => {
|
||||
expect(
|
||||
buildFilePreviewText({
|
||||
operation: 'update',
|
||||
streamedContent: '',
|
||||
})
|
||||
).toBe('')
|
||||
|
||||
expect(
|
||||
buildFilePreviewText({
|
||||
operation: 'update',
|
||||
streamedContent: 'updated body',
|
||||
})
|
||||
).toBe('updated body')
|
||||
})
|
||||
|
||||
it('builds append previews from the existing file content', () => {
|
||||
expect(
|
||||
buildFilePreviewText({
|
||||
operation: 'append',
|
||||
existingContent: 'line one',
|
||||
streamedContent: 'line two',
|
||||
})
|
||||
).toBe('line one\nline two')
|
||||
})
|
||||
|
||||
it('applies anchored replace_between previews', () => {
|
||||
expect(
|
||||
buildFilePreviewText({
|
||||
operation: 'patch',
|
||||
existingContent: ['# Title', 'before', 'after', 'footer'].join('\n'),
|
||||
streamedContent: 'replacement',
|
||||
edit: {
|
||||
strategy: 'anchored',
|
||||
mode: 'replace_between',
|
||||
before_anchor: '# Title',
|
||||
after_anchor: 'after',
|
||||
},
|
||||
})
|
||||
).toBe(['# Title', 'replacement', 'after', 'footer'].join('\n'))
|
||||
})
|
||||
|
||||
it('applies delete_between previews without streamed replacement text', () => {
|
||||
expect(
|
||||
buildFilePreviewText({
|
||||
operation: 'patch',
|
||||
existingContent: ['keep', 'start', 'remove me', 'end', 'keep too'].join('\n'),
|
||||
streamedContent: '',
|
||||
edit: {
|
||||
strategy: 'anchored',
|
||||
mode: 'delete_between',
|
||||
start_anchor: 'start',
|
||||
end_anchor: 'end',
|
||||
},
|
||||
})
|
||||
).toBe(['keep', 'end', 'keep too'].join('\n'))
|
||||
})
|
||||
|
||||
it('applies search_replace previews', () => {
|
||||
expect(
|
||||
buildFilePreviewText({
|
||||
operation: 'patch',
|
||||
existingContent: 'hello world',
|
||||
streamedContent: 'sim',
|
||||
edit: {
|
||||
strategy: 'search_replace',
|
||||
search: 'world',
|
||||
},
|
||||
})
|
||||
).toBe('hello sim')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import {
|
||||
fetchWorkspaceFileBuffer,
|
||||
getWorkspaceFile,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
|
||||
const logger = createLogger('CopilotFilePreview')
|
||||
|
||||
type FilePreviewEdit = {
|
||||
strategy?: string
|
||||
search?: string
|
||||
replaceAll?: boolean
|
||||
mode?: string
|
||||
occurrence?: number
|
||||
before_anchor?: string
|
||||
after_anchor?: string
|
||||
anchor?: string
|
||||
start_anchor?: string
|
||||
end_anchor?: string
|
||||
}
|
||||
|
||||
interface BuildFilePreviewTextOptions {
|
||||
operation: 'create' | 'append' | 'update' | 'patch'
|
||||
streamedContent: string
|
||||
existingContent?: string
|
||||
edit?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function findAnchorIndex(lines: string[], anchor: string, occurrence = 1, afterIndex = -1): number {
|
||||
const trimmed = anchor.trim()
|
||||
let count = 0
|
||||
for (let i = afterIndex + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === trimmed) {
|
||||
count++
|
||||
if (count === occurrence) return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function extractPatchPreview(
|
||||
streamedContent: string,
|
||||
existingContent: string,
|
||||
edit?: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const strategy = typeof edit?.strategy === 'string' ? edit.strategy : undefined
|
||||
const lines = existingContent.split('\n')
|
||||
const occurrence =
|
||||
typeof edit?.occurrence === 'number' && Number.isFinite(edit.occurrence) ? edit.occurrence : 1
|
||||
|
||||
if (strategy === 'search_replace') {
|
||||
const search = typeof edit?.search === 'string' ? edit.search : ''
|
||||
if (!search) return undefined
|
||||
if (edit?.replaceAll === true) {
|
||||
return existingContent.split(search).join(streamedContent)
|
||||
}
|
||||
const firstIdx = existingContent.indexOf(search)
|
||||
if (firstIdx === -1) return undefined
|
||||
return (
|
||||
existingContent.slice(0, firstIdx) +
|
||||
streamedContent +
|
||||
existingContent.slice(firstIdx + search.length)
|
||||
)
|
||||
}
|
||||
|
||||
const mode = typeof edit?.mode === 'string' ? edit.mode : undefined
|
||||
if (!mode) return undefined
|
||||
|
||||
if (mode === 'replace_between') {
|
||||
const beforeAnchor = typeof edit?.before_anchor === 'string' ? edit.before_anchor : undefined
|
||||
const afterAnchor = typeof edit?.after_anchor === 'string' ? edit.after_anchor : undefined
|
||||
if (!beforeAnchor || !afterAnchor) return undefined
|
||||
|
||||
const beforeIdx = findAnchorIndex(lines, beforeAnchor, occurrence)
|
||||
const afterIdx = findAnchorIndex(lines, afterAnchor, occurrence, beforeIdx)
|
||||
if (beforeIdx === -1 || afterIdx === -1 || afterIdx <= beforeIdx) return undefined
|
||||
|
||||
const spliced = [
|
||||
...lines.slice(0, beforeIdx + 1),
|
||||
...(streamedContent.length > 0 ? streamedContent.split('\n') : []),
|
||||
...lines.slice(afterIdx),
|
||||
]
|
||||
return spliced.join('\n')
|
||||
}
|
||||
|
||||
if (mode === 'insert_after') {
|
||||
const anchor = typeof edit?.anchor === 'string' ? edit.anchor : undefined
|
||||
if (!anchor) return undefined
|
||||
|
||||
const anchorIdx = findAnchorIndex(lines, anchor, occurrence)
|
||||
if (anchorIdx === -1) return undefined
|
||||
|
||||
const spliced = [
|
||||
...lines.slice(0, anchorIdx + 1),
|
||||
...(streamedContent.length > 0 ? streamedContent.split('\n') : []),
|
||||
...lines.slice(anchorIdx + 1),
|
||||
]
|
||||
return spliced.join('\n')
|
||||
}
|
||||
|
||||
if (mode === 'delete_between') {
|
||||
const startAnchor = typeof edit?.start_anchor === 'string' ? edit.start_anchor : undefined
|
||||
const endAnchor = typeof edit?.end_anchor === 'string' ? edit.end_anchor : undefined
|
||||
if (!startAnchor || !endAnchor) return undefined
|
||||
|
||||
const startIdx = findAnchorIndex(lines, startAnchor, occurrence)
|
||||
const endIdx = findAnchorIndex(lines, endAnchor, occurrence, startIdx)
|
||||
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return undefined
|
||||
|
||||
const spliced = [...lines.slice(0, startIdx), ...lines.slice(endIdx)]
|
||||
return spliced.join('\n')
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function shouldApplyPatchPreview(streamedContent: string, edit?: Record<string, unknown>): boolean {
|
||||
const strategy = typeof edit?.strategy === 'string' ? edit.strategy : undefined
|
||||
const mode = typeof edit?.mode === 'string' ? edit.mode : undefined
|
||||
|
||||
if (strategy === 'anchored' && mode === 'delete_between') {
|
||||
return true
|
||||
}
|
||||
|
||||
return streamedContent.length > 0
|
||||
}
|
||||
|
||||
function buildAppendPreview(existingContent: string, incomingContent: string): string {
|
||||
if (incomingContent.length === 0) return existingContent
|
||||
if (existingContent.length === 0) return incomingContent
|
||||
return `${existingContent}\n${incomingContent}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current UTF-8 text of a workspace file for streaming previews.
|
||||
*
|
||||
* Preview runs in the SSE loop on `workspace_file` **call** events, which are
|
||||
* processed **before** the async tool executor persists {@link storeFileIntent}.
|
||||
* Loading the base here avoids a race where `edit_content` `args_delta` arrives
|
||||
* before Redis holds `existingContent`, which would make append previews look like
|
||||
* full-file replacement until the intent landed.
|
||||
*/
|
||||
export async function loadWorkspaceFileTextForPreview(
|
||||
workspaceId: string,
|
||||
fileId: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const record = await getWorkspaceFile(workspaceId, fileId)
|
||||
if (!record) return undefined
|
||||
const buffer = await fetchWorkspaceFileBuffer(record)
|
||||
return buffer.toString('utf-8')
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load workspace file text for preview', {
|
||||
workspaceId,
|
||||
fileId,
|
||||
error: toError(error).message,
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFilePreviewText({
|
||||
operation,
|
||||
streamedContent,
|
||||
existingContent,
|
||||
edit,
|
||||
}: BuildFilePreviewTextOptions): string | undefined {
|
||||
if (operation === 'update') {
|
||||
return streamedContent
|
||||
}
|
||||
|
||||
if (operation === 'create') {
|
||||
return streamedContent
|
||||
}
|
||||
|
||||
if (operation === 'append') {
|
||||
if (existingContent !== undefined) {
|
||||
return buildAppendPreview(existingContent, streamedContent)
|
||||
}
|
||||
return streamedContent
|
||||
}
|
||||
|
||||
if (existingContent === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!shouldApplyPatchPreview(streamedContent, edit)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return extractPatchPreview(streamedContent, existingContent, edit)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { RenameFile } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import {
|
||||
getWorkspaceFile,
|
||||
resolveWorkspaceFileReference,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import { performRenameWorkspaceFile } from '@/lib/workspace-files/orchestration'
|
||||
import { validateFlatWorkspaceFileName } from './workspace-file'
|
||||
|
||||
const logger = createLogger('RenameFileServerTool')
|
||||
|
||||
interface RenameFileArgs {
|
||||
path?: string
|
||||
fileId?: string
|
||||
newName: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface RenameFileResult {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export const renameFileServerTool: BaseServerTool<RenameFileArgs, RenameFileResult> = {
|
||||
name: RenameFile.id,
|
||||
async execute(params: RenameFileArgs, context?: ServerToolContext): Promise<RenameFileResult> {
|
||||
if (!context?.userId) {
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
const workspaceId = context.workspaceId
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
|
||||
|
||||
const nested = params.args
|
||||
const path = params.path || (nested?.path as string) || ''
|
||||
const legacyFileId = params.fileId || (nested?.fileId as string) || ''
|
||||
const newName = params.newName || (nested?.newName as string) || ''
|
||||
|
||||
const targetRef = path || legacyFileId
|
||||
if (!targetRef) return { success: false, message: 'path is required' }
|
||||
|
||||
const nameError = validateFlatWorkspaceFileName(newName)
|
||||
if (nameError) return { success: false, message: nameError }
|
||||
|
||||
const existingFile = path
|
||||
? await resolveWorkspaceFileReference(workspaceId, path)
|
||||
: await getWorkspaceFile(workspaceId, legacyFileId)
|
||||
if (!existingFile) {
|
||||
return { success: false, message: `File not found: ${targetRef}` }
|
||||
}
|
||||
const fileId = existingFile.id
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performRenameWorkspaceFile({
|
||||
workspaceId,
|
||||
fileId,
|
||||
name: newName,
|
||||
userId: context.userId,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.error || 'Failed to rename file' }
|
||||
}
|
||||
|
||||
logger.info('File renamed via rename_file', {
|
||||
fileId,
|
||||
oldName: existingFile.name,
|
||||
newName,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File renamed from "${existingFile.name}" to "${newName}"`,
|
||||
data: {
|
||||
id: fileId,
|
||||
name: newName,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
|
||||
import {
|
||||
assertServerToolNotAborted,
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { ensureWorkflowAliasBacking } from '@/lib/copilot/vfs/workflow-alias-backing'
|
||||
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
|
||||
import { isPlanAliasPath } from '@/lib/copilot/vfs/workflow-aliases'
|
||||
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
|
||||
import { runSandboxTask } from '@/lib/execution/sandbox/run-task'
|
||||
import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
|
||||
import {
|
||||
fetchWorkspaceFileBuffer as downloadWsFile,
|
||||
getWorkspaceFile,
|
||||
getWorkspaceFileByName,
|
||||
resolveWorkspaceFileReference,
|
||||
uploadWorkspaceFile,
|
||||
type WorkspaceFileRecord,
|
||||
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
||||
import {
|
||||
performDeleteWorkspaceFileItems,
|
||||
performRenameWorkspaceFile,
|
||||
} from '@/lib/workspace-files/orchestration'
|
||||
import type { SandboxTaskId } from '@/sandbox-tasks/registry'
|
||||
import {
|
||||
compileDoc,
|
||||
DOCXJS_SOURCE_MIME,
|
||||
DocCompileUserError,
|
||||
getE2BDocFormat,
|
||||
PPTXGENJS_SOURCE_MIME,
|
||||
} from './doc-compile'
|
||||
import { buildEmbeddedImageRefWarning } from './embedded-image-refs'
|
||||
import { storeFileIntent } from './file-intent-store'
|
||||
|
||||
const logger = createLogger('WorkspaceFileServerTool')
|
||||
|
||||
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
const PDF_MIME = 'application/pdf'
|
||||
// Single source of the JS source MIMEs is doc-compile.ts; reuse to avoid drift.
|
||||
const PPTX_SOURCE_MIME = PPTXGENJS_SOURCE_MIME
|
||||
const DOCX_SOURCE_MIME = DOCXJS_SOURCE_MIME
|
||||
const PDF_SOURCE_MIME = 'text/x-pdflibjs'
|
||||
|
||||
type WorkspaceFileOperation = 'create' | 'append' | 'update' | 'delete' | 'rename' | 'patch'
|
||||
|
||||
type WorkspaceFileTarget =
|
||||
| {
|
||||
kind: 'new_file'
|
||||
fileName: string
|
||||
fileId?: string
|
||||
}
|
||||
| {
|
||||
kind: 'file_id'
|
||||
fileId: string
|
||||
fileName?: string
|
||||
}
|
||||
| {
|
||||
kind: 'path'
|
||||
path: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
type WorkspaceFileEdit =
|
||||
| {
|
||||
strategy: 'search_replace'
|
||||
search: string
|
||||
replace: string
|
||||
replaceAll?: boolean
|
||||
}
|
||||
| {
|
||||
strategy: 'anchored'
|
||||
mode: 'replace_between' | 'insert_after' | 'delete_between'
|
||||
occurrence?: number
|
||||
before_anchor?: string
|
||||
after_anchor?: string
|
||||
start_anchor?: string
|
||||
end_anchor?: string
|
||||
anchor?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
type WorkspaceFileArgs = {
|
||||
operation: WorkspaceFileOperation
|
||||
target?: WorkspaceFileTarget
|
||||
title?: string
|
||||
content?: string
|
||||
contentType?: string
|
||||
newName?: string
|
||||
edit?: WorkspaceFileEdit
|
||||
}
|
||||
|
||||
type WorkspaceFileResult = {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.html': 'text/html',
|
||||
'.json': 'application/json',
|
||||
'.csv': 'text/csv',
|
||||
'.pptx': PPTX_MIME,
|
||||
'.docx': DOCX_MIME,
|
||||
'.pdf': PDF_MIME,
|
||||
}
|
||||
|
||||
export function inferContentType(fileName: string, explicitType?: string): string {
|
||||
if (explicitType) return explicitType
|
||||
const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase()
|
||||
return EXT_TO_MIME[ext] || 'text/plain'
|
||||
}
|
||||
|
||||
export function validateFlatWorkspaceFileName(fileName: string): string | null {
|
||||
const trimmed = fileName.trim()
|
||||
if (!trimmed) return 'File name cannot be empty'
|
||||
const segments = trimmed.split('/').map((segment) => segment.trim())
|
||||
if (segments.some((segment) => !segment)) {
|
||||
return 'File path cannot contain empty segments'
|
||||
}
|
||||
if (segments.some((segment) => segment === '.' || segment === '..' || segment.includes('\\'))) {
|
||||
return 'File path cannot contain dot segments or backslashes'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function splitWorkspaceFilePath(fileName: string): {
|
||||
folderSegments: string[]
|
||||
leafName: string
|
||||
} {
|
||||
const segments = fileName
|
||||
.trim()
|
||||
.split('/')
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean)
|
||||
return {
|
||||
folderSegments: segments.slice(0, -1),
|
||||
leafName: segments[segments.length - 1] ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export interface DocumentFormatInfo {
|
||||
isDoc: boolean
|
||||
formatName?: 'PPTX' | 'DOCX' | 'PDF'
|
||||
sourceMime?: string
|
||||
taskId?: SandboxTaskId
|
||||
}
|
||||
|
||||
export function getDocumentFormatInfo(fileName: string): DocumentFormatInfo {
|
||||
const lowerName = fileName.toLowerCase()
|
||||
if (lowerName.endsWith('.pptx')) {
|
||||
return {
|
||||
isDoc: true,
|
||||
formatName: 'PPTX',
|
||||
sourceMime: PPTX_SOURCE_MIME,
|
||||
taskId: 'pptx-generate',
|
||||
}
|
||||
}
|
||||
if (lowerName.endsWith('.docx')) {
|
||||
return {
|
||||
isDoc: true,
|
||||
formatName: 'DOCX',
|
||||
sourceMime: DOCX_SOURCE_MIME,
|
||||
taskId: 'docx-generate',
|
||||
}
|
||||
}
|
||||
if (lowerName.endsWith('.pdf')) {
|
||||
return {
|
||||
isDoc: true,
|
||||
formatName: 'PDF',
|
||||
sourceMime: PDF_SOURCE_MIME,
|
||||
taskId: 'pdf-generate',
|
||||
}
|
||||
}
|
||||
return { isDoc: false }
|
||||
}
|
||||
|
||||
export type CompileForWriteResult =
|
||||
| { ok: true; sourceMime: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
/**
|
||||
* Shared write-time doc handling for create + edit_content: validates and builds
|
||||
* the document (E2B doc sandbox when enabled — Node pptx/docx, Python pdf/xlsx —
|
||||
* else isolated-vm JS) and returns the source MIME to store, or a user-facing
|
||||
* failure message. Non-doc files resolve to `fallbackMime`. Compilation happens
|
||||
* here exactly once per write; the artifact is content-addressed so a read can
|
||||
* later just load it.
|
||||
*/
|
||||
export async function compileDocForWrite(args: {
|
||||
source: string
|
||||
fileName: string
|
||||
workspaceId: string
|
||||
ownerKey: string
|
||||
signal?: AbortSignal
|
||||
fallbackMime: string
|
||||
}): Promise<CompileForWriteResult> {
|
||||
const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args
|
||||
const docInfo = getDocumentFormatInfo(fileName)
|
||||
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileName) : null
|
||||
|
||||
if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) {
|
||||
return {
|
||||
ok: false,
|
||||
message: isE2BDocEnabled
|
||||
? 'Excel (.xlsx) generation is currently behind the mothership-beta feature flag and is not available.'
|
||||
: 'Excel (.xlsx) generation requires the E2B document sandbox, which is not enabled in this environment.',
|
||||
}
|
||||
}
|
||||
|
||||
if (e2bFmt) {
|
||||
// compileDoc is load-or-build, so an identical re-write reuses the cached
|
||||
// binary instead of re-running E2B.
|
||||
try {
|
||||
await compileDoc({ source, fileName, workspaceId })
|
||||
} catch (err) {
|
||||
if (err instanceof DocCompileUserError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `${e2bFmt.formatName} generation failed: ${err.message}. Fix the code and retry.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `${e2bFmt.formatName} generation failed due to a system error: ${toError(err).message}. Retry shortly.`,
|
||||
}
|
||||
}
|
||||
return { ok: true, sourceMime: e2bFmt.sourceMime }
|
||||
}
|
||||
|
||||
if (docInfo.isDoc) {
|
||||
try {
|
||||
await runSandboxTask(docInfo.taskId!, { code: source, workspaceId }, { ownerKey, signal })
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `${docInfo.formatName} generation failed: ${toError(err).message}. Fix the code and retry.`,
|
||||
}
|
||||
}
|
||||
return { ok: true, sourceMime: docInfo.sourceMime! }
|
||||
}
|
||||
|
||||
return { ok: true, sourceMime: fallbackMime }
|
||||
}
|
||||
|
||||
export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, WorkspaceFileResult> = {
|
||||
name: WorkspaceFile.id,
|
||||
async execute(
|
||||
params: WorkspaceFileArgs,
|
||||
context?: ServerToolContext
|
||||
): Promise<WorkspaceFileResult> {
|
||||
const withMessageId = (message: string) =>
|
||||
context?.messageId ? `${message} [messageId:${context.messageId}]` : message
|
||||
|
||||
if (!context?.userId) {
|
||||
logger.error('Unauthorized attempt to access workspace files')
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const raw = params as Record<string, unknown>
|
||||
const nested = raw.args as Record<string, unknown> | undefined
|
||||
const normalized: WorkspaceFileArgs =
|
||||
params.operation && params.target
|
||||
? params
|
||||
: nested && typeof nested === 'object'
|
||||
? {
|
||||
operation: (nested.operation ?? raw.operation) as WorkspaceFileOperation,
|
||||
target: (nested.target ?? raw.target) as WorkspaceFileTarget | undefined,
|
||||
title: (nested.title ?? raw.title) as string | undefined,
|
||||
content: (nested.content ?? raw.content) as string | undefined,
|
||||
contentType: (nested.contentType ?? raw.contentType) as string | undefined,
|
||||
newName: (nested.newName ?? raw.newName) as string | undefined,
|
||||
edit: (nested.edit ?? raw.edit) as WorkspaceFileEdit | undefined,
|
||||
}
|
||||
: params
|
||||
const { operation } = normalized
|
||||
const workspaceId = context.workspaceId
|
||||
|
||||
const resolveExistingTarget = async (
|
||||
target: WorkspaceFileTarget | undefined,
|
||||
operationName: string
|
||||
): Promise<{ fileRecord?: WorkspaceFileRecord; vfsPath?: string; error?: string }> => {
|
||||
if (!target || (target.kind !== 'path' && target.kind !== 'file_id')) {
|
||||
return { error: `${operationName} requires target.kind=path with target.path` }
|
||||
}
|
||||
let fileRecord: WorkspaceFileRecord | null = null
|
||||
let vfsPath: string | undefined
|
||||
if (target.kind === 'path') {
|
||||
const alias = await resolveWorkflowAliasForWorkspace({
|
||||
workspaceId: workspaceId!,
|
||||
path: target.path,
|
||||
})
|
||||
if (!alias && isPlanAliasPath(target.path)) {
|
||||
return { error: `Unsupported plan alias path or missing workflow: ${target.path}` }
|
||||
}
|
||||
if (alias) {
|
||||
if (alias.kind === 'plans_dir') {
|
||||
return { error: `Plan alias directory is not a file: ${target.path}` }
|
||||
}
|
||||
fileRecord = await resolveWorkspaceFileReference(workspaceId!, alias.backingPath)
|
||||
if (!fileRecord && alias.kind === 'changelog') {
|
||||
await ensureWorkflowAliasBacking({
|
||||
workspaceId: workspaceId!,
|
||||
userId: context.userId,
|
||||
workflowId: alias.workflowId,
|
||||
workflowName: alias.workflowName,
|
||||
})
|
||||
fileRecord = await resolveWorkspaceFileReference(workspaceId!, alias.backingPath)
|
||||
}
|
||||
vfsPath = alias.aliasPath
|
||||
} else {
|
||||
fileRecord = await resolveWorkspaceFileReference(workspaceId!, target.path)
|
||||
vfsPath = target.path
|
||||
}
|
||||
} else {
|
||||
fileRecord = await getWorkspaceFile(workspaceId!, target.fileId)
|
||||
}
|
||||
if (!fileRecord) {
|
||||
const ref = target.kind === 'path' ? target.path : target.fileId
|
||||
return { error: `File not found: ${ref}` }
|
||||
}
|
||||
if (target.fileName && target.fileName !== fileRecord.name) {
|
||||
return {
|
||||
error: `Target mismatch: "${target.fileName}" does not match resolved file "${fileRecord.name}"`,
|
||||
}
|
||||
}
|
||||
return { fileRecord, vfsPath }
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
|
||||
|
||||
switch (operation) {
|
||||
case 'create': {
|
||||
const target = normalized.target
|
||||
if (!target || target.kind !== 'new_file') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'create requires target.kind=new_file with target.fileName',
|
||||
}
|
||||
}
|
||||
|
||||
const { folderSegments, leafName } = splitWorkspaceFilePath(target.fileName)
|
||||
const fileName = leafName
|
||||
const content = normalized.content ?? ''
|
||||
const explicitType = normalized.contentType
|
||||
const fileNameValidationError = validateFlatWorkspaceFileName(target.fileName)
|
||||
if (fileNameValidationError) return { success: false, message: fileNameValidationError }
|
||||
|
||||
const folderId = await ensureWorkspaceFileFolderPath({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
pathSegments: folderSegments,
|
||||
})
|
||||
const existingFile = await getWorkspaceFileByName(workspaceId, fileName, { folderId })
|
||||
if (existingFile) {
|
||||
return { success: false, message: `File "${target.fileName}" already exists` }
|
||||
}
|
||||
|
||||
const compiled = await compileDocForWrite({
|
||||
source: content,
|
||||
fileName,
|
||||
workspaceId,
|
||||
ownerKey: `user:${context.userId}`,
|
||||
signal: context.abortSignal,
|
||||
fallbackMime: inferContentType(fileName, explicitType),
|
||||
})
|
||||
if (!compiled.ok) {
|
||||
return { success: false, message: compiled.message }
|
||||
}
|
||||
const contentType = compiled.sourceMime
|
||||
|
||||
const fileBuffer = Buffer.from(content, 'utf-8')
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await uploadWorkspaceFile(
|
||||
workspaceId,
|
||||
context.userId,
|
||||
fileBuffer,
|
||||
fileName,
|
||||
contentType,
|
||||
{ folderId }
|
||||
)
|
||||
logger.info('Workspace file created via copilot', {
|
||||
fileId: result.id,
|
||||
name: fileName,
|
||||
size: fileBuffer.length,
|
||||
contentType,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${fileName}" created successfully (${fileBuffer.length} bytes)${embedWarning}`,
|
||||
data: {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
contentType,
|
||||
size: fileBuffer.length,
|
||||
downloadUrl: result.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
const target = normalized.target
|
||||
const {
|
||||
fileRecord: existingFile,
|
||||
vfsPath,
|
||||
error,
|
||||
} = await resolveExistingTarget(target, 'append')
|
||||
if (error || !existingFile) return { success: false, message: error || 'File not found' }
|
||||
|
||||
const currentBuffer = await downloadWsFile(existingFile)
|
||||
await storeFileIntent(workspaceId, existingFile.id, {
|
||||
operation: 'append',
|
||||
fileId: existingFile.id,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
chatId: context.chatId,
|
||||
messageId: context.messageId,
|
||||
channelId: context.parentToolCallId,
|
||||
fileRecord: existingFile,
|
||||
existingContent: currentBuffer.toString('utf-8'),
|
||||
contentType: normalized.contentType,
|
||||
title: normalized.title,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: withMessageId(
|
||||
`Intent set: append to "${existingFile.name}". Wait for this success result, then call edit_content in the next step with the content to write. Do not call edit_content in parallel.`
|
||||
),
|
||||
data: { id: existingFile.id, name: existingFile.name, vfsPath, operation: 'append' },
|
||||
}
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const target = normalized.target
|
||||
const { fileRecord, vfsPath, error } = await resolveExistingTarget(target, 'update')
|
||||
if (error || !fileRecord) return { success: false, message: error || 'File not found' }
|
||||
|
||||
await storeFileIntent(workspaceId, fileRecord.id, {
|
||||
operation: 'update',
|
||||
fileId: fileRecord.id,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
chatId: context.chatId,
|
||||
messageId: context.messageId,
|
||||
channelId: context.parentToolCallId,
|
||||
fileRecord,
|
||||
contentType: normalized.contentType,
|
||||
title: normalized.title,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: withMessageId(
|
||||
`Intent set: update "${fileRecord.name}". Wait for this success result, then call edit_content in the next step with the replacement content. Do not call edit_content in parallel.`
|
||||
),
|
||||
data: { id: fileRecord.id, name: fileRecord.name, vfsPath, operation: 'update' },
|
||||
}
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
const target = normalized.target
|
||||
if (!target || target.kind !== 'file_id') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'rename requires target.kind=file_id with target.fileId',
|
||||
}
|
||||
}
|
||||
if (!normalized.newName) {
|
||||
return { success: false, message: 'newName is required for rename operation' }
|
||||
}
|
||||
const fileNameValidationError = validateFlatWorkspaceFileName(normalized.newName)
|
||||
if (fileNameValidationError) return { success: false, message: fileNameValidationError }
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, target.fileId)
|
||||
if (!fileRecord) {
|
||||
return { success: false, message: `File with ID "${target.fileId}" not found` }
|
||||
}
|
||||
|
||||
const oldName = fileRecord.name
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performRenameWorkspaceFile({
|
||||
workspaceId,
|
||||
fileId: target.fileId,
|
||||
name: normalized.newName,
|
||||
userId: context.userId,
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.error || 'Failed to rename file' }
|
||||
}
|
||||
|
||||
logger.info('Workspace file renamed via copilot', {
|
||||
fileId: target.fileId,
|
||||
oldName,
|
||||
newName: normalized.newName,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File renamed from "${oldName}" to "${normalized.newName}"`,
|
||||
data: { id: target.fileId, name: normalized.newName },
|
||||
}
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const target = normalized.target
|
||||
if (!target || target.kind !== 'file_id') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'delete requires target.kind=file_id with target.fileId',
|
||||
}
|
||||
}
|
||||
|
||||
const fileRecord = await getWorkspaceFile(workspaceId, target.fileId)
|
||||
if (!fileRecord) {
|
||||
return { success: false, message: `File with ID "${target.fileId}" not found` }
|
||||
}
|
||||
|
||||
assertServerToolNotAborted(context)
|
||||
const result = await performDeleteWorkspaceFileItems({
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
fileIds: [target.fileId],
|
||||
})
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.error || 'Failed to delete file' }
|
||||
}
|
||||
|
||||
logger.info('Workspace file deleted via copilot', {
|
||||
fileId: target.fileId,
|
||||
name: fileRecord.name,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${fileRecord.name}" deleted successfully`,
|
||||
data: { id: target.fileId, name: fileRecord.name },
|
||||
}
|
||||
}
|
||||
|
||||
case 'patch': {
|
||||
const target = normalized.target
|
||||
if (!normalized.edit) {
|
||||
return { success: false, message: 'edit is required for patch operation' }
|
||||
}
|
||||
|
||||
const { fileRecord, vfsPath, error } = await resolveExistingTarget(target, 'patch')
|
||||
if (error || !fileRecord) return { success: false, message: error || 'File not found' }
|
||||
|
||||
const currentBuffer = await downloadWsFile(fileRecord)
|
||||
const existingContent = currentBuffer.toString('utf-8')
|
||||
|
||||
if (normalized.edit.strategy === 'search_replace') {
|
||||
const search = normalized.edit.search
|
||||
const firstIdx = existingContent.indexOf(search)
|
||||
if (firstIdx === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch failed: search string not found in file "${fileRecord.name}". Search: "${truncate(search, 100)}"`,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!normalized.edit.replaceAll &&
|
||||
existingContent.indexOf(search, firstIdx + 1) !== -1
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch failed: search string is ambiguous — found at multiple locations in "${fileRecord.name}". Use a longer unique search string or replaceAll.`,
|
||||
}
|
||||
}
|
||||
} else if (normalized.edit.strategy === 'anchored') {
|
||||
if (!normalized.edit.mode) {
|
||||
return { success: false, message: 'anchored strategy requires mode' }
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown patch strategy: "${(normalized.edit as { strategy?: string }).strategy}"`,
|
||||
}
|
||||
}
|
||||
|
||||
await storeFileIntent(workspaceId, fileRecord.id, {
|
||||
operation: 'patch',
|
||||
fileId: fileRecord.id,
|
||||
workspaceId,
|
||||
userId: context.userId,
|
||||
chatId: context.chatId,
|
||||
messageId: context.messageId,
|
||||
channelId: context.parentToolCallId,
|
||||
fileRecord,
|
||||
existingContent,
|
||||
edit: {
|
||||
strategy: normalized.edit.strategy,
|
||||
...(normalized.edit.strategy === 'search_replace'
|
||||
? {
|
||||
search: normalized.edit.search,
|
||||
replaceAll: normalized.edit.replaceAll,
|
||||
}
|
||||
: {
|
||||
mode: normalized.edit.mode,
|
||||
occurrence: normalized.edit.occurrence,
|
||||
before_anchor: normalized.edit.before_anchor,
|
||||
after_anchor: normalized.edit.after_anchor,
|
||||
anchor: normalized.edit.anchor,
|
||||
start_anchor: normalized.edit.start_anchor,
|
||||
end_anchor: normalized.edit.end_anchor,
|
||||
}),
|
||||
},
|
||||
contentType: normalized.contentType,
|
||||
title: normalized.title,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: withMessageId(
|
||||
`Intent set: patch "${fileRecord.name}" (${normalized.edit.strategy}). Wait for this success result, then call edit_content in the next step with the replacement/insert content. Do not call edit_content in parallel.`
|
||||
),
|
||||
data: { id: fileRecord.id, name: fileRecord.name, vfsPath, operation: 'patch' },
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown operation: ${operation}. Supported: create, append, update, patch, rename, delete.`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
|
||||
logger.error('Error in workspace_file tool', {
|
||||
operation,
|
||||
error: errorMessage,
|
||||
userId: context.userId,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to ${operation} file: ${errorMessage}`,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user