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
1110 lines
34 KiB
TypeScript
1110 lines
34 KiB
TypeScript
/**
|
|
* Workspace file storage system
|
|
* Files uploaded at workspace level persist indefinitely and are accessible across all workflows
|
|
*/
|
|
|
|
import { randomBytes } from 'crypto'
|
|
import { db } from '@sim/db'
|
|
import { workspaceFiles } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage, getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
|
|
import { generateShortId } from '@sim/utils/id'
|
|
import { and, eq, isNull, sql } from 'drizzle-orm'
|
|
import type { ShareRecord } from '@/lib/api/contracts/public-shares'
|
|
import {
|
|
checkStorageQuota,
|
|
decrementStorageUsage,
|
|
incrementStorageUsage,
|
|
} from '@/lib/billing/storage'
|
|
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
|
|
import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
|
|
import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver'
|
|
import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases'
|
|
import { generateRestoreName } from '@/lib/core/utils/restore-name'
|
|
import { getServePathPrefix } from '@/lib/uploads'
|
|
import {
|
|
deleteFile,
|
|
downloadFile,
|
|
hasCloudStorage,
|
|
headObject,
|
|
uploadFile,
|
|
} from '@/lib/uploads/core/storage-service'
|
|
import { getFileMetadataByKey, insertFileMetadata } from '@/lib/uploads/server/metadata'
|
|
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
|
|
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
|
|
import { isUuid, sanitizeFileName } from '@/executor/constants'
|
|
import type { UserFile } from '@/executor/types'
|
|
import type { WorkspaceFileFolderRecord } from './workspace-file-folder-manager'
|
|
import {
|
|
assertWorkspaceFileFolderTarget,
|
|
buildWorkspaceFileFolderPathMap,
|
|
fileNameExistsInWorkspaceFolder,
|
|
findWorkspaceFileFolderIdByPath,
|
|
getWorkspaceFileFolderPath,
|
|
listWorkspaceFileFolders,
|
|
normalizeWorkspaceFileItemName,
|
|
} from './workspace-file-folder-manager'
|
|
|
|
const logger = createLogger('WorkspaceFileStorage')
|
|
|
|
export type WorkspaceFileScope = 'active' | 'archived' | 'all'
|
|
|
|
export class FileConflictError extends Error {
|
|
readonly code = 'FILE_EXISTS' as const
|
|
constructor(name: string) {
|
|
super(`A file named "${name}" already exists in this workspace`)
|
|
}
|
|
}
|
|
|
|
export interface WorkspaceFileRecord {
|
|
id: string
|
|
workspaceId: string
|
|
name: string
|
|
key: string
|
|
path: string // Full serve path including storage type
|
|
url?: string // Presigned URL for external access (optional, regenerated as needed)
|
|
size: number
|
|
type: string
|
|
uploadedBy: string
|
|
folderId?: string | null
|
|
folderPath?: string | null
|
|
deletedAt?: Date | null
|
|
uploadedAt: Date
|
|
updatedAt: Date
|
|
/** Pass-through to `downloadFile` when not default `workspace` (e.g. chat mothership uploads). */
|
|
storageContext?: 'workspace' | 'mothership'
|
|
/** Public share state, attached at the API boundary. `null` when never shared. */
|
|
share?: ShareRecord | null
|
|
}
|
|
|
|
interface ListWorkspaceFilesOptions {
|
|
scope?: WorkspaceFileScope
|
|
folders?: WorkspaceFileFolderRecord[]
|
|
hydrateFolderPaths?: boolean
|
|
includeReservedSystemFiles?: boolean
|
|
}
|
|
|
|
/**
|
|
* Workspace file key pattern: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
*/
|
|
const WORKSPACE_KEY_PATTERN = /^workspace\/([a-f0-9-]{36})\/(\d+)-([a-z0-9]+)-(.+)$/
|
|
|
|
/**
|
|
* Check if a key matches workspace file pattern
|
|
* Format: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
*/
|
|
export function matchesWorkspaceFilePattern(key: string): boolean {
|
|
if (!key || key.startsWith('/api/') || key.startsWith('http')) {
|
|
return false
|
|
}
|
|
return WORKSPACE_KEY_PATTERN.test(key)
|
|
}
|
|
|
|
/**
|
|
* Parse workspace file key to extract workspace ID
|
|
* Format: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
* @returns workspaceId if key matches pattern, null otherwise
|
|
*/
|
|
export function parseWorkspaceFileKey(key: string): string | null {
|
|
if (!matchesWorkspaceFilePattern(key)) {
|
|
return null
|
|
}
|
|
|
|
const match = key.match(WORKSPACE_KEY_PATTERN)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
const workspaceId = match[1]
|
|
return isUuid(workspaceId) ? workspaceId : null
|
|
}
|
|
|
|
/**
|
|
* Generate workspace-scoped storage key with explicit prefix
|
|
* Format: workspace/{workspaceId}/{timestamp}-{random}-{filename}
|
|
*/
|
|
export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string {
|
|
const timestamp = Date.now()
|
|
const random = randomBytes(8).toString('hex')
|
|
const safeFileName = sanitizeFileName(fileName)
|
|
return `workspace/${workspaceId}/${timestamp}-${random}-${safeFileName}`
|
|
}
|
|
|
|
const MAX_COPY_SUFFIX = 1000
|
|
const MAX_UPLOAD_UNIQUE_RETRIES = 8
|
|
|
|
/**
|
|
* Inserts ` (n)` before the last extension (e.g. `a.pdf` → `a (1).pdf`), or appends for names without.
|
|
*/
|
|
function withCopySuffix(fileName: string, n: number): string {
|
|
const lastDot = fileName.lastIndexOf('.')
|
|
const hasExtension = lastDot > 0 && lastDot < fileName.length - 1
|
|
if (hasExtension) {
|
|
return `${fileName.slice(0, lastDot)} (${n})${fileName.slice(lastDot)}`
|
|
}
|
|
return `${fileName} (${n})`
|
|
}
|
|
|
|
/**
|
|
* Picks a display name that does not collide with an active workspace file (`original_name`).
|
|
*/
|
|
async function allocateUniqueWorkspaceFileName(
|
|
workspaceId: string,
|
|
baseName: string,
|
|
folderId?: string | null
|
|
): Promise<string> {
|
|
if (!(await fileExistsInWorkspace(workspaceId, baseName, folderId))) {
|
|
return baseName
|
|
}
|
|
for (let n = 1; n <= MAX_COPY_SUFFIX; n++) {
|
|
const candidate = withCopySuffix(baseName, n)
|
|
if (!(await fileExistsInWorkspace(workspaceId, candidate, folderId))) {
|
|
return candidate
|
|
}
|
|
}
|
|
throw new FileConflictError(baseName)
|
|
}
|
|
|
|
/**
|
|
* Upload a file to workspace-scoped storage
|
|
*/
|
|
export async function uploadWorkspaceFile(
|
|
workspaceId: string,
|
|
userId: string,
|
|
fileBuffer: Buffer,
|
|
fileName: string,
|
|
contentType: string,
|
|
options?: { folderId?: string | null; exactName?: boolean }
|
|
): Promise<UserFile> {
|
|
logger.info(`Uploading workspace file: ${fileName} for workspace ${workspaceId}`)
|
|
|
|
const folderId = await assertWorkspaceFileFolderTarget(workspaceId, options?.folderId)
|
|
const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File')
|
|
const exactName = options?.exactName ?? false
|
|
const quotaCheck = await checkStorageQuota(userId, fileBuffer.length)
|
|
|
|
if (!quotaCheck.allowed) {
|
|
throw new Error(quotaCheck.error || 'Storage limit exceeded')
|
|
}
|
|
|
|
let lastError: unknown
|
|
const maxAttempts = exactName ? 1 : MAX_UPLOAD_UNIQUE_RETRIES
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
const uniqueName = exactName
|
|
? normalizedFileName
|
|
: await allocateUniqueWorkspaceFileName(workspaceId, normalizedFileName, folderId)
|
|
if (exactName && (await fileExistsInWorkspace(workspaceId, uniqueName, folderId))) {
|
|
throw new FileConflictError(uniqueName)
|
|
}
|
|
const storageKey = generateWorkspaceFileKey(workspaceId, uniqueName)
|
|
let fileId = `wf_${generateShortId()}`
|
|
|
|
try {
|
|
logger.info(`Generated storage key: ${storageKey}`)
|
|
|
|
const metadata: Record<string, string> = {
|
|
originalName: uniqueName,
|
|
uploadedAt: new Date().toISOString(),
|
|
purpose: 'workspace',
|
|
userId: userId,
|
|
workspaceId: workspaceId,
|
|
...(folderId ? { folderId } : {}),
|
|
}
|
|
|
|
const uploadResult = await uploadFile({
|
|
file: fileBuffer,
|
|
fileName: storageKey,
|
|
contentType,
|
|
context: 'workspace',
|
|
preserveKey: true,
|
|
customKey: storageKey,
|
|
metadata,
|
|
})
|
|
|
|
logger.info(`Upload returned key: ${uploadResult.key}`)
|
|
|
|
const usingCloudStorage = hasCloudStorage()
|
|
|
|
if (!usingCloudStorage) {
|
|
const metadataRecord = await insertFileMetadata({
|
|
id: fileId,
|
|
key: uploadResult.key,
|
|
userId,
|
|
workspaceId,
|
|
folderId,
|
|
context: 'workspace',
|
|
originalName: uniqueName,
|
|
contentType,
|
|
size: fileBuffer.length,
|
|
})
|
|
fileId = metadataRecord.id
|
|
logger.info(`Stored metadata in database for local file: ${uploadResult.key}`)
|
|
} else {
|
|
const existing = await getFileMetadataByKey(uploadResult.key, 'workspace')
|
|
|
|
if (!existing) {
|
|
logger.warn(`Metadata not found for cloud file ${uploadResult.key}, inserting...`)
|
|
const metadataRecord = await insertFileMetadata({
|
|
id: fileId,
|
|
key: uploadResult.key,
|
|
userId,
|
|
workspaceId,
|
|
folderId,
|
|
context: 'workspace',
|
|
originalName: uniqueName,
|
|
contentType,
|
|
size: fileBuffer.length,
|
|
})
|
|
fileId = metadataRecord.id
|
|
} else {
|
|
fileId = existing.id
|
|
logger.info(`Using existing metadata record for cloud file: ${uploadResult.key}`)
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`Successfully uploaded workspace file: ${uniqueName} with key: ${uploadResult.key}`
|
|
)
|
|
|
|
try {
|
|
await incrementStorageUsage(userId, fileBuffer.length, workspaceId)
|
|
} catch (storageError) {
|
|
logger.error(`Failed to update storage tracking:`, storageError)
|
|
}
|
|
|
|
const pathPrefix = getServePathPrefix()
|
|
const serveUrl = `${pathPrefix}${encodeURIComponent(uploadResult.key)}?context=workspace`
|
|
|
|
return {
|
|
id: fileId,
|
|
name: uniqueName,
|
|
size: fileBuffer.length,
|
|
type: contentType,
|
|
url: serveUrl,
|
|
key: uploadResult.key,
|
|
context: 'workspace',
|
|
}
|
|
} catch (error) {
|
|
lastError = error
|
|
if (error instanceof FileConflictError) {
|
|
throw error
|
|
}
|
|
if (getPostgresErrorCode(error) === '23505') {
|
|
if (exactName) {
|
|
throw new FileConflictError(normalizedFileName)
|
|
}
|
|
logger.warn(
|
|
`Unique name conflict on upload (attempt ${attempt + 1}/${MAX_UPLOAD_UNIQUE_RETRIES}), retrying with a new name`
|
|
)
|
|
continue
|
|
}
|
|
logger.error(`Failed to upload workspace file ${fileName}:`, error)
|
|
throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`)
|
|
}
|
|
}
|
|
|
|
logger.error(
|
|
`Failed to upload workspace file after ${MAX_UPLOAD_UNIQUE_RETRIES} attempts`,
|
|
lastError
|
|
)
|
|
throw new FileConflictError(fileName)
|
|
}
|
|
|
|
/**
|
|
* Finalize a workspace file that was uploaded directly to cloud storage
|
|
* (presigned PUT or completed multipart). Verifies the object exists,
|
|
* checks quota, allocates a non-colliding display name, inserts metadata,
|
|
* and increments storage usage.
|
|
*
|
|
* Throws if the object is missing in storage, quota is exceeded, or the
|
|
* caller cannot resolve a unique name within the retry budget.
|
|
*/
|
|
export interface RegisterUploadedWorkspaceFileResult {
|
|
file: UserFile
|
|
/** True when a new metadata row was inserted; false when an existing row was reused. */
|
|
created: boolean
|
|
}
|
|
|
|
export async function registerUploadedWorkspaceFile(params: {
|
|
workspaceId: string
|
|
userId: string
|
|
key: string
|
|
originalName: string
|
|
contentType: string
|
|
folderId?: string | null
|
|
}): Promise<RegisterUploadedWorkspaceFileResult> {
|
|
const { workspaceId, userId, key, originalName, contentType } = params
|
|
const folderId = await assertWorkspaceFileFolderTarget(workspaceId, params.folderId)
|
|
const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File')
|
|
|
|
if (!hasCloudStorage()) {
|
|
throw new Error('Direct-upload registration requires cloud storage')
|
|
}
|
|
|
|
if (parseWorkspaceFileKey(key) !== workspaceId) {
|
|
throw new Error('Storage key does not belong to this workspace')
|
|
}
|
|
|
|
const head = await headObject(key, 'workspace')
|
|
if (!head) {
|
|
throw new Error('Uploaded object not found in storage')
|
|
}
|
|
const verifiedSize = head.size
|
|
|
|
const cleanupOrphan = async (reason: string) => {
|
|
try {
|
|
await deleteFile({ key, context: 'workspace' })
|
|
} catch (deleteError) {
|
|
logger.error(`Failed to clean up orphaned object after ${reason}`, deleteError)
|
|
}
|
|
}
|
|
|
|
if (verifiedSize > MAX_WORKSPACE_FILE_SIZE) {
|
|
await cleanupOrphan('size-cap rejection')
|
|
throw new Error(`File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes`)
|
|
}
|
|
|
|
/**
|
|
* Existence check precedes the quota guard so a network-dropped retry doesn't
|
|
* double-charge quota or orphan-cleanup an already-registered object.
|
|
*/
|
|
const existing = await getFileMetadataByKey(key, 'workspace')
|
|
|
|
let fileId = existing?.id ?? ''
|
|
let displayName = existing?.originalName ?? ''
|
|
let created = false
|
|
|
|
if (!existing) {
|
|
const quotaCheck = await checkStorageQuota(userId, verifiedSize)
|
|
if (!quotaCheck.allowed) {
|
|
await cleanupOrphan('quota rejection')
|
|
throw new Error(quotaCheck.error || 'Storage limit exceeded')
|
|
}
|
|
|
|
let lastInsertError: unknown
|
|
for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) {
|
|
fileId = `wf_${generateShortId()}`
|
|
displayName = await allocateUniqueWorkspaceFileName(
|
|
workspaceId,
|
|
normalizedOriginalName,
|
|
folderId
|
|
)
|
|
try {
|
|
await insertFileMetadata({
|
|
id: fileId,
|
|
key,
|
|
userId,
|
|
workspaceId,
|
|
folderId,
|
|
context: 'workspace',
|
|
originalName: displayName,
|
|
contentType,
|
|
size: verifiedSize,
|
|
})
|
|
created = true
|
|
lastInsertError = undefined
|
|
break
|
|
} catch (insertError) {
|
|
lastInsertError = insertError
|
|
if (getPostgresErrorCode(insertError) === '23505') {
|
|
logger.warn(
|
|
`Unique name conflict on register (attempt ${attempt + 1}/${MAX_UPLOAD_UNIQUE_RETRIES}), retrying with a new name`
|
|
)
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!created) {
|
|
logger.error(
|
|
'Failed to insert metadata after direct upload; cleaning up storage object',
|
|
lastInsertError
|
|
)
|
|
await cleanupOrphan('metadata insert failure')
|
|
if (getPostgresErrorCode(lastInsertError) === '23505') {
|
|
throw new FileConflictError(normalizedOriginalName)
|
|
}
|
|
throw lastInsertError instanceof Error
|
|
? lastInsertError
|
|
: new Error('Failed to insert workspace file metadata')
|
|
}
|
|
|
|
try {
|
|
await incrementStorageUsage(userId, verifiedSize, workspaceId)
|
|
} catch (storageError) {
|
|
logger.error('Failed to update storage tracking:', storageError)
|
|
}
|
|
} else {
|
|
logger.info(`Using existing metadata record for direct upload: ${key}`)
|
|
}
|
|
|
|
const pathPrefix = getServePathPrefix()
|
|
const serveUrl = `${pathPrefix}${encodeURIComponent(key)}?context=workspace`
|
|
|
|
return {
|
|
file: {
|
|
id: fileId,
|
|
name: displayName,
|
|
size: verifiedSize,
|
|
type: contentType,
|
|
url: serveUrl,
|
|
key,
|
|
context: 'workspace',
|
|
},
|
|
created,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Like `withCopySuffix` but with `n=1` meaning "no suffix" — used by retry loops where
|
|
* the first attempt should try the original name (`image.png`, `image (2).png`, ...).
|
|
* Exported for tests.
|
|
*/
|
|
export function suffixedName(name: string, n: number): string {
|
|
return n <= 1 ? name : withCopySuffix(name, n)
|
|
}
|
|
|
|
const MAX_CHAT_DISPLAY_NAME_RETRIES = 1000
|
|
|
|
/** Postgres constraint name for the partial unique index on `(chat_id, display_name)`. */
|
|
export const CHAT_DISPLAY_NAME_INDEX = 'workspace_files_chat_display_name_unique'
|
|
|
|
/**
|
|
* Track a file that was already uploaded to workspace S3 as a chat-scoped upload.
|
|
* Links the existing workspaceFiles metadata record (created by the storage service
|
|
* during upload) to the chat by setting chatId and context='mothership'.
|
|
* Falls back to inserting a new record if none exists for the key.
|
|
*
|
|
* Allocates a collision-free `displayName` (the partial unique index on
|
|
* (chat_id, display_name) WHERE context='mothership' enforces this) and returns it
|
|
* so callers can surface the same name to the model in the VFS read hint.
|
|
*/
|
|
export async function trackChatUpload(
|
|
workspaceId: string,
|
|
userId: string,
|
|
chatId: string,
|
|
s3Key: string,
|
|
fileName: string,
|
|
contentType: string,
|
|
size: number
|
|
): Promise<{ displayName: string }> {
|
|
for (let n = 1; n <= MAX_CHAT_DISPLAY_NAME_RETRIES; n++) {
|
|
const candidate = suffixedName(fileName, n)
|
|
try {
|
|
const updated = await db
|
|
.update(workspaceFiles)
|
|
.set({ chatId, context: 'mothership', displayName: candidate })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.key, s3Key),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.returning({ id: workspaceFiles.id })
|
|
|
|
if (updated.length > 0) {
|
|
logger.info(
|
|
`Linked existing file record to chat: ${fileName} (display: ${candidate}) for chat ${chatId}`
|
|
)
|
|
return { displayName: candidate }
|
|
}
|
|
|
|
const fileId = `wf_${generateShortId()}`
|
|
|
|
await db.insert(workspaceFiles).values({
|
|
id: fileId,
|
|
key: s3Key,
|
|
userId,
|
|
workspaceId,
|
|
context: 'mothership',
|
|
chatId,
|
|
originalName: fileName,
|
|
displayName: candidate,
|
|
contentType,
|
|
size,
|
|
})
|
|
|
|
logger.info(`Tracked chat upload: ${fileName} (display: ${candidate}) for chat ${chatId}`)
|
|
return { displayName: candidate }
|
|
} catch (error) {
|
|
// Other 23505s (e.g. active-key collision from a racing same-s3Key insert) signal
|
|
// a different invariant — retrying would silently rename a row another caller owns.
|
|
if (
|
|
getPostgresErrorCode(error) === '23505' &&
|
|
getPostgresConstraintName(error) === CHAT_DISPLAY_NAME_INDEX
|
|
) {
|
|
logger.warn(
|
|
`Chat upload displayName collision on attempt ${n} for "${candidate}" in chat ${chatId}, retrying with suffix`
|
|
)
|
|
continue
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
throw new FileConflictError(fileName)
|
|
}
|
|
|
|
/**
|
|
* Check if a file with the same name already exists in workspace
|
|
*/
|
|
export async function fileExistsInWorkspace(
|
|
workspaceId: string,
|
|
fileName: string,
|
|
folderId?: string | null
|
|
): Promise<boolean> {
|
|
try {
|
|
return await fileNameExistsInWorkspaceFolder(workspaceId, fileName, folderId)
|
|
} catch (error) {
|
|
logger.error(`Failed to check file existence for ${fileName}:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
function mapWorkspaceFileRecord(
|
|
file: typeof workspaceFiles.$inferSelect,
|
|
workspaceId: string,
|
|
folderPaths: Map<string, string>
|
|
): WorkspaceFileRecord {
|
|
const pathPrefix = getServePathPrefix()
|
|
return {
|
|
id: file.id,
|
|
workspaceId: file.workspaceId || workspaceId,
|
|
name: file.originalName,
|
|
key: file.key,
|
|
path: `${pathPrefix}${encodeURIComponent(file.key)}?context=workspace`,
|
|
size: file.size,
|
|
type: file.contentType,
|
|
uploadedBy: file.userId,
|
|
folderId: file.folderId,
|
|
folderPath: file.folderId ? (folderPaths.get(file.folderId) ?? null) : null,
|
|
deletedAt: file.deletedAt,
|
|
uploadedAt: file.uploadedAt,
|
|
updatedAt: file.updatedAt,
|
|
}
|
|
}
|
|
|
|
async function mapSingleWorkspaceFileRecord(
|
|
file: typeof workspaceFiles.$inferSelect,
|
|
workspaceId: string
|
|
): Promise<WorkspaceFileRecord> {
|
|
if (!file.folderId) {
|
|
return mapWorkspaceFileRecord(file, workspaceId, new Map())
|
|
}
|
|
|
|
const folderPath = await getWorkspaceFileFolderPath(workspaceId, file.folderId, {
|
|
includeDeleted: true,
|
|
})
|
|
return mapWorkspaceFileRecord(
|
|
file,
|
|
workspaceId,
|
|
folderPath ? new Map([[file.folderId, folderPath]]) : new Map()
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Look up a single active workspace file by its original name.
|
|
* Returns the record if found, or null if no matching file exists.
|
|
* Throws on DB errors so callers can distinguish "not found" from "lookup failed."
|
|
*/
|
|
export async function getWorkspaceFileByName(
|
|
workspaceId: string,
|
|
fileName: string,
|
|
options?: { folderId?: string | null }
|
|
): Promise<WorkspaceFileRecord | null> {
|
|
const folderId = options?.folderId ?? null
|
|
const files = await db
|
|
.select()
|
|
.from(workspaceFiles)
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.originalName, fileName),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
folderId ? eq(workspaceFiles.folderId, folderId) : isNull(workspaceFiles.folderId),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (files.length === 0) return null
|
|
|
|
return mapSingleWorkspaceFileRecord(files[0], workspaceId)
|
|
}
|
|
|
|
/**
|
|
* List all files for a workspace
|
|
*/
|
|
export async function listWorkspaceFiles(
|
|
workspaceId: string,
|
|
options?: ListWorkspaceFilesOptions
|
|
): Promise<WorkspaceFileRecord[]> {
|
|
try {
|
|
const {
|
|
scope = 'active',
|
|
hydrateFolderPaths = true,
|
|
includeReservedSystemFiles = false,
|
|
} = options ?? {}
|
|
const files = await db
|
|
.select()
|
|
.from(workspaceFiles)
|
|
.where(
|
|
scope === 'all'
|
|
? and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
: scope === 'archived'
|
|
? and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
sql`${workspaceFiles.deletedAt} IS NOT NULL`
|
|
)
|
|
: and(
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.orderBy(workspaceFiles.uploadedAt)
|
|
|
|
const needsFolderPaths =
|
|
files.some((file) => file.folderId) && (hydrateFolderPaths || !includeReservedSystemFiles)
|
|
const folders = needsFolderPaths
|
|
? includeReservedSystemFiles && options?.folders
|
|
? options.folders
|
|
: await listWorkspaceFileFolders(workspaceId, {
|
|
scope: 'all',
|
|
includeReservedSystemFolders: true,
|
|
})
|
|
: []
|
|
const folderPaths = needsFolderPaths ? buildWorkspaceFileFolderPathMap(folders) : new Map()
|
|
|
|
return files
|
|
.map((file) => mapWorkspaceFileRecord(file, workspaceId, folderPaths))
|
|
.filter((file) => {
|
|
if (includeReservedSystemFiles) return true
|
|
return !isReservedWorkflowAliasBackingDisplayPath(file.folderPath)
|
|
})
|
|
} catch (error) {
|
|
logger.error(`Failed to list workspace files for ${workspaceId}:`, error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize a workspace file reference to either a display name or canonical file ID.
|
|
* Supports raw IDs, `files/{name}`, `files/{name}/content`, and `files/{name}/meta.json`.
|
|
* Files are addressed by their sanitized canonical path; id-based VFS paths are not supported.
|
|
*/
|
|
export function normalizeWorkspaceFileReference(fileReference: string): string {
|
|
const trimmed = fileReference.trim().replace(/^\/+/, '')
|
|
const withoutDeletedPrefix = trimmed.startsWith('recently-deleted/')
|
|
? trimmed.slice('recently-deleted/'.length)
|
|
: trimmed
|
|
|
|
if (withoutDeletedPrefix.startsWith('files/')) {
|
|
const withoutPrefix = withoutDeletedPrefix.slice('files/'.length)
|
|
if (withoutPrefix.endsWith('/meta.json')) {
|
|
return decodeVfsPathSegments(withoutPrefix.slice(0, -'/meta.json'.length)).join('/')
|
|
}
|
|
if (withoutPrefix.endsWith('/content')) {
|
|
return decodeVfsPathSegments(withoutPrefix.slice(0, -'/content'.length)).join('/')
|
|
}
|
|
return decodeVfsPathSegments(withoutPrefix).join('/')
|
|
}
|
|
|
|
return decodeVfsPathSegments(withoutDeletedPrefix).join('/')
|
|
}
|
|
|
|
/**
|
|
* Canonical sandbox mount path for an existing workspace file.
|
|
*/
|
|
export function getSandboxWorkspaceFilePath(
|
|
file: Pick<WorkspaceFileRecord, 'folderPath' | 'name'>
|
|
): string {
|
|
return `/home/user/${canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name })}`
|
|
}
|
|
|
|
/**
|
|
* Find a workspace file record in an existing list from either its id or a VFS/name reference.
|
|
* For copilot `open_resource` and the resource panel, use {@link getWorkspaceFile} with the file id.
|
|
*/
|
|
export function findWorkspaceFileRecord(
|
|
files: WorkspaceFileRecord[],
|
|
fileReference: string
|
|
): WorkspaceFileRecord | null {
|
|
const exactIdMatch = files.find((file) => file.id === fileReference)
|
|
if (exactIdMatch) {
|
|
return exactIdMatch
|
|
}
|
|
|
|
const normalizedReference = normalizeWorkspaceFileReference(fileReference)
|
|
const normalizedIdMatch = files.find((file) => file.id === normalizedReference)
|
|
if (normalizedIdMatch) {
|
|
return normalizedIdMatch
|
|
}
|
|
|
|
const segmentKey = normalizedReference
|
|
.split('/')
|
|
.map((segment) => normalizeVfsSegment(segment))
|
|
.join('/')
|
|
const normalizedPathMatch = files.find((file) => {
|
|
const folderPath = file.folderPath
|
|
?.split('/')
|
|
.map((segment) => normalizeVfsSegment(segment))
|
|
.join('/')
|
|
const fullPath = folderPath
|
|
? `${folderPath}/${normalizeVfsSegment(file.name)}`
|
|
: normalizeVfsSegment(file.name)
|
|
return fullPath === segmentKey
|
|
})
|
|
if (normalizedPathMatch) return normalizedPathMatch
|
|
|
|
return files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null
|
|
}
|
|
|
|
async function getWorkspaceFileByExactReference(
|
|
workspaceId: string,
|
|
fileReference: string
|
|
): Promise<WorkspaceFileRecord | null> {
|
|
const segments = fileReference
|
|
.split('/')
|
|
.map((segment) => segment.trim())
|
|
.filter(Boolean)
|
|
|
|
if (segments.length === 0) return null
|
|
if (segments.length === 1) {
|
|
return getWorkspaceFileByName(workspaceId, segments[0], { folderId: null })
|
|
}
|
|
|
|
const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1), {
|
|
includeReservedSystemFolders: true,
|
|
})
|
|
return folderId ? getWorkspaceFileByName(workspaceId, segments.at(-1) ?? '', { folderId }) : null
|
|
}
|
|
|
|
/**
|
|
* Resolve a workspace file record from either its id or a VFS/name reference.
|
|
*/
|
|
export async function resolveWorkspaceFileReference(
|
|
workspaceId: string,
|
|
fileReference: string
|
|
): Promise<WorkspaceFileRecord | null> {
|
|
const alias = await resolveWorkflowAliasForWorkspace({ workspaceId, path: fileReference })
|
|
if (alias) {
|
|
if (alias.kind === 'plans_dir') return null
|
|
return resolveWorkspaceFileReference(workspaceId, alias.backingPath)
|
|
}
|
|
|
|
const normalizedReference = normalizeWorkspaceFileReference(fileReference)
|
|
if (normalizedReference.startsWith('wf_')) {
|
|
const file = await getWorkspaceFile(workspaceId, normalizedReference)
|
|
if (file) return file
|
|
}
|
|
|
|
const exactReferenceFile = await getWorkspaceFileByExactReference(
|
|
workspaceId,
|
|
normalizedReference
|
|
)
|
|
if (exactReferenceFile) return exactReferenceFile
|
|
|
|
const files = await listWorkspaceFiles(workspaceId, { includeReservedSystemFiles: true })
|
|
return findWorkspaceFileRecord(files, fileReference)
|
|
}
|
|
|
|
/**
|
|
* Get a specific workspace file
|
|
*/
|
|
export async function getWorkspaceFile(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
options?: { includeDeleted?: boolean }
|
|
): Promise<WorkspaceFileRecord | null> {
|
|
try {
|
|
const { includeDeleted = false } = options ?? {}
|
|
const files = await db
|
|
.select()
|
|
.from(workspaceFiles)
|
|
.where(
|
|
includeDeleted
|
|
? and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
: and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
.limit(1)
|
|
|
|
if (files.length === 0) return null
|
|
|
|
return mapSingleWorkspaceFileRecord(files[0], workspaceId)
|
|
} catch (error) {
|
|
logger.error(`Failed to get workspace file ${fileId}:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download workspace file content
|
|
*/
|
|
export async function fetchWorkspaceFileBuffer(fileRecord: WorkspaceFileRecord): Promise<Buffer> {
|
|
logger.info(`Downloading workspace file: ${fileRecord.name}`)
|
|
|
|
try {
|
|
const buffer = await downloadFile({
|
|
key: fileRecord.key,
|
|
context: fileRecord.storageContext ?? 'workspace',
|
|
})
|
|
logger.info(
|
|
`Successfully downloaded workspace file: ${fileRecord.name} (${buffer.length} bytes)`
|
|
)
|
|
return buffer
|
|
} catch (error) {
|
|
logger.error(`Failed to download workspace file ${fileRecord.name}:`, error)
|
|
throw new Error(`Failed to download file: ${getErrorMessage(error, 'Unknown error')}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a workspace file's content (re-uploads to same storage key)
|
|
*/
|
|
export async function updateWorkspaceFileContent(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
userId: string,
|
|
content: Buffer,
|
|
contentType?: string
|
|
): Promise<WorkspaceFileRecord> {
|
|
logger.info(`Updating workspace file content: ${fileId} for workspace ${workspaceId}`)
|
|
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
const sizeDiff = content.length - fileRecord.size
|
|
if (sizeDiff > 0) {
|
|
const quotaCheck = await checkStorageQuota(userId, sizeDiff)
|
|
if (!quotaCheck.allowed) {
|
|
throw new Error(quotaCheck.error || 'Storage limit exceeded')
|
|
}
|
|
}
|
|
|
|
const nextContentType = contentType || fileRecord.type
|
|
|
|
try {
|
|
const metadata: Record<string, string> = {
|
|
originalName: fileRecord.name,
|
|
uploadedAt: new Date().toISOString(),
|
|
purpose: 'workspace',
|
|
userId,
|
|
workspaceId,
|
|
...(fileRecord.folderId ? { folderId: fileRecord.folderId } : {}),
|
|
}
|
|
|
|
await uploadFile({
|
|
file: content,
|
|
fileName: fileRecord.key,
|
|
contentType: nextContentType,
|
|
context: 'workspace',
|
|
preserveKey: true,
|
|
customKey: fileRecord.key,
|
|
metadata,
|
|
})
|
|
|
|
await db
|
|
.update(workspaceFiles)
|
|
.set({ size: content.length, contentType: nextContentType, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
)
|
|
|
|
if (sizeDiff !== 0) {
|
|
try {
|
|
if (sizeDiff > 0) {
|
|
await incrementStorageUsage(userId, sizeDiff, workspaceId)
|
|
} else {
|
|
await decrementStorageUsage(userId, Math.abs(sizeDiff), workspaceId)
|
|
}
|
|
} catch (storageError) {
|
|
logger.error(`Failed to update storage tracking:`, storageError)
|
|
}
|
|
}
|
|
|
|
logger.info(`Successfully updated workspace file content: ${fileRecord.name}`)
|
|
|
|
return {
|
|
...fileRecord,
|
|
size: content.length,
|
|
type: nextContentType,
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to update workspace file content ${fileId}:`, error)
|
|
throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rename a workspace file (updates the display name in the database)
|
|
*/
|
|
export async function renameWorkspaceFile(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
newName: string
|
|
): Promise<WorkspaceFileRecord> {
|
|
logger.info(`Renaming workspace file: ${fileId} to "${newName}" in workspace ${workspaceId}`)
|
|
|
|
const trimmedName = newName.trim()
|
|
const normalizedName = normalizeWorkspaceFileItemName(trimmedName, 'File')
|
|
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
if (fileRecord.name === normalizedName) {
|
|
return fileRecord
|
|
}
|
|
|
|
const exists = await fileExistsInWorkspace(workspaceId, normalizedName, fileRecord.folderId)
|
|
if (exists) {
|
|
throw new FileConflictError(normalizedName)
|
|
}
|
|
|
|
let updated: { id: string }[]
|
|
try {
|
|
updated = await db
|
|
.update(workspaceFiles)
|
|
.set({ originalName: normalizedName, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
)
|
|
.returning({ id: workspaceFiles.id })
|
|
} catch (error: unknown) {
|
|
if (getPostgresErrorCode(error) === '23505') {
|
|
throw new FileConflictError(normalizedName)
|
|
}
|
|
throw error
|
|
}
|
|
|
|
if (updated.length === 0) {
|
|
throw new Error('File not found or could not be renamed')
|
|
}
|
|
|
|
logger.info(`Successfully renamed workspace file ${fileId} to "${normalizedName}"`)
|
|
|
|
return {
|
|
...fileRecord,
|
|
name: normalizedName,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Soft delete a workspace file.
|
|
*/
|
|
export async function deleteWorkspaceFile(workspaceId: string, fileId: string): Promise<void> {
|
|
logger.info(`Deleting workspace file: ${fileId}`)
|
|
|
|
try {
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId)
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
await db
|
|
.update(workspaceFiles)
|
|
.set({ deletedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace'),
|
|
isNull(workspaceFiles.deletedAt)
|
|
)
|
|
)
|
|
|
|
logger.info(`Successfully archived workspace file: ${fileRecord.name}`)
|
|
} catch (error) {
|
|
logger.error(`Failed to delete workspace file ${fileId}:`, error)
|
|
throw new Error(`Failed to delete file: ${getErrorMessage(error, 'Unknown error')}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore a soft-deleted workspace file.
|
|
*/
|
|
export async function restoreWorkspaceFile(workspaceId: string, fileId: string): Promise<void> {
|
|
logger.info(`Restoring workspace file: ${fileId}`)
|
|
|
|
const fileRecord = await getWorkspaceFile(workspaceId, fileId, { includeDeleted: true })
|
|
if (!fileRecord) {
|
|
throw new Error('File not found')
|
|
}
|
|
|
|
if (!fileRecord.deletedAt) {
|
|
throw new Error('File is not archived')
|
|
}
|
|
|
|
const ws = await getWorkspaceWithOwner(workspaceId)
|
|
if (!ws || ws.archivedAt) {
|
|
throw new Error('Cannot restore file into an archived workspace')
|
|
}
|
|
|
|
/**
|
|
* A concurrent upload/rename can claim the chosen name after `generateRestoreName`'s check (MVCC).
|
|
* Retries pick a new random suffix; 23505 maps to {@link FileConflictError} after exhaustion.
|
|
*/
|
|
const maxUniqueViolationRetries = 8
|
|
let attemptedRestoreName = ''
|
|
|
|
for (let attempt = 0; attempt < maxUniqueViolationRetries; attempt++) {
|
|
attemptedRestoreName = ''
|
|
try {
|
|
const newName = await generateRestoreName(
|
|
fileRecord.name,
|
|
(candidate) => fileExistsInWorkspace(workspaceId, candidate, null),
|
|
{ hasExtension: true }
|
|
)
|
|
attemptedRestoreName = newName
|
|
|
|
await db
|
|
.update(workspaceFiles)
|
|
.set({ deletedAt: null, folderId: null, originalName: newName, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(workspaceFiles.id, fileId),
|
|
eq(workspaceFiles.workspaceId, workspaceId),
|
|
eq(workspaceFiles.context, 'workspace')
|
|
)
|
|
)
|
|
|
|
logger.info(`Successfully restored workspace file: ${newName}`)
|
|
return
|
|
} catch (error: unknown) {
|
|
if (getPostgresErrorCode(error) !== '23505') {
|
|
throw error
|
|
}
|
|
if (attempt === maxUniqueViolationRetries - 1) {
|
|
throw new FileConflictError(attemptedRestoreName || fileRecord.name)
|
|
}
|
|
}
|
|
}
|
|
}
|