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,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