Files
orca/src/main/ipc/filesystem-mutations.ts
T
Jinjing b8554f1c59 fix(composer): clarify failed attachment drops (#20704)
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape

* fix(composer): name the attachments a drop could not add, in one toast

* fix(composer, source-control): use one stable failure toast slot

- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling

* refactor: centralize filesystem import types and clarify failure naming

Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.

* Reuse single toast slot for composer drop failures

Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
2026-09-14 15:22:05 -07:00

315 lines
12 KiB
TypeScript

import { app, ipcMain } from 'electron'
import { constants } from 'node:fs'
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import type { Store } from '../persistence'
import { resolveAuthorizedPath } from './filesystem-auth'
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
import { importExternalPathsSsh } from './filesystem-import-ssh'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
import { assertNotExists, rethrowWithUserMessage } from './filesystem-create-path-guards'
import type {
ImportItemResult,
ImportSkipReason,
ResolveDroppedPathsResult,
StagedExternalImportSource
} from '../../shared/filesystem-import-result-types'
import { importOneSource } from './filesystem-import-local'
import {
stagedRuntimeUploadByteLength,
stageOneSourceForRuntimeUpload
} from './filesystem-runtime-upload-staging'
import { streamExternalFileToRuntime } from './runtime-upload-file-stream'
import { abortWhenRendererGone } from './renderer-lifetime-abort'
import { sweepAbandonedRuntimeUploadTempPath } from './runtime-upload-temp-sweep'
import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
/**
* IPC handlers for file/folder creation and renaming.
* Deletion is handled separately via `fs:deletePath` (shell.trashItem).
*/
export function registerFilesystemMutationHandlers(store: Store): void {
ipcMain.handle(
'fs:createFile',
async (
_event,
args: { filePath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.createFile(args.filePath)
}
const filePath = await resolveAuthorizedPath(args.filePath, store)
await mkdir(dirname(filePath), { recursive: true })
try {
// Use the 'wx' flag for atomic create-if-not-exists, avoiding TOCTOU races
await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' })
} catch (error) {
rethrowWithUserMessage(error, filePath)
}
}
)
ipcMain.handle(
'fs:createDir',
async (
_event,
args: { dirPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.createDir(args.dirPath)
}
const dirPath = await resolveAuthorizedPath(args.dirPath, store)
await assertNotExists(dirPath)
await mkdir(dirPath, { recursive: true })
}
)
// Note: fs.rename throws EXDEV if old and new paths are on different
// filesystems/volumes. This is unlikely since both paths are under the same
// workspace root, but a cross-drive rename would surface as an IPC error.
ipcMain.handle(
'fs:rename',
async (
_event,
args: { oldPath: string; newPath: string; connectionId?: string } & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.renameNoClobber(args.oldPath, args.newPath)
}
// Why: rename() operates on directory entries, not file contents. If
// oldPath is a symlink, we must rename the link itself rather than
// resolving it to its target — following the link would rename the
// target file (potentially elsewhere in the worktree) and leave the
// symlink dangling. newPath must also preserve its leaf so we don't
// accidentally write into a symlinked destination name.
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
await renameLocalPathSerializedByDestination(oldPath, newPath)
}
)
ipcMain.handle(
'fs:copy',
async (
_event,
args: {
sourcePath: string
destinationPath: string
connectionId?: string
} & SshMutationExpectation
): Promise<void> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
const provider = requireSshFilesystemProvider(args.connectionId)
return provider.copy(args.sourcePath, args.destinationPath)
}
const sourcePath = await resolveAuthorizedPath(args.sourcePath, store, {
preserveSymlink: true
})
const destinationPath = await resolveAuthorizedPath(args.destinationPath, store, {
preserveSymlink: true
})
await mkdir(dirname(destinationPath), { recursive: true })
// Why: duplicate/copy callers deconflict before copying. COPYFILE_EXCL
// keeps a late race from silently overwriting an existing file.
await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL)
}
)
ipcMain.handle(
'fs:importExternalPaths',
async (
_event,
args: {
sourcePaths: string[]
destDir: string
connectionId?: string
ensureDir?: boolean
} & SshMutationExpectation
): Promise<{ results: ImportItemResult[] }> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
if (args.connectionId) {
return importExternalPathsSsh(args.sourcePaths, args.destDir, args.connectionId, {
ensureDir: args.ensureDir,
assertCurrent: () =>
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
})
}
// Why: destDir must be authorized before any copy work begins. If the
// destination is outside allowed roots, the entire import fails.
// This only applies to local imports — remote paths are authorized by
// the SSH connection boundary (see importExternalPathsSsh).
const resolvedDest = await resolveAuthorizedPath(args.destDir, store)
const results: ImportItemResult[] = []
const reservedNames = new Set<string>()
for (const sourcePath of args.sourcePaths) {
const result = await importOneSource(sourcePath, resolvedDest, reservedNames)
results.push(result)
if (result.status === 'imported') {
reservedNames.add(basename(result.destPath))
}
}
return { results }
}
)
ipcMain.handle(
'fs:stageExternalPathsForRuntimeUpload',
async (
_event,
args: { sourcePaths: string[] }
): Promise<{ sources: StagedExternalImportSource[] }> => {
const sources: StagedExternalImportSource[] = []
// Why: one budget for the whole drop — per-source counters would let five
// 2 GB files through a ceiling meant to cap the drop.
let totalBytes = 0
for (const sourcePath of args.sourcePaths) {
const source = await stageOneSourceForRuntimeUpload(sourcePath, totalBytes)
totalBytes += stagedRuntimeUploadByteLength(source)
sources.push(source)
}
return { sources }
}
)
// Why: the file handle and the runtime socket both live in main, so the byte
// pump runs here. The renderer keeps deconflict/commit/rollback orchestration
// and never sees file contents.
ipcMain.handle(
'fs:uploadExternalFileToRuntime',
async (event, args: RuntimeUploadFileStreamRequest): Promise<{ byteLength: number }> => {
const userDataPath = app.getPath('userData')
// Why: the streamer's manual-disconnect check keys on the environment id,
// and the renderer may pass any selector the store resolves.
const request = {
...args,
environmentId: resolveEnvironment(userDataPath, args.environmentId).id
}
// Why: the renderer's own loop died with its window. Now that the bytes
// move in main, a reload or close has to stop the transfer explicitly,
// or a multi-GB upload outlives the window that asked for it.
const lifetime = abortWhenRendererGone(event.sender)
try {
return await streamExternalFileToRuntime({
...request,
userDataPath,
signal: lifetime.signal
})
} catch (error) {
if (lifetime.signal.aborted) {
// Why: the renderer owns temp cleanup, and it is gone — so the
// abandoned temp path is only collectable from here.
await sweepAbandonedRuntimeUploadTempPath(userDataPath, request)
}
throw error
} finally {
lifetime.dispose()
}
}
)
// Why: terminal drag-and-drop resolver. Local worktrees pass paths through
// unchanged (reference-in-place; preserves zero-latency drop). SSH worktrees
// upload each path into `${worktreePath}/.orca/drops/` and return remote
// paths the remote agent can read. Kept as a separate IPC from
// fs:importExternalPaths because terminal semantics differ from the
// explorer's "copy into user-picked destDir". See docs/terminal-drop-ssh.md.
ipcMain.handle(
'fs:resolveDroppedPathsForAgent',
async (
_event,
args: {
paths: string[]
worktreePath: string
connectionId?: string
} & SshMutationExpectation
): Promise<ResolveDroppedPathsResult> => {
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
// Why: `== null` (not `!args.connectionId`) so an empty string is
// treated as a renderer error, not silently routed to the local branch.
if (args.connectionId == null) {
return {
resolvedPaths: resolveLocalDroppedPathsForAgent(args.paths, args.worktreePath),
skipped: [],
failed: []
}
}
const worktreePath = args.worktreePath.replace(/\/+$/, '')
const destDir = `${worktreePath}/.orca/drops`
const { results } = await importExternalPathsSsh(args.paths, destDir, args.connectionId, {
ensureDir: true,
assertCurrent: () =>
assertSshMutationExpectation(
args.connectionId,
args.expectedSshTargetId,
args.expectedSshConnectionGeneration,
args.expectedExecutionHostId
)
})
const resolvedPaths: string[] = []
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []
const failed: { sourcePath: string; reason: string }[] = []
// Iterate in input order so injected paths align with the user's drop order.
for (const r of results) {
if (r.status === 'imported') {
resolvedPaths.push(r.destPath)
} else if (r.status === 'skipped') {
skipped.push({ sourcePath: r.sourcePath, reason: r.reason })
} else {
failed.push({ sourcePath: r.sourcePath, reason: r.reason })
}
}
return { resolvedPaths, skipped, failed }
}
)
}