diff --git a/src/main/codex-accounts/fs-utils-remove-retry.test.ts b/src/main/codex-accounts/fs-utils-remove-retry.test.ts new file mode 100644 index 00000000000..aee362f9d10 --- /dev/null +++ b/src/main/codex-accounts/fs-utils-remove-retry.test.ts @@ -0,0 +1,60 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fsPromises = vi.hoisted(() => ({ rm: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + fsPromises.rm.mockImplementation(actual.rm) + return { ...actual, rm: fsPromises.rm } +}) + +import { removeFileWithWindowsRetryAsync } from './fs-utils' + +const originalPlatform = process.platform +const dirs: string[] = [] + +function fileInTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-fs-remove-retry-')) + dirs.push(dir) + const file = join(dir, 'draft.json') + writeFileSync(file, '{}') + return file +} + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + fsPromises.rm.mockClear() + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +describe('removeFileWithWindowsRetryAsync', () => { + // An antivirus scan holding the file open must not fail the delete (#1507). + it('retries a Windows sharing violation', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const file = fileInTempDir() + fsPromises.rm.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' })) + + await removeFileWithWindowsRetryAsync(file) + + expect(fsPromises.rm).toHaveBeenCalledTimes(2) + expect(existsSync(file)).toBe(false) + }) + + it('treats a missing file as deleted', async () => { + const file = fileInTempDir() + await removeFileWithWindowsRetryAsync(file) + await expect(removeFileWithWindowsRetryAsync(file)).resolves.toBeUndefined() + }) + + it('does not retry off Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }) + fsPromises.rm.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' })) + + await expect(removeFileWithWindowsRetryAsync(fileInTempDir())).rejects.toThrow('busy') + expect(fsPromises.rm).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 295dfac9b66..3d599a0671d 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto' import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { rename } from 'node:fs/promises' +import { rename, rm } from 'node:fs/promises' import { dirname } from 'node:path' import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' @@ -224,6 +224,21 @@ export async function renameFileWithWindowsRetryAsync( } } +/** Deletes `target`, treating a missing file as deleted; retries like the rename above (#1507). */ +export async function removeFileWithWindowsRetryAsync(target: string): Promise { + for (let attempt = 1; ; attempt++) { + try { + await rm(target, { force: true }) + return + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + export function copyFileWithWindowsRetry(source: string, target: string): void { runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) } diff --git a/src/main/durable-file-write-process.test.ts b/src/main/durable-file-write-process.test.ts new file mode 100644 index 00000000000..ca8bd5ea840 --- /dev/null +++ b/src/main/durable-file-write-process.test.ts @@ -0,0 +1,53 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fsPromises = vi.hoisted(() => ({ rm: vi.fn(), rename: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + fsPromises.rm.mockImplementation(actual.rm) + fsPromises.rename.mockImplementation(actual.rename) + return { ...actual, rm: fsPromises.rm, rename: fsPromises.rename } +}) + +import { durableWriteTempPath, writeFileProcessDurable } from './durable-file-write' + +const dirs: string[] = [] + +function tempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-process-durable-')) + dirs.push(dir) + return dir +} + +afterEach(() => { + fsPromises.rm.mockClear() + fsPromises.rename.mockClear() + dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true })) +}) + +describe('writeFileProcessDurable', () => { + // The temp file is gone once renamed; removing it again is a wasted file-system call. + it('renames the temp file into place without removing it afterwards', async () => { + const file = join(tempDir(), 'draft.json') + + await writeFileProcessDurable(durableWriteTempPath(file), file, '{"a":1}') + + expect(readFileSync(file, 'utf8')).toBe('{"a":1}') + expect(fsPromises.rm).not.toHaveBeenCalled() + }) + + it('removes the temp file when the rename fails', async () => { + const dir = tempDir() + const file = join(dir, 'draft.json') + fsPromises.rename.mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' })) + + await expect( + writeFileProcessDurable(durableWriteTempPath(file), file, '{"a":1}') + ).rejects.toThrow('denied') + expect(readdirSync(dir)).toEqual([]) + }) +}) diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 05e849327b7..96c04c43f7d 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,7 +4,7 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { copyFile, open, readdir, rm, stat } from 'node:fs/promises' +import { copyFile, open, readdir, rm, stat, writeFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { publishFileWithoutOverwrite, @@ -165,6 +165,26 @@ export async function writeFileDurableIfCurrent( } } +/** + * Write to a temp file and rename it over `finalPath`, with no fsync. Survives the process being + * killed once it returns, since the rename sits in the page cache; a power loss can still revert it. + */ +export async function writeFileProcessDurable( + tmpPath: string, + finalPath: string, + payload: string +): Promise { + let renamed = false + try { + await writeFile(tmpPath, payload, { mode: 0o600 }) + renamed = await renameFileWithWindowsRetryAsync(tmpPath, finalPath) + } finally { + if (!renamed) { + await rm(tmpPath, { force: true }).catch(() => {}) + } + } +} + /** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */ export function durableWriteTempPath(finalPath: string): string { return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp` diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index 620baa9b595..2567971b815 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -3,6 +3,7 @@ import { realpathSync } from 'node:fs' import { realpath } from 'node:fs/promises' import type { Store } from '../persistence' import { getAllowedRoots } from './filesystem-allowed-roots' +import { getNativeChatAttachmentAllowedRoots } from '../window/native-chat-attachment-store' import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment' import { ensureAuthorizedRootsCache, @@ -54,7 +55,12 @@ type AllowedRootsSnapshot = { get: () => readonly string[] } function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot { let roots: readonly string[] | undefined - return { get: () => (roots ??= getAllowedRoots(store)) } + return { get: () => (roots ??= getAuthorizedRoots(store)) } +} + +/** Workspace roots, plus Orca's own storage for chat draft images so a relaunch can read them. */ +function getAuthorizedRoots(store: Store): string[] { + return [...getAllowedRoots(store), ...getNativeChatAttachmentAllowedRoots()] } export function isPathAllowed( @@ -71,7 +77,7 @@ export function isPathAllowed( return true } } - return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) => + return (allowedRoots?.get() ?? getAuthorizedRoots(store)).some((root) => isDescendantOrEqual(resolvedTarget, root) ) } @@ -223,7 +229,7 @@ async function isPathAllowedByCanonicalAllowedRoot( if (!sourcePath) { return false } - for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) { + for (const root of allowedRoots?.get() ?? getAuthorizedRoots(store)) { const resolvedRoot = resolve(root) if (!isDescendantOrEqual(sourcePath, resolvedRoot)) { continue diff --git a/src/main/ipc/native-chat-drafts.test.ts b/src/main/ipc/native-chat-drafts.test.ts new file mode 100644 index 00000000000..29d08949166 --- /dev/null +++ b/src/main/ipc/native-chat-drafts.test.ts @@ -0,0 +1,129 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SidecarSnapshotFile from '../sidecar-snapshot-file' + +const { handlers, listeners, ipcMainMock, isTrustedUIRendererMock } = vi.hoisted(() => { + const handlerMap = new Map unknown>() + const listenerMap = new Map void>() + return { + handlers: handlerMap, + listeners: listenerMap, + ipcMainMock: { + removeHandler: vi.fn(), + removeAllListeners: vi.fn(), + handle: (channel: string, fn: (...args: unknown[]) => unknown) => handlerMap.set(channel, fn), + on: (channel: string, fn: (...args: unknown[]) => void) => listenerMap.set(channel, fn) + }, + isTrustedUIRendererMock: vi.fn(() => true) + } +}) + +const sidecar = vi.hoisted(() => ({ removeSidecarSnapshot: vi.fn() })) + +vi.mock('../sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal() + sidecar.removeSidecarSnapshot.mockImplementation(actual.removeSidecarSnapshot) + return { ...actual, removeSidecarSnapshot: sidecar.removeSidecarSnapshot } +}) +vi.mock('electron', () => ({ ipcMain: ipcMainMock })) +vi.mock('./ui', () => ({ isTrustedUIRenderer: isTrustedUIRendererMock })) + +import { drainNativeChatDrafts, registerNativeChatDraftHandlers } from './native-chat-drafts' + +const trusted = { sender: { id: 1 } } +const draft = { text: 'hello', attachments: [] } +let tempRoot = '' +let root = '' + +function invoke(channel: string, event: unknown, args?: unknown): unknown { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`no handler for ${channel}`) + } + return handler(event, args) +} + +function loadSync(event: { sender: { id: number } }): unknown { + const reply: { sender: { id: number }; returnValue?: unknown } = { ...event } + listeners.get('nativeChat:drafts:loadSync')?.(reply) + return reply.returnValue +} + +beforeEach(async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'orca-native-chat-draft-ipc-')) + root = join(tempRoot, 'native-chat-drafts') + isTrustedUIRendererMock.mockReturnValue(true) + registerNativeChatDraftHandlers(root) +}) + +afterEach(async () => { + await drainNativeChatDrafts() + await rm(tempRoot, { recursive: true, force: true }) +}) + +describe('native chat draft IPC', () => { + it('saves, loads and clears a draft for the app window', async () => { + await expect( + invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft }) + ).resolves.toBe('persisted') + await expect(invoke('nativeChat:drafts:load', trusted)).resolves.toEqual([ + { scopeKey: 'session:s1', draft } + ]) + expect(loadSync(trusted)).toEqual([{ scopeKey: 'session:s1', draft }]) + + await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft: null }) + expect(await readdir(root)).toEqual([]) + }) + + it('refuses every other renderer', async () => { + await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft }) + isTrustedUIRendererMock.mockReturnValue(false) + const other = { sender: { id: 2 } } + + expect(await invoke('nativeChat:drafts:write', other, { scopeKey: 'session:s2', draft })).toBe( + 'failed' + ) + expect( + await invoke('nativeChat:drafts:write', other, { scopeKey: 'session:s1', draft: null }) + ).toBe('failed') + expect(await invoke('nativeChat:drafts:load', other)).toEqual([]) + expect(loadSync(other)).toEqual([]) + isTrustedUIRendererMock.mockReturnValue(true) + expect(await invoke('nativeChat:drafts:load', trusted)).toEqual([ + { scopeKey: 'session:s1', draft } + ]) + }) + + it('refuses a write without a chat', async () => { + expect(await invoke('nativeChat:drafts:write', trusted, { draft })).toBe('failed') + expect(await invoke('nativeChat:drafts:write', trusted, null)).toBe('failed') + }) + + // macOS keeps Orca running with no window; reopening one registers the handlers again. + it('keeps the same store when the window is reopened, so a failed clear is still owed', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft }) + sidecar.removeSidecarSnapshot.mockRejectedValueOnce( + Object.assign(new Error('denied'), { code: 'EACCES' }) + ) + expect( + await invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft: null }) + ).toBe('failed') + + registerNativeChatDraftHandlers(root) + + expect(await invoke('nativeChat:drafts:load', trusted)).toEqual([]) + await drainNativeChatDrafts() + expect(await readdir(root)).toEqual([]) + }) + + it('lets a draft written just before quitting land before the app exits', async () => { + void invoke('nativeChat:drafts:write', trusted, { scopeKey: 'session:s1', draft }) + + await drainNativeChatDrafts() + + expect((await readdir(root)).filter((name) => name.endsWith('.json'))).toHaveLength(1) + }) +}) diff --git a/src/main/ipc/native-chat-drafts.ts b/src/main/ipc/native-chat-drafts.ts new file mode 100644 index 00000000000..9a7e71af2c6 --- /dev/null +++ b/src/main/ipc/native-chat-drafts.ts @@ -0,0 +1,51 @@ +import { ipcMain } from 'electron' +import { + parseNativeChatDraft, + type NativeChatDraftStoreResult, + type SavedNativeChatDraft +} from '../../shared/native-chat-draft-record' +import { + createNativeChatDraftStore, + type NativeChatDraftStore +} from '../native-chat/native-chat-draft-store' +import { isTrustedUIRenderer } from './ui' + +// Why one per root: macOS keeps the app alive with no window, and a reopened window must find the +// same store, with its failed writes still owed, rather than a second one reading the files anew. +const stores = new Map() + +/** Drafts are the app window's own; no other renderer may read or write them. */ +export function registerNativeChatDraftHandlers(root: string): void { + const drafts = stores.get(root) ?? createNativeChatDraftStore(root) + stores.set(root, drafts) + ipcMain.removeHandler('nativeChat:drafts:load') + ipcMain.removeHandler('nativeChat:drafts:write') + ipcMain.removeAllListeners('nativeChat:drafts:loadSync') + ipcMain.handle( + 'nativeChat:drafts:load', + (event): Promise | SavedNativeChatDraft[] => + isTrustedUIRenderer(event.sender) ? drafts.load() : [] + ) + ipcMain.on('nativeChat:drafts:loadSync', (event) => { + event.returnValue = isTrustedUIRenderer(event.sender) ? drafts.loadSync() : [] + }) + ipcMain.handle( + 'nativeChat:drafts:write', + (event, args: unknown): Promise | NativeChatDraftStoreResult => { + if (!isTrustedUIRenderer(event.sender) || typeof args !== 'object' || args === null) { + return 'failed' + } + const scopeKey = 'scopeKey' in args ? args.scopeKey : undefined + if (typeof scopeKey !== 'string' || scopeKey === '') { + return 'failed' + } + // A cleared draft is null; anything unreadable is treated as cleared, never written. + return drafts.write(scopeKey, 'draft' in args ? parseNativeChatDraft(args.draft) : null) + } + ) +} + +/** Lets drafts written just before quitting land before the app exits. */ +export async function drainNativeChatDrafts(): Promise { + await Promise.all(Array.from(stores.values(), (drafts) => drafts.drain())) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 4e67441ea34..d878829f757 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -65,6 +65,7 @@ const { registerWorkspacePortHandlersMock, registerLocalhostWorktreeLabelHandlersMock, registerNativeChatHandlersMock, + registerNativeChatDraftHandlersMock, registerEmulatorFrameStreamHandlersMock, registerEmulatorVideoStreamHandlersMock } = vi.hoisted(() => ({ @@ -132,6 +133,7 @@ const { registerWorkspacePortHandlersMock: vi.fn(), registerLocalhostWorktreeLabelHandlersMock: vi.fn(), registerNativeChatHandlersMock: vi.fn(), + registerNativeChatDraftHandlersMock: vi.fn(), registerEmulatorFrameStreamHandlersMock: vi.fn(), registerEmulatorVideoStreamHandlersMock: vi.fn() })) @@ -394,6 +396,10 @@ vi.mock('../native-chat', () => ({ registerNativeChatHandlers: registerNativeChatHandlersMock })) +vi.mock('../native-chat-drafts', () => ({ + registerNativeChatDraftHandlers: registerNativeChatDraftHandlersMock +})) + import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { recordStructuredAgentSessionHostInstallRefusal } from '../../runtime/structured-agent-session-host-refusal' import { registerCoreHandlers } from './register-core-handlers' @@ -586,6 +592,9 @@ describe('registerCoreHandlers', () => { ) expect(aiVaultOptions.getActiveRuntimeAiVaultHostInfos()).toEqual([]) expect(registerNativeChatHandlersMock).toHaveBeenCalled() + expect(registerNativeChatDraftHandlersMock).toHaveBeenCalledWith( + expect.stringContaining('native-chat-drafts') + ) expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalledWith(store) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index d3a647cafd7..113754b8046 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -1,4 +1,5 @@ import { app } from 'electron' +import { join } from 'node:path' import { registerAppHandlers } from '../app' import { registerCliHandlers } from '../cli' import { registerPreflightHandlers } from '../preflight' @@ -27,6 +28,7 @@ import { registerEphemeralVmHandlers } from '../ephemeral-vm' import { registerAiVaultHandlers } from '../ai-vault' import { registerAiVaultSearchHandlers } from '../ai-vault-search' import { registerNativeChatHandlers } from '../native-chat' +import { registerNativeChatDraftHandlers } from '../native-chat-drafts' import { registerNotificationHandlers } from '../notifications' import { registerNotebookHandlers } from '../notebook' import { registerOnboardingHandlers } from '../onboarding' @@ -241,6 +243,7 @@ export function registerCoreHandlers( prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args) }) registerNativeChatHandlers() + registerNativeChatDraftHandlers(join(app.getPath('userData'), 'native-chat-drafts')) registerClipboardHandlers(store) registerUpdaterHandlers(store) registerSpeechHandlers(store) diff --git a/src/main/native-chat/native-chat-draft-send-kill.test.ts b/src/main/native-chat/native-chat-draft-send-kill.test.ts new file mode 100644 index 00000000000..b6ea3554f99 --- /dev/null +++ b/src/main/native-chat/native-chat-draft-send-kill.test.ts @@ -0,0 +1,96 @@ +// The send ordering against a real kill: the composer's draft cache, saving through this main +// store, in a child process that SIGKILLs itself while a message is out. A kill before the host has +// the message must leave it saved (restored unsent); one after the save that follows must not. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { build } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { createNativeChatDraftStore } from './native-chat-draft-store' + +const CACHE = resolve( + __dirname, + '../../renderer/src/components/native-chat/native-chat-draft-cache' +) + +const CHILD_SOURCE = ` +import { createNativeChatDraftStore } from './native-chat-draft-store' +import { + clearNativeChatDraftForSend, + writeNativeChatDraftCache +} from ${JSON.stringify(CACHE)} +const [root, mode] = process.argv.slice(2) +const store = createNativeChatDraftStore(root) +// The desktop bridge, minus the IPC hop. +Object.assign(globalThis, { window: { api: { nativeChat: { drafts: store } } } }) +const KEY = 'session:s1' +void (async () => { + writeNativeChatDraftCache(KEY, 'sent text', 'now') + await store.drain() + // Enter: the box empties and the message goes out; the host has not taken it yet. + const save = clearNativeChatDraftForSend(KEY, () => writeNativeChatDraftCache(KEY, '', 'now')) + if (mode === 'host-took-it') { + save() + } + // Every write asked of the store so far has landed before the kill. + await store.drain() + process.stdout.write('killing', () => process.kill(process.pid, 'SIGKILL')) +})() +` + +const roots: string[] = [] +let bundleDir = '' +let childEntry = '' + +beforeAll(async () => { + bundleDir = await mkdtemp(join(tmpdir(), 'orca-native-chat-draft-send-kill-')) + childEntry = join(bundleDir, 'send-kill-child.cjs') + await build({ + stdin: { contents: CHILD_SOURCE, resolveDir: __dirname, loader: 'ts' }, + outfile: childEntry, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ['electron', '@tiptap/react'], + logLevel: 'silent' + }) +}, 30_000) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +afterAll(async () => { + if (bundleDir) { + await rm(bundleDir, { recursive: true, force: true }) + } +}) + +async function killedDuringSend(mode: 'before-host' | 'host-took-it') { + const tempRoot = await mkdtemp(join(tmpdir(), 'orca-native-chat-drafts-')) + roots.push(tempRoot) + const root = join(tempRoot, 'native-chat-drafts') + const child = await runProcess({ + program: process.execPath, + args: [childEntry, root, mode], + timeoutMs: 20_000 + }) + expect(child.stdout).toBe('killing') + expect(child.signal).toBe('SIGKILL') + return createNativeChatDraftStore(root).load() +} + +describe('a send killed partway', () => { + it('restores the message when the host did not have it yet', async () => { + expect(await killedDuringSend('before-host')).toEqual([ + { scopeKey: 'session:s1', draft: { text: 'sent text', attachments: [] } } + ]) + }) + + it('does not bring the message back once the host had it and the box was saved', async () => { + expect(await killedDuringSend('host-took-it')).toEqual([]) + }) +}) diff --git a/src/main/native-chat/native-chat-draft-store.bench.test.ts b/src/main/native-chat/native-chat-draft-store.bench.test.ts new file mode 100644 index 00000000000..8fcc5df71d5 --- /dev/null +++ b/src/main/native-chat/native-chat-draft-store.bench.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createNativeChatDraftStore, type NativeChatDraftStore } from './native-chat-draft-store' + +// How long a draft write takes in the main process (the IPC hop is not here): the save after a +// send, and the bounded wait a queued message's take-back does before deleting the host's copy. +const ITERATIONS = 1000 +const BUDGET_P50_MS = 2 +const BUDGET_P99_MS = 20 +const describeBench = process.env.ORCA_NATIVE_CHAT_DRAFT_BENCH === '1' ? describe : describe.skip + +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.ceil(sorted.length * fraction) - 1]! +} + +const typed = (index: number) => ({ + text: `a message being typed ${index} `.repeat(8), + attachments: [] +}) + +async function timeClears( + store: NativeChatDraftStore, + before: (scopeKey: string, index: number) => Promise +): Promise<{ p50: number; p99: number }> { + const samples: number[] = [] + for (let index = 0; index < ITERATIONS; index += 1) { + const scopeKey = `session:bench-${index % 16}` + await before(scopeKey, index) + const startedAt = performance.now() + await store.write(scopeKey, null) + samples.push(performance.now() - startedAt) + } + return { p50: percentile(samples, 0.5), p99: percentile(samples, 0.99) } +} + +describeBench('native chat draft store latency', () => { + let root = '' + let store: NativeChatDraftStore + + beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-native-chat-draft-bench-')) + store = createNativeChatDraftStore(join(root, 'drafts')) + await store.load() + }) + + afterAll(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it.each([ + ['clear alone', async () => {}], + [ + 'clear after a typing write', + (scopeKey: string, index: number) => store.write(scopeKey, typed(index)) + ], + [ + 'clear with a typing write in flight', + async (scopeKey: string, index: number) => void store.write(scopeKey, typed(index)) + ] + ] as const)('%s', async (name, before) => { + const { p50, p99 } = await timeClears(store, before) + console.log( + `[native-chat-draft-bench] ${name}: p50=${p50.toFixed(3)}ms p99=${p99.toFixed(3)}ms` + ) + expect(p50).toBeLessThanOrEqual(BUDGET_P50_MS) + expect(p99).toBeLessThanOrEqual(BUDGET_P99_MS) + }) + + it('put-back rewrite', async () => { + const samples: number[] = [] + for (let index = 0; index < ITERATIONS; index += 1) { + const startedAt = performance.now() + await store.write(`session:bench-${index % 16}`, typed(index)) + samples.push(performance.now() - startedAt) + } + const p50 = percentile(samples, 0.5) + const p99 = percentile(samples, 0.99) + console.log( + `[native-chat-draft-bench] put-back rewrite: p50=${p50.toFixed(3)}ms p99=${p99.toFixed(3)}ms` + ) + expect(p50).toBeLessThanOrEqual(BUDGET_P50_MS) + expect(p99).toBeLessThanOrEqual(BUDGET_P99_MS) + }) +}) diff --git a/src/main/native-chat/native-chat-draft-store.failed-write.test.ts b/src/main/native-chat/native-chat-draft-store.failed-write.test.ts new file mode 100644 index 00000000000..30e2efaa4c5 --- /dev/null +++ b/src/main/native-chat/native-chat-draft-store.failed-write.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as SidecarSnapshotFile from '../sidecar-snapshot-file' + +const mocks = vi.hoisted(() => ({ + removeSidecarSnapshot: vi.fn(), + writeSidecarSnapshot: vi.fn(), + mkdir: vi.fn() +})) + +vi.mock('../sidecar-snapshot-file', async (importOriginal) => { + const actual = await importOriginal() + mocks.removeSidecarSnapshot.mockImplementation(actual.removeSidecarSnapshot) + mocks.writeSidecarSnapshot.mockImplementation(actual.writeSidecarSnapshot) + return { + ...actual, + removeSidecarSnapshot: mocks.removeSidecarSnapshot, + writeSidecarSnapshot: mocks.writeSidecarSnapshot + } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + mocks.mkdir.mockImplementation(actual.mkdir) + return { ...actual, mkdir: mocks.mkdir } +}) + +import { createNativeChatDraftStore } from './native-chat-draft-store' + +const roots: string[] = [] +const draft = (text: string) => ({ text, attachments: [] }) +const busy = () => Object.assign(new Error('busy'), { code: 'EBUSY' }) + +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-drafts-failed-')) + roots.push(root) + return join(root, 'native-chat-drafts') +} + +async function sentDraftOnDisk() { + const root = await freshRoot() + const store = createNativeChatDraftStore(root) + await store.load() + await store.write('session:s1', draft('sent text')) + return { root, store } +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(async () => { + vi.clearAllMocks() + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +// A clear whose delete failed must not leave the sent text to come back, in this run or the next. +describe('a draft write that failed', () => { + it('is not served to a reloaded window, and is retried when Orca quits', async () => { + const { root, store } = await sentDraftOnDisk() + mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy()) + + expect(await store.write('session:s1', null)).toBe('failed') + expect(await store.load()).toEqual([]) + + await store.drain() + expect(await readdir(root)).toEqual([]) + expect(await createNativeChatDraftStore(root).load()).toEqual([]) + }) + + it("is superseded by the chat's next write", async () => { + const { root, store } = await sentDraftOnDisk() + mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy()) + await store.write('session:s1', null) + + expect(await store.write('session:s1', draft('typed again'))).toBe('persisted') + await store.drain() + + expect(mocks.removeSidecarSnapshot).toHaveBeenCalledOnce() + expect(await createNativeChatDraftStore(root).load()).toEqual([ + { scopeKey: 'session:s1', draft: draft('typed again') } + ]) + }) + + it('is dropped, and logged, when the retry at quit fails too', async () => { + const { store } = await sentDraftOnDisk() + mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy()).mockRejectedValueOnce(busy()) + await store.write('session:s1', null) + + await store.drain() + await store.drain() + + expect(mocks.removeSidecarSnapshot).toHaveBeenCalledTimes(2) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('dropped a draft write'), + 'EBUSY' + ) + }) + + it('never outlives a newer write asked for before it failed', async () => { + const { root, store } = await sentDraftOnDisk() + mocks.removeSidecarSnapshot.mockRejectedValueOnce(busy()) + const clear = store.write('session:s1', null) + const next = store.write('session:s1', draft('newer')) + + expect(await clear).toBe('failed') + expect(await next).toBe('persisted') + await store.drain() + + expect(mocks.removeSidecarSnapshot).toHaveBeenCalledOnce() + expect(await createNativeChatDraftStore(root).load()).toEqual([ + { scopeKey: 'session:s1', draft: draft('newer') } + ]) + }) +}) + +describe('the drafts folder', () => { + it('is created once, and again after a write failed', async () => { + const root = await freshRoot() + const store = createNativeChatDraftStore(root) + await store.write('session:s1', draft('a')) + await store.write('session:s1', draft('ab')) + await store.write('session:s2', draft('b')) + expect(mocks.mkdir).toHaveBeenCalledOnce() + + mocks.writeSidecarSnapshot.mockRejectedValueOnce(busy()) + expect(await store.write('session:s1', draft('abc'))).toBe('failed') + await store.write('session:s1', draft('abcd')) + expect(mocks.mkdir).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/native-chat-draft-store.test.ts b/src/main/native-chat/native-chat-draft-store.test.ts new file mode 100644 index 00000000000..2a95ad92af4 --- /dev/null +++ b/src/main/native-chat/native-chat-draft-store.test.ts @@ -0,0 +1,283 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { build } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { withSidecarSnapshotQueue } from '../sidecar-snapshot-file' +import { createNativeChatDraftStore } from './native-chat-draft-store' + +const roots: string[] = [] +let bundleDir = '' +let childEntry = '' + +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-drafts-')) + roots.push(root) + return join(root, 'native-chat-drafts') +} + +const draft = (text: string) => ({ text, attachments: [] }) +const fileOf = (scopeKey: string) => `${createHash('sha256').update(scopeKey).digest('hex')}.json` + +// A renderer that sends, then the app is killed: the clear it was told had landed must hold. +const CHILD_SOURCE = ` +import { createNativeChatDraftStore } from './native-chat-draft-store' +const [root, mode] = process.argv.slice(2) +const store = createNativeChatDraftStore(root) +void (async () => { + await store.write('session:s1', { text: 'hello', attachments: [] }) + if (mode === 'typing-in-flight') { + void store.write('session:s1', { text: 'hello world', attachments: [] }) + } + const result = await store.write('session:s1', null) + if (mode === 'typing-in-flight') { + await store.drain() + } + process.stdout.write(result, () => process.kill(process.pid, 'SIGKILL')) +})() +` + +beforeAll(async () => { + bundleDir = await mkdtemp(join(tmpdir(), 'orca-native-chat-draft-child-')) + childEntry = join(bundleDir, 'draft-child.cjs') + await build({ + stdin: { contents: CHILD_SOURCE, resolveDir: __dirname, loader: 'ts' }, + outfile: childEntry, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + external: ['electron'], + logLevel: 'silent' + }) +}, 30_000) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +afterAll(async () => { + if (bundleDir) { + await rm(bundleDir, { recursive: true, force: true }) + } +}) + +describe('a clear confirmed before the app is killed', () => { + it.each(['clear', 'typing-in-flight'])('stays cleared (%s)', async (mode) => { + const root = await freshRoot() + + const child = await runProcess({ + program: process.execPath, + args: [childEntry, root, mode], + timeoutMs: 20_000 + }) + + expect(child.stdout).toBe('persisted') + expect(child.signal).toBe('SIGKILL') + expect(await createNativeChatDraftStore(root).load()).toEqual([]) + }) +}) + +describe('the native chat draft store', () => { + it('loads what an earlier run saved, oldest first', async () => { + const root = await freshRoot() + const first = createNativeChatDraftStore(root) + await first.write('pane:tab-1:leaf-1', draft('older')) + await new Promise((resolve) => setTimeout(resolve, 5)) + await first.write('session:s1', { + text: 'newer', + attachments: [{ id: 'a1', path: '/tmp/a.png', location: 'local' }], + tuiInputSeed: { agent: 'claude', text: 'seed', createdAt: 1 } + }) + + const next = createNativeChatDraftStore(root) + + expect(await next.load()).toEqual([ + { scopeKey: 'pane:tab-1:leaf-1', draft: draft('older') }, + { + scopeKey: 'session:s1', + draft: { + text: 'newer', + attachments: [{ id: 'a1', path: '/tmp/a.png', location: 'local' }], + tuiInputSeed: { agent: 'claude', text: 'seed', createdAt: 1 } + } + } + ]) + expect(next.loadSync()).toEqual(await next.load()) + }) + + it('keeps the ids of outbox entries a draft took in, for a later reader', async () => { + const root = await freshRoot() + await createNativeChatDraftStore(root).write('session:s1', { + ...draft('back'), + importedOutboxEntryIds: ['m1'] + }) + + expect(await createNativeChatDraftStore(root).load()).toEqual([ + { scopeKey: 'session:s1', draft: { ...draft('back'), importedOutboxEntryIds: ['m1'] } } + ]) + }) + + it('keeps the sends a draft still holds for their host', async () => { + const root = await freshRoot() + const heldSends = [{ clientMessageId: 'm1', text: 'sent once', attachments: [], sentAt: 5 }] + await createNativeChatDraftStore(root).write('session:s1', { ...draft(''), heldSends }) + + expect(await createNativeChatDraftStore(root).load()).toEqual([ + { scopeKey: 'session:s1', draft: { ...draft(''), heldSends } } + ]) + }) + + it('applies writes for one chat in the order they were asked for', async () => { + const root = await freshRoot() + const store = createNativeChatDraftStore(root) + const writes = [ + store.write('session:s1', draft('h')), + store.write('session:s1', draft('hello')), + store.write('session:s1', null), + store.write('session:s1', draft('next')) + ] + + expect(await Promise.all(writes)).toEqual(['persisted', 'persisted', 'persisted', 'persisted']) + expect(await createNativeChatDraftStore(root).load()).toEqual([ + { scopeKey: 'session:s1', draft: draft('next') } + ]) + }) + + it('deletes the file for a cleared draft', async () => { + const root = await freshRoot() + const store = createNativeChatDraftStore(root) + await store.write('session:s1', draft('hello')) + await store.write('session:s1', draft('')) + + expect(await readdir(root)).toEqual([]) + }) + + it('drops unreadable files and keeps a write made while loading', async () => { + const root = await freshRoot() + await createNativeChatDraftStore(root).write('session:s1', draft('old')) + await writeFile(join(root, 'broken.json'), '{not json') + + const store = createNativeChatDraftStore(root) + const written = store.write('session:s1', draft('new')) + + expect(await store.load()).toEqual([{ scopeKey: 'session:s1', draft: draft('new') }]) + await written + expect((await readdir(root)).filter((name) => name === 'broken.json')).toEqual([]) + }) + + it('sweeps temp files a killed run left mid-write', async () => { + const root = await freshRoot() + await createNativeChatDraftStore(root).write('session:s1', draft('hello')) + const [saved] = await readdir(root) + await writeFile(join(root, `${saved}.99999.1.ab.tmp`), '{"partial') + + await createNativeChatDraftStore(root).load() + + expect(await readdir(root)).toEqual([saved]) + }) + + // A downgrade, or a file that can't be read right now, must not cost the user a draft. + it("keeps a newer build's drafts and files it could not read", async () => { + const root = await freshRoot() + const store = createNativeChatDraftStore(root) + await store.write('session:s1', draft('mine')) + const newer = { v: 2, scopeKey: 'session:s2', savedAt: 1, text: 'from a newer build' } + await writeFile(join(root, fileOf('session:s2')), JSON.stringify(newer)) + // A directory where a file should be: reading it fails with something other than ENOENT. + await mkdir(join(root, fileOf('session:s3'))) + await writeFile(join(root, fileOf('session:s4')), JSON.stringify({ v: 1, scopeKey: 7 })) + + expect(await createNativeChatDraftStore(root).load()).toEqual([ + { scopeKey: 'session:s1', draft: draft('mine') } + ]) + expect((await readdir(root)).sort()).toEqual( + [fileOf('session:s1'), fileOf('session:s2'), fileOf('session:s3')].sort() + ) + }) + + it('removes JSON files that carry no newer version', async () => { + const root = await freshRoot() + await createNativeChatDraftStore(root).write('session:s1', draft('mine')) + for (const [key, body] of [ + ['session:empty', '{}'], + ['session:array', '[]'], + ['session:string', '"x"'], + ['session:older', JSON.stringify({ v: 0, scopeKey: 'session:older', text: 'old' })] + ]) { + await writeFile(join(root, fileOf(key)), body) + } + + await createNativeChatDraftStore(root).load() + + expect(await readdir(root)).toEqual([fileOf('session:s1')]) + }) + + // Deletions at load share the chat's queue, so a write made meanwhile always survives them. + it('never deletes, at load, a draft written while loading', async () => { + const root = await freshRoot() + const seed = createNativeChatDraftStore(root) + for (let index = 0; index < 130; index += 1) { + await seed.write(`session:${index}`, draft(`draft-${index}`)) + if (index < 2) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + } + await writeFile(join(root, fileOf('session:garbage')), '{not json') + + // Hold both chats' queues so load reads the old files, then the writes land, then load decides. + let release = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + void withSidecarSnapshotQueue(join(root, fileOf('session:0')), () => held) + void withSidecarSnapshotQueue(join(root, fileOf('session:garbage')), () => held) + const store = createNativeChatDraftStore(root) + const oldest = store.write('session:0', draft('typed after relaunch')) + const overGarbage = store.write('session:garbage', draft('typed over garbage')) + const loaded = store.load() + await new Promise((resolve) => setTimeout(resolve, 50)) + release() + await loaded + + expect(await oldest).toBe('persisted') + expect(await overGarbage).toBe('persisted') + const next = new Map( + (await createNativeChatDraftStore(root).load()).map(({ scopeKey, draft }) => [ + scopeKey, + draft + ]) + ) + expect(next.get('session:0')).toEqual(draft('typed after relaunch')) + expect(next.get('session:garbage')).toEqual(draft('typed over garbage')) + }) + + it('keeps only the newest drafts past the bound', async () => { + const root = await freshRoot() + const store = createNativeChatDraftStore(root) + for (let index = 0; index < 130; index += 1) { + await store.write(`session:${index}`, draft(`draft-${index}`)) + if (index < 2) { + // The two oldest are written in earlier milliseconds than the rest. + await new Promise((resolve) => setTimeout(resolve, 5)) + } + } + + const loaded = await createNativeChatDraftStore(root).load() + + expect(loaded).toHaveLength(128) + expect(loaded.map(({ scopeKey }) => scopeKey)).not.toContain('session:0') + expect(loaded.map(({ scopeKey }) => scopeKey)).not.toContain('session:1') + expect((await readdir(root)).filter((name) => name.endsWith('.json'))).toHaveLength(128) + }) + + it('reports a failed write without throwing', async () => { + const root = await freshRoot() + await writeFile(join(root, '..', 'blocker'), '') + const store = createNativeChatDraftStore(join(root, '..', 'blocker', 'drafts')) + + await expect(store.write('session:s1', draft('hello'))).resolves.toBe('failed') + }) +}) diff --git a/src/main/native-chat/native-chat-draft-store.ts b/src/main/native-chat/native-chat-draft-store.ts new file mode 100644 index 00000000000..1f3243b7b80 --- /dev/null +++ b/src/main/native-chat/native-chat-draft-store.ts @@ -0,0 +1,285 @@ +// Composer drafts kept by the main process, one sidecar file per chat. A clear deletes the file +// and a write renames a temp file over it, so once either returned it survives the app being +// killed. No fsync: a power loss can still lose the last seconds, as browser storage would. + +import { createHash } from 'node:crypto' +import { readdirSync, readFileSync } from 'node:fs' +import { mkdir, readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { removeStaleDurableWriteTempFiles } from '../durable-file-write' +import { + removeSidecarSnapshot, + sidecarSnapshotFile, + withSidecarSnapshotQueue, + writeSidecarSnapshot +} from '../sidecar-snapshot-file' +import { + isEmptyNativeChatDraft, + parseNativeChatDraft, + type NativeChatDraftStoreResult, + type PersistedNativeChatDraft, + type SavedNativeChatDraft +} from '../../shared/native-chat-draft-record' + +const FILE_VERSION = 1 +// Why a cap at load: drafts die with their chat; this bounds those whose end this client missed. +const MAX_SAVED_DRAFTS = 128 + +type Saved = { draft: PersistedNativeChatDraft; savedAt: number } +type Intent = { draft: PersistedNativeChatDraft | null; savedAt: number } + +type DraftFile = + | ({ kind: 'draft'; scopeKey: string } & Saved) + /** Not JSON, JSON with no newer version, or a version-1 file that is not a draft. */ + | { kind: 'garbage' } + /** A newer build's draft, or a file that could not be read this time: never deleted. */ + | { kind: 'kept'; reason: string } + | { kind: 'gone' } + +export type NativeChatDraftStore = { + /** Every saved draft, oldest first, once the writes already asked for have landed. */ + load: () => Promise + /** The same without waiting, for a renderer that needs drafts before an async load returned. */ + loadSync: () => SavedNativeChatDraft[] + /** Resolves once the file op returned. Writes for one chat apply in the order they arrived. */ + write: ( + scopeKey: string, + draft: PersistedNativeChatDraft | null + ) => Promise + /** Waits for every write asked for so far, then retries each failed one once (at quit). */ + drain: () => Promise +} + +function draftFile(root: string, scopeKey: string): string { + // Why hashed: a pane key holds ':', which Windows refuses in a file name. + return sidecarSnapshotFile(root, `${createHash('sha256').update(scopeKey).digest('hex')}.json`) +} + +function classifyDraftFile(raw: string): DraftFile { + let value: unknown + try { + value = JSON.parse(raw) + } catch { + return { kind: 'garbage' } + } + const v = typeof value === 'object' && value !== null && 'v' in value ? value.v : undefined + if (typeof v === 'number' && v > FILE_VERSION) { + return { kind: 'kept', reason: `version ${v}` } + } + if (v !== FILE_VERSION) { + return { kind: 'garbage' } + } + const scopeKey = + typeof value === 'object' && value !== null && 'scopeKey' in value && value.scopeKey + const savedAt = typeof value === 'object' && value !== null && 'savedAt' in value && value.savedAt + const draft = parseNativeChatDraft(value) + return typeof scopeKey === 'string' && draft + ? { kind: 'draft', scopeKey, draft, savedAt: typeof savedAt === 'number' ? savedAt : 0 } + : { kind: 'garbage' } +} + +function errorCode(error: unknown): string { + return error instanceof Error && 'code' in error ? String(error.code) : 'unknown' +} + +async function readDraftFile(file: string): Promise { + try { + return classifyDraftFile(await readFile(file, 'utf8')) + } catch (error) { + const code = errorCode(error) + return code === 'ENOENT' ? { kind: 'gone' } : { kind: 'kept', reason: code } + } +} + +function oldestFirst(saved: Iterable<[string, Saved]>): SavedNativeChatDraft[] { + return Array.from(saved) + .sort((left, right) => left[1].savedAt - right[1].savedAt) + .map(([scopeKey, { draft }]) => ({ scopeKey, draft })) +} + +export function createNativeChatDraftStore(root: string): NativeChatDraftStore { + // What each chat's draft should be, failed writes included, so a reloaded window never sees + // text whose clear did not reach the file. + const saved = new Map() + const written = new Set() + const pending = new Set>() + // The newest write per chat, so an older failure never outlives a newer write. + const latest = new Map() + // A write whose file op failed: superseded by the chat's next write, else retried once at quit. + const failed = new Map() + let rootReady: Promise | null = null + let loaded = false + + const track = (work: Promise): Promise => { + pending.add(work) + void work.then( + () => pending.delete(work), + () => pending.delete(work) + ) + return work + } + + // Load-time deletes go through the chat's queue and recheck, so they never undo a write. + const removeIfStill = (file: string, stillDoomed: () => Promise | boolean) => + withSidecarSnapshotQueue(file, async () => { + if (await stillDoomed()) { + await removeSidecarSnapshot(file) + } + }).catch((error: unknown) => console.warn('[native-chat-drafts] could not delete', file, error)) + + const ready = (async () => { + const found: ({ file: string; scopeKey: string } & Saved)[] = [] + const removals: Promise[] = [] + const names = await readdir(root).catch(() => []) + // Temp files a killed run left mid-write; this run's own are never swept. + const interrupted = new Set( + names.filter((name) => name.endsWith('.tmp')).map((name) => name.split('.json.')[0]) + ) + await Promise.all( + Array.from(interrupted, (hash) => + removeStaleDurableWriteTempFiles(join(root, `${hash}.json`)) + ) + ) + for (const name of names.filter((entry) => entry.endsWith('.json'))) { + const file = join(root, name) + const read = await readDraftFile(file) + switch (read.kind) { + case 'draft': + found.push({ ...read, file }) + break + case 'garbage': + removals.push( + removeIfStill(file, async () => (await readDraftFile(file)).kind === 'garbage') + ) + break + case 'kept': + console.warn('[native-chat-drafts] kept a draft file it cannot use:', name, read.reason) + break + case 'gone': + break + } + } + found.sort((left, right) => left.savedAt - right.savedAt) + for (const [index, entry] of found.entries()) { + if (written.has(entry.scopeKey)) { + continue + } + if (index < found.length - MAX_SAVED_DRAFTS) { + removals.push(removeIfStill(entry.file, () => !written.has(entry.scopeKey))) + } else { + saved.set(entry.scopeKey, { draft: entry.draft, savedAt: entry.savedAt }) + } + } + await Promise.all(removals) + loaded = true + })() + + const apply = async (scopeKey: string, { draft, savedAt }: Intent): Promise => { + const file = draftFile(root, scopeKey) + if (!draft || isEmptyNativeChatDraft(draft)) { + await removeSidecarSnapshot(file) + return + } + // Why once: a mkdir per typing pause queues ahead of the clear; any failure makes it again. + rootReady ??= mkdir(root, { recursive: true, mode: 0o700 }) + try { + await rootReady + await writeSidecarSnapshot( + file, + { v: FILE_VERSION, scopeKey, savedAt, ...draft }, + { durability: 'process' } + ) + } catch (error) { + rootReady = null + throw error + } + } + + const settle = async (): Promise => { + await Promise.all(pending) + } + + return { + load: async () => { + await ready + await settle() + return oldestFirst(saved) + }, + loadSync: () => { + if (loaded) { + return oldestFirst(saved) + } + const found: [string, Saved][] = [] + let names: string[] = [] + try { + names = readdirSync(root).filter((entry) => entry.endsWith('.json')) + } catch { + // No drafts folder yet. + } + for (const name of names) { + try { + const read = classifyDraftFile(readFileSync(join(root, name), 'utf8')) + if (read.kind === 'draft') { + found.push([read.scopeKey, read]) + } + } catch { + // Unreadable for now: skipped here, judged by the async load. + } + } + return oldestFirst(found).slice(-MAX_SAVED_DRAFTS) + }, + write: (scopeKey, draft) => { + written.add(scopeKey) + failed.delete(scopeKey) + const intent: Intent = { draft, savedAt: Date.now() } + latest.set(scopeKey, intent) + if (draft && !isEmptyNativeChatDraft(draft)) { + saved.set(scopeKey, { draft, savedAt: intent.savedAt }) + } else { + saved.delete(scopeKey) + } + // Why queued per chat: a typing write still in flight must never land after the clear behind it. + return track( + withSidecarSnapshotQueue(draftFile(root, scopeKey), () => apply(scopeKey, intent)).then( + (): NativeChatDraftStoreResult => { + if (latest.get(scopeKey) === intent) { + latest.delete(scopeKey) + } + return 'persisted' + }, + (error: unknown): NativeChatDraftStoreResult => { + if (latest.get(scopeKey) === intent) { + failed.set(scopeKey, intent) + } + console.warn( + '[native-chat-drafts] write failed; retrying at its next write or at quit:', + errorCode(error) + ) + return 'failed' + } + ) + ) + }, + drain: async () => { + await settle() + const retries = Array.from(failed) + failed.clear() + await Promise.all( + retries.map(([scopeKey, intent]) => + withSidecarSnapshotQueue(draftFile(root, scopeKey), () => apply(scopeKey, intent)).then( + () => { + if (latest.get(scopeKey) === intent) { + latest.delete(scopeKey) + } + }, + (error: unknown) => + console.warn( + '[native-chat-drafts] dropped a draft write that failed twice:', + errorCode(error) + ) + ) + ) + ) + } + } +} diff --git a/src/main/sidecar-snapshot-file.test.ts b/src/main/sidecar-snapshot-file.test.ts index 7df50763602..45f956cac22 100644 --- a/src/main/sidecar-snapshot-file.test.ts +++ b/src/main/sidecar-snapshot-file.test.ts @@ -1,9 +1,58 @@ -import { describe, expect, it } from 'vitest' +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' +import type * as DurableFileWrite from './durable-file-write' + +const durable = vi.hoisted(() => ({ + writeFileDurable: vi.fn(), + writeFileProcessDurable: vi.fn(), + removeStaleDurableWriteTempFiles: vi.fn() +})) + +const fsPromises = vi.hoisted(() => ({ rm: vi.fn() })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + fsPromises.rm.mockImplementation(actual.rm) + return { ...actual, rm: fsPromises.rm } +}) + +vi.mock('./durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + durable.writeFileDurable.mockImplementation(actual.writeFileDurable) + durable.writeFileProcessDurable.mockImplementation(actual.writeFileProcessDurable) + durable.removeStaleDurableWriteTempFiles.mockImplementation( + actual.removeStaleDurableWriteTempFiles + ) + return { ...actual, ...durable } +}) + import { _getSidecarSnapshotPendingFileCountForTests, - withSidecarSnapshotQueue + readSidecarSnapshot, + removeSidecarSnapshot, + withSidecarSnapshotQueue, + writeSidecarSnapshot } from './sidecar-snapshot-file' +const roots: string[] = [] + +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-sidecar-snapshot-')) + roots.push(root) + return root +} + +const originalPlatform = process.platform + +afterEach(async () => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + vi.clearAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + describe('sidecar snapshot queues', () => { it('releases completed per-file queue entries', async () => { await Promise.all( @@ -16,3 +65,61 @@ describe('sidecar snapshot queues', () => { expect(_getSidecarSnapshotPendingFileCountForTests()).toBe(0) }) }) + +describe('sidecar snapshot writes', () => { + it('flushes to disk by default, as every existing caller expects', async () => { + const file = join(await freshRoot(), 'snapshot.json') + + await writeSidecarSnapshot(file, { a: 1 }) + + expect(durable.writeFileDurable).toHaveBeenCalledOnce() + expect(durable.writeFileProcessDurable).not.toHaveBeenCalled() + expect(await readSidecarSnapshot(file)).toEqual({ a: 1 }) + }) + + it("skips the disk flush only when asked for 'process' durability", async () => { + const file = join(await freshRoot(), 'snapshot.json') + + await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' }) + + expect(durable.writeFileProcessDurable).toHaveBeenCalledOnce() + expect(durable.writeFileDurable).not.toHaveBeenCalled() + expect(await readSidecarSnapshot(file)).toEqual({ a: 1 }) + }) + + // Only a crashed run leaves temp files; listing the folder on every write only adds latency. + it("sweeps a file's stale temp files once per run, not on every write", async () => { + const file = join(await freshRoot(), 'snapshot.json') + + await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' }) + await writeSidecarSnapshot(file, { a: 2 }, { durability: 'process' }) + await writeSidecarSnapshot(file, { a: 3 }) + + expect(durable.removeStaleDurableWriteTempFiles).toHaveBeenCalledOnce() + expect(await readSidecarSnapshot(file)).toEqual({ a: 3 }) + }) + + it('removes a snapshot, and treats a missing one as removed', async () => { + const root = await freshRoot() + const file = join(root, 'snapshot.json') + await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' }) + + await withSidecarSnapshotQueue(file, () => removeSidecarSnapshot(file)) + await expect(removeSidecarSnapshot(file)).resolves.toBeUndefined() + + expect(await readdir(root)).toEqual([]) + }) + + // A virus scanner holding a just-written draft open must not fail the clear at send (#1507). + it('retries a delete Windows refused while another process held the file', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const root = await freshRoot() + const file = join(root, 'snapshot.json') + await writeSidecarSnapshot(file, { a: 1 }, { durability: 'process' }) + fsPromises.rm.mockRejectedValueOnce(Object.assign(new Error('busy'), { code: 'EBUSY' })) + + await removeSidecarSnapshot(file) + + expect(await readdir(root)).toEqual([]) + }) +}) diff --git a/src/main/sidecar-snapshot-file.ts b/src/main/sidecar-snapshot-file.ts index 4ee5c2dfa5c..6e8087b7662 100644 --- a/src/main/sidecar-snapshot-file.ts +++ b/src/main/sidecar-snapshot-file.ts @@ -6,11 +6,15 @@ import { join } from 'node:path' import { durableWriteTempPath, removeStaleDurableWriteTempFiles, - writeFileDurable + writeFileDurable, + writeFileProcessDurable } from './durable-file-write' +import { removeFileWithWindowsRetryAsync } from './codex-accounts/fs-utils' const queues = new Map>() const staleTempCleanups = new Map>() +// Why once per run: only a crashed run leaves temp files, so sweeping again finds nothing. +const sweptFiles = new Set() const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000 export function sidecarSnapshotFile(snapshotDirectory: string, fileName: string): string { @@ -43,19 +47,36 @@ export async function readSidecarSnapshot(file: string): Promise { } } -export async function writeSidecarSnapshot(file: string, payload: unknown): Promise { - let cleanup = staleTempCleanups.get(file) - if (!cleanup) { - cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS }) - staleTempCleanups.set(file, cleanup) - void cleanup.then(() => { - if (staleTempCleanups.get(file) === cleanup) { - staleTempCleanups.delete(file) - } - }) +/** + * `durability: 'process'` skips fsync: the write survives the app being killed but not a power + * loss, for sidecars written on a latency-sensitive path. + */ +export async function writeSidecarSnapshot( + file: string, + payload: unknown, + options: { durability?: 'power-loss' | 'process' } = {} +): Promise { + if (!sweptFiles.has(file)) { + let cleanup = staleTempCleanups.get(file) + if (!cleanup) { + cleanup = removeStaleDurableWriteTempFiles(file, { minimumAgeMs: STALE_TEMP_AGE_MS }) + staleTempCleanups.set(file, cleanup) + void cleanup.then(() => { + sweptFiles.add(file) + if (staleTempCleanups.get(file) === cleanup) { + staleTempCleanups.delete(file) + } + }) + } + await cleanup } - await cleanup - await writeFileDurable(durableWriteTempPath(file), file, JSON.stringify(payload)) + const write = options.durability === 'process' ? writeFileProcessDurable : writeFileDurable + await write(durableWriteTempPath(file), file, JSON.stringify(payload)) +} + +/** Delete a sidecar; a missing one is already removed. Run it inside the file's queue. */ +export async function removeSidecarSnapshot(file: string): Promise { + await removeFileWithWindowsRetryAsync(file) } export function _getSidecarSnapshotPendingFileCountForTests(): number { diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index ce8148ea5d2..6365ebba727 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -50,6 +50,23 @@ describe('startup ordering', () => { expect(willQuit.slice(barrierStart)).toContain("{ name: 'browser', promise: browserShutdown }") }) + // A chat's clear at send, still being written as Orca quits, must land before it exits. + it('waits for native chat draft writes before the committed quit exits', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) + const willQuitStart = source.indexOf("app.on('will-quit'") + const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) + const willQuit = source.slice(willQuitStart, windowAllClosedStart) + const barrierStart = willQuit.indexOf('settleTeardownWithinDeadline([') + + expect(barrierStart).toBeGreaterThanOrEqual(0) + expect(willQuit.slice(barrierStart)).toContain( + "{ name: 'native-chat-drafts', promise: drainNativeChatDrafts() }" + ) + }) + it('registers repeatable serve signal handling before headless startup completes', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 5a89c9e283f..3535f4443fb 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -32,6 +32,7 @@ import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' import { mainProcessState as state } from './main-process-state' import { isDevParentShutdownRequested } from './configure-process' import { getCanonicalUserDataPath } from '../persistence' +import { drainNativeChatDrafts } from '../ipc/native-chat-drafts' // Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. let daemonDisconnectDone = false @@ -270,6 +271,7 @@ function installWillQuitHandler(): void { { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, { name: 'stats', promise: statsFlush }, + { name: 'native-chat-drafts', promise: drainNativeChatDrafts() }, { name: 'state', promise: storeFlush } ]) .then((pendingTeardowns) => { diff --git a/src/main/window/clipboard-image-temp-file.test.ts b/src/main/window/clipboard-image-temp-file.test.ts index 71ca0804c0e..6cfdb223e77 100644 --- a/src/main/window/clipboard-image-temp-file.test.ts +++ b/src/main/window/clipboard-image-temp-file.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { authorizeExternalPathMock, writeFileMock, getPathMock, writeFileBase64Mock } = vi.hoisted( - () => ({ - authorizeExternalPathMock: vi.fn(), - writeFileMock: vi.fn(), - getPathMock: vi.fn(() => '/var/folders/ab/T'), - writeFileBase64Mock: vi.fn() - }) -) +const { + authorizeExternalPathMock, + writeFileMock, + getPathMock, + writeFileBase64Mock, + saveNativeChatAttachmentFileMock +} = vi.hoisted(() => ({ + authorizeExternalPathMock: vi.fn(), + writeFileMock: vi.fn(), + getPathMock: vi.fn(() => '/var/folders/ab/T'), + writeFileBase64Mock: vi.fn(), + saveNativeChatAttachmentFileMock: vi.fn(async (fileName: string) => `/user-data/chat/${fileName}`) +})) vi.mock('node:fs/promises', () => ({ default: { writeFile: writeFileMock } })) vi.mock('node:crypto', () => ({ randomUUID: () => 'uuid-1' })) @@ -21,6 +26,9 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ }) })) vi.mock('../ipc/filesystem-auth', () => ({ authorizeExternalPath: authorizeExternalPathMock })) +vi.mock('./native-chat-attachment-store', () => ({ + saveNativeChatAttachmentFile: saveNativeChatAttachmentFileMock +})) import { saveClipboardImageBufferAsTempFile } from './clipboard-image-temp-file' @@ -47,4 +55,18 @@ describe('saveClipboardImageBufferAsTempFile', () => { expect(writeFileBase64Mock).toHaveBeenCalled() expect(authorizeExternalPathMock).not.toHaveBeenCalled() }) + + // A chat draft can hold the image across a reboot, which OS temp does not survive. + it("keeps a chat attachment in Orca's attachment storage, not OS temp", async () => { + const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1]), { + chatAttachment: true + }) + + expect(savedPath.startsWith('/user-data/chat/orca-paste-')).toBe(true) + expect(saveNativeChatAttachmentFileMock).toHaveBeenCalledWith( + expect.stringMatching(/^orca-paste-.+\.png$/), + Buffer.from([1]) + ) + expect(writeFileMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 0024c4b6d1f..8c82ebbcced 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -7,10 +7,13 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' import { authorizeExternalPath } from '../ipc/filesystem-auth' +import { saveNativeChatAttachmentFile } from './native-chat-attachment-store' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null runtimeEnvironmentId?: string | null + /** A chat attachment can sit in a saved draft across a reboot, so it is kept out of OS temp. */ + chatAttachment?: boolean } const REMOTE_CLIPBOARD_IMAGE_TEMP_DIR = '/tmp' @@ -40,6 +43,10 @@ export async function saveClipboardImageBufferAsTempFile( return remotePath } + if (args?.chatAttachment) { + return saveNativeChatAttachmentFile(fileName, buffer) + } + const tempPath = path.join(getAppEnvironment().getPath('temp'), fileName) await fs.writeFile(tempPath, buffer) // Why: the OS temp dir is outside every allowed root, so without this the diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index e81140d734a..b61de8d0485 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -43,6 +43,7 @@ import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' import { writeClipboardTextAndVerify } from './clipboard-text-write-verify' import { isDashboardPopoutRenderer } from './dashboard-popout-window' +import { scheduleNativeChatAttachmentSweep } from './native-chat-attachment-store' let trustedClipboardRendererWebContentsId: number | null = null @@ -103,6 +104,7 @@ export function registerClipboardHandlers(store: Store): void { void cleanupExpiredRemoteClipboardFiles() scheduleLegacyRemoteClipboardFileCleanup() + scheduleNativeChatAttachmentSweep() ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { assertTrustedClipboardTextSender(event) diff --git a/src/main/window/dragged-temp-file-copy.ts b/src/main/window/dragged-temp-file-copy.ts index cacd4f4c9c5..09f9615fe09 100644 --- a/src/main/window/dragged-temp-file-copy.ts +++ b/src/main/window/dragged-temp-file-copy.ts @@ -23,7 +23,7 @@ const TEMPORARY_ITEMS_SEGMENT = 'TemporaryItems' const DRAG_PROVIDER_DIR_PREFIX = 'NSIRD_' const COPY_ROOT_NAME = 'orca-drops' const COPY_DIR_PREFIX = 'orca-drop-' -const COPY_DIR_PATTERN = /^orca-drop-[A-Za-z0-9]{6}$/ +export const DRAG_TEMP_COPY_DIR_PATTERN = /^orca-drop-[A-Za-z0-9]{6}$/ // Why: open drafts and startup prompts read the copy lazily, often days later, so // keep it well past the drop; the TTL still bounds what the copy budget holds. export const DRAG_TEMP_COPY_TTL_MS = 7 * 24 * 60 * 60 * 1000 @@ -36,6 +36,8 @@ export type DragTempCopyEnvironment = { sourceTempRoot: string /** Orca-owned directory that holds one `orca-drop-*` directory per copy. */ copyRoot: string + /** Creates or accepts `copyRoot`; the default demands a private root, as a shared temp dir needs. */ + prepareCopyRoot?: (copyRoot: string) => Promise } export type DragTempCopyItemResult = @@ -158,7 +160,7 @@ export async function materializeDragTempPath( } signal?.throwIfAborted() - copyDir = await createCopyDirectory(env.copyRoot) + copyDir = await createCopyDirectory(env) const destPath = join(copyDir, basename(canonicalSource)) // Why: stream from the checked handle, capped at the inspected size, so a // swapped or growing source cannot slip through; unlike cp, ditto or @@ -211,7 +213,7 @@ export async function sweepExpiredDragTempCopies( await sweepExpiredOwnedDirectories(copyRoot, { nowMs, ttlMs: DRAG_TEMP_COPY_TTL_MS, - ownsEntry: (name) => COPY_DIR_PATTERN.test(name) + ownsEntry: (name) => DRAG_TEMP_COPY_DIR_PATTERN.test(name) }) } @@ -226,7 +228,7 @@ async function measureRetainedCopyBytes(copyRoot: string): Promise { } try { for await (const entry of rootDir) { - if (!entry.isDirectory() || !COPY_DIR_PATTERN.test(entry.name)) { + if (!entry.isDirectory() || !DRAG_TEMP_COPY_DIR_PATTERN.test(entry.name)) { continue } const copyDir = join(copyRoot, entry.name) @@ -293,12 +295,12 @@ function isPathWithin(root: string, candidate: string): boolean { return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)) } -async function createCopyDirectory(copyRoot: string): Promise { +async function createCopyDirectory(env: DragTempCopyEnvironment): Promise { try { - if (!(await ensureOwnedTempStagingRoot(copyRoot))) { + if (!(await (env.prepareCopyRoot ?? ensureOwnedTempStagingRoot)(env.copyRoot))) { throw new DropCopyError('storage-not-private') } - return await mkdtemp(join(copyRoot, COPY_DIR_PREFIX)) + return await mkdtemp(join(env.copyRoot, COPY_DIR_PREFIX)) } catch (error) { if (error instanceof DropCopyError) { throw error diff --git a/src/main/window/native-chat-attachment-store.test.ts b/src/main/window/native-chat-attachment-store.test.ts new file mode 100644 index 00000000000..e46c11bc6d6 --- /dev/null +++ b/src/main/window/native-chat-attachment-store.test.ts @@ -0,0 +1,115 @@ +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + symlink, + utimes, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' +import { materializeDragTempPaths } from './dragged-temp-file-copy' +import type { Store } from '../persistence' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { + ensureNativeChatAttachmentRoot, + getNativeChatAttachmentRoot, + NATIVE_CHAT_ATTACHMENT_TTL_MS, + saveNativeChatAttachmentFile, + sweepExpiredNativeChatAttachments +} from './native-chat-attachment-store' + +let userData: string + +beforeEach(async () => { + userData = await mkdtemp(join(tmpdir(), 'orca-chat-attachments-')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store reads only getPath. + setAppEnvironment({ getPath: () => userData } as unknown as AppEnvironment) +}) + +afterEach(async () => { + await rm(userData, { recursive: true, force: true }) +}) + +describe('native chat attachment storage', () => { + it('keeps each image in its own directory under user data', async () => { + const saved = await saveNativeChatAttachmentFile('orca-paste-1.png', Buffer.from([7])) + + expect(dirname(dirname(saved))).toBe(getNativeChatAttachmentRoot()) + expect(getNativeChatAttachmentRoot().startsWith(userData)).toBe(true) + expect(await readFile(saved)).toEqual(Buffer.from([7])) + }) + + // Nothing authorized it in this process: a relaunch forgets every per-file grant. + it('lets a restored draft read its image with no grant from this session', async () => { + const saved = await saveNativeChatAttachmentFile('orca-paste-1.png', Buffer.from([7])) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: with no repos and no workspace dir, authorization reads only these two. + const store = { getRepos: () => [], getSettings: () => ({}) } as unknown as Store + + await expect(resolveAuthorizedPath(saved, store)).resolves.toBe(await realpath(saved)) + }) + + // User data is already per user; a restored or mode-less root must not stop every paste. + it('saves into a root that is not private, but never through a symlinked one', async () => { + await mkdir(getNativeChatAttachmentRoot(), { mode: 0o755 }) + await chmod(getNativeChatAttachmentRoot(), 0o755) + const saved = await saveNativeChatAttachmentFile('orca-paste-1.png', Buffer.from([7])) + expect(await readFile(saved)).toEqual(Buffer.from([7])) + + await rm(getNativeChatAttachmentRoot(), { recursive: true }) + const elsewhere = await mkdtemp(join(tmpdir(), 'orca-chat-elsewhere-')) + await symlink(elsewhere, getNativeChatAttachmentRoot()) + await expect( + saveNativeChatAttachmentFile('orca-paste-2.png', Buffer.from([7])) + ).rejects.toThrow() + await rm(elsewhere, { recursive: true, force: true }) + }) + + // A paste usually creates the folder first; a composer drop must still copy into it. + it.skipIf(process.platform === 'win32')( + 'creates the folder owner-only, and a composer drop copies into it after a paste', + async () => { + await saveNativeChatAttachmentFile('orca-paste-1.png', Buffer.from([7])) + expect((await lstat(getNativeChatAttachmentRoot())).mode & 0o777).toBe(0o700) + await chmod(getNativeChatAttachmentRoot(), 0o755) + const sourceTempRoot = join(userData, 'T') + const dragged = join(sourceTempRoot, 'TemporaryItems', 'NSIRD_screencaptureui_1', 'Shot.png') + await mkdir(dirname(dragged), { recursive: true }) + await writeFile(dragged, Buffer.from([9])) + + const [result] = await materializeDragTempPaths([dragged], { + platform: 'darwin', + sourceTempRoot, + copyRoot: getNativeChatAttachmentRoot(), + prepareCopyRoot: ensureNativeChatAttachmentRoot + }) + + expect(result).toMatchObject({ status: 'imported' }) + expect(result?.status === 'imported' && dirname(dirname(result.destPath))).toBe( + getNativeChatAttachmentRoot() + ) + } + ) + + it('sweeps pastes and drag copies past the age limit, and keeps younger ones', async () => { + const old = await saveNativeChatAttachmentFile('orca-paste-old.png', Buffer.from([1])) + const fresh = await saveNativeChatAttachmentFile('orca-paste-new.png', Buffer.from([2])) + const oldDrop = join(getNativeChatAttachmentRoot(), 'orca-drop-abc123') + await mkdir(oldDrop, { mode: 0o700 }) + const now = Date.now() + const expired = new Date(now - NATIVE_CHAT_ATTACHMENT_TTL_MS - 1000) + await utimes(dirname(old), expired, expired) + await utimes(oldDrop, expired, expired) + + await sweepExpiredNativeChatAttachments(now) + + expect(await readdir(getNativeChatAttachmentRoot())).toEqual([basename(dirname(fresh))]) + }) +}) diff --git a/src/main/window/native-chat-attachment-store.ts b/src/main/window/native-chat-attachment-store.ts new file mode 100644 index 00000000000..b2d1be74ca1 --- /dev/null +++ b/src/main/window/native-chat-attachment-store.ts @@ -0,0 +1,86 @@ +import { lstat, mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' +import { DRAG_TEMP_COPY_DIR_PATTERN } from './dragged-temp-file-copy' +import { sweepExpiredOwnedDirectories } from './owned-temp-staging-root' + +// Images pasted or dropped (macOS drag copies) into a chat live here rather than in OS temp, so +// a draft restored after a reboot still names a real file. One directory per image, swept by age. +const ATTACHMENT_ROOT_NAME = 'native-chat-attachments' +const ATTACHMENT_DIR_PREFIX = 'chat-image-' +const ATTACHMENT_DIR_PATTERN = /^chat-image-[A-Za-z0-9]{6}$/ +// Why: an unsent draft can wait weeks; past this its chip shows as missing and blocks Send. +export const NATIVE_CHAT_ATTACHMENT_TTL_MS = 30 * 24 * 60 * 60 * 1000 +const SWEEP_FIRST_DELAY_MS = 30 * 1000 +const SWEEP_INTERVAL_MS = 60 * 60 * 1000 + +export function getNativeChatAttachmentRoot(): string { + return join(getAppEnvironment().getPath('userData'), ATTACHMENT_ROOT_NAME) +} + +/** Writes one chat attachment under Orca's own storage and returns its path. */ +export async function saveNativeChatAttachmentFile( + fileName: string, + contents: Buffer +): Promise { + const root = getNativeChatAttachmentRoot() + if (!(await ensureNativeChatAttachmentRoot(root))) { + throw new Error('Chat attachment storage is not a directory') + } + const filePath = join(await mkdtemp(join(root, ATTACHMENT_DIR_PREFIX)), fileName) + await writeFile(filePath, contents) + return filePath +} + +/** The attachment root as a filesystem root, so a restored draft's previews still resolve. */ +export function getNativeChatAttachmentAllowedRoots(): string[] { + return hasAppEnvironment() ? [resolve(getNativeChatAttachmentRoot())] : [] +} + +/** + * The one rule for this folder, for pastes and composer drops alike: Orca creates it owner-only, + * and an existing one only has to be a real directory, not a symlink. + */ +export async function ensureNativeChatAttachmentRoot( + root = getNativeChatAttachmentRoot() +): Promise { + await mkdir(root, { recursive: true, mode: 0o700 }) + return isRealDirectory(root) +} + +// Why not the shared-temp ownership check: user data is already per user, and its modes may not +// survive a restore or a filesystem without them. +async function isRealDirectory(path: string): Promise { + try { + return (await lstat(path)).isDirectory() + } catch { + return false + } +} + +export async function sweepExpiredNativeChatAttachments(nowMs = Date.now()): Promise { + const root = getNativeChatAttachmentRoot() + if (!(await isRealDirectory(root))) { + return + } + await sweepExpiredOwnedDirectories(root, { + nowMs, + ttlMs: NATIVE_CHAT_ATTACHMENT_TTL_MS, + ownsEntry: (name) => ATTACHMENT_DIR_PATTERN.test(name) || DRAG_TEMP_COPY_DIR_PATTERN.test(name) + }) +} + +let sweepScheduled = false + +/** Sweep shortly after startup, then hourly, so a long-running app still expires attachments. */ +export function scheduleNativeChatAttachmentSweep(): void { + if (sweepScheduled) { + return + } + sweepScheduled = true + const sweep = (): void => { + void sweepExpiredNativeChatAttachments().catch(() => undefined) + } + setTimeout(sweep, SWEEP_FIRST_DELAY_MS).unref() + setInterval(sweep, SWEEP_INTERVAL_MS).unref() +} diff --git a/src/main/window/native-file-drop-relay.test.ts b/src/main/window/native-file-drop-relay.test.ts index 276387bc6a4..353e1fcbf27 100644 --- a/src/main/window/native-file-drop-relay.test.ts +++ b/src/main/window/native-file-drop-relay.test.ts @@ -36,6 +36,11 @@ vi.mock('./darwin-user-temp-dir', () => ({ getDarwinUserTempDir: async () => '/private/var/folders/ab/xyz/T' })) +vi.mock('./native-chat-attachment-store', () => ({ + getNativeChatAttachmentRoot: () => '/user-data/native-chat-attachments', + ensureNativeChatAttachmentRoot: vi.fn() +})) + import { createNativeFileDropQueue, MAX_PENDING_DRAG_TEMP_COPIES, @@ -473,4 +478,30 @@ describe('registerFileDropRelay', () => { expect(webContents.send).not.toHaveBeenCalled() } ) + + // A chat draft can hold the image across a reboot; terminal drops keep their temp copies. + it.skipIf(process.platform !== 'darwin')( + "copies a composer drop into the chat's attachment storage", + async () => { + materializeMock.mockResolvedValue([copied(DRAG_TEMP, COPY)]) + const { window, webContents } = createWindow() + registerFileDropRelay(window) + + relay()({ sender: webContents }, { paths: [DRAG_TEMP], target: 'composer' }) + await settle() + relay()({ sender: webContents }, { paths: [DRAG_TEMP], target: 'terminal' }) + await settle() + + expect(materializeMock.mock.calls.map((call) => call[1].copyRoot)).toEqual([ + '/user-data/native-chat-attachments', + expect.stringContaining('orca-drops') + ]) + // The chat folder has one rule for pastes and drops; the shared temp root keeps its own. + const { ensureNativeChatAttachmentRoot } = await import('./native-chat-attachment-store') + expect(materializeMock.mock.calls.map((call) => call[1].prepareCopyRoot)).toEqual([ + ensureNativeChatAttachmentRoot, + undefined + ]) + } + ) }) diff --git a/src/main/window/native-file-drop-relay.ts b/src/main/window/native-file-drop-relay.ts index b8af442e895..9927dd1385e 100644 --- a/src/main/window/native-file-drop-relay.ts +++ b/src/main/window/native-file-drop-relay.ts @@ -16,6 +16,10 @@ import { type DragTempCopyEnvironment } from './dragged-temp-file-copy' import { getDarwinUserTempDir } from './darwin-user-temp-dir' +import { + ensureNativeChatAttachmentRoot, + getNativeChatAttachmentRoot +} from './native-chat-attachment-store' // Why: copies run one at a time, so a hung copy must not hold every later copy forever. const DRAG_TEMP_COPY_TIMEOUT_MS = 2 * 60 * 1000 @@ -29,7 +33,9 @@ type RendererLifetime = { signal: AbortSignal; dispose: () => void } type NativeFileDropQueueDeps = { forward: (payload: NativeFileDropPayload) => void platform: NodeJS.Platform - getCopyEnvironment: () => Promise + getCopyEnvironment: ( + target: AcceptedNativeFileDropPayload['target'] + ) => Promise watchRenderer: () => RendererLifetime copyTimeoutMs?: number } @@ -47,10 +53,17 @@ export function registerFileDropRelay(mainWindow: BrowserWindow): void { } }, platform: process.platform, - getCopyEnvironment: async () => ({ + getCopyEnvironment: async (target) => ({ platform: process.platform, sourceTempRoot: await getDarwinUserTempDir(), - copyRoot: getDragTempCopyRoot(app.getPath('temp')) + // Why: a chat draft can hold a dropped image across a reboot, so it is kept with pastes, + // under the same folder rule. + ...(target === NATIVE_FILE_DROP_TARGET.composer + ? { + copyRoot: getNativeChatAttachmentRoot(), + prepareCopyRoot: ensureNativeChatAttachmentRoot + } + : { copyRoot: getDragTempCopyRoot(app.getPath('temp')) }) }), watchRenderer: () => abortWhenRendererGone(mainWebContents) }) @@ -205,7 +218,9 @@ async function copyAndForward( try { // Why: race the signal too, since a hung fs call never reaches the copy's abort checks. prepared = await rejectOnAbort( - deps.getCopyEnvironment().then((env) => prepareNativeFileDrop(payload, env, signal)), + deps + .getCopyEnvironment(payload.target) + .then((env) => prepareNativeFileDrop(payload, env, signal)), signal ) } catch { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 220b90c18a6..67141741d8c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -171,6 +171,7 @@ export type { NativeChatApi, NativeChatAppendedMessages, NativeChatAppendedPayload, + NativeChatDraftsApi, NativeChatReadSessionResult, NativeChatSubscribeArgs, NativeChatSubscriptionFrame diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index 90cb5b0cfba..9f9cc2272ee 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -3,6 +3,11 @@ import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types' +import type { + NativeChatDraftStoreResult, + PersistedNativeChatDraft, + SavedNativeChatDraft +} from '../../shared/native-chat-draft-record' // notFound marks a not-yet-on-disk miss (retry-worthy) vs a real read/parse error (#8401). export type NativeChatReadSessionResult = @@ -71,4 +76,27 @@ export type NativeChatApi = { args: NativeChatSubscribeArgs, onFrame: (frame: NativeChatSubscriptionFrame) => void ) => () => void + /** Composer drafts: kept by the main process on desktop, in browser storage in the web client. */ + drafts: NativeChatDraftsApi +} + +export type NativeChatDraftsApi = { + /** Every saved draft, oldest first. */ + load: () => Promise + /** The same, blocking; only for a renderer that needs drafts before `load` returned. */ + loadSync: () => SavedNativeChatDraft[] + /** `null` clears. Resolves once written to the file (no fsync), or once the write failed. */ + write: ( + scopeKey: string, + draft: PersistedNativeChatDraft | null + ) => Promise + /** + * Save a send's clear at Enter instead of once the host has the message. Browser storage reaches + * disk lazily anyway, and clearing first frees the quota the outbox append shares with drafts. + */ + savesSendClearAtOnce?: true + /** Another window changed a draft; only where several windows share one draft store. */ + onExternalChange?: ( + listener: (scopeKey: string, draft: PersistedNativeChatDraft | null) => void + ) => void } diff --git a/src/preload/api/native-chat-bridge.ts b/src/preload/api/native-chat-bridge.ts index 3a0a5d9161a..e168fdc8308 100644 --- a/src/preload/api/native-chat-bridge.ts +++ b/src/preload/api/native-chat-bridge.ts @@ -37,5 +37,10 @@ export const nativeChatApi = { ipcRenderer.removeListener('nativeChat:appended', listener) ipcRenderer.send('nativeChat:unsubscribe', { subscriptionId: args.subscriptionId }) } + }, + drafts: { + load: () => ipcRenderer.invoke('nativeChat:drafts:load'), + loadSync: () => ipcRenderer.sendSync('nativeChat:drafts:loadSync'), + write: (scopeKey, draft) => ipcRenderer.invoke('nativeChat:drafts:write', { scopeKey, draft }) } } satisfies PreloadApi['nativeChat'] diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index 51957a5eec5..0e9e5fec519 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -93,6 +93,7 @@ export const uiClipboardAndWindowControlsApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + chatAttachment?: boolean }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), clipboardHasImage: (): Promise => ipcRenderer.invoke('clipboard:hasImage'), readClipboardFilePaths: (): Promise => ipcRenderer.invoke('clipboard:readFilePaths'), diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index c36896a2481..71d63022657 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -12,6 +12,8 @@ export type UiWindowApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + /** Keep a local image in Orca's chat attachment storage instead of OS temp. */ + chatAttachment?: boolean }) => Promise clipboardHasImage: () => Promise /** Paths of files a file manager copied; empty when there are none or the host cannot list them. */ diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 4186481e7fa..e68256fd2b3 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -21,6 +21,7 @@ import { import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' +import { listRuntimeSessionHostIdsForStartup } from '../startup/startup-runtime-session-hosts' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -30,24 +31,12 @@ import { import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId + parseExecutionHostId } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' import { ensureLocalRuntimeCapabilities } from '../runtime/local-runtime-capabilities' - -async function listRuntimeSessionHostIdsForStartup(): Promise { - try { - return (await window.api.runtimeEnvironments.list()).map((environment) => - toRuntimeExecutionHostId(environment.id) - ) - } catch (err) { - console.warn('Failed to list runtime session hosts for startup:', err) - return [] - } -} +import { preloadNativeChatDrafts } from '../components/native-chat/native-chat-draft-storage' /** * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, @@ -141,6 +130,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) ) })() + // Why before the session hydrates: a chat's composer shows its saved draft on first paint. + const draftsPromise = timeRendererStartupStep('native-chat-drafts-load', () => + preloadNativeChatDrafts() + ) const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) => // Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip. timeRendererStartupStep('session-get', () => @@ -193,6 +186,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('repo-catalog-final-settlement', () => actions.awaitLocalRepoCatalogSettlement() ) + await draftsPromise if (!cancelled) { const sessionHydrationOptions = { additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index ad5ad98a62a..53aa23058c4 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -8,6 +8,7 @@ import type { } from '../../../../shared/native-chat-session-options' import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' +import type * as NativeChatDraftCacheModule from './native-chat-draft-cache' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), @@ -103,7 +104,8 @@ vi.mock('./use-native-chat-draft', () => ({ return { draft: mocks.draft, setDraft: mocks.setDraft, flushDraftAppends: () => {} } } })) -vi.mock('./native-chat-draft-cache', () => ({ +vi.mock('./native-chat-draft-cache', async (importOriginal) => ({ + ...(await importOriginal()), readNativeChatDraftCache: () => '' })) vi.mock('./NativeChatComposerField', () => ({ @@ -245,7 +247,7 @@ describe('NativeChatComposer', () => { ) }) - it('associates a delayed submit with its optimistic cache entry', () => { + it('associates a delayed submit with its optimistic cache entry', async () => { const onOptimisticSend = vi.fn(() => 'pending-1') render( { /> ) - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') @@ -386,7 +388,7 @@ describe('NativeChatComposer', () => { expect(mocks.setDraft).toHaveBeenCalledWith('') }) - it('disables Send and blocks a send while an image attachment is still pending', () => { + it('disables Send and blocks a send while an image attachment is still pending', async () => { mocks.imageAttachments = [{ id: 'image-1', path: '', pending: true }] render( { expect(mocks.fieldProps?.sendButtonDisabled).toBe(true) - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() expect(mocks.sendNativeChatMessageWithImageAttachments).not.toHaveBeenCalled() }) - it('enables Send and dispatches once a pending attachment resolves', () => { + it('enables Send and dispatches once a pending attachment resolves', async () => { mocks.imageAttachments = [{ id: 'image-1', path: '', pending: true }] const view = render( { ) expect(mocks.fieldProps?.sendButtonDisabled).toBe(false) - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(mocks.sendNativeChatMessageWithImageAttachments).toHaveBeenCalledWith( 'codex', @@ -441,7 +443,7 @@ describe('NativeChatComposer', () => { ) }) - it('types Codex slash composer sends instead of pasting them', () => { + it('types Codex slash composer sends instead of pasting them', async () => { mocks.draft = '/status' render( { /> ) - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(mocks.sendNativeChatTypedCommand).toHaveBeenCalledWith({}, 'pty-1', '/status') expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() }) - it('keeps Codex skill sends pasted', () => { + it('keeps Codex skill sends pasted', async () => { mocks.draft = '$ref-oss' render( { /> ) - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '$ref-oss', undefined) expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() }) - it.each(['claude'] as const)('keeps %s slash composer sends pasted', (agent) => { + it.each(['claude'] as const)('keeps %s slash composer sends pasted', async (agent) => { mocks.draft = '/clear' render( { /> ) - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(mocks.sendNativeChatMessage).toHaveBeenCalledWith({}, 'pty-1', '/clear', undefined) expect(mocks.sendNativeChatTypedCommand).not.toHaveBeenCalled() }) - it('retires the launch-draft seed once a send clears the TUI input line', () => { + it('retires the launch-draft seed once a send clears the TUI input line', async () => { render( { ) expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled() - act(() => mocks.fieldProps?.onSend?.()) + await act(async () => mocks.fieldProps?.onSend?.()) expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1') }) @@ -536,7 +538,7 @@ describe('NativeChatComposer', () => { /> ) - expect(new Set(mocks.draftScopeKeys)).toEqual(new Set(['tab-1:leaf-1'])) + expect(new Set(mocks.draftScopeKeys)).toEqual(new Set(['pane:tab-1:leaf-1'])) expect(view.getByTestId('native-chat-composer-field')).toBe(field) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d9fb9f1ec2d..dafa6a68525 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -10,6 +10,7 @@ import { type HistoryState } from './native-chat-composer-state' import { useNativeChatDraft } from './use-native-chat-draft' +import { nativeChatDraftKey } from './native-chat-draft-cache' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' import type { NativeChatResolvedTarget } from './native-chat-composer-target' @@ -71,22 +72,21 @@ const NativeChatComposerPane = forwardRef attachment.pending) + // A pasted image has no agent-readable path until its save lands, and a missing one never + // will; sending would ship the message without the image the chip promises. + const hasBlockedAttachment = imageAttachments.some((chip) => chip.pending || chip.missing) const sendButtonDisabled = isWorking ? !hasPty || !onStop - : disabled || hasPendingAttachment || (draft.trim() === '' && imageAttachments.length === 0) + : disabled || hasBlockedAttachment || (draft.trim() === '' && imageAttachments.length === 0) const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({ terminalTabId, @@ -250,6 +250,7 @@ const NativeChatComposerPane = forwardRef ) : null} + {/* Why visible: Send stays disabled while a chip is missing, and a tooltip alone hides the reason. */} + {imageAttachments.some((attachment) => attachment.missing) ? ( +

+ {translate( + 'components.native-chat.composer.imageMissing', + 'Image no longer available. Remove it to send.' + )} +

+ ) : null} { expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(undefined, '', undefined) }) + + // Read before its grant comes back, a restored image would stay blank. + it('reads a restored image only once its check is done', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '/Users/me/shot.png', checking: true }) + + expect(mocks.useLocalImageSrc).toHaveBeenLastCalledWith( + undefined, + '/Users/me/shot.png', + undefined + ) + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 52e9a5f7c3a..45e3312b84b 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react' -import { Image as ImageIcon, Loader2, X } from 'lucide-react' +import { Image as ImageIcon, ImageOff, Loader2, X } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' +import { cn } from '@/lib/utils' import { isNativeChatPastedImagePath } from './native-chat-image-paste' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -42,8 +43,11 @@ export function NativeChatImageAttachmentPreview({ return () => observer.disconnect() }, []) const isPending = attachment.pending === true + const isMissing = attachment.missing === true const localSrc = useLocalImageSrc( - !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, + !isPending && !isMissing && !attachment.checking && (isNearViewport || isOpen) + ? attachment.path + : undefined, attachment.path, attachment.connectionId ) @@ -58,7 +62,11 @@ export function NativeChatImageAttachmentPreview({ 'components.native-chat.composer.imageSaving', 'Saving pasted image…' ) - const label = isPending ? pendingLabel : filename + const missingLabel = `${filename}: ${translate( + 'components.native-chat.composer.imageMissing', + 'Image no longer available. Remove it to send.' + )}` + const label = isPending ? pendingLabel : isMissing ? missingLabel : filename return ( <> @@ -73,9 +81,14 @@ export function NativeChatImageAttachmentPreview({ aria-busy={isPending} title={label} onClick={() => setIsOpen(true)} - className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-background transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className={cn( + 'flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-background transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', + isMissing && 'border-destructive' + )} > - {thumbnailSrc ? ( + {isMissing ? ( + + ) : thumbnailSrc ? ( {label} {pendingLabel} + ) : isMissing ? ( + <> + + {missingLabel} + ) : ( <> diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx index 652086366c0..29969973504 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx @@ -1,4 +1,4 @@ -import { readNativeChatDraftDocument } from './native-chat-draft-cache' +import { readNativeChatDraftDocument } from './native-chat-draft-document-cache' // @vitest-environment happy-dom import { createRef } from 'react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index ceb812e6291..70d45a869aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -1,7 +1,7 @@ import { readNativeChatDraftDocument, writeNativeChatDraftDocument -} from './native-chat-draft-cache' +} from './native-chat-draft-document-cache' import { closeHistory } from '@tiptap/pm/history' import { Slice } from '@tiptap/pm/model' import { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index e2c4c6ff030..8d6fdfc4bac 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -35,7 +35,7 @@ describe('NativeChatStructuredSession', () => { resetStructuredSessionMocks() }) - it('gives what a Stop withdrew back to the composer this pane shows', () => { + it('gives what a Stop withdrew back to the chat draft every pane on it shows', () => { render( { ) const paneKey = structuredAgentSessionPaneKey('structured-tab-1', 'session-1') expect(mocks.composerProps).toMatchObject({ paneKey }) - expect(mocks.controllerProps).toMatchObject({ composerScopeKey: paneKey }) + expect(mocks.controllerProps).toMatchObject({ composerScopeKey: 'session:session-1' }) }) it('routes the launch draft and app-menu paste to the structured composer', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6b79c8a7fc8..af2fccd3c3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -31,6 +31,7 @@ import { structuredAgentSessionReadFailureNotice } from './structured-agent-sess import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { nativeChatDraftKey } from './native-chat-draft-cache' const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] @@ -53,7 +54,8 @@ export function NativeChatStructuredSession( const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false) const controller = useStructuredAgentSession({ ...props, - composerScopeKey: paneKey, + composerScopeKey: nativeChatDraftKey({ sessionId: props.sessionId, paneKey }), + composerWorktreeId: fileLinkContext?.worktreeId, queueFollowUps, providerStarting: hostExecution.phase === 'starting', transportEnabled: provisionalLaunch.transportEnabled, diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-existence.ts b/src/renderer/src/components/native-chat/native-chat-attachment-existence.ts new file mode 100644 index 00000000000..0cdb73316b1 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-attachment-existence.ts @@ -0,0 +1,55 @@ +import { requirePathExistenceResults } from '../../../../shared/path-existence-batch' +import type { NativeChatDraftAttachment } from './native-chat-draft-storage' + +/** + * Ids of attachments whose file is gone. Only a file this machine can ask about is checked: a + * local one (granted for reading first, as at attach) or one on an SSH host. One on a runtime + * server, or with no recorded location, is never reported; nor is one whose check cannot answer + * (an unreachable SSH host, a refused grant): losing contact is not proof the file is gone. + */ +export async function findMissingNativeChatAttachments( + attachments: readonly NativeChatDraftAttachment[] +): Promise> { + const byConnection = new Map() + for (const attachment of attachments) { + const connectionId = attachment.connectionId ?? (attachment.location === 'local' ? '' : null) + if (connectionId !== null) { + byConnection.set(connectionId, [...(byConnection.get(connectionId) ?? []), attachment]) + } + } + const missing = new Set() + await Promise.all( + Array.from(byConnection, async ([connectionId, group]) => { + try { + const filePaths = group.map((attachment) => attachment.path) + if (!connectionId) { + // A relaunch forgot the read grant each local image got when it was attached. + await Promise.all(filePaths.map(grantRead)) + } + const results = requirePathExistenceResults( + await window.api.fs.pathsExist?.({ + filePaths, + ...(connectionId ? { connectionId } : {}) + }), + filePaths.length + ) + results.forEach((result, index) => { + if ('exists' in result && !result.exists) { + missing.add(group[index].id) + } + }) + } catch { + // Unverifiable: keep the chip as it is. + } + }) + ) + return missing +} + +async function grantRead(targetPath: string): Promise { + try { + await window.api.fs.authorizeExternalPath({ targetPath }) + } catch { + // Left ungranted: its preview stays blank and the check below cannot answer for it. + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 508a7c20e5e..ffda9656a7e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -14,10 +14,10 @@ import { useComposerDropListener } from '../../hooks/composer-state/composer-dro import type { NativeFileDropPayload } from '../../../../shared/native-file-drop' import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' import { - clearNativeChatAttachmentCacheForTests, - readNativeChatAttachmentCache, - useNativeChatComposerAttachments -} from './use-native-chat-composer-attachments' + clearNativeChatDraftCacheForTests, + readNativeChatDraftAttachments +} from './native-chat-draft-cache' +import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' const electron = vi.hoisted(() => ({ on: vi.fn(), @@ -163,7 +163,7 @@ describe('native chat composer drop scoping', () => { cleanup() resetLocalImageSrcStateForTests() vi.unstubAllGlobals() - clearNativeChatAttachmentCacheForTests() + clearNativeChatDraftCacheForTests() electron.send.mockClear() }) @@ -181,7 +181,7 @@ describe('native chat composer drop scoping', () => { reason: 'unresolved-paths', target: 'rejected' }) - expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) + expect(readNativeChatDraftAttachments('chat-a')).toEqual([]) }) it('notices an OS drop whose every path fails authorization', async () => { @@ -194,7 +194,7 @@ describe('native chat composer drop scoping', () => { expect(view.container.querySelector('[data-notice="chat-a"]')?.textContent).toBe( "Couldn't read the dropped files." ) - expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) + expect(readNativeChatDraftAttachments('chat-a')).toEqual([]) }) it('notices an OS drop whose owner changes during authorization', async () => { @@ -209,7 +209,7 @@ describe('native chat composer drop scoping', () => { expect(view.container.querySelector('[data-notice="chat-a"]')?.textContent).toBe( 'This workspace changed hosts while attaching — drop the files again.' ) - expect(readNativeChatAttachmentCache('chat-a')).toEqual([]) + expect(readNativeChatDraftAttachments('chat-a')).toEqual([]) }) it('attaches only to the dropped pane and leaves a hidden pane clean on remount', async () => { @@ -219,8 +219,8 @@ describe('native chat composer drop scoping', () => {