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 <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-20 23:08:47 -04:00
committed by GitHub
co-authored by E2E Test
parent dda855e837
commit b99552cabc
39 changed files with 1830 additions and 1925 deletions
+9
View File
@@ -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: >-
+17 -3
View File
@@ -173,15 +173,29 @@ export class RuntimeFileReads {
}
async function readLocalMobileFile(filePath: string, store: RuntimeStore): Promise<string> {
// 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()
}
@@ -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<RuntimeFileCommandHost, 'requireStore' | 'resolveRuntimeFileTarget'> & {
getSession(worktreeId: string): WorkspaceSessionState | null | undefined
updateSession(
worktreeId: string,
update: (session: WorkspaceSessionState) => WorkspaceSessionState
): void
}
export class HeadlessMarkdownDocuments {
private readonly saves = new Map<string, Promise<void>>()
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<RuntimeMarkdownReadTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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 }
})
}
}
@@ -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<PersistedOpenFile> = {}) {
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')
})
})
@@ -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<MarkdownFileIo, 'readFile'> & {
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<MarkdownFileIo>
}
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<MarkdownDiskFile>
writeFileIfUnchanged(
worktreeId: string,
file: MarkdownFileAddress,
content: string,
precondition: FileWriteDiskPrecondition
): Promise<FileWriteResult>
}
/** 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<string, Promise<unknown>>()
// 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<RuntimeMarkdownReadTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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 }
}
+243
View File
@@ -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:<env>`) 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<unknown>
export type MarkdownTabDiskRuntime = RuntimeLocalFileHost &
Pick<MarkdownTabDiskHost, 'getWorkspaceSessionForWorktree'> & {
resolveRuntimeFileTarget(selector: string): Promise<ResolvedRuntimeFileTarget>
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<MarkdownTabDiskRuntime, 'environmentPairingRevision'>,
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<PersistedOpenFile, 'relativePath' | 'externalSshTargetId'>
/** 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<unknown> {
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<MarkdownDiskFile> {
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<string, unknown> {
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<FileWriteResult> {
const pinned = (method: string, params: unknown): Promise<unknown> =>
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<unknown>,
address: { worktree: string; relativePath: string },
precondition: FileWriteDiskPrecondition
): Promise<FileWriteDiskConflict | null> {
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)
}
@@ -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
@@ -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<object>()),
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<typeof OrcaRuntimeService>
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<Runtime> {
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>
): 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)
})
})
@@ -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<RuntimeMarkdownReadTabResult>
saveMobileMarkdownTab?(
worktreeId: string,
tabId: string,
baseVersion: string,
content: string
): Promise<RuntimeMarkdownSaveTabResult>
/** Closes the whole tab. */
closeTerminal(tabId: string): void
/** Drops one split pane main already closed; never closes its tab. */
@@ -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<string, BrowserTabInfo>
protected abstract resolveMobileMarkdownWorktreeId(
...args: Parameters<
@@ -197,10 +197,11 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim
tabId: string
): Promise<RuntimeMarkdownReadTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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
@@ -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)
})
})
@@ -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<Request, 'id'>
: never
: never
export async function requestMobileMarkdownFromRenderer(
mainWindow: BrowserWindow,
request: RendererMobileMarkdownRequest
): Promise<RuntimeMarkdownReadTabResult | RuntimeMarkdownSaveTabResult> {
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'))
}
})
}
@@ -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()
@@ -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<RuntimeMarkdownReadTabResult>,
saveMobileMarkdownTab: (worktreeId, tabId, baseVersion, content) =>
requestMobileMarkdownFromRenderer(mainWindow, {
operation: 'save',
worktreeId,
tabId,
baseVersion,
content
}) as Promise<RuntimeMarkdownSaveTabResult>,
closeTerminal: (tabId) => send('ui:closeTerminal', { kind: 'tab', tabId }),
closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }),
closeTerminalTab: (tabId, options) =>
@@ -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()
@@ -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)
-6
View File
@@ -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
@@ -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
@@ -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 = {
@@ -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.
@@ -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
@@ -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'
}
@@ -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.
@@ -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<typeof createEditorStore> | 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
@@ -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))
)
@@ -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()
}
})
})
@@ -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()
}
})
})
@@ -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<typeof vi.fn>
}
fs: {
readFile: ReturnType<typeof vi.fn>
writeFile: ReturnType<typeof vi.fn>
}
}
}
let mobileMarkdownHandler: ((request: RuntimeMobileMarkdownRequest) => void) | null = null
export function setupWindow({
readFile,
writeFile = vi.fn().mockResolvedValue(undefined)
}: {
readFile: ReturnType<typeof vi.fn>
writeFile?: ReturnType<typeof vi.fn>
}): { 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<unknown> {
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<typeof vi.fn>
).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<void>; resolve: () => void } {
let resolve: () => void = () => {}
const promise = new Promise<void>((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
}
@@ -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()
}
})
})
@@ -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<string, Promise<void>>()
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<void> {
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<RuntimeMarkdownReadTabResult> {
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<RuntimeMarkdownSaveTabResult> {
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<T>(fileId: string, save: () => Promise<T>): Promise<T> {
const previous = saveQueues.get(fileId) ?? Promise.resolve()
let releaseQueue: () => void = () => {}
const current = new Promise<void>((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<string> {
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<void> {
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<void>((resolve, reject) => {
timeout = window.setTimeout(() => {
cleanup()
reject(new Error('save_timeout'))
}, 20_000)
onSaved = (event: Event): void => {
const detail = (event as CustomEvent<EditorFileSavedDetail>).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)
}
@@ -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)
}
@@ -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')
})
})
@@ -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
@@ -280,8 +280,6 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onOpenFileFromMobile: () => noopUnsubscribe,
onRevealEditorTabFromRuntime: () => noopUnsubscribe,
onOpenDiffFromMobile: () => noopUnsubscribe,
onMobileMarkdownRequest: () => noopUnsubscribe,
respondMobileMarkdownRequest: () => {},
onCloseTerminal: () => noopUnsubscribe,
onTerminalTabCloseRequest: () => noopUnsubscribe,
respondTerminalTabClose: () => {},
-28
View File
@@ -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
@@ -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
@@ -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<RuntimeMobileSessionTabsResult>('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<RuntimeMarkdownReadTabResult>('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<RuntimeMarkdownSaveTabResult>('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<RuntimeMobileSessionTabsResult>('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<RuntimeMarkdownReadTabResult>('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)
}
}
})
@@ -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<string, unknown> {
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<string> {
let tabId = ''
await expect
.poll(
async () => {
const { result } = await phone.call<RuntimeMobileSessionTabsResult>('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<RuntimeMarkdownReadTabResult>('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<RuntimeMarkdownSaveTabResult>('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()
}
})
}
@@ -18,6 +18,8 @@ import { cliServeProfile, spawnUntilReady, type ReadyProcess } from './orca-serv
export type HeadlessHostKind = 'orcad' | 'electron'
type RestartOptions = { cold?: boolean; whileStopped?: () => void | Promise<void> }
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<void>
/**
* 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<void>
dispose: () => Promise<void>
}
@@ -112,13 +117,14 @@ async function startOrcadHost(scratch: string, node: string): Promise<StartedHos
userDataDir: profile.userDataDir,
scratch,
pairingUrl: serve.ready.pairingUrl,
restart: async ({ cold } = {}) => {
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<StartedHost> {
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)