Files
orca/src/main/runtime/runtime-notifier-contract.ts
T
Jinwoo HongandE2E Test b99552cabc 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>
2026-10-20 23:08:47 -04:00

188 lines
7.6 KiB
TypeScript

import type {
RuntimeEditorTabReveal,
RuntimeFileOpenPosition
} from '../../shared/runtime-file-contracts'
import type { AgentLaunchPaneVerdictEvent } from '../../shared/agent-launch-pane-verdict'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type {
AgentLaunchTabPublished,
AgentLaunchTabViewerRule,
AgentLaunchTabPublishRequest
} from '../../shared/agent-launch-tab-publication'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
import type { TerminalPaneRevealTarget } from '../../shared/terminal-pane-reveal'
import type { TuiAgent } from '../../shared/tui-agent'
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows'
import type {
WorktreeBaseStatusEvent,
WorktreeRemoteBranchConflictEvent
} from '../../shared/worktree/base-ref-drift-types'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
RuntimeTerminalDriverState,
RuntimeTerminalPresentation
} from '../../shared/runtime-types'
type DriverState = RuntimeTerminalDriverState
export type RuntimeNotifier = {
automationsChanged?(payload: {
selector?: { kind: 'self' } | { kind: 'ssh'; targetId: string } | { kind: 'orphan' }
reason?: 'definition' | 'run' | 'usage'
}): void
worktreesChanged(
repoId: string,
renamed?: { oldWorktreeId: string; newWorktreeId: string },
// Why: false for metadata-only changes (sort order) that leave Git scan results valid.
options?: { invalidateGitScans?: boolean }
): void
worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void
worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void
reposChanged(): void
activateWorktree(
repoId: string,
worktreeId: string,
setup?: CreateWorktreeResult['setup'],
startup?: WorktreeStartupLaunch,
defaultTabs?: CreateWorktreeResult['defaultTabs'],
providesInitialSurface?: boolean
): void
createTerminal(
worktreeId: string,
opts: {
command?: string
cwd?: string
env?: Record<string, string>
title?: string
presentation?: RuntimeTerminalPresentation
}
): void
revealTerminalSession?(
worktreeId: string,
opts: {
ptyId: string
title?: string | null
cwd?: string
launchConfig?: SleepingAgentLaunchConfig
launchToken?: string
launchAgent?: TuiAgent
viewMode?: 'terminal' | 'chat'
targetGroupId?: string
afterTabId?: string
shellOverride?: string
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
agentLaunchViewer?: AgentLaunchTabViewerRule
tabId?: string
leafId?: string
splitFromLeafId?: string
splitDirection?: 'horizontal' | 'vertical'
splitTelemetrySource?: TerminalPaneSplitSource
focus?: boolean
expectedProcessIdentity?: {
terminalHandle: string
incarnationId: string
}
}
):
| Promise<{ tabId: string; title?: string | null; identity?: TerminalRevealIdentity }>
| { tabId: string; title?: string | null; identity?: TerminalRevealIdentity }
| void
/** Shows an agent launch's tab before its process exists; the pane attaches when it does. */
publishAgentLaunchTab?(
request: Omit<AgentLaunchTabPublishRequest, 'requestId'>
): Promise<AgentLaunchTabPublished>
/** A launch pane's fate, for the window to keep on its tab or act on. */
agentLaunchPaneVerdict?(event: AgentLaunchPaneVerdictEvent): void
resolveLegacyWorkerTerminalRecovery?(
paneKey: string,
resolution: 'adopted' | 'exited' | 'rolled_back',
ptyId?: string
): void
/** The fence lives in the workspace session, which a live renderer only re-reads at startup. */
splitTerminal(
tabId: string,
paneRuntimeId: number,
opts: {
direction: 'horizontal' | 'vertical'
command?: string
worktreeId?: string
sourceLeafId?: string
telemetrySource?: TerminalPaneSplitSource
newLeafId?: string
}
): void
/** `recordInteraction: false` marks a title the host applied, not one the user typed. */
renameTerminal(tabId: string, title: string | null, options?: { recordInteraction: false }): void
/** False when the window could not be reached; absent on notifiers that predate pane titles. */
setPaneTitle?(tabId: string, leafId: string, title: string | null): boolean
focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void
/** Raises the window onto a pane it shows; resolves false when the window does not know the worktree. */
revealTerminalPane?(target: TerminalPaneRevealTarget): Promise<boolean>
focusEditorTab?(tabId: string, worktreeId: string): void
closeSessionTab?(tabId: string, worktreeId: string): void | Promise<void>
moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void
/**
* Acts only on the host's own window: 'host'/'all' move it, 'caller'/'clients' open without moving
* it, absent keeps the original switch. Paired clients are never navigated (intended; 'all' == 'host').
*/
openFile?(
worktreeId: string,
filePath: string,
relativePath: string,
runtimeEnvironmentId?: string | null,
navigation?: RuntimeNavigationTarget,
position?: RuntimeFileOpenPosition
): void
/** Layout-owner builds: shows a tab main already wrote for a file it opened. */
revealEditorTab?(reveal: RuntimeEditorTabReveal): void
openDiff?(
worktreeId: string,
filePath: string,
relativePath: string,
staged: boolean,
runtimeEnvironmentId?: string | null,
navigation?: RuntimeNavigationTarget
): void
/** Closes the whole tab. */
closeTerminal(tabId: string): void
/** Drops one split pane main already closed; never closes its tab. */
closeTerminalPane?(tabId: string, leafId: string): void
closeTerminalTab?(
tabId: string,
options?: { localPtyTeardownOwnedExternally?: boolean; force?: boolean }
): Promise<void>
sleepWorktree(worktreeId: string): void
// Why: a phone opening a worktree wakes its slept agents by asking the host
// renderer to run its own navigation-free wake (experimental agent sleep);
// the runtime has no in-memory sleeping records or wake authority. Optional to
// match the many renderer-backed notifier methods only the real bridge wires.
resumeSleepingAgents?(worktreeId: string): void
terminalFitOverrideChanged(
ptyId: string,
mode: 'mobile-fit' | 'remote-desktop-fit' | 'desktop-fit',
cols: number,
rows: number
): void
// Why: presence-based lock signal — desktop renderer mounts the lock
// banner when `driver.kind === 'mobile'` and unmounts otherwise. The
// structured payload (vs a `locked: boolean`) carries the active mobile
// actor's clientId so the renderer can disambiguate multi-phone scenarios
// and so a future write coordinator can use the same signal as scheduling
// input. See docs/mobile-presence-lock.md.
terminalDriverChanged(ptyId: string, driver: DriverState): void
nativeChatLaunchDraftResolved?(
tabId: string,
resolution: { text: string; createdAt: number }
): void
browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void
browserRemoteViewersChanged?(browserPageId: string, hasRemoteViewers: boolean): void
clientHostedBrowserRowsChanged?(event: ClientHostedBrowserRowsEvent): void
}