mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* 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>
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
|
|
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 { 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 { registerRendererDocumentNavigation } from './renderer-document-navigation'
|
|
import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender'
|
|
import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay'
|
|
import { revealTerminalPaneInWindow } from './terminal-pane-reveal-relay'
|
|
import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay'
|
|
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch'
|
|
|
|
let runtimeNotifierTokenCounter = 0
|
|
let activeRuntimeNotifierToken: number | null = null
|
|
|
|
export function registerRuntimeWindowLifecycle(
|
|
mainWindow: BrowserWindow,
|
|
runtime: OrcaRuntimeService
|
|
): void {
|
|
const notifierToken = ++runtimeNotifierTokenCounter
|
|
activeRuntimeNotifierToken = notifierToken
|
|
runtime.attachWindow(mainWindow.id)
|
|
const mainWebContents = mainWindow.webContents
|
|
const rendererNotifications = createRuntimeRendererNotificationSender({
|
|
isWindowDestroyed: () => mainWindow.isDestroyed(),
|
|
webContents: mainWebContents,
|
|
onFailure: (reason) => runtime.markGraphReloadFailed(mainWindow.id, reason)
|
|
})
|
|
const send = rendererNotifications.send
|
|
runtime.setNotifier({
|
|
worktreesChanged: (repoId, renamed, options) => {
|
|
// Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation.
|
|
if (options?.invalidateGitScans !== false) {
|
|
runWorktreeChangeInvalidators(repoId)
|
|
}
|
|
send('worktrees:changed', renamed ? { repoId, renamed } : { repoId })
|
|
},
|
|
worktreeBaseStatus: (event) => send('worktree:baseStatus', event),
|
|
worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event),
|
|
reposChanged: () => send('repos:changed'),
|
|
automationsChanged: (payload) => send('automations:changed', payload),
|
|
activateWorktree: (
|
|
repoId,
|
|
worktreeId,
|
|
setup?: CreateWorktreeResult['setup'],
|
|
startup?: WorktreeStartupLaunch,
|
|
defaultTabs?: CreateWorktreeResult['defaultTabs'],
|
|
providesInitialSurface?: boolean
|
|
) => {
|
|
send('ui:activateWorktree', {
|
|
repoId,
|
|
worktreeId,
|
|
...(setup ? { setup } : {}),
|
|
...(startup ? { startup } : {}),
|
|
...(defaultTabs ? { defaultTabs } : {}),
|
|
...(providesInitialSurface ? { providesInitialSurface } : {})
|
|
})
|
|
},
|
|
createTerminal: (worktreeId, opts) =>
|
|
send('ui:createTerminal', {
|
|
worktreeId,
|
|
command: opts.command,
|
|
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
...(opts.env ? { env: opts.env } : {}),
|
|
title: opts.title,
|
|
...(opts.presentation ? { presentation: opts.presentation } : {})
|
|
}),
|
|
revealTerminalSession: (worktreeId, opts) =>
|
|
new Promise((resolve, reject) => {
|
|
const requestId = randomUUID()
|
|
const expectedIdentity = opts.expectedProcessIdentity
|
|
? opts.tabId && opts.leafId
|
|
? { worktreeId, tabId: opts.tabId, leafId: opts.leafId, ptyId: opts.ptyId }
|
|
: null
|
|
: undefined
|
|
if (expectedIdentity === null) {
|
|
reject(new Error('terminal_reveal_identity_required'))
|
|
return
|
|
}
|
|
const timer = setTimeout(() => {
|
|
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
|
reject(new Error('Terminal reveal timed out'))
|
|
}, 10_000)
|
|
const handler = (event: Electron.IpcMainEvent, reply: TerminalTabCreateReply): void => {
|
|
// Why: requestId is renderer-supplied, so only the targeted main window may satisfy the reveal.
|
|
if (event.sender !== mainWindow.webContents || reply.requestId !== requestId) {
|
|
return
|
|
}
|
|
clearTimeout(timer)
|
|
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
|
if (reply.error) {
|
|
reject(new Error(reply.error))
|
|
return
|
|
}
|
|
if (
|
|
expectedIdentity &&
|
|
(!reply.identity ||
|
|
reply.identity.worktreeId !== expectedIdentity.worktreeId ||
|
|
reply.identity.tabId !== expectedIdentity.tabId ||
|
|
reply.identity.leafId !== expectedIdentity.leafId ||
|
|
reply.identity.ptyId !== expectedIdentity.ptyId)
|
|
) {
|
|
reject(new Error('terminal_reveal_identity_mismatch'))
|
|
return
|
|
}
|
|
resolve({
|
|
tabId: reply.tabId!,
|
|
title: reply.title,
|
|
...(reply.identity ? { identity: reply.identity } : {})
|
|
})
|
|
}
|
|
ipcMain.on('terminal:tabCreateReply', handler)
|
|
const sent = send('ui:createTerminal', {
|
|
requestId,
|
|
worktreeId,
|
|
ptyId: opts.ptyId,
|
|
title: opts.title ?? undefined,
|
|
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
|
|
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
|
|
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
|
|
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
|
|
...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}),
|
|
...(opts.afterTabId ? { afterTabId: opts.afterTabId } : {}),
|
|
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
|
|
activate: opts.activate !== false,
|
|
...(opts.presentation ? { presentation: opts.presentation } : {}),
|
|
...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}),
|
|
...(opts.agentLaunchViewer ? { agentLaunchViewer: opts.agentLaunchViewer } : {}),
|
|
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
|
|
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
|
|
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
|
|
...(opts.splitFromLeafId !== undefined ? { splitFromLeafId: opts.splitFromLeafId } : {}),
|
|
...(opts.splitDirection !== undefined ? { splitDirection: opts.splitDirection } : {}),
|
|
...(opts.splitTelemetrySource !== undefined
|
|
? { splitTelemetrySource: opts.splitTelemetrySource }
|
|
: {}),
|
|
...(opts.focus !== undefined ? { focus: opts.focus } : {})
|
|
})
|
|
if (!sent) {
|
|
clearTimeout(timer)
|
|
ipcMain.removeListener('terminal:tabCreateReply', handler)
|
|
reject(new Error('runtime_unavailable'))
|
|
}
|
|
}),
|
|
publishAgentLaunchTab: (request) =>
|
|
requestAgentLaunchTabPublishFromRenderer(mainWindow, request),
|
|
agentLaunchPaneVerdict: (event) => send('ui:agentLaunchPaneVerdict', event),
|
|
resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) =>
|
|
send('agentStatus:legacyWorkerTerminalRecovery', {
|
|
paneKey,
|
|
resolution,
|
|
...(ptyId ? { ptyId } : {})
|
|
}),
|
|
splitTerminal: (tabId, paneRuntimeId, opts) => {
|
|
send('ui:splitTerminal', {
|
|
tabId,
|
|
paneRuntimeId,
|
|
direction: opts.direction,
|
|
command: opts.command,
|
|
worktreeId: opts.worktreeId,
|
|
sourceLeafId: opts.sourceLeafId,
|
|
telemetrySource: opts.telemetrySource,
|
|
newLeafId: opts.newLeafId
|
|
})
|
|
},
|
|
renameTerminal: (tabId, title, options) =>
|
|
send('ui:renameTerminal', { tabId, title, ...options }),
|
|
setPaneTitle: (tabId, leafId, title) => send('ui:setPaneTitle', { tabId, leafId, title }),
|
|
focusTerminal: (tabId, worktreeId, leafId) =>
|
|
send('ui:focusTerminal', { tabId, worktreeId, leafId }),
|
|
revealTerminalPane: (target) => revealTerminalPaneInWindow(mainWindow, target),
|
|
focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }),
|
|
closeSessionTab: (tabId, worktreeId) =>
|
|
requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId),
|
|
moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) =>
|
|
send('ui:moveSessionTab', { worktreeId, ...move }),
|
|
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?, position?) =>
|
|
send('ui:openFileFromMobile', {
|
|
worktreeId,
|
|
filePath,
|
|
relativePath,
|
|
runtimeEnvironmentId,
|
|
...(navigation ? { navigation } : {}),
|
|
...(position ? { position } : {})
|
|
}),
|
|
revealEditorTab: (reveal) => send('ui:revealEditorTabFromRuntime', reveal),
|
|
openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?, navigation?) =>
|
|
send('ui:openDiffFromMobile', {
|
|
worktreeId,
|
|
filePath,
|
|
relativePath,
|
|
staged,
|
|
runtimeEnvironmentId,
|
|
...(navigation ? { navigation } : {})
|
|
}),
|
|
closeTerminal: (tabId) => send('ui:closeTerminal', { kind: 'tab', tabId }),
|
|
closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }),
|
|
closeTerminalTab: (tabId, options) =>
|
|
requestTerminalTabCloseFromRenderer(mainWindow, tabId, options),
|
|
sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }),
|
|
resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }),
|
|
terminalFitOverrideChanged: (ptyId, mode, cols, rows) =>
|
|
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }),
|
|
terminalDriverChanged: (ptyId, driver) =>
|
|
send('runtime:terminalDriverChanged', { ptyId, driver }),
|
|
nativeChatLaunchDraftResolved: (tabId, resolution) =>
|
|
send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }),
|
|
browserDriverChanged: (browserPageId, driver) =>
|
|
send('runtime:browserDriverChanged', { browserPageId, driver }),
|
|
browserRemoteViewersChanged: (browserPageId, hasRemoteViewers) =>
|
|
send('runtime:browserRemoteViewersChanged', { browserPageId, hasRemoteViewers }),
|
|
clientHostedBrowserRowsChanged: (event) => send('runtime:clientHostedBrowserRowsChanged', event)
|
|
})
|
|
// Switch point: the window reads its sidebar from main's feed and re-reads on each notice.
|
|
const detachSidebarInputsNotices = ORCA_SIDEBAR_INPUTS_IS_MAIN
|
|
? runtime.onClientEvent(
|
|
(event) => {
|
|
if (event.type === 'sidebarInputsChanged') {
|
|
send('runtime:sidebarInputsChanged', event)
|
|
}
|
|
},
|
|
{ consumesTerminalSideEffects: false }
|
|
)
|
|
: null
|
|
registerRendererDocumentNavigation(mainWebContents, () => {
|
|
rendererNotifications.onMainFrameReloadStarted()
|
|
const fence = runtime.markRendererReloading(mainWindow.id)
|
|
return () => {
|
|
if (fence && runtime.markRendererReloadCancelled(mainWindow.id, fence)) {
|
|
rendererNotifications.onMainFrameReloadCancelled()
|
|
}
|
|
}
|
|
})
|
|
mainWebContents.on('did-finish-load', () => {
|
|
rendererNotifications.onMainFrameLoadFinished()
|
|
})
|
|
mainWebContents.on('render-process-gone', () => {
|
|
rendererNotifications.onRendererProcessGone()
|
|
})
|
|
mainWindow.on('closed', () => {
|
|
detachSidebarInputsNotices?.()
|
|
rendererNotifications.close()
|
|
runtime.markGraphUnavailable(mainWindow.id)
|
|
if (activeRuntimeNotifierToken === notifierToken) {
|
|
// Why: the notifier closes over the window; clear it in the no-window gap so the runtime can't retain destroyed graphs.
|
|
runtime.setNotifier(null)
|
|
activeRuntimeNotifierToken = null
|
|
}
|
|
})
|
|
}
|