d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
664 lines
23 KiB
TypeScript
664 lines
23 KiB
TypeScript
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}`,
|
|
}
|
|
}
|
|
},
|
|
}
|