From b99552cabc61a55ba3f49db10aa7f0c839bd7db7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:08:47 -0400 Subject: [PATCH] fix(runtime): a phone reads and saves Markdown tabs on a headless server (#31101) * fix(runtime): a phone reads and saves Markdown tabs on a headless server With no window to relay through, markdown.readTab and markdown.saveTab answered renderer_unavailable. Main now reads the tab's file (or its saved draft) from disk and saves with a version compare plus the disk write precondition, in the shapes the window's editor relay returns. With a window attached the relay is unchanged. * Headless Markdown save: keep the draft's disk baseline, honour read-only tabs, re-find the record to clear * Use the disk baseline only for a draft * ci: run the phone Markdown spec's orcad case in the orca serve mode-switch job * Headless Markdown: answer from disk only; a window's saved draft stays its view state Clearing a saved draft wrote workspace layout from main, which no layout command carries. Main now never shows or edits a draft; the desktop's restore check reports a disk change. * Phone Markdown: one path through main and the disk, window or not A paired server's workspace is read and saved on that server with files.read / files.writeIfUnchanged, so it answers from its own disk and keeps the precondition. The window relay (ui:mobileMarkdownRequest and the renderer bridge) is removed. * e2e: a phone saves a Markdown tab the desktop holds unsaved edits in The phone reads the disk with the tab marked dirty; its save lands on disk, and the desktop keeps its draft and shows the changed-on-disk banner. * Group the Markdown disk host's imports * Drop an unneeded store cast from the dirty-readers test * Phone Markdown: refuse a save on a server too old to check the disk; cover the untitled tab A server before #30605 has no files.writeIfUnchanged, so the save is refused with the existing update-required message rather than written unchecked. * Phone Markdown: refuse a file an SSH host holds outside the workspace; pin the untitled answer * Phone Markdown: untitled tabs stay read-only; older servers save like the desktop editor; a closed-source preview still reads - The desktop lists an untitled document's tab with isUntitled (optional, additive); main answers it read-only with unsupported_untitled and refuses its save before touching the disk. - A server without the files.write.disk-precondition capability gets the desktop editor's guarded save: compare from here, then a plain files.write, or a conflict. Every call of a save is pinned to the pairing it probed. - A preview whose source tab was closed has no saved record; it reads its own file, read-only. * e2e: read the untitled tab's answer without a cast * Phone Markdown: pin a save's pairing before its first read A save captures the server's pairing before it reads, and every call of the save, the read included, is pinned to it, so a re-pair between the read and the write is refused. * Test: with no window, a phone saves an existing untitled.md and never creates a deleted one --------- Co-authored-by: E2E Test --- .github/workflows/e2e.yml | 9 + src/main/runtime/files/file-reads.ts | 20 +- .../files/headless-markdown-document.ts | 169 ------ .../markdown-tab-disk-documents.test.ts | 270 +++++++++ .../runtime/markdown-tab-disk-documents.ts | 211 +++++++ src/main/runtime/markdown-tab-disk-host.ts | 243 ++++++++ .../mobile-session-editor-projection.ts | 4 +- ...runtime-mobile-markdown-tab-routes.test.ts | 554 ++++++++++++++++++ src/main/runtime/runtime-notifier-contract.ts | 9 - .../workspace-file-session-targets.ts | 64 +- .../mobile-markdown-request-relay.test.ts | 104 ---- .../window/mobile-markdown-request-relay.ts | 91 --- ...runtime-window-lifecycle-file-open.test.ts | 1 - src/main/window/runtime-window-lifecycle.ts | 19 - ...ime-window-pane-title-notification.test.ts | 1 - ...ui-bridge-clipboard-and-window-controls.ts | 15 - src/preload/api/ui-command-event-api.ts | 6 - .../editor/editor-autosave-controller.ts | 6 +- .../src/components/editor/editor-autosave.ts | 2 - .../editor-clean-tab-draft-invariant.test.ts | 15 - .../editor/editor-save-disk-precondition.ts | 8 +- .../components/editor/editor-save-queue.ts | 17 +- .../editor-save-shared-model-dirty.test.ts | 45 -- .../editor/editor-save-shared-model.test.ts | 20 - .../ipc-events/app-lifetime-ipc-bridge.ts | 2 - .../editor-buffer-dirty-readers.test.ts | 66 +-- ...mobile-markdown-bridge-save-guards.test.ts | 241 -------- .../mobile-markdown-bridge-test-harness.ts | 151 ----- .../runtime/mobile-markdown-bridge.test.ts | 518 ---------------- .../src/runtime/mobile-markdown-bridge.ts | 310 ---------- .../runtime/mobile-markdown-save-rollback.ts | 93 --- .../mobile-session-editor-tabs.test.ts | 34 ++ .../mobile-session-editor-tabs.ts | 1 + .../src/web/preload-api/web-ui-api.ts | 2 - src/shared/mobile-markdown-document.ts | 28 - .../runtime-mobile-session-tab-contracts.ts | 2 + .../desktop-window-mobile-markdown.spec.ts | 243 ++++++++ .../headless-serve-mobile-markdown.spec.ts | 136 +++++ .../helpers/headless-layout-oracle-hosts.ts | 25 +- 39 files changed, 1830 insertions(+), 1925 deletions(-) delete mode 100644 src/main/runtime/files/headless-markdown-document.ts create mode 100644 src/main/runtime/markdown-tab-disk-documents.test.ts create mode 100644 src/main/runtime/markdown-tab-disk-documents.ts create mode 100644 src/main/runtime/markdown-tab-disk-host.ts create mode 100644 src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts delete mode 100644 src/main/window/mobile-markdown-request-relay.test.ts delete mode 100644 src/main/window/mobile-markdown-request-relay.ts delete mode 100644 src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts delete mode 100644 src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts delete mode 100644 src/renderer/src/runtime/mobile-markdown-bridge.test.ts delete mode 100644 src/renderer/src/runtime/mobile-markdown-bridge.ts delete mode 100644 src/renderer/src/runtime/mobile-markdown-save-rollback.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.test.ts create mode 100644 tests/e2e/desktop-window-mobile-markdown.spec.ts create mode 100644 tests/e2e/headless-serve-mobile-markdown.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ad0f571ce44..604594879f5 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -490,6 +490,15 @@ jobs: ORCA_E2E_ORCAD_SERVE: '1' ORCA_E2E_FORWARD_APP_LOGS: '1' run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1 + # Only this job builds out/orcad, so the spec's orcad case runs here; the e2e shards run its Electron case. + - name: Phone Markdown on headless orcad serve + if: >- + !cancelled() && + (inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/headless-serve-mobile-markdown.spec.ts')) + env: + SKIP_BUILD: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/headless-serve-mobile-markdown.spec.ts --grep "headless orcad server" --project=electron-headless --workers=1 # The layout oracle's headless scenarios run on both serve hosts, so they need this job's orcad. - name: Layout oracle on headless serve (orcad and Electron) if: >- diff --git a/src/main/runtime/files/file-reads.ts b/src/main/runtime/files/file-reads.ts index adf3de7eb15..76d58f8c417 100644 --- a/src/main/runtime/files/file-reads.ts +++ b/src/main/runtime/files/file-reads.ts @@ -173,15 +173,29 @@ export class RuntimeFileReads { } async function readLocalMobileFile(filePath: string, store: RuntimeStore): Promise { + // Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views). + const { bytes } = await readLocalRuntimeFilePrefix( + filePath, + store, + MOBILE_FILE_READ_MAX_BYTES + 1 + ) + return bytes.toString('utf8') +} + +/** Up to `maxBytes` from the start of an authorized local file, and the file's full size. */ +export async function readLocalRuntimeFilePrefix( + filePath: string, + store: RuntimeStore, + maxBytes: number +): Promise<{ bytes: Buffer; size: number }> { const authorizedPath = await resolveRuntimeAuthorizedPath(filePath, store) const fileStat = await stat(authorizedPath) - // Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views). - const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) + const readLimit = Math.min(fileStat.size, maxBytes) const handle = await open(authorizedPath, 'r') try { const buffer = Buffer.alloc(readLimit) const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) - return buffer.subarray(0, bytesRead).toString('utf8') + return { bytes: buffer.subarray(0, bytesRead), size: fileStat.size } } finally { await handle.close() } diff --git a/src/main/runtime/files/headless-markdown-document.ts b/src/main/runtime/files/headless-markdown-document.ts deleted file mode 100644 index 687fdca5848..00000000000 --- a/src/main/runtime/files/headless-markdown-document.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { isBinaryBuffer } from '../../../shared/binary-buffer' -import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' -import { - hashMarkdownContent, - isMarkdownContentByteLengthOverLimit, - MOBILE_MARKDOWN_EDIT_MAX_BYTES, - MOBILE_MARKDOWN_READ_MAX_BYTES, - truncateMobileMarkdownRead, - type RuntimeMarkdownReadTabResult, - type RuntimeMarkdownSaveTabResult -} from '../../../shared/mobile-markdown-document' -import type { - PersistedOpenFile, - WorkspaceSessionState -} from '../../../shared/workspace-session-state-types' -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' -import { - openLocalRegularFile, - readLocalFilePrefix -} from '../../ipc/filesystem/local-regular-file-read' -import { buildHeadlessMobileSessionEditorTabs } from '../mobile-session-editor-projection' -import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root' -import { requireRuntimeFileProvider } from './file-target' -import type { RuntimeFileCommandHost } from './file-command-host' - -type Host = Pick & { - getSession(worktreeId: string): WorkspaceSessionState | null | undefined - updateSession( - worktreeId: string, - update: (session: WorkspaceSessionState) => WorkspaceSessionState - ): void -} - -export class HeadlessMarkdownDocuments { - private readonly saves = new Map>() - - constructor(private readonly host: Host) {} - - private target(worktreeId: string, tabId: string): PersistedOpenFile { - const session = this.host.getSession(worktreeId) - const tab = - session && - buildHeadlessMobileSessionEditorTabs(worktreeId, session).find( - (candidate) => candidate.id === tabId && candidate.type === 'markdown' - ) - const file = - tab && - session.openFilesByWorktree?.[worktreeId]?.find( - (candidate) => - candidate.filePath === tab.filePath && - !candidate.runtimeEnvironmentId && - !candidate.externalSshTargetId - ) - if (!file) { - throw new Error('tab_not_found') - } - return file - } - - private async disk(worktreeId: string, file: PersistedOpenFile) { - const target = await this.host.resolveRuntimeFileTarget(worktreeId) - const provider = requireRuntimeFileProvider(target, this.host) - const path = await resolveRuntimeAuthorizedPath(file.filePath, this.host.requireStore()) - const { handle, stats } = await openLocalRegularFile(path) - try { - const bytes = await readLocalFilePrefix( - handle, - Math.min(stats.size, MOBILE_MARKDOWN_READ_MAX_BYTES + 4) - ) - if (isBinaryBuffer(bytes)) { - throw new Error('binary_file') - } - return { content: bytes.toString('utf8'), byteLength: stats.size, provider } - } finally { - await handle.close() - } - } - - async read(worktreeId: string, tabId: string): Promise { - const file = this.target(worktreeId, tabId) - const disk = file.dirtyDraftContent === undefined ? await this.disk(worktreeId, file) : null - const content = file.dirtyDraftContent ?? disk?.content ?? '' - const preview = truncateMobileMarkdownRead(content) - const tooLarge = isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES) - const readOnlyReason = file.readOnly - ? 'unsupported_tab' - : tooLarge - ? 'file_too_large' - : undefined - return { - tabId, - filePath: file.filePath, - relativePath: file.relativePath, - content: preview.content, - version: hashMarkdownContent(preview.content), - isDirty: file.dirtyDraftContent !== undefined, - source: file.dirtyDraftContent === undefined ? 'file' : 'draft', - editable: !readOnlyReason, - ...(readOnlyReason ? { readOnlyReason } : {}), - ...(preview.truncated - ? { truncated: true, byteLength: disk?.byteLength ?? preview.byteLength } - : {}) - } - } - - async save( - worktreeId: string, - tabId: string, - baseVersion: string, - content: string - ): Promise { - const file = this.target(worktreeId, tabId) - const key = `${worktreeId}:${file.filePath}` - return runKeyedSerializedOperation(this.saves, key, async () => { - const current = this.target(worktreeId, tabId) - if (current.readOnly) { - throw new Error('unsupported_tab') - } - if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) { - throw new Error('file_too_large') - } - const disk = await this.disk(worktreeId, current) - const baseline = current.dirtyDraftContent ?? disk.content - if (isMarkdownContentByteLengthOverLimit(baseline, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) { - throw new Error('file_too_large') - } - if (hashMarkdownContent(baseline) !== baseVersion && baseline !== content) { - throw new Error('conflict') - } - const expectedSignature = - current.dirtyDraftContent !== undefined - ? current.lastKnownDiskSignature - : getDiskBaselineSignature(disk.content) - // A restored draft without its disk baseline cannot safely replace external edits. - if (!expectedSignature) { - throw new Error('conflict') - } - const written = await disk.provider.writeFileWithDiskPrecondition(current.filePath, content, { - expectedSignature - }) - if (!written.ok) { - throw new Error('conflict') - } - const verified = await this.disk(worktreeId, current) - if (verified.content !== content) { - throw new Error('save_verification_failed') - } - this.host.updateSession(worktreeId, (session) => ({ - ...session, - openFilesByWorktree: { - ...session.openFilesByWorktree, - [worktreeId]: (session.openFilesByWorktree?.[worktreeId] ?? []).map((candidate) => { - if ( - candidate.filePath !== current.filePath || - candidate.runtimeEnvironmentId || - candidate.externalSshTargetId || - candidate.dirtyDraftContent !== current.dirtyDraftContent - ) { - return candidate - } - const { dirtyDraftContent: _draft, ...saved } = candidate - return { ...saved, lastKnownDiskSignature: getDiskBaselineSignature(content) } - }) - } - })) - return { tabId, version: hashMarkdownContent(content), isDirty: false as const, content } - }) - } -} diff --git a/src/main/runtime/markdown-tab-disk-documents.test.ts b/src/main/runtime/markdown-tab-disk-documents.test.ts new file mode 100644 index 00000000000..123f984cc63 --- /dev/null +++ b/src/main/runtime/markdown-tab-disk-documents.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, vi } from 'vitest' +import { + hashMarkdownContent, + MOBILE_MARKDOWN_EDIT_MAX_BYTES, + MOBILE_MARKDOWN_READ_MAX_BYTES +} from '../../shared/mobile-markdown-document' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { getDiskBaselineSignature } from '../../shared/disk-baseline-signature' +import { compareDiskWritePrecondition } from '../../shared/file-write-disk-precondition' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { + readMarkdownTabFromDisk, + saveMarkdownTabToDisk, + type ListedMarkdownTab, + type MarkdownTabDiskHost +} from './markdown-tab-disk-documents' + +const WORKTREE = 'repo::/srv/repo' +const FILE_PATH = '/srv/repo/README.md' +const TAB: ListedMarkdownTab = { + id: FILE_PATH, + mode: 'edit', + filePath: FILE_PATH, + sourceFilePath: FILE_PATH, + sourceRelativePath: 'README.md', + isDirty: false +} + +function makeHost(disk: string, draft?: string, saved: Partial = {}) { + const file: PersistedOpenFile = { + filePath: FILE_PATH, + relativePath: 'README.md', + worktreeId: WORKTREE, + language: 'markdown', + runtimeEnvironmentId: null, + ...(draft === undefined + ? {} + : { dirtyDraftContent: draft, lastKnownDiskSignature: getDiskBaselineSignature(disk) }), + ...saved + } + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { [WORKTREE]: [file] } + } + const state = { disk } + const io = { + readFile: vi.fn(async (_worktreeId: string, _file: { filePath: string }, maxBytes: number) => { + const bytes = Buffer.from(state.disk) + return { content: bytes.subarray(0, maxBytes).toString('utf8'), byteLength: bytes.length } + }), + writeFileIfUnchanged: vi.fn( + async ( + _worktreeId: string, + _file: { filePath: string }, + content: string, + precondition: { expectedSignature: string } + ) => { + if (compareDiskWritePrecondition(precondition, state.disk)) { + return { ok: false as const, diskConflict: 'changed' as const } + } + state.disk = content + return { ok: true as const } + } + ) + } + const host = { + ...io, + getWorkspaceSessionForWorktree: () => session, + forSave: async () => io + } satisfies MarkdownTabDiskHost + return { host, state } +} + +describe('Markdown tab documents on disk', () => { + it('reads the file from disk when the tab has no saved draft', async () => { + const { host } = makeHost('# Notes\n') + + await expect(readMarkdownTabFromDisk(host, WORKTREE, TAB)).resolves.toEqual({ + tabId: FILE_PATH, + filePath: FILE_PATH, + relativePath: 'README.md', + content: '# Notes\n', + isDirty: false, + version: hashMarkdownContent('# Notes\n'), + source: 'file', + editable: true + }) + }) + + it('reads the disk, never unsaved desktop text, and reports the tab dirty as it was listed', async () => { + const { host } = makeHost('# Disk\n', '# Draft\n') + + await expect( + readMarkdownTabFromDisk(host, WORKTREE, { ...TAB, isDirty: true }) + ).resolves.toMatchObject({ + content: '# Disk\n', + isDirty: true, + source: 'file', + editable: true, + version: hashMarkdownContent('# Disk\n') + }) + }) + + it("reads a preview tab's source file, read-only", async () => { + const { host } = makeHost('# Notes\n') + const preview: ListedMarkdownTab = { + ...TAB, + id: `markdown-preview::${FILE_PATH}`, + mode: 'markdown-preview' + } + + await expect(readMarkdownTabFromDisk(host, WORKTREE, preview)).resolves.toMatchObject({ + tabId: preview.id, + filePath: FILE_PATH, + content: '# Notes\n', + editable: false, + readOnlyReason: 'unsupported_preview' + }) + await expect( + saveMarkdownTabToDisk(host, WORKTREE, preview, hashMarkdownContent('# Notes\n'), '# Edit\n') + ).rejects.toThrow('unsupported_preview') + expect(host.writeFileIfUnchanged).not.toHaveBeenCalled() + }) + + it('marks a read truncated when the host sent less than the whole file', async () => { + const { host } = makeHost('# Notes\n') + host.readFile.mockResolvedValueOnce({ content: 'a'.repeat(10), byteLength: 600_000 }) + + await expect(readMarkdownTabFromDisk(host, WORKTREE, TAB)).resolves.toMatchObject({ + truncated: true, + byteLength: 600_000, + editable: false, + readOnlyReason: 'file_too_large' + }) + }) + + it('marks a file over the edit cap read-only, and a file over the read cap truncated', async () => { + const editable = makeHost('a'.repeat(MOBILE_MARKDOWN_EDIT_MAX_BYTES + 1)) + await expect(readMarkdownTabFromDisk(editable.host, WORKTREE, TAB)).resolves.toMatchObject({ + editable: false, + readOnlyReason: 'file_too_large' + }) + + const size = MOBILE_MARKDOWN_READ_MAX_BYTES + 10 + const large = makeHost('a'.repeat(size)) + const read = await readMarkdownTabFromDisk(large.host, WORKTREE, TAB) + expect(read).toMatchObject({ truncated: true, byteLength: size, editable: false }) + expect(Buffer.byteLength(read.content)).toBe(MOBILE_MARKDOWN_READ_MAX_BYTES) + expect(read.version).toBe(hashMarkdownContent(read.content)) + }) + + it('saves over the version the phone read, refusing any change on disk since', async () => { + const { host, state } = makeHost('# Notes\n') + + await expect( + saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# New\n') + ).resolves.toEqual({ + tabId: FILE_PATH, + version: hashMarkdownContent('# New\n'), + isDirty: false, + content: '# New\n' + }) + expect(state.disk).toBe('# New\n') + expect(host.writeFileIfUnchanged).toHaveBeenCalledWith( + WORKTREE, + expect.objectContaining({ filePath: FILE_PATH }), + '# New\n', + { + expectedSignature: getDiskBaselineSignature('# Notes\n') + } + ) + }) + + it('refuses a save from a stale version and leaves the disk alone', async () => { + const { host, state } = makeHost('# Changed on the server\n') + + await expect( + saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# Phone\n') + ).rejects.toThrow('conflict') + expect(state.disk).toBe('# Changed on the server\n') + expect(host.writeFileIfUnchanged).not.toHaveBeenCalled() + }) + + it('answers a repeated save that already landed as saved', async () => { + const { host } = makeHost('# New\n') + + await expect( + saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# New\n') + ).resolves.toMatchObject({ content: '# New\n', isDirty: false }) + expect(host.writeFileIfUnchanged).not.toHaveBeenCalled() + }) + + it('refuses when the disk changes between the read and the write', async () => { + const { host, state } = makeHost('# Notes\n') + host.readFile.mockImplementationOnce(async () => { + const read = { content: state.disk, byteLength: Buffer.byteLength(state.disk) } + state.disk = '# Raced\n' + return read + }) + + await expect( + saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Notes\n'), '# Phone\n') + ).rejects.toThrow('conflict') + expect(state.disk).toBe('# Raced\n') + }) + + it("saves the disk, leaving a window's saved draft for that window", async () => { + const { host, state } = makeHost('# Disk\n', '# Draft\n') + + await saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Disk\n'), '# Phone\n') + + expect(state.disk).toBe('# Phone\n') + }) + + it('runs two saves of one file one after the other', async () => { + const { host, state } = makeHost('# Notes\n') + const base = hashMarkdownContent('# Notes\n') + + // Run together, the second would pass the same version check and fail the disk precondition. + const results = await Promise.all([ + saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# Same\n'), + saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# Same\n') + ]) + + expect(results.map((result) => result.content)).toEqual(['# Same\n', '# Same\n']) + expect(host.writeFileIfUnchanged).toHaveBeenCalledTimes(1) + expect(state.disk).toBe('# Same\n') + }) + + it('keeps saving after a failed save of the same file', async () => { + const { host, state } = makeHost('# Notes\n') + host.writeFileIfUnchanged.mockRejectedValueOnce(new Error('EACCES')) + const base = hashMarkdownContent('# Notes\n') + + await expect(saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# First\n')).rejects.toThrow( + 'EACCES' + ) + await saveMarkdownTabToDisk(host, WORKTREE, TAB, base, '# Second\n') + + expect(state.disk).toBe('# Second\n') + }) + + it('never saves a read-only tab', async () => { + const { host } = makeHost('# Log\n', undefined, { readOnly: true }) + + await expect(readMarkdownTabFromDisk(host, WORKTREE, TAB)).resolves.toMatchObject({ + editable: false, + readOnlyReason: 'unsupported_tab' + }) + await expect( + saveMarkdownTabToDisk(host, WORKTREE, TAB, hashMarkdownContent('# Log\n'), '# Edit\n') + ).rejects.toThrow('unsupported_tab') + expect(host.writeFileIfUnchanged).not.toHaveBeenCalled() + }) + + it('refuses a tab the saved session no longer holds', async () => { + const { host } = makeHost('# Notes\n') + + await expect( + readMarkdownTabFromDisk(host, WORKTREE, { + ...TAB, + id: 'gone', + sourceFilePath: '/srv/repo/GONE.md' + }) + ).rejects.toThrow('tab_not_found') + }) +}) diff --git a/src/main/runtime/markdown-tab-disk-documents.ts b/src/main/runtime/markdown-tab-disk-documents.ts new file mode 100644 index 00000000000..df6995f45d7 --- /dev/null +++ b/src/main/runtime/markdown-tab-disk-documents.ts @@ -0,0 +1,211 @@ +/** + * A phone's Markdown read and save, window or not: main answers from the file on disk, through the + * workspace's own host. The desktop's unsaved text stays the desktop's: the phone sees the disk, + * with the listed tab's `isDirty` as its "changed on desktop" signal, and the desktop's editor + * reports a phone save as a change on disk. + */ +import { + hashMarkdownContent, + isMarkdownContentByteLengthOverLimit, + MOBILE_MARKDOWN_EDIT_MAX_BYTES, + MOBILE_MARKDOWN_READ_MAX_BYTES, + truncateMobileMarkdownRead, + utf8ByteLength, + type RuntimeMarkdownReadTabResult, + type RuntimeMarkdownSaveTabResult +} from '../../shared/mobile-markdown-document' +import type { RuntimeMobileSessionMarkdownTab } from '../../shared/runtime-types' +import type { + FileWriteDiskPrecondition, + FileWriteResult +} from '../../shared/file-write-disk-precondition' +import { getDiskBaselineSignature } from '../../shared/disk-baseline-signature' +import type { + PersistedOpenFile, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { findListedEditorFile } from './mobile-session-editor-projection' + +export type MarkdownDiskFile = { content: string; byteLength: number } + +type MarkdownFileAddress = Pick< + PersistedOpenFile, + 'filePath' | 'relativePath' | 'externalSshTargetId' +> + +export type MarkdownTabDiskHost = Pick & { + getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState | null | undefined + /** The file calls of one save, all reaching the host its first call reached. */ + forSave(worktreeId: string, file: MarkdownFileAddress): Promise +} + +export type MarkdownFileIo = { + /** A prefix of the file decoded (at most `maxBytes`, the host may send less), and its full + * size; throws `binary_file` for binary. */ + readFile( + worktreeId: string, + file: MarkdownFileAddress, + maxBytes: number + ): Promise + writeFileIfUnchanged( + worktreeId: string, + file: MarkdownFileAddress, + content: string, + precondition: FileWriteDiskPrecondition + ): Promise +} + +/** The tab as the phone was shown it; a preview names its source file in `sourceFilePath`. */ +export type ListedMarkdownTab = Pick< + RuntimeMobileSessionMarkdownTab, + 'id' | 'mode' | 'filePath' | 'sourceFilePath' | 'sourceRelativePath' | 'isDirty' | 'isUntitled' +> + +// Why up to 4 bytes over: a character straddling the cap still decodes whole before the cut. +const READ_SLACK_BYTES = 4 + +const saveQueues = new Map>() + +// Why the saved session: it names the file behind the listed tab, window or not. +function savedEditorFile( + host: MarkdownTabDiskHost, + worktreeId: string, + tab: ListedMarkdownTab +): PersistedOpenFile { + const session = host.getWorkspaceSessionForWorktree(worktreeId) + const files = session?.openFilesByWorktree?.[worktreeId] ?? [] + const sourceFilePath = tab.sourceFilePath || tab.filePath + const file = session + ? findListedEditorFile(files, session, worktreeId, { id: tab.id, filePath: sourceFilePath }) + : undefined + if (!file && tab.mode === 'markdown-preview') { + // Why: a preview outlives its closed source tab, and only edit tabs are saved; it stays read-only. + return { + filePath: sourceFilePath, + relativePath: tab.sourceRelativePath, + worktreeId, + language: 'markdown' + } + } + if (!file || (file.language !== 'markdown' && tab.mode !== 'markdown-preview')) { + throw new Error('tab_not_found') + } + return file +} + +/** Why a tab is read-only on the phone whatever the file holds; checked before any disk access. */ +function tabReadOnlyReason( + tab: ListedMarkdownTab, + file: PersistedOpenFile +): RuntimeMarkdownReadTabResult['readOnlyReason'] { + if (tab.mode === 'markdown-preview') { + return 'unsupported_preview' + } + // Why: a phone save must never make a file of a document the user has not named and saved. + if (tab.isUntitled) { + return 'unsupported_untitled' + } + // Why: the editor never saves a read-only tab (a log view), so the phone may not either. + return file.readOnly ? 'unsupported_tab' : undefined +} + +function readOnlyReason( + tab: ListedMarkdownTab, + file: PersistedOpenFile, + byteLength: number +): RuntimeMarkdownReadTabResult['readOnlyReason'] { + return ( + tabReadOnlyReason(tab, file) ?? + (byteLength > MOBILE_MARKDOWN_EDIT_MAX_BYTES ? 'file_too_large' : undefined) + ) +} + +export async function readMarkdownTabFromDisk( + host: MarkdownTabDiskHost, + worktreeId: string, + tab: ListedMarkdownTab +): Promise { + const file = savedEditorFile(host, worktreeId, tab) + const disk = await host.readFile( + worktreeId, + file, + MOBILE_MARKDOWN_READ_MAX_BYTES + READ_SLACK_BYTES + ) + const preview = truncateMobileMarkdownRead(disk.content) + const reason = readOnlyReason(tab, file, disk.byteLength) + return { + tabId: tab.id, + filePath: file.filePath, + relativePath: file.relativePath, + content: preview.content, + // Why the listed flag: unsaved desktop text is the desktop's; the phone only learns it exists. + isDirty: tab.isDirty, + version: hashMarkdownContent(preview.content), + source: 'file', + editable: reason === undefined, + ...(reason ? { readOnlyReason: reason } : {}), + ...(disk.byteLength > utf8ByteLength(preview.content) + ? { truncated: true, byteLength: disk.byteLength } + : {}) + } +} + +export async function saveMarkdownTabToDisk( + host: MarkdownTabDiskHost, + worktreeId: string, + tab: ListedMarkdownTab, + baseVersion: string, + content: string +): Promise { + if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) { + throw new Error('file_too_large') + } + const key = `${worktreeId}\0${savedEditorFile(host, worktreeId, tab).filePath}` + const previous = saveQueues.get(key) ?? Promise.resolve() + const save = previous + .catch(() => undefined) + .then(() => saveOnce(host, worktreeId, tab, baseVersion, content)) + saveQueues.set(key, save) + try { + return await save + } finally { + if (saveQueues.get(key) === save) { + saveQueues.delete(key) + } + } +} + +async function saveOnce( + host: MarkdownTabDiskHost, + worktreeId: string, + tab: ListedMarkdownTab, + baseVersion: string, + content: string +): Promise { + const file = savedEditorFile(host, worktreeId, tab) + const refusal = tabReadOnlyReason(tab, file) + if (refusal) { + throw new Error(refusal) + } + const io = await host.forSave(worktreeId, file) + const disk = await io.readFile(worktreeId, file, MOBILE_MARKDOWN_EDIT_MAX_BYTES + 1) + const reason = readOnlyReason(tab, file, disk.byteLength) + if (reason) { + throw new Error(reason) + } + const saved = { tabId: tab.id, isDirty: false as const, version: hashMarkdownContent(content) } + if (hashMarkdownContent(disk.content) !== baseVersion) { + // Why: a repeated save tap that already landed is a success, not a conflict. + if (disk.content === content) { + return { ...saved, content } + } + throw new Error('conflict') + } + const written = await io.writeFileIfUnchanged(worktreeId, file, content, { + expectedSignature: getDiskBaselineSignature(disk.content) + }) + if (!written.ok) { + throw new Error('conflict') + } + return { ...saved, content } +} diff --git a/src/main/runtime/markdown-tab-disk-host.ts b/src/main/runtime/markdown-tab-disk-host.ts new file mode 100644 index 00000000000..1cba7b40d6e --- /dev/null +++ b/src/main/runtime/markdown-tab-disk-host.ts @@ -0,0 +1,243 @@ +/** + * Where a phone's Markdown tab file is read and written: this machine's disk for a local workspace, + * and for a paired server's (`runtime:`) the same `files.read` / `files.writeIfUnchanged` calls + * the desktop editor makes, so that server reads its own disk and does the compare-and-write itself. + * A raw `ssh:` host is refused on both, by the route every local file command takes. + */ +import { isBinaryBuffer } from '../../shared/binary-buffer' +import { parseExecutionHostId } from '../../shared/execution-host' +import { + compareDiskWritePrecondition, + diskConflictFromReadFailure, + FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, + FILE_WRITE_IF_UNCHANGED_METHOD, + parseFileWriteResult, + type FileWriteDiskConflict, + type FileWriteDiskPrecondition, + type FileWriteResult +} from '../../shared/file-write-disk-precondition' +import { utf8ByteLength } from '../../shared/mobile-markdown-document' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { assertFileMutationOwnershipCapability } from '../../shared/file-mutation-ownership' +import type { PersistedOpenFile } from '../../shared/workspace-session-state-types' +import { readLocalRuntimeFilePrefix } from './files/file-reads' +import { + requireLocalRuntimeFileRoute, + requireRuntimeFileProvider, + type ResolvedRuntimeFileTarget, + type RuntimeLocalFileHost +} from './files/file-target' +import type { + MarkdownDiskFile, + MarkdownFileIo, + MarkdownTabDiskHost +} from './markdown-tab-disk-documents' +import { OrchestrationError } from './orchestration/orchestration-error' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' + +type EnvironmentCall = ( + environmentId: string, + method: string, + params: unknown, + expectedPairingRevision?: number +) => Promise + +export type MarkdownTabDiskRuntime = RuntimeLocalFileHost & + Pick & { + resolveRuntimeFileTarget(selector: string): Promise + callEnvironment: EnvironmentCall + /** The server's current pairing, so every call of one save reaches the server it probed. */ + environmentPairingRevision(environmentId: string): number | undefined + } + +export function createMarkdownTabDiskHost(runtime: MarkdownTabDiskRuntime): MarkdownTabDiskHost { + const routeOf = async (worktreeId: string, file: MarkdownFileAddress) => { + // Why: a file outside the workspace on an SSH host is that host's; no process here serves it. + if (file.externalSshTargetId) { + throw new SshHostNotServedError(file.externalSshTargetId) + } + const target = await runtime.resolveRuntimeFileTarget(`id:${worktreeId}`) + const host = parseExecutionHostId(target.executionHostId) + if (host?.kind === 'runtime') { + return { environmentId: host.environmentId, target } + } + requireLocalRuntimeFileRoute(target) + return { environmentId: null, target } + } + // `pairingRevision` is the pairing a save captured before its first call; a phone read leaves it unset. + const fileIo = (pairingRevision: number | undefined): MarkdownFileIo => ({ + readFile: async (worktreeId, file, maxBytes) => { + const { environmentId } = await routeOf(worktreeId, file) + if (environmentId) { + return readFileOnEnvironment( + runtime.callEnvironment, + environmentId, + worktreeId, + file, + pairingRevision + ) + } + const { bytes, size } = await readLocalRuntimeFilePrefix( + file.filePath, + runtime.requireStore(), + maxBytes + ) + if (isBinaryBuffer(bytes)) { + throw new Error('binary_file') + } + return { content: bytes.toString('utf8'), byteLength: size } + }, + writeFileIfUnchanged: async (worktreeId, file, content, precondition) => { + const { environmentId, target } = await routeOf(worktreeId, file) + if (environmentId) { + return writeFileOnEnvironment( + runtime.callEnvironment, + environmentId, + pairingRevision, + worktreeId, + file, + content, + precondition + ) + } + return requireRuntimeFileProvider(target, runtime).writeFileWithDiskPrecondition( + file.filePath, + content, + precondition + ) + } + }) + return { + getWorkspaceSessionForWorktree: (worktreeId) => + runtime.getWorkspaceSessionForWorktree(worktreeId), + readFile: fileIo(undefined).readFile, + forSave: async (worktreeId, file) => { + const { environmentId } = await routeOf(worktreeId, file) + return fileIo(environmentId ? pairingRevisionOf(runtime, environmentId) : undefined) + } + } +} + +function pairingRevisionOf( + runtime: Pick, + environmentId: string +): number | undefined { + try { + return runtime.environmentPairingRevision(environmentId) + } catch (error) { + throw new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + error instanceof Error ? error.message : String(error) + ) + } +} + +type MarkdownFileAddress = Pick + +/** The server's answer passes through; losing it is `remote_runtime_unavailable`, never a disk verdict. */ +async function callEnvironment( + call: EnvironmentCall, + environmentId: string, + method: string, + params: unknown, + expectedPairingRevision?: number +): Promise { + try { + return await call(environmentId, method, params, expectedPairingRevision) + } catch (error) { + if (error instanceof OrchestrationError && error.code !== 'server_required') { + throw error + } + throw new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + error instanceof Error ? error.message : String(error) + ) + } +} + +async function readFileOnEnvironment( + call: EnvironmentCall, + environmentId: string, + worktreeId: string, + file: MarkdownFileAddress, + pairingRevision: number | undefined +): Promise { + const address = { worktree: `id:${worktreeId}`, relativePath: file.relativePath } + const read = await callEnvironment(call, environmentId, 'files.read', address, pairingRevision) + if (!isRecord(read) || typeof read.content !== 'string') { + throw new Error('invalid_runtime_response') + } + const cut = utf8ByteLength(read.content) + if (read.truncated !== true) { + return { content: read.content, byteLength: cut } + } + // Why: a truncated `files.read` reports where it cut, not the file size the phone labels. + const stat = await callEnvironment(call, environmentId, 'files.stat', address, pairingRevision) + const size = isRecord(stat) && typeof stat.size === 'number' ? stat.size : 0 + return { content: read.content, byteLength: Math.max(size, cut + 1) } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * The desktop editor's guarded remote save (`writeRemoteFileIfUnchanged`): a server that advertises + * the disk check compares at write time; an older one is compared from here before a plain write. + * Every call is pinned to the pairing the save captured before its first read, so a re-pair never + * receives the write. + */ +async function writeFileOnEnvironment( + call: EnvironmentCall, + environmentId: string, + pairingRevision: number | undefined, + worktreeId: string, + file: MarkdownFileAddress, + content: string, + diskPrecondition: FileWriteDiskPrecondition +): Promise { + const pinned = (method: string, params: unknown): Promise => + callEnvironment(call, environmentId, method, params, pairingRevision) + const status = await pinned('status.get', undefined) + const capabilities = + isRecord(status) && Array.isArray(status.capabilities) ? status.capabilities : [] + assertFileMutationOwnershipCapability({ capabilities }) + const params = { + worktree: `id:${worktreeId}`, + relativePath: file.relativePath, + content, + // Why `local`: the server writes only a workspace it runs itself, so the write never hops on. + expectedExecutionHostId: 'local' + } + if (capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY)) { + return parseFileWriteResult( + await pinned(FILE_WRITE_IF_UNCHANGED_METHOD, { ...params, diskPrecondition }) + ) + } + const diskConflict = await diskConflictOnServer(pinned, params, diskPrecondition) + if (diskConflict) { + return { ok: false, diskConflict } + } + return parseFileWriteResult(await pinned('files.write', params)) +} + +/** For a server that would write unguarded: a read that cannot prove the disk fails the save. */ +async function diskConflictOnServer( + pinned: (method: string, params: unknown) => Promise, + address: { worktree: string; relativePath: string }, + precondition: FileWriteDiskPrecondition +): Promise { + let read: unknown + try { + read = await pinned('files.read', { + worktree: address.worktree, + relativePath: address.relativePath + }) + } catch (error) { + return diskConflictFromReadFailure(precondition, error) + } + if (!isRecord(read) || typeof read.content !== 'string' || read.truncated === true) { + return compareDiskWritePrecondition(precondition, undefined) + } + return compareDiskWritePrecondition(precondition, read.content) +} diff --git a/src/main/runtime/mobile-session-editor-projection.ts b/src/main/runtime/mobile-session-editor-projection.ts index 48375b6ba38..51e9d73899b 100644 --- a/src/main/runtime/mobile-session-editor-projection.ts +++ b/src/main/runtime/mobile-session-editor-projection.ts @@ -92,7 +92,7 @@ export function buildHeadlessMobileSessionEditorTabs( } /** The record the phone's tab names: two owners can hold one path, so by the id it was listed under. */ -function listedFile( +export function findListedEditorFile( files: readonly PersistedOpenFile[], session: WorkspaceSessionState, worktreeId: string, @@ -117,7 +117,7 @@ export function retireHeadlessEditorTab( ): WorkspaceSessionState | null { const files = session.openFilesByWorktree?.[worktreeId] ?? [] const file = ORCA_LAYOUT_OWNER_IS_MAIN - ? listedFile(files, session, worktreeId, tab) + ? findListedEditorFile(files, session, worktreeId, tab) : files.find((candidate) => candidate.filePath === tab.filePath) if (!file) { return null diff --git a/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts b/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts new file mode 100644 index 00000000000..1665a966a11 --- /dev/null +++ b/src/main/runtime/orca-runtime-mobile-markdown-tab-routes.test.ts @@ -0,0 +1,554 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { hashMarkdownContent } from '../../shared/mobile-markdown-document' +import type { RuntimeMobileSessionMarkdownTab } from '../../shared/runtime-types' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY, + FILE_WRITE_IF_UNCHANGED_METHOD +} from '../../shared/file-write-disk-precondition' +import { SshHostNotServedError } from '../providers/ssh-host-not-served-error' +import { OrchestrationError } from './orchestration/orchestration-error' +import { RpcDispatcher } from './rpc/dispatcher' +import { FILE_METHODS } from './rpc/methods/files' +import { SESSION_TAB_MARKDOWN_METHODS } from './rpc/methods/session-tab-markdown-methods' + +// Why: authorization is not under test; both runtimes read a temp workspace the store never registered. +vi.mock('./files/chat-scratch-file-root', async (importOriginal) => ({ + ...(await importOriginal()), + resolveRuntimeAuthorizedPath: async (path: string) => path +})) + +const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec') +await import('./orca-runtime-test-lifecycle.spec') +const { TEST_WORKTREE_ID, makeRuntimeStoreWithWorkspaceSession } = + await import('./orca-runtime-test-fixtures.spec') + +type Runtime = InstanceType + +let root = '' +let readme = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-markdown-tab-routes-')) + readme = join(root, 'README.md') + await writeFile(readme, '# Notes\n') +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +function sessionWithFile(filePath: string, relativePath: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + [TEST_WORKTREE_ID]: [ + { filePath, relativePath, worktreeId: TEST_WORKTREE_ID, language: 'markdown' } + ] + } + } +} + +function sessionWithReadme(externalSshTargetId?: string): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + openFilesByWorktree: { + [TEST_WORKTREE_ID]: [ + { + filePath: readme, + relativePath: 'README.md', + worktreeId: TEST_WORKTREE_ID, + language: 'markdown', + runtimeEnvironmentId: null, + ...(externalSshTargetId ? { externalSshTargetId } : {}) + } + ] + }, + activeTabTypeByWorktree: { [TEST_WORKTREE_ID]: 'editor' }, + activeFileIdByWorktree: { [TEST_WORKTREE_ID]: readme } + } +} + +async function runtimeOnHost( + executionHostId: ExecutionHostId, + session = sessionWithReadme() +): Promise { + const harness = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(harness.runtimeStore) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + const target = await runtime.resolveRuntimeFileTarget(`id:${TEST_WORKTREE_ID}`) + vi.spyOn(runtime, 'resolveRuntimeFileTarget').mockResolvedValue({ + ...target, + worktree: { ...target.worktree, path: root }, + executionHostId + }) + return runtime +} + +const CURRENT_SERVER = [ + FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, + FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY +] +/** A server from before #30605: it advertises no disk check and serves no `files.writeIfUnchanged`. */ +const PRE_DISK_CHECK_SERVER = [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] +const PAIRING_REVISION = 7 + +/** A desktop whose workspace runs on `env-a`, wired to that server's runtime the way the transport is. */ +async function desktopOnServer( + serverHost: ExecutionHostId = 'local', + capabilities: string[] = CURRENT_SERVER +) { + const server = await runtimeOnHost(serverHost) + const serverDispatch = new RpcDispatcher({ + runtime: server, + methods: [...FILE_METHODS, ...SESSION_TAB_MARKDOWN_METHODS].filter( + (method) => + method.name !== FILE_WRITE_IF_UNCHANGED_METHOD || + capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY) + ) + }) + const serverCalls: string[] = [] + const pins: (number | undefined)[] = [] + const serverForwards = vi.spyOn(server, 'callOrchestrationWorkerServer') + const desktop = await runtimeOnHost('runtime:env-a') + const pairing = { revision: PAIRING_REVISION } + vi.spyOn(desktop, 'resolveOrchestrationWorkerServer').mockImplementation(() => ({ + environmentId: 'env-a', + name: 'env-a', + peerFingerprint: 'peer', + pairingRevision: pairing.revision + })) + const call = vi + .spyOn(desktop, 'callOrchestrationWorkerServer') + .mockImplementation(async (environmentId, method, params, _timeout, _envelope, internal) => { + expect(environmentId).toBe('env-a') + serverCalls.push(method) + const pin = internal?.expectedEnvironmentPairingRevision + pins.push(pin) + // As `callRuntimeEnvironment` refuses a call pinned to a pairing that has since changed. + if (pin !== undefined && pin !== pairing.revision) { + throw new OrchestrationError( + 'runtime_environment_changed', + 'Runtime environment pairing changed; refresh and try again' + ) + } + if (method === 'status.get') { + return { runtimeId: 'server-runtime', capabilities } + } + const response = await serverDispatch.dispatch({ + id: `call-${serverCalls.length}`, + authToken: 'tok', + method, + params + }) + if (!response.ok) { + throw new OrchestrationError(response.error.code, response.error.message) + } + return response.result + }) + return { desktop, call, serverCalls, serverForwards, pins, pairing } +} + +describe("a phone's Markdown tab on a paired server's workspace", () => { + it('reads the file on that server, which answers from its own disk', async () => { + const { desktop, serverCalls, serverForwards } = await desktopOnServer() + + await expect( + desktop.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme) + ).resolves.toMatchObject({ + tabId: readme, + content: '# Notes\n', + version: hashMarkdownContent('# Notes\n'), + isDirty: false, + editable: true + }) + expect(serverCalls).toEqual(['files.read']) + expect(serverForwards).not.toHaveBeenCalled() + }) + + it('never hops on from a server that does not run the workspace itself', async () => { + const { desktop, serverCalls, serverForwards } = await desktopOnServer('runtime:env-b') + + await expect(desktop.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme)).rejects.toThrow( + 'runtime:env-b is not dispatched by this process' + ) + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toThrow() + expect(serverCalls).toEqual(['files.read', 'files.read']) + expect(serverForwards).not.toHaveBeenCalled() + expect(await readFile(readme, 'utf8')).toBe('# Notes\n') + }) + + it('saves through the server, which checks the precondition and writes', async () => { + const { desktop, serverCalls, serverForwards, pins } = await desktopOnServer() + + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).resolves.toMatchObject({ content: '# Phone\n', isDirty: false }) + expect(await readFile(readme, 'utf8')).toBe('# Phone\n') + expect(serverCalls).toEqual(['files.read', 'status.get', 'files.writeIfUnchanged']) + expect(pins).toEqual([PAIRING_REVISION, PAIRING_REVISION, PAIRING_REVISION]) + expect(serverForwards).not.toHaveBeenCalled() + }) + + it("refuses a save the server's disk moved under", async () => { + const { desktop, call } = await desktopOnServer() + const forward = call.getMockImplementation()! + call.mockImplementation(async (environmentId, method, params) => { + if (method === 'files.writeIfUnchanged') { + await writeFile(readme, '# Changed on the server\n') + } + return forward(environmentId, method, params) + }) + + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toThrow('conflict') + expect(await readFile(readme, 'utf8')).toBe('# Changed on the server\n') + }) + + it('saves an unchanged file on a server too old to check the disk, comparing here first', async () => { + const { desktop, serverCalls, pins } = await desktopOnServer('local', PRE_DISK_CHECK_SERVER) + + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).resolves.toMatchObject({ content: '# Phone\n', isDirty: false }) + expect(await readFile(readme, 'utf8')).toBe('# Phone\n') + expect(serverCalls).toEqual(['files.read', 'status.get', 'files.read', 'files.write']) + expect(pins).toEqual([PAIRING_REVISION, PAIRING_REVISION, PAIRING_REVISION, PAIRING_REVISION]) + }) + + it('refuses a changed file on a server too old to check the disk, and never writes', async () => { + const { desktop, call, serverCalls } = await desktopOnServer('local', PRE_DISK_CHECK_SERVER) + const forward = call.getMockImplementation()! + call.mockImplementation(async (environmentId, method, params, ...rest) => { + if (method === 'status.get') { + await writeFile(readme, '# Changed on the server\n') + } + return forward(environmentId, method, params, ...rest) + }) + + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toThrow('conflict') + expect(serverCalls).not.toContain('files.write') + expect(serverCalls).not.toContain(FILE_WRITE_IF_UNCHANGED_METHOD) + expect(await readFile(readme, 'utf8')).toBe('# Changed on the server\n') + }) + + it('refuses a save whose server was re-paired after its first read, writing nothing', async () => { + const { desktop, call, serverCalls, pairing } = await desktopOnServer() + const forward = call.getMockImplementation()! + call.mockImplementation(async (environmentId, method, params, ...rest) => { + const answer = await forward(environmentId, method, params, ...rest) + if (method === 'files.read') { + pairing.revision = PAIRING_REVISION + 1 + } + return answer + }) + + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toThrow('pairing changed') + expect(serverCalls).not.toContain(FILE_WRITE_IF_UNCHANGED_METHOD) + expect(await readFile(readme, 'utf8')).toBe('# Notes\n') + }) + + it('reports a lost server as unavailable, never as a missing or empty file', async () => { + const { desktop, call } = await desktopOnServer() + call.mockRejectedValue(new Error('socket closed')) + + const read = desktop.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme) + await expect(read).rejects.toBeInstanceOf(RemoteRuntimeClientError) + await expect(read).rejects.toMatchObject({ code: 'remote_runtime_unavailable' }) + await expect( + desktop.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toMatchObject({ code: 'remote_runtime_unavailable' }) + expect(await readFile(readme, 'utf8')).toBe('# Notes\n') + }) +}) + +describe("a phone's Markdown tab on a raw ssh: host", () => { + it('refuses it with the same error on read and save', async () => { + const runtime = await runtimeOnHost('ssh:target-1') + const forwards = vi.spyOn(runtime, 'callOrchestrationWorkerServer') + + await expect( + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme) + ).rejects.toBeInstanceOf(SshHostNotServedError) + await expect( + runtime.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Changed\n' + ) + ).rejects.toBeInstanceOf(SshHostNotServedError) + expect(forwards).not.toHaveBeenCalled() + }) +}) + +describe("a phone's Markdown tab on a file an SSH host holds outside the workspace", () => { + it("refuses it on read and save without touching this machine's file at that path", async () => { + const runtime = await runtimeOnHost('local', sessionWithReadme('target-1')) + + await expect( + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, readme) + ).rejects.toBeInstanceOf(SshHostNotServedError) + await expect( + runtime.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + readme, + hashMarkdownContent('# Notes\n'), + '# Changed\n' + ) + ).rejects.toBeInstanceOf(SshHostNotServedError) + expect(await readFile(readme, 'utf8')).toBe('# Notes\n') + }) +}) + +describe("a phone's Markdown tab with a desktop window attached", () => { + function windowTab( + overrides: Partial + ): RuntimeMobileSessionMarkdownTab { + return { + type: 'markdown', + id: 'tab-readme', + title: 'README.md', + filePath: readme, + relativePath: 'README.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive: true, + sourceFileId: readme, + sourceFilePath: readme, + sourceRelativePath: 'README.md', + documentVersion: `file:${readme}`, + ...overrides + } + } + + function runtimeWithWindowTabs( + tabs: RuntimeMobileSessionMarkdownTab[], + session = sessionWithReadme() + ): Runtime { + const harness = makeRuntimeStoreWithWorkspaceSession(session) + const runtime = new OrcaRuntimeService(harness.runtimeStore) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: tabs[0]?.id ?? null, + activeTabType: 'markdown', + tabs + } + ] + }) + return runtime + } + + it("reads the disk, not the desktop's unsaved text, and says the desktop has some", async () => { + const runtime = runtimeWithWindowTabs([windowTab({ isDirty: true })]) + + await expect( + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme') + ).resolves.toMatchObject({ + tabId: 'tab-readme', + content: '# Notes\n', + isDirty: true, + source: 'file', + editable: true + }) + }) + + it("reads a preview tab's source file, read-only", async () => { + const runtime = runtimeWithWindowTabs([ + windowTab({ id: `markdown-preview::${readme}`, mode: 'markdown-preview' }) + ]) + + await expect( + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, `markdown-preview::${readme}`) + ).resolves.toMatchObject({ + content: '# Notes\n', + editable: false, + readOnlyReason: 'unsupported_preview' + }) + }) + + it('refuses a listed tab whose file the saved session does not hold', async () => { + const unsaved = join(root, 'not-in-session.md') + const runtime = runtimeWithWindowTabs([ + windowTab({ id: unsaved, filePath: unsaved, sourceFilePath: unsaved }) + ]) + + await expect(runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, unsaved)).rejects.toThrow( + 'tab_not_found' + ) + }) + + it('reads a preview whose source tab was closed from its own file, read-only', async () => { + const other = join(root, 'other.md') + await writeFile(other, '# Other\n') + const runtime = runtimeWithWindowTabs([ + windowTab({ + id: `markdown-preview::${other}`, + mode: 'markdown-preview', + filePath: other, + relativePath: 'other.md', + sourceFileId: `markdown-preview::${other}`, + sourceFilePath: other, + sourceRelativePath: 'other.md' + }) + ]) + + await expect( + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, `markdown-preview::${other}`) + ).resolves.toMatchObject({ + filePath: other, + content: '# Other\n', + editable: false, + readOnlyReason: 'unsupported_preview' + }) + }) + + it('reads an untitled tab read-only and never saves it', async () => { + const runtime = runtimeWithWindowTabs([windowTab({ isUntitled: true, isDirty: true })]) + + await expect( + runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, 'tab-readme') + ).resolves.toMatchObject({ + content: '# Notes\n', + isDirty: true, + editable: false, + readOnlyReason: 'unsupported_untitled' + }) + await expect( + runtime.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + 'tab-readme', + hashMarkdownContent('# Notes\n'), + '# Phone\n' + ) + ).rejects.toThrow('unsupported_untitled') + expect(await readFile(readme, 'utf8')).toBe('# Notes\n') + }) + + it('never makes the file of an untitled tab that has none on disk', async () => { + const untitled = join(root, 'untitled.md') + const runtime = runtimeWithWindowTabs( + [ + windowTab({ + id: untitled, + filePath: untitled, + relativePath: 'untitled.md', + sourceFilePath: untitled, + sourceRelativePath: 'untitled.md', + isUntitled: true, + isDirty: true + }) + ], + sessionWithFile(untitled, 'untitled.md') + ) + + await expect(runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, untitled)).rejects.toThrow( + 'ENOENT' + ) + await expect( + runtime.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + untitled, + hashMarkdownContent(''), + '# Phone\n' + ) + ).rejects.toThrow('unsupported_untitled') + expect(existsSync(untitled)).toBe(false) + }) +}) + +describe("a phone's Markdown tab with no window, on a desktop's untitled document", () => { + it('saves it as an ordinary file, because the saved session does not record untitled', async () => { + const untitled = join(root, 'untitled.md') + await writeFile(untitled, '') + const runtime = await runtimeOnHost('local', sessionWithFile(untitled, 'untitled.md')) + + const read = await runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, untitled) + expect(read).toMatchObject({ content: '', editable: true }) + + await expect( + runtime.saveMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, untitled, read.version, '# Phone\n') + ).resolves.toMatchObject({ content: '# Phone\n', isDirty: false }) + expect(await readFile(untitled, 'utf8')).toBe('# Phone\n') + }) +}) + +describe("a phone's Markdown tab with no window, on a file missing from disk", () => { + it('fails the read and never makes the file on save', async () => { + const missing = join(root, 'untitled.md') + const runtime = await runtimeOnHost('local', sessionWithFile(missing, 'untitled.md')) + + await expect(runtime.readMobileMarkdownTab(`id:${TEST_WORKTREE_ID}`, missing)).rejects.toThrow( + 'ENOENT' + ) + await expect( + runtime.saveMobileMarkdownTab( + `id:${TEST_WORKTREE_ID}`, + missing, + hashMarkdownContent(''), + '# Phone\n' + ) + ).rejects.toThrow('ENOENT') + expect(existsSync(missing)).toBe(false) + }) +}) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index ef450a86ac5..ce5f8b1a994 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -23,8 +23,6 @@ import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { RuntimeBrowserDriverState, - RuntimeMarkdownReadTabResult, - RuntimeMarkdownSaveTabResult, RuntimeMobileSessionTabMove, RuntimeTerminalDriverState, RuntimeTerminalPresentation @@ -152,13 +150,6 @@ export type RuntimeNotifier = { runtimeEnvironmentId?: string | null, navigation?: RuntimeNavigationTarget ): void - readMobileMarkdownTab?(worktreeId: string, tabId: string): Promise - saveMobileMarkdownTab?( - worktreeId: string, - tabId: string, - baseVersion: string, - content: string - ): Promise /** Closes the whole tab. */ closeTerminal(tabId: string): void /** Drops one split pane main already closed; never closes its tab. */ diff --git a/src/main/runtime/workspaces/workspace-file-session-targets.ts b/src/main/runtime/workspaces/workspace-file-session-targets.ts index 3385098125f..46a8cf91375 100644 --- a/src/main/runtime/workspaces/workspace-file-session-targets.ts +++ b/src/main/runtime/workspaces/workspace-file-session-targets.ts @@ -19,16 +19,16 @@ import { import { getLocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' import { resolveWorktreeHostRouting } from '../worktree-launch-host-repo' import { isFloatingWorkspaceSelector } from '../../../shared/floating-workspace-worktree' -import { HeadlessMarkdownDocuments } from '../files/headless-markdown-document' +import { + readMarkdownTabFromDisk, + saveMarkdownTabToDisk, + type ListedMarkdownTab, + type MarkdownTabDiskHost +} from '../markdown-tab-disk-documents' +import { createMarkdownTabDiskHost } from '../markdown-tab-disk-host' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends RuntimeHeadlessTabGroups { - private readonly headlessMarkdown = new HeadlessMarkdownDocuments({ - requireStore: () => this.requireStore(), - resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), - getSession: (id) => this.getWorkspaceSessionForWorktree(id), - updateSession: (id, update) => this.updateHeadlessWorkspaceContent(id, update) - }) protected abstract getListedBrowserTabsByPageId(worktreeId: string): Map protected abstract resolveMobileMarkdownWorktreeId( ...args: Parameters< @@ -197,10 +197,11 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim tabId: string ): Promise { const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) - if (!this.notifier?.readMobileMarkdownTab) { - return this.headlessMarkdown.read(worktreeId, tabId) - } - return await this.notifier.readMobileMarkdownTab(worktreeId, tabId) + return await readMarkdownTabFromDisk( + this.markdownTabDiskHost(), + worktreeId, + this.listedMarkdownTab(worktreeId, tabId) + ) } async saveMobileMarkdownTab( @@ -210,10 +211,45 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim content: string ): Promise { const worktreeId = await this.resolveMobileMarkdownWorktreeId(worktreeSelector, tabId) - if (!this.notifier?.saveMobileMarkdownTab) { - return this.headlessMarkdown.save(worktreeId, tabId, baseVersion, content) + return await saveMarkdownTabToDisk( + this.markdownTabDiskHost(), + worktreeId, + this.listedMarkdownTab(worktreeId, tabId), + baseVersion, + content + ) + } + + private listedMarkdownTab(worktreeId: string, tabId: string): ListedMarkdownTab { + const tab = this.mobileSessionTabsByWorktree + .get(worktreeId) + ?.tabs.find((candidate) => candidate.type === 'markdown' && candidate.id === tabId) + if (tab?.type !== 'markdown') { + throw new Error('tab_not_found') } - return await this.notifier.saveMobileMarkdownTab(worktreeId, tabId, baseVersion, content) + return tab + } + + private markdownTabDiskHost(): MarkdownTabDiskHost { + return createMarkdownTabDiskHost({ + requireStore: () => this.requireStore(), + getWorkspaceSessionForWorktree: (worktreeId) => + this.getWorkspaceSessionForWorktree(worktreeId), + resolveRuntimeFileTarget: (selector) => this.resolveRuntimeFileTarget(selector), + callEnvironment: (environmentId, method, params, expectedPairingRevision) => + this.callOrchestrationWorkerServer( + environmentId, + method, + params, + undefined, + undefined, + expectedPairingRevision === undefined + ? undefined + : { expectedEnvironmentPairingRevision: expectedPairingRevision } + ), + environmentPairingRevision: (environmentId) => + this.resolveOrchestrationWorkerServer(environmentId).pairingRevision + }) } // Why: `getRepo(id)` is host-blind and never read `worktree.hostId`, which outranks every repo diff --git a/src/main/window/mobile-markdown-request-relay.test.ts b/src/main/window/mobile-markdown-request-relay.test.ts deleted file mode 100644 index db587f07344..00000000000 --- a/src/main/window/mobile-markdown-request-relay.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { EventEmitter } from 'node:events' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const ipcEmitter = new EventEmitter() -const ipcMainMock = { - on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { - ipcEmitter.on(channel, listener) - }), - removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { - ipcEmitter.removeListener(channel, listener) - }) -} - -vi.mock('electron', () => ({ - ipcMain: ipcMainMock -})) - -describe('requestMobileMarkdownFromRenderer', () => { - beforeEach(() => { - ipcEmitter.removeAllListeners() - ipcMainMock.on.mockClear() - ipcMainMock.removeListener.mockClear() - }) - - it('ignores markdown responses from other renderer processes', async () => { - const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay') - const mainWebContents = { - send: vi.fn() - } - const otherWebContents = {} - const mainWindow = { - isDestroyed: () => false, - webContents: mainWebContents - } - - const pending = requestMobileMarkdownFromRenderer(mainWindow as never, { - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - const sentRequest = mainWebContents.send.mock.calls[0]?.[1] as { id: string } - - ipcEmitter.emit( - 'ui:mobileMarkdownResponse', - { sender: otherWebContents }, - { id: sentRequest.id, ok: false, error: 'wrong_renderer' } - ) - ipcEmitter.emit( - 'ui:mobileMarkdownResponse', - { sender: mainWebContents }, - { - id: sentRequest.id, - ok: true, - result: { - tabId: 'tab-md', - filePath: '/repo/README.md', - relativePath: 'README.md', - content: '# ok', - isDirty: false, - version: 'v1', - source: 'file', - editable: true - } - } - ) - - await expect(pending).resolves.toMatchObject({ content: '# ok' }) - }) - - it('rejects and cleans up when the BrowserWindow closes and webContents becomes unavailable', async () => { - const { requestMobileMarkdownFromRenderer } = await import('./mobile-markdown-request-relay') - const webContents = Object.assign(new EventEmitter(), { - send: vi.fn() - }) - let windowClosed = false - const mainWindow = Object.assign(new EventEmitter(), { - isDestroyed: () => false - }) - Object.defineProperty(mainWindow, 'webContents', { - get: () => { - if (windowClosed) { - throw new Error('webContents unavailable after close') - } - return webContents - } - }) - - const pending = requestMobileMarkdownFromRenderer(mainWindow as never, { - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(1) - - windowClosed = true - mainWindow.emit('closed') - - await expect(pending).rejects.toThrow('renderer_unavailable') - expect(ipcEmitter.listenerCount('ui:mobileMarkdownResponse')).toBe(0) - expect(webContents.listenerCount('destroyed')).toBe(0) - expect(webContents.listenerCount('render-process-gone')).toBe(0) - }) -}) diff --git a/src/main/window/mobile-markdown-request-relay.ts b/src/main/window/mobile-markdown-request-relay.ts deleted file mode 100644 index d1c80a38920..00000000000 --- a/src/main/window/mobile-markdown-request-relay.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { randomUUID } from 'node:crypto' - -import { ipcMain } from 'electron' -import type { BrowserWindow } from 'electron' -import type { - RuntimeMarkdownReadTabResult, - RuntimeMarkdownSaveTabResult, - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../../shared/mobile-markdown-document' - -const MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS = 20_000 - -type RendererMobileMarkdownRequest = RuntimeMobileMarkdownRequest extends infer Request - ? Request extends { id: string } - ? Omit - : never - : never - -export async function requestMobileMarkdownFromRenderer( - mainWindow: BrowserWindow, - request: RendererMobileMarkdownRequest -): Promise { - if (mainWindow.isDestroyed()) { - throw new Error('renderer_unavailable') - } - const webContents = mainWindow.webContents - const id = randomUUID() - return await new Promise((resolve, reject) => { - let settled = false - const onRendererUnavailable = (): void => finish(new Error('renderer_unavailable')) - const finish = ( - error?: Error, - result?: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult - ): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - ipcMain.removeListener('ui:mobileMarkdownResponse', onResponse) - if (typeof mainWindow.removeListener === 'function') { - mainWindow.removeListener('closed', onRendererUnavailable) - } - if (typeof webContents.removeListener === 'function') { - webContents.removeListener('destroyed', onRendererUnavailable) - webContents.removeListener('render-process-gone', onRendererUnavailable) - } - if (error) { - reject(error) - } else if (result) { - resolve(result) - } else { - reject(new Error('renderer_unavailable')) - } - } - const timeout = setTimeout( - () => finish(new Error('renderer_timeout')), - MOBILE_MARKDOWN_RENDERER_TIMEOUT_MS - ) - const onResponse = ( - event: Electron.IpcMainEvent, - response: RuntimeMobileMarkdownResponse - ): void => { - if (event.sender !== webContents) { - return - } - if (response.id !== id) { - return - } - if (response.ok) { - finish(undefined, response.result) - } else { - finish(new Error(response.error)) - } - } - ipcMain.on('ui:mobileMarkdownResponse', onResponse) - if (typeof mainWindow.once === 'function') { - mainWindow.once('closed', onRendererUnavailable) - } - if (typeof webContents.once === 'function') { - webContents.once('destroyed', onRendererUnavailable) - webContents.once('render-process-gone', onRendererUnavailable) - } - try { - webContents.send('ui:mobileMarkdownRequest', { id, ...request }) - } catch { - finish(new Error('renderer_unavailable')) - } - }) -} diff --git a/src/main/window/runtime-window-lifecycle-file-open.test.ts b/src/main/window/runtime-window-lifecycle-file-open.test.ts index cbf24a225b2..42400be8ba1 100644 --- a/src/main/window/runtime-window-lifecycle-file-open.test.ts +++ b/src/main/window/runtime-window-lifecycle-file-open.test.ts @@ -5,7 +5,6 @@ import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() })) -vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() })) vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() })) vi.mock('./session-tab-close-request-relay', () => ({ requestSessionTabCloseFromRenderer: vi.fn() diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 00ed7c98ed5..838453bb49e 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -4,16 +4,11 @@ import { ipcMain } from 'electron' import type { BrowserWindow } from 'electron' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' -import type { - RuntimeMarkdownReadTabResult, - RuntimeMarkdownSaveTabResult -} from '../../shared/mobile-markdown-document' import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types' import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators' import { requestAgentLaunchTabPublishFromRenderer } from './agent-launch-tab-publish-relay' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { requestMobileMarkdownFromRenderer } from './mobile-markdown-request-relay' import { registerRendererDocumentNavigation } from './renderer-document-navigation' import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender' import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay' @@ -205,20 +200,6 @@ export function registerRuntimeWindowLifecycle( runtimeEnvironmentId, ...(navigation ? { navigation } : {}) }), - readMobileMarkdownTab: (worktreeId, tabId) => - requestMobileMarkdownFromRenderer(mainWindow, { - operation: 'read', - worktreeId, - tabId - }) as Promise, - saveMobileMarkdownTab: (worktreeId, tabId, baseVersion, content) => - requestMobileMarkdownFromRenderer(mainWindow, { - operation: 'save', - worktreeId, - tabId, - baseVersion, - content - }) as Promise, closeTerminal: (tabId) => send('ui:closeTerminal', { kind: 'tab', tabId }), closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }), closeTerminalTab: (tabId, options) => diff --git a/src/main/window/runtime-window-pane-title-notification.test.ts b/src/main/window/runtime-window-pane-title-notification.test.ts index 1035b3c3b2e..b2f96f119fc 100644 --- a/src/main/window/runtime-window-pane-title-notification.test.ts +++ b/src/main/window/runtime-window-pane-title-notification.test.ts @@ -5,7 +5,6 @@ import type { RuntimeNotifier } from '../runtime/runtime-notifier-contract' vi.mock('electron', () => ({ ipcMain: { on: vi.fn(), removeListener: vi.fn() } })) vi.mock('../ipc/worktree-change-invalidators', () => ({ runWorktreeChangeInvalidators: vi.fn() })) -vi.mock('./mobile-markdown-request-relay', () => ({ requestMobileMarkdownFromRenderer: vi.fn() })) vi.mock('./renderer-document-navigation', () => ({ registerRendererDocumentNavigation: vi.fn() })) vi.mock('./session-tab-close-request-relay', () => ({ requestSessionTabCloseFromRenderer: vi.fn() 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 b34ea58eb4c..24a22c56ac9 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -1,9 +1,5 @@ import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { ipcRenderer, webFrame } from 'electron' -import type { - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../../shared/mobile-markdown-document' import { richMarkdownContextMenuCommandChannel, richMarkdownContextMenuTargetChannel, @@ -41,17 +37,6 @@ export const uiClipboardAndWindowControlsApi = { ipcRenderer.on('ui:openDiffFromMobile', listener) return () => ipcRenderer.removeListener('ui:openDiffFromMobile', listener) }, - onMobileMarkdownRequest: ( - callback: (request: RuntimeMobileMarkdownRequest) => void - ): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, request: RuntimeMobileMarkdownRequest) => - callback(request) - ipcRenderer.on('ui:mobileMarkdownRequest', listener) - return () => ipcRenderer.removeListener('ui:mobileMarkdownRequest', listener) - }, - respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse): void => { - ipcRenderer.send('ui:mobileMarkdownResponse', response) - }, onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, target: TerminalSurfaceCloseTarget) => callback(target) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index bdb7dfb8ee9..c127f3ec2d6 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -42,10 +42,6 @@ import type { RuntimeTerminalCreateRequestPayload, RuntimeTerminalPresentation } from '../../shared/runtime-types' -import type { - RuntimeMobileMarkdownRequest, - RuntimeMobileMarkdownResponse -} from '../../shared/mobile-markdown-document' import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' import type { TerminalTabCloseRequest, @@ -287,8 +283,6 @@ export type UiCommandEventApi = { navigation?: RuntimeNavigationTarget }) => void ) => () => void - onMobileMarkdownRequest: (callback: (request: RuntimeMobileMarkdownRequest) => void) => () => void - respondMobileMarkdownRequest: (response: RuntimeMobileMarkdownResponse) => void onCloseTerminal: (callback: (target: TerminalSurfaceCloseTarget) => void) => () => void onTerminalTabCloseRequest: (callback: (request: TerminalTabCloseRequest) => void) => () => void respondTerminalTabClose: (response: TerminalTabCloseResponse) => void diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index a58b2075247..10796df2f9b 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -95,11 +95,7 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } - const result = await queueSave( - file, - content, - detail.overwrite ? 'overwrite' : detail.origin === 'mobile' ? 'mobile' : 'user' - ) + const result = await queueSave(file, content, detail.overwrite ? 'overwrite' : 'user') if (result !== 'written') { detail.reject(result === 'blocked-stale' ? EDITOR_STALE_SAVE_MESSAGE : NOT_SAVED_MESSAGE) return diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index eb344419628..64ad5f70ad7 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -48,8 +48,6 @@ export type EditorSaveFileTarget = { fallbackContent?: string /** Only the conflict banner's Overwrite sets this. */ overwrite?: true - /** A save sent from the mobile app; its refusal is reported to mobile, not the desktop banner. */ - origin?: 'mobile' } export type EditorSaveAndCloseDetail = { diff --git a/src/renderer/src/components/editor/editor-clean-tab-draft-invariant.test.ts b/src/renderer/src/components/editor/editor-clean-tab-draft-invariant.test.ts index cb895bdac7e..4fa9553105b 100644 --- a/src/renderer/src/components/editor/editor-clean-tab-draft-invariant.test.ts +++ b/src/renderer/src/components/editor/editor-clean-tab-draft-invariant.test.ts @@ -8,7 +8,6 @@ import { createEditorStore } from './editor-autosave-controller-test-fixture' import { reloadTabContentFromDisk } from './ExternalFileChangeBanner' import { mayPushTabTextIntoModel } from './editor-stale-mount-guard' import { stampCleanTabDiskBaseline } from './editor-clean-tab-disk-baseline' -import { rollBackFailedMobileSave } from '@/runtime/mobile-markdown-save-rollback' import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature' import { GUARDED_PATH, stubGuardedDisk } from './editor-save-guarded-disk-fixture' @@ -91,20 +90,6 @@ it('reload leaves no draft', () => { expectNoCleanDraft(store) }) -it('a refused mobile save restoring a clean tab leaves no draft of the disk text', () => { - const { store, id } = openCleanTab('disk') - store.getState().setEditorDraft(id, 'phone') - store.getState().markFileDirty(id, true) - rollBackFailedMobileSave(new Error('save_timeout'), { - fileId: id, - injectedContent: 'phone', - previousDraft: 'disk', - previousDirty: false, - stampedBaseline: null - }) - expectNoCleanDraft(store) -}) - it("never restores a clean tab's leftover draft into the model", () => { const { store, id } = openCleanTab('disk') // A Markdown-whitespace draft may stay on a clean tab; it still never enters the model. diff --git a/src/renderer/src/components/editor/editor-save-disk-precondition.ts b/src/renderer/src/components/editor/editor-save-disk-precondition.ts index 8112f02a690..dc6c23b1fb8 100644 --- a/src/renderer/src/components/editor/editor-save-disk-precondition.ts +++ b/src/renderer/src/components/editor/editor-save-disk-precondition.ts @@ -10,11 +10,11 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-typ import { writeRuntimeFile } from '@/runtime/runtime-file-mutation-client' /** - * `user`: an explicit desktop save (shortcut, save-and-close dialog). `mobile`: a save from the - * mobile app. `restart`: the save-all before a restart. `overwrite`: the conflict banner's - * Overwrite, the only consent to replace a disk change. + * `user`: an explicit desktop save (shortcut, save-and-close dialog). `restart`: the save-all + * before a restart. `overwrite`: the conflict banner's Overwrite, the only consent to replace a + * disk change. */ -export type EditorSaveTrigger = 'autosave' | 'user' | 'mobile' | 'restart' | 'overwrite' +export type EditorSaveTrigger = 'autosave' | 'user' | 'restart' | 'overwrite' /** * `written`: disk holds the text. `blocked-stale`: the host refused because disk moved on since the diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index d2cd0408ed3..00f79c88f5a 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -159,10 +159,7 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { const connectionId = fileContext.connectionId const precondition = editorSavePrecondition(liveFile, trigger) if (precondition.kind === 'blocked') { - // Why: a mobile refusal is reported to mobile; the desktop tab may not even be dirty. - if (trigger !== 'mobile') { - setModelExternalMutation(store.getState(), file.id, 'changed') - } + setModelExternalMutation(store.getState(), file.id, 'changed') return 'blocked-stale' } // Why: stamp before writing so useEditorExternalWatch ignores our own fs:changed echo (editor-self-write-registry). @@ -191,13 +188,11 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { if (!writeResult.ok) { clearSelfWrite(liveFile.filePath, liveFile.runtimeEnvironmentId) // Why: the banner (or deleted tombstone) explains the refusal and pauses autosave. - if (trigger !== 'mobile') { - setModelExternalMutation( - store.getState(), - file.id, - writeResult.diskConflict === 'missing' ? 'deleted' : 'changed' - ) - } + setModelExternalMutation( + store.getState(), + file.id, + writeResult.diskConflict === 'missing' ? 'deleted' : 'changed' + ) return 'blocked-stale' } diff --git a/src/renderer/src/components/editor/editor-save-shared-model-dirty.test.ts b/src/renderer/src/components/editor/editor-save-shared-model-dirty.test.ts index fb99180ec13..d5272cd6ed5 100644 --- a/src/renderer/src/components/editor/editor-save-shared-model-dirty.test.ts +++ b/src/renderer/src/components/editor/editor-save-shared-model-dirty.test.ts @@ -18,11 +18,6 @@ import { overwriteDiskWithTabEdits } from './ExternalFileChangeBanner' import { isStaleCachedEditorText, mayPushTabTextIntoModel } from './editor-stale-mount-guard' -import { - rollBackFailedMobileSave, - stampMobileVerifiedBaseline -} from '@/runtime/mobile-markdown-save-rollback' -import { EDITOR_STALE_SAVE_MESSAGE } from './editor-save-disk-precondition' import { stampCleanTabDiskBaseline } from './editor-clean-tab-disk-baseline' const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn() })) @@ -150,46 +145,6 @@ describe('a clean tab of a dirty shared model', () => { expect(store.getState().editorDrafts[b.id]).toBe('unsaved in B') }) - it('a phone save to a dirty model adopts nothing; after it fails the dirty sibling is still refused', async () => { - const { store, a, b, tab } = openSharedTabsWithDirtySibling() - const cleanup = attachEditorAutosaveController(store) - try { - // The agent wrote 'E'; the phone read it and saves through the clean tab A. - expect(stampMobileVerifiedBaseline(a, { content: 'E', source: 'file' })).toBeNull() - await expect( - requestEditorFileSave({ fileId: a.id, fallbackContent: 'phone', origin: 'mobile' }) - ).rejects.toThrow(/changed on disk/) - expect(tab(a.id)?.lastKnownDiskSignature).toBe(getDiskBaselineSignature('S0')) - - await expect(requestEditorFileSave({ fileId: b.id })).rejects.toThrow(/changed on disk/) - } finally { - cleanup() - } - }) - - it('restores the previous shared baseline when a phone save that adopted one fails', () => { - const { store, a, b, tab } = openSharedTabsWithDirtySibling() - store.getState().clearEditorDraft(b.id) - store.getState().markFileDirty(b.id, false) - - const adopted = stampMobileVerifiedBaseline(tab(a.id)!, { content: 'E', source: 'file' }) - expect(adopted).toEqual({ - adopted: getDiskBaselineSignature('E'), - previous: getDiskBaselineSignature('S0') - }) - rollBackFailedMobileSave(new Error(EDITOR_STALE_SAVE_MESSAGE), { - fileId: a.id, - injectedContent: 'phone', - previousDraft: undefined, - previousDirty: false, - stampedBaseline: adopted - }) - - for (const id of [a.id, b.id]) { - expect(tab(id)?.lastKnownDiskSignature).toBe(getDiskBaselineSignature('S0')) - } - }) - it("drops a clean tab's leftover draft when the model moves to a newer load, so it cannot overwrite", async () => { const { store, a, b, tab } = openSharedTabsWithDirtySibling() // B undid back to clean but kept a draft equal to the old text; the tab is hidden. diff --git a/src/renderer/src/components/editor/editor-save-shared-model.test.ts b/src/renderer/src/components/editor/editor-save-shared-model.test.ts index ec6d90d237e..42fabcb3a6f 100644 --- a/src/renderer/src/components/editor/editor-save-shared-model.test.ts +++ b/src/renderer/src/components/editor/editor-save-shared-model.test.ts @@ -20,9 +20,7 @@ import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signa import { ORCA_EDITOR_SAVE_DIRTY_FILES_EVENT } from '../../../../shared/editor-save-events' import { GUARDED_PATH, stubGuardedDisk } from './editor-save-guarded-disk-fixture' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' -import { rollBackFailedMobileSave } from '@/runtime/mobile-markdown-save-rollback' import { toast } from 'sonner' -import { EDITOR_STALE_SAVE_MESSAGE } from './editor-save-disk-precondition' const mocks = vi.hoisted(() => ({ readRuntimeFileContent: vi.fn() })) const storeRef = vi.hoisted((): { current: ReturnType | null } => ({ @@ -236,24 +234,6 @@ describe('two tabs sharing one model', () => { } }) - it('a refused mobile save that carried desktop edits marks both tabs', () => { - const { store, a, b, edit, tab } = openSharedTabs('A') - edit(a.id, 'mobile text') - - const error = rollBackFailedMobileSave(new Error(EDITOR_STALE_SAVE_MESSAGE), { - fileId: a.id, - injectedContent: 'mobile text', - previousDraft: 'desktop edits', - previousDirty: true, - stampedBaseline: null - }) - - expect(error).toBeInstanceOf(Error) - expect(store.getState().editorDrafts[a.id]).toBe('desktop edits') - expect(tab(a.id)?.externalMutation).toBe('changed') - expect(tab(b.id)?.externalMutation).toBe('changed') - }) - it("never touches a rich-markdown sibling's pending typing; at worst its save is refused", async () => { const md = '/repo/notes.md' let released = false diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index b4c4ccbbae1..ea6775ab1e8 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -3,7 +3,6 @@ import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host- import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration' import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background' -import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge' import { remoteRuntimeTerminalColorPush } from '@/runtime/remote-runtime-terminal-color-push' import { resetAgentHookCompletionNotificationCoordinators } from '../agent-hook-completion-notifications' import { useAppStore } from '../../store' @@ -51,7 +50,6 @@ export function installAppLifetimeIpcEvents(): () => void { const unsubs: (() => void)[] = [] const backgroundWakeDispatcher = createBackgroundSleepingAgentWakeDispatcher() unsubs.push(backgroundWakeDispatcher.dispose) - unsubs.push(attachMobileMarkdownBridge()) unsubs.push( window.api.automations.onChanged((payload) => emitAutomationsChangedWindowEvent(payload)) ) diff --git a/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts b/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts index 71b2490c455..65748b29b77 100644 --- a/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts +++ b/src/renderer/src/runtime/editor-buffer-dirty-readers.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '../store' import type { AppState } from '../store/types' import { buildEditorSessionData } from '../lib/workspace-session' @@ -6,14 +6,7 @@ import { sameOpenFiles } from './web-session-tabs-sync/state-equality-files' import { buildRuntimeMobileOpenFilesProjection } from './sync-runtime-graph/sync-projections' import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' import { makeState } from './sync-runtime-graph-test-harness' -import { attachMobileMarkdownBridge } from './mobile-markdown-bridge' -import { - cleanupMobileMarkdownBridgeHarness, - openMarkdownFile, - resetEditorState, - sendRequest, - setupWindow -} from './mobile-markdown-bridge-test-harness' +import type { Tab } from '../../../shared/tab-types' vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null @@ -21,6 +14,33 @@ vi.mock('@/lib/connection-context', () => ({ const FILE_ID = '/repo/README.md' +function resetEditorState(): void { + useAppStore.setState({ openFiles: [], editorDrafts: {} }) + const tab: Tab = { + id: 'tab-md', + entityId: FILE_ID, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'editor', + label: FILE_ID, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + useAppStore.setState({ unifiedTabsByWorktree: { 'wt-1': [tab] } }) +} + +function openMarkdownFile(): void { + useAppStore.getState().openFile({ + filePath: FILE_ID, + relativePath: 'README.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit' + }) +} + /** Dirties the tab through its buffer record and returns the store on either side. */ function dirtyThroughRecord(): { before: AppState; after: AppState } { const before = useAppStore.getState() @@ -36,10 +56,6 @@ describe('a dirty change on the buffer record reaches every openFiles reader', ( openMarkdownFile() }) - afterEach(() => { - cleanupMobileMarkdownBridgeHarness() - }) - it('web session tab sync sees the open files change', () => { const { before, after } = dirtyThroughRecord() @@ -81,28 +97,4 @@ describe('a dirty change on the buffer record reaches every openFiles reader', ( expect(markdownTab(before)).toMatchObject({ isDirty: false }) expect(markdownTab(after)).toMatchObject({ isDirty: true }) }) - - it('the mobile markdown bridge reports the tab dirty', async () => { - setupWindow({ - readFile: vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) - }) - const detach = attachMobileMarkdownBridge() - dirtyThroughRecord() - - try { - const response = await sendRequest({ - id: 'read-dirty', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(response).toMatchObject({ - ok: true, - result: { source: 'file', isDirty: true } - }) - } finally { - detach() - } - }) }) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts deleted file mode 100644 index 385f89d623b..00000000000 --- a/src/renderer/src/runtime/mobile-markdown-bridge-save-guards.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - hashMarkdownContent, - MOBILE_MARKDOWN_EDIT_MAX_BYTES -} from '../../../shared/mobile-markdown-document' -import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller' -import { useAppStore } from '../store' -import { attachMobileMarkdownBridge } from './mobile-markdown-bridge' -import { - cleanupMobileMarkdownBridgeHarness, - createDeferred, - openMarkdownFile, - resetEditorState, - sendRequest, - setupWindow, - guardedWriteRefusal -} from './mobile-markdown-bridge-test-harness' -import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' - -vi.mock('@/lib/connection-context', () => ({ - getConnectionIdForFile: () => null -})) - -describe('mobile markdown bridge save guards', () => { - beforeEach(() => { - resetEditorState() - }) - - afterEach(() => { - cleanupMobileMarkdownBridgeHarness() - }) - - it('restores the previous desktop draft when a mobile save write fails', async () => { - openMarkdownFile() - const state = useAppStore.getState() - state.setEditorDraft('/repo/README.md', 'desktop draft') - state.markFileDirty('/repo/README.md', true) - setupWindow({ - readFile: vi.fn().mockResolvedValue({ content: 'desktop draft', isBinary: false }), - writeFile: vi.fn().mockRejectedValue(new Error('disk full')) - }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const response = await sendRequest({ - id: 'save-fail', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('desktop draft'), - content: 'mobile edit' - }) - - expect(response).toMatchObject({ id: 'save-fail', ok: false }) - expect(useAppStore.getState().editorDrafts['/repo/README.md']).toBe('desktop draft') - expect(useAppStore.getState().openFiles[0]?.isDirty).toBe(true) - } finally { - detachAutosave() - detachBridge() - } - }) - - it('restores the previous desktop draft when save verification fails after write', async () => { - openMarkdownFile() - const state = useAppStore.getState() - state.setEditorDraft('/repo/README.md', 'desktop draft') - state.markFileDirty('/repo/README.md', true) - state.setLastKnownDiskSignature('/repo/README.md', getDiskBaselineSignature('desktop draft')) - let diskContent = 'desktop draft' - const readFile = vi.fn().mockImplementation(async () => ({ - content: 'verified mismatch', - isBinary: false - })) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const response = await sendRequest({ - id: 'save-verify-fail', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('desktop draft'), - content: 'mobile edit' - }) - - expect(response).toMatchObject({ id: 'save-verify-fail', ok: false }) - expect(diskContent).toBe('mobile edit') - expect(useAppStore.getState().editorDrafts['/repo/README.md']).toBe('desktop draft') - expect(useAppStore.getState().openFiles[0]?.isDirty).toBe(true) - } finally { - detachAutosave() - detachBridge() - } - }) - - it('serializes saves so duplicate base versions do not both write', async () => { - openMarkdownFile() - let diskContent = 'original' - const firstWrite = createDeferred() - const readFile = vi.fn().mockImplementation(async () => ({ - content: diskContent, - isBinary: false - })) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - if (content === 'first edit') { - await firstWrite.promise - } - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const first = sendRequest({ - id: 'save-a', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'first edit' - }) - await new Promise((resolve) => setTimeout(resolve, 0)) - const second = sendRequest({ - id: 'save-b', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'second edit' - }) - firstWrite.resolve() - - await expect(first).resolves.toMatchObject({ id: 'save-a', ok: true }) - await expect(second).resolves.toMatchObject({ id: 'save-b', ok: false, error: 'conflict' }) - expect(diskContent).toBe('first edit') - } finally { - detachAutosave() - detachBridge() - } - }) - - it('treats a duplicate same-content save as idempotent success', async () => { - openMarkdownFile() - let diskContent = 'original' - const firstWrite = createDeferred() - const readFile = vi.fn().mockImplementation(async () => ({ - content: diskContent, - isBinary: false - })) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - if (content === 'mobile edit' && diskContent === 'original') { - await firstWrite.promise - } - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const first = sendRequest({ - id: 'save-a', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'mobile edit' - }) - await new Promise((resolve) => setTimeout(resolve, 0)) - const second = sendRequest({ - id: 'save-b', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'mobile edit' - }) - firstWrite.resolve() - - await expect(first).resolves.toMatchObject({ id: 'save-a', ok: true }) - await expect(second).resolves.toMatchObject({ id: 'save-b', ok: true }) - expect(diskContent).toBe('mobile edit') - } finally { - detachAutosave() - detachBridge() - } - }) - - it('rejects oversized multibyte mobile saves before writing', async () => { - openMarkdownFile() - const content = '😀'.repeat(Math.floor(MOBILE_MARKDOWN_EDIT_MAX_BYTES / 4) + 1) - const writeFile = vi.fn().mockResolvedValue(undefined) - setupWindow({ - readFile: vi.fn().mockResolvedValue({ content: 'original', isBinary: false }), - writeFile - }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'save-large-multibyte', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content - }) - - expect(response).toMatchObject({ - id: 'save-large-multibyte', - ok: false, - error: 'file_too_large' - }) - expect(writeFile).not.toHaveBeenCalled() - } finally { - detach() - } - }) -}) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts b/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts deleted file mode 100644 index c873202a72e..00000000000 --- a/src/renderer/src/runtime/mobile-markdown-bridge-test-harness.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { expect, vi } from 'vitest' -import { - compareDiskWritePrecondition, - type FileWriteDiskPrecondition -} from '../../../shared/file-write-disk-precondition' -import type { RuntimeMobileMarkdownRequest } from '../../../shared/mobile-markdown-document' -import { useAppStore } from '../store' -import type { Tab } from '../../../shared/tab-types' - -type WindowStub = { - addEventListener: Window['addEventListener'] - removeEventListener: Window['removeEventListener'] - dispatchEvent: Window['dispatchEvent'] - setTimeout: Window['setTimeout'] - clearTimeout: Window['clearTimeout'] - api: { - ui: { - onMobileMarkdownRequest: ( - callback: (request: RuntimeMobileMarkdownRequest) => void - ) => () => void - respondMobileMarkdownRequest: ReturnType - } - fs: { - readFile: ReturnType - writeFile: ReturnType - } - } -} - -let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | null = null - -export function setupWindow({ - readFile, - writeFile = vi.fn().mockResolvedValue(undefined) -}: { - readFile: ReturnType - writeFile?: ReturnType -}): { responses: unknown[] } { - const eventTarget = new EventTarget() - const responses: unknown[] = [] - mobileMarkdownHandler = null - vi.stubGlobal('window', { - addEventListener: eventTarget.addEventListener.bind(eventTarget), - removeEventListener: eventTarget.removeEventListener.bind(eventTarget), - dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - api: { - ui: { - onMobileMarkdownRequest: (callback) => { - mobileMarkdownHandler = callback - return () => { - mobileMarkdownHandler = null - } - }, - respondMobileMarkdownRequest: vi.fn((response) => responses.push(response)) - }, - fs: { readFile, writeFile } - } - } satisfies WindowStub) - return { responses } -} - -export function resetEditorState(): void { - useAppStore.setState({ - openFiles: [], - editorDrafts: {}, - worktreesByRepo: { - repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo', branch: 'main', hostId: 'local' }] - }, - repos: [ - { - id: 'repo', - path: '/repo', - displayName: 'repo', - kind: 'git', - executionHostId: 'local' - } - ] - } as never) - useAppStore.setState({ - unifiedTabsByWorktree: { 'wt-1': [markdownUnifiedTab('tab-md', '/repo/README.md', 'group-1')] } - }) -} - -/** The desktop tab strip entry a phone names a markdown tab by. */ -export function markdownUnifiedTab(id: string, fileId: string, groupId: string): Tab { - return { - id, - entityId: fileId, - groupId, - worktreeId: 'wt-1', - contentType: 'editor', - label: fileId, - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } -} - -export function openMarkdownFile(): void { - useAppStore.getState().openFile({ - filePath: '/repo/README.md', - relativePath: 'README.md', - worktreeId: 'wt-1', - language: 'markdown', - mode: 'edit' - }) -} - -export async function sendRequest(request: RuntimeMobileMarkdownRequest): Promise { - expect(mobileMarkdownHandler).not.toBeNull() - mobileMarkdownHandler?.(request) - for (let i = 0; i < 20; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 0)) - const response = ( - window.api.ui.respondMobileMarkdownRequest as ReturnType - ).mock.calls - .map((call) => call[0]) - .find((candidate) => candidate?.id === request.id) - if (response) { - return response - } - } - throw new Error(`No response for ${request.id}`) -} - -export function createDeferred(): { promise: Promise; resolve: () => void } { - let resolve: () => void = () => {} - const promise = new Promise((next) => { - resolve = next - }) - return { promise, resolve } -} - -export function cleanupMobileMarkdownBridgeHarness(): void { - vi.unstubAllGlobals() - mobileMarkdownHandler = null -} - -/** What the real writer answers for `diskContent`: a refusal when the precondition no longer holds. */ -export function guardedWriteRefusal( - diskPrecondition: FileWriteDiskPrecondition | undefined, - diskContent: string | null -): { ok: false; diskConflict: 'changed' | 'missing' } | null { - const diskConflict = diskPrecondition - ? compareDiskWritePrecondition(diskPrecondition, diskContent) - : null - return diskConflict ? { ok: false, diskConflict } : null -} diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts b/src/renderer/src/runtime/mobile-markdown-bridge.test.ts deleted file mode 100644 index bd190190312..00000000000 --- a/src/renderer/src/runtime/mobile-markdown-bridge.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - hashMarkdownContent, - MOBILE_MARKDOWN_EDIT_MAX_BYTES, - MOBILE_MARKDOWN_READ_MAX_BYTES -} from '../../../shared/mobile-markdown-document' -import { attachEditorAutosaveController } from '../components/editor/editor-autosave-controller' -import { ORCA_EDITOR_FILE_CACHE_STALE_EVENT } from '../components/editor/editor-autosave' -import { registerPendingEditorFlush } from '../components/editor/editor-pending-flush' -import { useAppStore } from '../store' -import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' -import { attachMobileMarkdownBridge } from './mobile-markdown-bridge' -import { MOBILE_STALE_SAVE_MESSAGE } from './mobile-markdown-save-rollback' -import { - cleanupMobileMarkdownBridgeHarness, - markdownUnifiedTab, - openMarkdownFile, - resetEditorState, - sendRequest, - setupWindow, - guardedWriteRefusal -} from './mobile-markdown-bridge-test-harness' - -vi.mock('@/lib/connection-context', () => ({ - getConnectionIdForFile: () => null -})) - -describe('mobile markdown bridge', () => { - beforeEach(() => { - resetEditorState() - }) - - afterEach(() => { - cleanupMobileMarkdownBridgeHarness() - }) - - it('flushes pending rich markdown changes before read', async () => { - openMarkdownFile() - setupWindow({ - readFile: vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) - }) - const detach = attachMobileMarkdownBridge() - const unregisterFlush = registerPendingEditorFlush('/repo/README.md', () => { - useAppStore.getState().setEditorDraft('/repo/README.md', '# pending\n') - useAppStore.getState().markFileDirty('/repo/README.md', true) - }) - - try { - const response = await sendRequest({ - id: 'read-1', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(response).toMatchObject({ - id: 'read-1', - ok: true, - result: { content: '# pending\n', source: 'draft', editable: true } - }) - } finally { - unregisterFlush() - detach() - } - }) - - it('reads a restored floating-workspace tab for a paired client as user-named', async () => { - useAppStore.getState().openFile({ - filePath: '/Users/me/notes.md', - relativePath: 'notes.md', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - language: 'markdown', - mode: 'edit' - }) - const tabId = useAppStore.getState().openFiles[0]?.id ?? '' - const readFile = vi.fn().mockResolvedValue({ content: '# notes', isBinary: false }) - setupWindow({ readFile }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'read-floating', - operation: 'read', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - tabId - }) - - expect(response).toMatchObject({ ok: true, result: { content: '# notes', source: 'file' } }) - expect(readFile).toHaveBeenCalledWith( - expect.objectContaining({ filePath: '/Users/me/notes.md', access: { kind: 'user-file' } }) - ) - } finally { - detach() - } - }) - - it('reads a project tab for a paired client inside its root', async () => { - openMarkdownFile() - const readFile = vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) - setupWindow({ readFile }) - const detach = attachMobileMarkdownBridge() - - try { - await sendRequest({ - id: 'read-project', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(readFile.mock.calls[0]?.[0]).not.toHaveProperty('access') - } finally { - detach() - } - }) - - it('reads a markdown tab that sits in a split group the desktop is not focused on', async () => { - openMarkdownFile() - useAppStore.setState({ - unifiedTabsByWorktree: { - 'wt-1': [markdownUnifiedTab('tab-md', '/repo/README.md', 'group-split')] - }, - groupsByWorktree: { - 'wt-1': [ - { id: 'group-main', worktreeId: 'wt-1', activeTabId: null, tabOrder: [] }, - { - id: 'group-split', - worktreeId: 'wt-1', - activeTabId: 'tab-md', - tabOrder: ['tab-md'] - } - ] - }, - activeGroupIdByWorktree: { 'wt-1': 'group-main' } - }) - setupWindow({ readFile: vi.fn().mockResolvedValue({ content: '# split', isBinary: false }) }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'read-split', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(response).toMatchObject({ ok: true, result: { content: '# split' } }) - } finally { - detach() - } - }) - - it('reads the requested tab when the same file is open in two groups', async () => { - openMarkdownFile() - useAppStore.setState({ - unifiedTabsByWorktree: { - 'wt-1': [ - markdownUnifiedTab('tab-main', '/repo/README.md', 'group-main'), - markdownUnifiedTab('tab-split', '/repo/README.md', 'group-split') - ] - } - }) - setupWindow({ readFile: vi.fn().mockResolvedValue({ content: '# both', isBinary: false }) }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'read-duplicate', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-split' - }) - - expect(response).toMatchObject({ - ok: true, - result: { tabId: 'tab-split', filePath: '/repo/README.md', content: '# both' } - }) - } finally { - detach() - } - }) - - it('rejects save when a clean file changed after mobile read', async () => { - openMarkdownFile() - const writeFile = vi.fn().mockResolvedValue(undefined) - setupWindow({ - readFile: vi.fn().mockResolvedValue({ content: 'changed on disk', isBinary: false }), - writeFile - }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'save-1', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'mobile edit' - }) - - expect(response).toMatchObject({ id: 'save-1', ok: false, error: 'conflict' }) - expect(writeFile).not.toHaveBeenCalled() - } finally { - detach() - } - }) - - it('saves through the editor save controller and verifies written content', async () => { - openMarkdownFile() - let diskContent = 'original' - const readFile = vi.fn().mockImplementation(async () => ({ - content: diskContent, - isBinary: false - })) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const response = await sendRequest({ - id: 'save-2', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'mobile edit' - }) - - expect(writeFile).toHaveBeenCalledWith( - expect.objectContaining({ - filePath: '/repo/README.md', - content: 'mobile edit', - connectionId: undefined, - expectedExecutionHostId: 'local', - diskPrecondition: expect.objectContaining({ allowMissing: true }) - }) - ) - expect(response).toMatchObject({ - id: 'save-2', - ok: true, - result: { content: 'mobile edit', isDirty: false } - }) - } finally { - detachAutosave() - detachBridge() - } - }) - - it('reports a refused phone save to the phone only, with its own message', async () => { - openMarkdownFile() - const tabId = useAppStore.getState().openFiles[0].id - // The tab never loaded on the desktop; the phone read 'original', then an agent wrote. - let diskContent = 'original' - const readFile = vi.fn().mockImplementation(async () => { - const content = diskContent - diskContent = 'agent rewrite' - return { content, isBinary: false } - }) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const response = await sendRequest({ - id: 'save-stale', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('original'), - content: 'mobile edit' - }) - - expect(writeFile).toHaveBeenCalledWith( - expect.objectContaining({ - diskPrecondition: expect.objectContaining({ - expectedSignature: getDiskBaselineSignature('original') - }) - }) - ) - expect(diskContent).toBe('agent rewrite') - expect(response).toMatchObject({ - id: 'save-stale', - ok: false, - error: MOBILE_STALE_SAVE_MESSAGE - }) - const tab = useAppStore.getState().openFiles.find((file) => file.id === tabId) - expect(tab?.externalMutation).toBeUndefined() - expect(tab?.isDirty).toBe(false) - // The baseline stamped from the phone's read is withdrawn with the failed save. - expect(tab?.lastKnownDiskSignature).toBeUndefined() - } finally { - detachAutosave() - detachBridge() - } - }) - - it.each([ - ['saves', 'D1'], - ['types', null] - ] as const)( - 'refuses a phone save when the desktop %s during the phone-verifying read', - async (_label, desktopSave) => { - openMarkdownFile() - const tabId = useAppStore.getState().openFiles[0].id - useAppStore.getState().setLastKnownDiskSignature(tabId, getDiskBaselineSignature('D0')) - let diskContent = 'D0' - const readFile = vi.fn().mockImplementation(async () => { - const content = diskContent - if (desktopSave) { - diskContent = desktopSave - useAppStore - .getState() - .setLastKnownDiskSignature(tabId, getDiskBaselineSignature(desktopSave)) - } else { - useAppStore.getState().setEditorDraft(tabId, 'desktop typing') - useAppStore.getState().markFileDirty(tabId, true) - } - return { content, isBinary: false } - }) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const response = await sendRequest({ - id: 'save-race', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('D0'), - content: 'phone edit' - }) - - expect(response).toMatchObject({ id: 'save-race', ok: false }) - expect(diskContent).toBe(desktopSave ?? 'D0') - if (!desktopSave) { - expect(useAppStore.getState().editorDrafts[tabId]).toBe('desktop typing') - } - } finally { - detachAutosave() - detachBridge() - } - } - ) - - it('lets the phone save after an external change to a clean desktop tab', async () => { - openMarkdownFile() - const tabId = useAppStore.getState().openFiles[0].id - // The desktop loaded 'A' earlier; an agent then wrote 'B' and the desktop tab is still clean. - useAppStore.getState().setLastKnownDiskSignature(tabId, getDiskBaselineSignature('A')) - let diskContent = 'B' - const readFile = vi.fn().mockImplementation(async () => ({ - content: diskContent, - isBinary: false - })) - const writeFile = vi.fn().mockImplementation(async ({ content, diskPrecondition }) => { - const refusal = guardedWriteRefusal(diskPrecondition, diskContent) - if (refusal) { - return refusal - } - diskContent = content - return { ok: true } - }) - setupWindow({ readFile, writeFile }) - const dropped: string[] = [] - const onStale = (event: Event): void => { - if (event instanceof CustomEvent) { - dropped.push(event.detail.fileId) - } - } - window.addEventListener(ORCA_EDITOR_FILE_CACHE_STALE_EVENT, onStale) - const detachBridge = attachMobileMarkdownBridge() - const detachAutosave = attachEditorAutosaveController(useAppStore as never) - - try { - const response = await sendRequest({ - id: 'save-after-change', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent('B'), - content: 'phone edit' - }) - - expect(response).toMatchObject({ id: 'save-after-change', ok: true }) - expect(diskContent).toBe('phone edit') - // The tab's baseline and text both moved to the write, so nothing needed dropping. - expect(useAppStore.getState().openFiles[0]?.lastKnownDiskSignature).toBe( - getDiskBaselineSignature('phone edit') - ) - expect(dropped).toEqual([]) - } finally { - window.removeEventListener(ORCA_EDITOR_FILE_CACHE_STALE_EVENT, onStale) - detachAutosave() - detachBridge() - } - }) - - it('marks oversized multibyte desktop drafts as read-only for mobile editing', async () => { - openMarkdownFile() - const content = '😀'.repeat(Math.floor(MOBILE_MARKDOWN_EDIT_MAX_BYTES / 4) + 1) - const readFile = vi.fn().mockResolvedValue({ content: 'disk', isBinary: false }) - const state = useAppStore.getState() - state.setEditorDraft('/repo/README.md', content) - state.markFileDirty('/repo/README.md', true) - setupWindow({ readFile }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'read-large-multibyte', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(response).toMatchObject({ - id: 'read-large-multibyte', - ok: true, - result: { editable: false, readOnlyReason: 'file_too_large' } - }) - expect(readFile).not.toHaveBeenCalled() - } finally { - detach() - } - }) - - it('reads a markdown file between the edit and read budgets whole, as read-only', async () => { - openMarkdownFile() - const content = 'a'.repeat(632 * 1024) - setupWindow({ readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'read-632k', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(response).toMatchObject({ - ok: true, - result: { content, editable: false, readOnlyReason: 'file_too_large' } - }) - expect(response).not.toHaveProperty('result.truncated') - } finally { - detach() - } - }) - - it('truncates a markdown file over the read budget on a UTF-8 boundary', async () => { - openMarkdownFile() - // The 3-byte euro sign straddles the budget, so the cut must drop it whole. - const prefix = 'a'.repeat(MOBILE_MARKDOWN_READ_MAX_BYTES - 1) - const content = `${prefix}\u20actail` - setupWindow({ readFile: vi.fn().mockResolvedValue({ content, isBinary: false }) }) - const detach = attachMobileMarkdownBridge() - - try { - const response = await sendRequest({ - id: 'read-over-budget', - operation: 'read', - worktreeId: 'wt-1', - tabId: 'tab-md' - }) - - expect(response).toMatchObject({ - ok: true, - result: { - editable: false, - readOnlyReason: 'file_too_large', - truncated: true, - byteLength: MOBILE_MARKDOWN_READ_MAX_BYTES - 1 + 3 + 4, - version: hashMarkdownContent(prefix) - } - }) - expect(response).toHaveProperty('result.content', prefix) - const save = await sendRequest({ - id: 'save-truncated', - operation: 'save', - worktreeId: 'wt-1', - tabId: 'tab-md', - baseVersion: hashMarkdownContent(prefix), - content: prefix - }) - expect(save).toMatchObject({ ok: false, error: 'file_too_large' }) - } finally { - detach() - } - }) -}) diff --git a/src/renderer/src/runtime/mobile-markdown-bridge.ts b/src/renderer/src/runtime/mobile-markdown-bridge.ts deleted file mode 100644 index 49b4bda43d1..00000000000 --- a/src/renderer/src/runtime/mobile-markdown-bridge.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { - ORCA_EDITOR_FILE_SAVED_EVENT, - requestEditorFileSave, - requestEditorSaveQuiesce, - type EditorFileSavedDetail -} from '@/components/editor/editor-autosave' -import { flushPendingEditorBuffer } from '@/components/editor/editor-pending-flush' -import { getConnectionIdForFile } from '@/lib/connection-context' -import { editorTabFileAccess } from '@/lib/local-file-access' -import { useAppStore } from '@/store' -import type { OpenFile } from '@/store/slices/editor' -import { readRuntimeFileContent } from './runtime-file-read-client' -import { requireRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target' -import { - hashMarkdownContent, - isMarkdownContentByteLengthOverLimit, - MOBILE_MARKDOWN_EDIT_MAX_BYTES, - truncateMobileMarkdownRead, - type RuntimeMarkdownReadTabResult, - type RuntimeMarkdownSaveTabResult, - type RuntimeMobileMarkdownRequest, - type RuntimeMobileMarkdownResponse -} from '../../../shared/mobile-markdown-document' -import { - rollBackFailedMobileSave, - stampMobileVerifiedBaseline -} from './mobile-markdown-save-rollback' - -const saveQueues = new Map>() - -type FileContent = { - content: string - isBinary: boolean -} - -export function attachMobileMarkdownBridge(): () => void { - if (typeof window.api.ui.onMobileMarkdownRequest !== 'function') { - return () => {} - } - return window.api.ui.onMobileMarkdownRequest((request) => { - void handleMobileMarkdownRequest(request) - }) -} - -async function handleMobileMarkdownRequest(request: RuntimeMobileMarkdownRequest): Promise { - try { - const result = - request.operation === 'read' - ? await readMobileMarkdownTab(request.worktreeId, request.tabId) - : await saveMobileMarkdownTab( - request.worktreeId, - request.tabId, - request.baseVersion, - request.content - ) - respond({ id: request.id, ok: true, result }) - } catch (error) { - respond({ - id: request.id, - ok: false, - error: error instanceof Error ? error.message : String(error) - }) - } -} - -async function readMobileMarkdownTab( - worktreeId: string, - tabId: string -): Promise { - const target = resolveMarkdownTarget(worktreeId, tabId) - flushEditorState(target.sourceFile.id) - const { content, source } = await readCurrentContent(target.sourceFile) - const readOnlyReason = getReadOnlyReason(target.tab, target.sourceFile, content) - const preview = truncateMobileMarkdownRead(content) - return { - tabId, - filePath: target.sourceFile.filePath, - relativePath: target.sourceFile.relativePath, - content: preview.content, - isDirty: target.sourceFile.isDirty || source === 'draft', - version: hashMarkdownContent(preview.content), - source, - editable: readOnlyReason === undefined, - ...(readOnlyReason ? { readOnlyReason } : {}), - ...(preview.truncated ? { truncated: true, byteLength: preview.byteLength } : {}) - } -} - -async function saveMobileMarkdownTab( - worktreeId: string, - tabId: string, - baseVersion: string, - content: string -): Promise { - if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) { - throw new Error('file_too_large') - } - const target = resolveMarkdownTarget(worktreeId, tabId) - return await enqueueMarkdownSave(target.sourceFile.id, async () => { - const freshTarget = resolveMarkdownTarget(worktreeId, tabId) - const readOnlyReason = getReadOnlyReason(freshTarget.tab, freshTarget.sourceFile, content) - if (readOnlyReason) { - throw new Error(readOnlyReason) - } - - flushEditorState(freshTarget.sourceFile.id) - const current = await readCurrentContent(freshTarget.sourceFile) - const currentVersion = hashMarkdownContent(current.content) - if (currentVersion !== baseVersion) { - if (current.content === content) { - // Why: duplicate mobile save taps can race behind the first successful - // write; treat an already-saved identical file as success, not conflict. - return { - tabId, - version: currentVersion, - isDirty: false, - content: current.content - } - } - throw new Error('conflict') - } - - const state = useAppStore.getState() - // Why live: a desktop save during the read moved the baseline past the version the phone saw. - const sourceFile = state.openFiles.find((file) => file.id === freshTarget.sourceFile.id) - if (!sourceFile) { - throw new Error('tab_not_found') - } - // Why: desktop typing during the disk read is newer than the version the phone edited. - if (current.source === 'file' && state.editorDrafts[sourceFile.id] !== undefined) { - throw new Error('conflict') - } - const stampedBaseline = stampMobileVerifiedBaseline(sourceFile, current) - const previousDraft = state.editorDrafts[sourceFile.id] - const previousDirty = sourceFile.isDirty - state.setEditorDraft(sourceFile.id, content) - state.markFileDirty(sourceFile.id, true) - let verified: string - try { - await waitForPositiveSave(freshTarget.sourceFile, content) - verified = await readFileContent(freshTarget.sourceFile) - if (verified !== content) { - throw new Error('save_verification_failed') - } - } catch (error) { - throw rollBackFailedMobileSave(error, { - fileId: freshTarget.sourceFile.id, - injectedContent: content, - previousDraft, - previousDirty, - stampedBaseline - }) - } - - return { - tabId, - version: hashMarkdownContent(verified), - isDirty: false, - content: verified - } - }) -} - -async function enqueueMarkdownSave(fileId: string, save: () => Promise): Promise { - const previous = saveQueues.get(fileId) ?? Promise.resolve() - let releaseQueue: () => void = () => {} - const current = new Promise((resolve) => { - releaseQueue = resolve - }) - const queued = previous.catch(() => undefined).then(() => current) - saveQueues.set(fileId, queued) - await previous.catch(() => undefined) - try { - return await save() - } finally { - releaseQueue() - if (saveQueues.get(fileId) === queued) { - saveQueues.delete(fileId) - } - } -} - -function resolveMarkdownTarget( - worktreeId: string, - tabId: string -): { tab: OpenFile; sourceFile: OpenFile } { - const state = useAppStore.getState() - // Why every group: the phone lists tabs from all split groups, not just the desktop's focused one. - const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (candidate) => candidate.id === tabId && candidate.contentType === 'editor' - ) - // Hosts publish the file id when a file has no unified tab. - const fileId = unifiedTab?.entityId ?? tabId - const tab = state.openFiles.find((file) => file.worktreeId === worktreeId && file.id === fileId) - if (!tab || !isMarkdownTab(tab)) { - throw new Error('tab_not_found') - } - const sourceFile = - tab.mode === 'markdown-preview' && tab.markdownPreviewSourceFileId - ? (state.openFiles.find( - (file) => file.worktreeId === worktreeId && file.id === tab.markdownPreviewSourceFileId - ) ?? tab) - : tab - return { tab, sourceFile } -} - -function isMarkdownTab(file: OpenFile): boolean { - if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { - return false - } - return file.language === 'markdown' || file.mode === 'markdown-preview' -} - -function getReadOnlyReason( - tab: OpenFile, - sourceFile: OpenFile, - content: string -): RuntimeMarkdownReadTabResult['readOnlyReason'] { - if (tab.mode === 'markdown-preview') { - return 'unsupported_preview' - } - if (sourceFile.isUntitled) { - return 'unsupported_untitled' - } - if (isMarkdownContentByteLengthOverLimit(content, MOBILE_MARKDOWN_EDIT_MAX_BYTES)) { - return 'file_too_large' - } - return undefined -} - -async function readCurrentContent( - file: OpenFile -): Promise<{ content: string; source: 'draft' | 'file' }> { - const draft = useAppStore.getState().editorDrafts[file.id] - if (draft !== undefined) { - return { content: draft, source: 'draft' } - } - return { content: await readFileContent(file), source: 'file' } -} - -async function readFileContent(file: OpenFile): Promise { - const connectionId = getConnectionIdForFile(file.worktreeId, file.filePath) ?? undefined - const state = useAppStore.getState() - const result: FileContent = await readRuntimeFileContent({ - target: requireRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId), - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - connectionId, - expectedExternalSshTargetId: file.externalSshTargetId, - access: editorTabFileAccess(state, file) - }) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content -} - -function flushEditorState(fileId: string): void { - // Why: rich markdown serializes through a debounce. Mobile reads/saves must - // observe desktop-visible text, not a stale draft from before that debounce. - flushPendingEditorBuffer(useAppStore.getState(), fileId) -} - -async function waitForPositiveSave(file: OpenFile, content: string): Promise { - let timeout: number | null = null - let onSaved: ((event: Event) => void) | null = null - const cleanup = (): void => { - if (timeout !== null) { - window.clearTimeout(timeout) - timeout = null - } - if (onSaved) { - window.removeEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved as EventListener) - onSaved = null - } - } - const saved = new Promise((resolve, reject) => { - timeout = window.setTimeout(() => { - cleanup() - reject(new Error('save_timeout')) - }, 20_000) - onSaved = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (detail?.fileId !== file.id || detail.content !== content) { - return - } - cleanup() - resolve() - } - window.addEventListener(ORCA_EDITOR_FILE_SAVED_EVENT, onSaved as EventListener) - }) - - try { - await requestEditorSaveQuiesce({ fileId: file.id }) - const liveFile = useAppStore.getState().openFiles.find((openFile) => openFile.id === file.id) - if (!liveFile) { - throw new Error('tab_not_found') - } - await requestEditorFileSave({ fileId: file.id, fallbackContent: content, origin: 'mobile' }) - await saved - } catch (error) { - cleanup() - throw error - } -} - -function respond(response: RuntimeMobileMarkdownResponse): void { - window.api.ui.respondMobileMarkdownRequest(response) -} diff --git a/src/renderer/src/runtime/mobile-markdown-save-rollback.ts b/src/renderer/src/runtime/mobile-markdown-save-rollback.ts deleted file mode 100644 index e658002a9dc..00000000000 --- a/src/renderer/src/runtime/mobile-markdown-save-rollback.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useAppStore } from '@/store' -import type { OpenFile } from '@/store/slices/editor' -import { - EDITOR_STALE_SAVE_MESSAGE, - isEditorModelDirty, - setEditorDiskBaseline -} from '@/components/editor/editor-save-disk-precondition' -import { setModelExternalMutation } from '@/components/editor/editor-save-rebaseline' -import { getDiskBaselineSignature } from '../../../shared/disk-baseline-signature' - -export const MOBILE_STALE_SAVE_MESSAGE = - 'This file changed on the computer. Reload to get the latest version, then reapply your edit.' - -/** The shared baseline a phone save adopted, and the one it replaced. */ -export type MobileAdoptedBaseline = { adopted: string; previous: string | undefined } - -/** - * When no tab of the model has edits, the version mobile just read from disk becomes the model's - * baseline for this save. A dirty model keeps its baseline, so the save is refused and the phone told. - */ -export function stampMobileVerifiedBaseline( - file: OpenFile, - current: { content: string; source: 'draft' | 'file' } -): MobileAdoptedBaseline | null { - const state = useAppStore.getState() - if (current.source !== 'file' || isEditorModelDirty(state, file)) { - return null - } - const adopted = getDiskBaselineSignature(current.content) - const previous = file.lastKnownDiskSignature - if (previous === adopted) { - return null - } - setEditorDiskBaseline(state, file.id, adopted) - return { adopted, previous } -} - -/** Puts the desktop tab back as it was and returns the error to send to mobile. */ -export function rollBackFailedMobileSave( - error: unknown, - save: { - fileId: string - injectedContent: string - previousDraft: string | undefined - previousDirty: boolean - stampedBaseline: MobileAdoptedBaseline | null - } -): unknown { - restoreFailedMobileSaveDraft( - save.fileId, - save.injectedContent, - save.previousDraft, - save.previousDirty - ) - const state = useAppStore.getState() - const liveFile = state.openFiles.find((openFile) => openFile.id === save.fileId) - // Why: nothing was written, so the adopted baseline must not outlive the save. - if ( - save.stampedBaseline !== null && - liveFile?.lastKnownDiskSignature === save.stampedBaseline.adopted - ) { - setEditorDiskBaseline(state, save.fileId, save.stampedBaseline.previous) - } - if (!(error instanceof Error) || error.message !== EDITOR_STALE_SAVE_MESSAGE) { - return error - } - // Why: desktop edits rode along in this save, so the desktop must learn of the conflict too. - if (save.previousDirty) { - setModelExternalMutation(state, save.fileId, 'changed') - } - return new Error(MOBILE_STALE_SAVE_MESSAGE) -} - -function restoreFailedMobileSaveDraft( - fileId: string, - injectedContent: string, - previousDraft: string | undefined, - previousDirty: boolean -): void { - const state = useAppStore.getState() - const currentDraft = state.editorDrafts[fileId] - if (currentDraft !== undefined && currentDraft !== injectedContent) { - return - } - // Why: mobile save injects a desktop draft only to reuse the editor save path. - // If save or verification fails, put the desktop editor back exactly as it was before. - if (previousDraft === undefined) { - state.clearEditorDraft(fileId) - } else { - state.setEditorDraft(fileId, previousDraft) - } - state.markFileDirty(fileId, previousDirty) -} diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.test.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.test.ts new file mode 100644 index 00000000000..fb9381b3071 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots' +import { makeState } from '../sync-runtime-graph-test-harness' + +function listedMarkdownTab(isUntitled: boolean | undefined) { + const state = makeState({ + browserTabsByWorktree: {}, + tabBarOrderByWorktree: { 'wt-1': ['/repo/untitled.md'] }, + openFiles: [ + { + id: '/repo/untitled.md', + filePath: '/repo/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + language: 'markdown', + mode: 'edit', + isDirty: true, + ...(isUntitled === undefined ? {} : { isUntitled }) + } + ] + }) + return buildMobileSessionTabSnapshots(state)[0]?.tabs.find((tab) => tab.type === 'markdown') +} + +describe('a listed Markdown tab', () => { + it('says when its document is untitled, so a phone save cannot make the file', () => { + expect(listedMarkdownTab(true)).toMatchObject({ isUntitled: true }) + }) + + it('omits the flag for a named document', () => { + expect(listedMarkdownTab(undefined)).not.toHaveProperty('isUntitled') + expect(listedMarkdownTab(false)).not.toHaveProperty('isUntitled') + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts index 6c6e32f820d..dbe69a83008 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-editor-tabs.ts @@ -44,6 +44,7 @@ export function buildMobileMarkdownTab( sourceFileId: sourceFile.id, sourceFilePath: sourceFile.filePath, sourceRelativePath: sourceFile.relativePath, + ...(sourceFile.isUntitled ? { isUntitled: true as const } : {}), documentVersion: draftVersion ?? `file:${sourceFile.id}`, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 339027211a7..dcf3393e8bc 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -280,8 +280,6 @@ export function createWebUiApi(): NonNullable['ui']> { onOpenFileFromMobile: () => noopUnsubscribe, onRevealEditorTabFromRuntime: () => noopUnsubscribe, onOpenDiffFromMobile: () => noopUnsubscribe, - onMobileMarkdownRequest: () => noopUnsubscribe, - respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, onTerminalTabCloseRequest: () => noopUnsubscribe, respondTerminalTabClose: () => {}, diff --git a/src/shared/mobile-markdown-document.ts b/src/shared/mobile-markdown-document.ts index 14a6474cf5d..a5ff33d5a50 100644 --- a/src/shared/mobile-markdown-document.ts +++ b/src/shared/mobile-markdown-document.ts @@ -13,34 +13,6 @@ export type RuntimeMarkdownReadOnlyReason = | 'unsupported_untitled' | 'file_too_large' -export type RuntimeMobileMarkdownRequest = - | { - id: string - operation: 'read' - worktreeId: string - tabId: string - } - | { - id: string - operation: 'save' - worktreeId: string - tabId: string - baseVersion: string - content: string - } - -export type RuntimeMobileMarkdownResponse = - | { - id: string - ok: true - result: RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult - } - | { - id: string - ok: false - error: string - } - export type RuntimeMarkdownReadTabResult = { tabId: string filePath: string diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 52347cd5e7d..fb28a681b33 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -55,6 +55,8 @@ export type RuntimeMobileSessionMarkdownTab = { sourceFileId: string sourceFilePath: string sourceRelativePath: string + /** Set by a desktop for a "New Markdown" document not yet named; hosts and phones before it omit it. */ + isUntitled?: true documentVersion: string color?: string | null isPinned?: boolean diff --git a/tests/e2e/desktop-window-mobile-markdown.spec.ts b/tests/e2e/desktop-window-mobile-markdown.spec.ts new file mode 100644 index 00000000000..eaea2255be9 --- /dev/null +++ b/tests/e2e/desktop-window-mobile-markdown.spec.ts @@ -0,0 +1,243 @@ +/** + * A phone reads and saves a Markdown tab on a desktop whose editor holds unsaved text for it. + * + * The phone always works on the file on disk, window or not: it sees the disk with the tab marked + * dirty, and its save lands on disk. The desktop keeps its unsaved text and reports the newer disk + * content with its usual changed-on-disk banner. + */ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts' +import type { + RuntimeMarkdownReadTabResult, + RuntimeMarkdownSaveTabResult +} from '../../src/shared/mobile-markdown-document' +import { expect, test } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' +import { createDesktopMobilePairingOffer } from './helpers/paired-electron-client' +import { pairMobileClient } from './helpers/paired-mobile-client' + +const DISK = '# Shared notes\n\nOn disk.\n' +const DRAFT_LINE = 'unsaved desktop line' +const PHONE = '# Shared notes\n\nSaved from the phone.\n' +const BANNER = 'This file changed on disk while you have unsaved edits.' + +test('a phone saves a Markdown tab the desktop has unsaved edits in, without touching them', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const filePath = await createMarkdownFixture( + context, + '.orca-e2e-phone-markdown', + 'desktop-draft', + testInfo.workerIndex, + DISK + ) + const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage)) + try { + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await expect(editor).toContainText('On disk.') + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+End') + await orcaPage.keyboard.press('Enter') + await orcaPage.keyboard.insertText(DRAFT_LINE) + const desktopDraft = () => + orcaPage.evaluate((path) => { + const state = window.__store!.getState() + const file = state.openFiles.find((entry) => entry.filePath === path) + return { + draft: file ? state.editorDrafts[file.id] : undefined, + isDirty: file?.isDirty === true, + externalMutation: file?.externalMutation ?? null + } + }, filePath) + await expect.poll(async () => (await desktopDraft()).draft ?? '').toContain(DRAFT_LINE) + const before = await desktopDraft() + expect(before.isDirty).toBe(true) + + const worktree = `id:${context.worktreeId}` + let tabId = '' + await expect + .poll( + async () => { + const listed = await phone.call('session.tabs.list', { + worktree + }) + const tab = listed.tabs.find( + (entry) => entry.type === 'markdown' && entry.sourceFilePath === filePath + ) + tabId = tab?.id ?? '' + return tab?.type === 'markdown' && tab.isDirty + }, + { timeout: 30_000, message: 'the desktop never listed its dirty Markdown tab' } + ) + .toBe(true) + + // Why poll: the desktop persists its session (which names the file) on a debounce. + let read: RuntimeMarkdownReadTabResult | null = null + await expect + .poll( + async () => { + const response = await phone.request('markdown.readTab', { + worktree, + tabId + }) + read = response.ok ? response.result : null + return response.ok ? 'read' : `${response.error.code}: ${response.error.message}` + }, + { timeout: 30_000 } + ) + .toBe('read') + expect(read).toMatchObject({ + tabId, + filePath, + content: DISK, + source: 'file', + isDirty: true, + editable: true + }) + + const saved = await phone.call('markdown.saveTab', { + worktree, + tabId, + baseVersion: read!.version, + content: PHONE + }) + expect(saved).toMatchObject({ tabId, isDirty: false, content: PHONE }) + expect(readFileSync(filePath, 'utf8')).toBe(PHONE) + + await expect(orcaPage.getByText(BANNER)).toBeVisible({ timeout: 30_000 }) + await testInfo.attach('desktop-after-phone-save', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) + const after = await desktopDraft() + expect(after).toEqual({ ...before, externalMutation: 'changed' }) + await expect(editor).toContainText(DRAFT_LINE) + expect(readFileSync(filePath, 'utf8')).toBe(PHONE) + } finally { + phone.dispose() + await cleanupMarkdownFixture(filePath) + } +}) + +test("a phone reads the desktop's unsaved untitled Markdown tab read-only and never saves it", async ({ + orcaPage +}, testInfo) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const context = await getActiveWorktreeContext(orcaPage) + const phone = pairMobileClient(await createDesktopMobilePairingOffer(orcaPage)) + let untitledPath = '' + try { + untitledPath = await orcaPage.evaluate(async (worktreeId) => { + const store = window.__store! + const before = new Set(store.getState().openFiles.map((file) => file.id)) + const groupId = store.getState().activeGroupIdByWorktree[worktreeId] ?? '' + await store.getState().openNewMarkdownInActiveWorkspace(groupId) + const created = store + .getState() + .openFiles.find((file) => !before.has(file.id) && file.isUntitled === true) + if (!created) { + throw new Error('No untitled Markdown tab was created') + } + return created.filePath + }, context.worktreeId) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.click() + await orcaPage.keyboard.insertText(DRAFT_LINE) + await expect + .poll(() => + orcaPage.evaluate((path) => { + const state = window.__store!.getState() + const file = state.openFiles.find((entry) => entry.filePath === path) + return file?.isDirty === true && file.isUntitled === true + }, untitledPath) + ) + .toBe(true) + + const worktree = `id:${context.worktreeId}` + let tabId = '' + await expect + .poll(async () => { + const listed = await phone.call('session.tabs.list', { + worktree + }) + tabId = + listed.tabs.find( + (entry) => entry.type === 'markdown' && entry.sourceFilePath === untitledPath + )?.id ?? '' + return tabId + }) + .not.toBe('') + + // Why poll: the desktop persists its session (which names the file) on a debounce. + let read: RuntimeMarkdownReadTabResult | null = null + await expect + .poll( + async () => { + const response = await phone.request('markdown.readTab', { + worktree, + tabId + }) + read = response.ok ? response.result : null + return response.ok ? 'read' : `${response.error.code}: ${response.error.message}` + }, + { timeout: 30_000 } + ) + .toBe('read') + const disk = readFileSync(untitledPath, 'utf8') + expect(read).toMatchObject({ + tabId, + filePath: untitledPath, + content: disk, + source: 'file', + isDirty: true, + editable: false, + readOnlyReason: 'unsupported_untitled' + }) + expect(JSON.stringify(read)).not.toContain(DRAFT_LINE) + + const save = await phone.request('markdown.saveTab', { + worktree, + tabId, + baseVersion: read!.version, + content: PHONE + }) + expect(save).toMatchObject({ ok: false, error: { message: 'unsupported_untitled' } }) + expect(readFileSync(untitledPath, 'utf8')).toBe(disk) + + // With no file on disk the read fails, and a save still makes none. + rmSync(untitledPath) + const missingRead = await phone.request('markdown.readTab', { worktree, tabId }) + testInfo.annotations.push({ + type: 'untitled-missing-read', + description: JSON.stringify(missingRead.ok ? missingRead.result : missingRead.error) + }) + expect(missingRead.ok).toBe(false) + const missingSave = await phone.request('markdown.saveTab', { + worktree, + tabId, + baseVersion: read!.version, + content: PHONE + }) + expect(missingSave).toMatchObject({ ok: false, error: { message: 'unsupported_untitled' } }) + expect(existsSync(untitledPath)).toBe(false) + } finally { + phone.dispose() + if (untitledPath) { + await cleanupMarkdownFixture(untitledPath) + } + } +}) diff --git a/tests/e2e/headless-serve-mobile-markdown.spec.ts b/tests/e2e/headless-serve-mobile-markdown.spec.ts new file mode 100644 index 00000000000..a7571762b47 --- /dev/null +++ b/tests/e2e/headless-serve-mobile-markdown.spec.ts @@ -0,0 +1,136 @@ +/** + * A phone reads and saves a Markdown tab on a headless server, from the server's own disk. + * + * A headless host lists the editor tabs its saved session holds (a converted SSH host's move + * carries them in). With no window there is no editor to relay through, so the server answers + * from the file itself and saves with a compare against the version the phone last read. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { RuntimeClient } from '../../src/cli/runtime/client' +import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts' +import type { + RuntimeMarkdownReadTabResult, + RuntimeMarkdownSaveTabResult +} from '../../src/shared/mobile-markdown-document' +import { expect, test } from './helpers/orca-app' +import { + addRepoWorktree, + cliClient, + orcadNodeExecutable, + pairedClient, + startHeadlessOracleHost, + type HeadlessHostKind +} from './helpers/headless-layout-oracle-hosts' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Saves an open Markdown editor for the worktree, as a desktop or a move leaves it. */ +function saveMarkdownEditor(userDataDir: string, worktreeId: string, filePath: string): void { + mutateStoppedProfileState(userDataDir, (state) => { + const session = state.workspaceSession + if (!isRecord(session)) { + throw new Error('The profile has no workspace session') + } + session.openFilesByWorktree = { + ...(isRecord(session.openFilesByWorktree) ? session.openFilesByWorktree : {}), + [worktreeId]: [ + { + filePath, + relativePath: path.basename(filePath), + worktreeId, + language: 'markdown', + runtimeEnvironmentId: null + } + ] + } + }) +} + +async function markdownTabId(phone: RuntimeClient, worktree: string): Promise { + let tabId = '' + await expect + .poll( + async () => { + const { result } = await phone.call('session.tabs.list', { + worktree + }) + tabId = result.tabs.find((tab) => tab.type === 'markdown')?.id ?? '' + return tabId + }, + { timeout: 30_000, message: 'the server never listed the saved Markdown tab' } + ) + .not.toBe('') + return tabId +} + +async function readTab(phone: RuntimeClient, worktree: string, tabId: string) { + return (await phone.call('markdown.readTab', { worktree, tabId })) + .result +} + +const HOSTS: HeadlessHostKind[] = ['electron', 'orcad'] + +for (const kind of HOSTS) { + test(`a phone reads and saves a Markdown tab on a headless ${kind} server`, async () => { + // Why skip in CI too: only the orcad mode-switch job builds out/orcad. + test.skip(kind === 'orcad' && !orcadNodeExecutable(), 'out/orcad is not built') + test.setTimeout(240_000) + const host = await startHeadlessOracleHost(kind) + try { + const cli = cliClient(host) + const phone = pairedClient(host, 'phone') + const worktreeId = await addRepoWorktree(host, cli) + const worktree = `id:${worktreeId}` + const listed = await cli.call<{ worktrees: { id: string; path: string }[] }>( + 'worktree.list', + { repo: `id:${worktreeId.split('::')[0]}` } + ) + const repoPath = listed.result.worktrees.find((entry) => entry.id === worktreeId)!.path + const filePath = path.join(repoPath, 'README.md') + await host.restart({ + whileStopped: () => saveMarkdownEditor(host.userDataDir, worktreeId, filePath) + }) + + const tabId = await markdownTabId(phone, worktree) + const read = await readTab(phone, worktree, tabId) + expect(read).toMatchObject({ + tabId, + filePath, + content: 'layout oracle\n', + source: 'file', + isDirty: false, + editable: true + }) + + const saved = ( + await phone.call('markdown.saveTab', { + worktree, + tabId, + baseVersion: read.version, + content: '# from the phone\n' + }) + ).result + expect(saved).toMatchObject({ tabId, isDirty: false, content: '# from the phone\n' }) + expect(readFileSync(filePath, 'utf8')).toBe('# from the phone\n') + + // A save over a version the disk no longer holds is refused, and the disk keeps its bytes. + writeFileSync(filePath, '# edited on the server\n') + await expect( + phone.call('markdown.saveTab', { + worktree, + tabId, + baseVersion: saved.version, + content: '# stale phone edit\n' + }) + ).rejects.toThrow('conflict') + expect(readFileSync(filePath, 'utf8')).toBe('# edited on the server\n') + expect((await readTab(phone, worktree, tabId)).content).toBe('# edited on the server\n') + } finally { + await host.dispose() + } + }) +} diff --git a/tests/e2e/helpers/headless-layout-oracle-hosts.ts b/tests/e2e/helpers/headless-layout-oracle-hosts.ts index f315d4fcf6b..1417a2be9fc 100644 --- a/tests/e2e/helpers/headless-layout-oracle-hosts.ts +++ b/tests/e2e/helpers/headless-layout-oracle-hosts.ts @@ -18,6 +18,8 @@ import { cliServeProfile, spawnUntilReady, type ReadyProcess } from './orca-serv export type HeadlessHostKind = 'orcad' | 'electron' +type RestartOptions = { cold?: boolean; whileStopped?: () => void | Promise } + export type HeadlessOracleHost = { kind: HeadlessHostKind userDataDir: string @@ -26,8 +28,11 @@ export type HeadlessOracleHost = { pairingUrl: string /** Run by `dispose`, after the host stops. */ cleanups: (() => void)[] - /** Stops serve and starts it again on the same profile and port; `cold` also kills the PTY daemon. */ - restart: (options?: { cold?: boolean }) => Promise + /** + * Stops serve and starts it again on the same profile and port; `cold` also kills the PTY daemon. + * `whileStopped` runs with no serve process alive, the only safe time to edit the profile. + */ + restart: (options?: RestartOptions) => Promise dispose: () => Promise } @@ -112,13 +117,14 @@ async function startOrcadHost(scratch: string, node: string): Promise { + restart: async ({ cold, whileStopped } = {}) => { // SIGTERM is orcad's graceful stop and leaves the PTY daemon running. await serve?.stop() serve = null if (cold) { await cleanupE2EDaemons(profile.userDataDir) } + await whileStopped?.() serve = await launch() }, dispose: async () => { @@ -140,10 +146,15 @@ async function startElectronHost(scratch: string): Promise { userDataDir: host.userDataDir, scratch, pairingUrl: host.offer.pairingUrl, - restart: ({ cold } = {}) => - host.restartServeProcess( - cold ? { betweenProcesses: () => cleanupE2EDaemons(host.userDataDir) } : {} - ), + restart: ({ cold, whileStopped } = {}) => + host.restartServeProcess({ + betweenProcesses: async () => { + if (cold) { + await cleanupE2EDaemons(host.userDataDir) + } + await whileStopped?.() + } + }), dispose: async () => { await host.dispose() rmSync(scratch, PROFILE_REMOVAL)