mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(workspaces): add main-owned project move pause barrier (#32940)
* fix(workspaces): add main-owned project move pause barrier * test(workspaces): type project move pause reply fixtures
This commit is contained in:
@@ -19,7 +19,10 @@ function isRestoredPane(value: unknown): value is RestoredPane {
|
||||
/** The window's Sleep: main runs it on each workspace's hosts and the window follows its notices. */
|
||||
export function registerWorktreeSleepHandlers(context: WorktreeIpcContext): void {
|
||||
ipcMain.handle('worktrees:sleep', (_event, request: WorkspaceSleepRequest) =>
|
||||
context.runtime.sleepWorkspaces(request)
|
||||
context.runtime.sleepWorkspaces({
|
||||
targets: request.targets,
|
||||
markChatsAsleep: request.markChatsAsleep
|
||||
})
|
||||
)
|
||||
// The user opened a workspace whose sleep is still running: its VM keeps running.
|
||||
ipcMain.handle('worktrees:sleepReopened', (_event, worktreeId: string) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeWorktreeTerminalSleepResult } from '../../../shared/runtime-types'
|
||||
import type { WorkspaceSleepTarget } from '../../../shared/workspace-sleep'
|
||||
import type { WorkspaceSleepNotice, WorkspaceSleepTarget } from '../../../shared/workspace-sleep'
|
||||
import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id'
|
||||
import { WorkspaceLifecycleQueue } from './workspace-lifecycle-queue'
|
||||
import {
|
||||
@@ -58,6 +58,40 @@ function queued(queue = new WorkspaceLifecycleQueue()): WorkspaceSleepQueue {
|
||||
const enqueue = queued()
|
||||
|
||||
describe('sleepWorkspaces', () => {
|
||||
it('correlates a project move with renderer notices and retains chat stop receipts without marking chats asleep', async () => {
|
||||
const notices: WorkspaceSleepNotice[] = []
|
||||
const { deps: d } = deps({
|
||||
notify: (notice) => notices.push(notice),
|
||||
stopOwnChats: async (_id, markAsleep) => {
|
||||
expect(markAsleep).toBe(false)
|
||||
return { stoppedSessionIds: ['chat'] }
|
||||
}
|
||||
})
|
||||
const outcomes = await sleepWorkspaces(d, queued(), {
|
||||
targets: [local('wt')],
|
||||
markChatsAsleep: false,
|
||||
projectMovePauseId: 'move-1'
|
||||
})
|
||||
expect(notices).toEqual([
|
||||
{ worktreeId: 'wt', phase: 'started', projectMovePauseId: 'move-1' },
|
||||
{
|
||||
worktreeId: 'wt',
|
||||
phase: 'ended',
|
||||
projectMovePauseId: 'move-1',
|
||||
slept: true,
|
||||
stoppedPtyIds: ['pty-wt'],
|
||||
records: []
|
||||
}
|
||||
])
|
||||
expect(outcomes).toEqual([
|
||||
{
|
||||
worktreeId: 'wt',
|
||||
chatFailed: false,
|
||||
receipts: [{ executionHostId: 'local', stoppedSessionIds: ['chat'] }]
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('runs a sleep in order: the window follows, terminals stop, chats stop, then the VM', async () => {
|
||||
const { log, deps: d } = deps()
|
||||
const outcomes = await sleepWorkspaces(d, enqueue, {
|
||||
|
||||
@@ -183,11 +183,13 @@ async function sleepWorkspace(
|
||||
deps: WorkspaceSleepDeps,
|
||||
target: WorkspaceSleepTarget,
|
||||
markAsleep: boolean,
|
||||
batch: { chatsSettled: () => void; batchChatsSettled: Promise<unknown> }
|
||||
batch: { chatsSettled: () => void; batchChatsSettled: Promise<unknown> },
|
||||
projectMovePauseId?: string
|
||||
): Promise<WorkspaceSleepAttempt> {
|
||||
const { worktreeId } = target
|
||||
const reopened = deps.watchReopen(worktreeId)
|
||||
deps.notify({ worktreeId, phase: 'started' })
|
||||
const move = projectMovePauseId ? { projectMovePauseId } : {}
|
||||
deps.notify({ worktreeId, phase: 'started', ...move })
|
||||
let failure: string | undefined
|
||||
let vmSuspendFailure: string | undefined
|
||||
let unverifiable = false
|
||||
@@ -224,7 +226,7 @@ async function sleepWorkspace(
|
||||
}
|
||||
}
|
||||
const records = target.terminalHost.kind === 'self' ? deps.ownRecords(worktreeId) : null
|
||||
deps.notify({ worktreeId, phase: 'ended', slept: !failure, stoppedPtyIds, records })
|
||||
deps.notify({ worktreeId, phase: 'ended', slept: !failure, stoppedPtyIds, records, ...move })
|
||||
return {
|
||||
worktreeId,
|
||||
...(failure ? { failure } : {}),
|
||||
@@ -261,10 +263,16 @@ export async function sleepWorkspaces(
|
||||
const { unverifiable: _unverifiable, ...outcome } = await queue.submit(target, {
|
||||
kind: 'sleep',
|
||||
run: () =>
|
||||
sleepWorkspace(deps, target, request.markChatsAsleep === true, {
|
||||
chatsSettled,
|
||||
batchChatsSettled
|
||||
}),
|
||||
sleepWorkspace(
|
||||
deps,
|
||||
target,
|
||||
request.markChatsAsleep === true,
|
||||
{
|
||||
chatsSettled,
|
||||
batchChatsSettled
|
||||
},
|
||||
request.projectMovePauseId
|
||||
),
|
||||
outcome: (attempt) => (attempt.unverifiable ? 'unverifiable' : 'settled')
|
||||
})
|
||||
return outcome
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { ipcMain as ipc, type BrowserWindow } from 'electron'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectMovePauseRequest } from '../../shared/project-move-pause'
|
||||
import type { WorkspaceSleepOutcome, WorkspaceSleepTarget } from '../../shared/workspace-sleep'
|
||||
|
||||
type PauseResponseFixture = { requestId?: string; protocol?: number } & (
|
||||
| { phase: 'prepare'; targets: WorkspaceSleepTarget[] }
|
||||
| { phase: 'persist' }
|
||||
| { phase: 'error'; error: string }
|
||||
)
|
||||
|
||||
vi.mock('electron', async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
return { ipcMain: new EventEmitter() }
|
||||
})
|
||||
import { pauseWorkspacesForProjectMove } from './project-move-pause'
|
||||
|
||||
function fixture() {
|
||||
const windowEvents = new EventEmitter()
|
||||
const webContents = Object.assign(new EventEmitter(), {
|
||||
isDestroyed: () => false,
|
||||
send: vi.fn<(channel: string, request: ProjectMovePauseRequest) => void>()
|
||||
})
|
||||
const window = Object.assign(windowEvents, { isDestroyed: () => false, webContents })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the broker uses only the lifecycle and webContents members supplied here.
|
||||
const mainWindow = window as unknown as BrowserWindow
|
||||
const outcomes: WorkspaceSleepOutcome[] = [
|
||||
{
|
||||
worktreeId: 'wt',
|
||||
chatFailed: false,
|
||||
receipts: [{ executionHostId: 'local', stoppedSessionIds: ['chat'] }]
|
||||
}
|
||||
]
|
||||
const sleepWorkspaces = vi.fn(async () => outcomes)
|
||||
const request = (): ProjectMovePauseRequest => {
|
||||
const value = webContents.send.mock.calls.at(-1)?.[1]
|
||||
if (!value) {
|
||||
throw new Error('no request')
|
||||
}
|
||||
return value
|
||||
}
|
||||
const respond = (value: PauseResponseFixture, sender: unknown = webContents): void => {
|
||||
ipc.emit(
|
||||
'ui:projectMovePauseResponse',
|
||||
{ sender },
|
||||
{ requestId: request().requestId, protocol: 1, ...value }
|
||||
)
|
||||
}
|
||||
const prepare = (): void =>
|
||||
respond({
|
||||
phase: 'prepare',
|
||||
targets: [{ worktreeId: 'wt', terminalHost: { kind: 'self' }, chatHosts: [{ kind: 'self' }] }]
|
||||
})
|
||||
return { window, mainWindow, webContents, sleepWorkspaces, outcomes, request, respond, prepare }
|
||||
}
|
||||
|
||||
describe('main-owned project move pause', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
ipc.removeAllListeners()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits for an explicit renderer persistence receipt and preserves all stop receipts', async () => {
|
||||
const f = fixture()
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt', 'wt'])
|
||||
f.prepare()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.sleepWorkspaces).toHaveBeenCalledWith({
|
||||
targets: [
|
||||
{ worktreeId: 'wt', terminalHost: { kind: 'self' }, chatHosts: [{ kind: 'self' }] }
|
||||
],
|
||||
markChatsAsleep: false,
|
||||
projectMovePauseId: expect.any(String)
|
||||
})
|
||||
let returned = false
|
||||
void paused.then(() => {
|
||||
returned = true
|
||||
})
|
||||
expect(f.request().phase).toBe('persist')
|
||||
expect(returned).toBe(false)
|
||||
f.respond({ phase: 'persist' })
|
||||
await expect(paused).resolves.toEqual(f.outcomes)
|
||||
expect(f.request().phase).toBe('release')
|
||||
expect(ipc.listenerCount('ui:projectMovePauseResponse')).toBe(0)
|
||||
expect(f.webContents.eventNames()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not stop hosts when an old preload or follower never answers the handshake', async () => {
|
||||
const f = fixture()
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt'])
|
||||
const rejected = expect(paused).rejects.toThrow('renderer_timeout')
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await rejected
|
||||
expect(f.sleepWorkspaces).not.toHaveBeenCalled()
|
||||
expect(ipc.listenerCount('ui:projectMovePauseResponse')).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores another window, stale request and unsupported protocol', async () => {
|
||||
const f = fixture()
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt'])
|
||||
f.respond({ phase: 'prepare', targets: [] }, {})
|
||||
f.respond({ phase: 'prepare', targets: [], requestId: 'stale' })
|
||||
f.respond({ phase: 'prepare', targets: [], protocol: 0 })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.sleepWorkspaces).not.toHaveBeenCalled()
|
||||
f.prepare()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
f.respond({ phase: 'persist' })
|
||||
await paused
|
||||
})
|
||||
|
||||
it('refuses a renderer that omits or substitutes a workspace before stopping hosts', async () => {
|
||||
const f = fixture()
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt'])
|
||||
f.respond({ phase: 'prepare', targets: [] })
|
||||
await expect(paused).rejects.toThrow('invalid_scope')
|
||||
expect(f.sleepWorkspaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects reload during the host stop instead of accepting a new renderer persistence reply', async () => {
|
||||
const f = fixture()
|
||||
const stop = Promise.withResolvers<WorkspaceSleepOutcome[]>()
|
||||
f.sleepWorkspaces.mockReturnValue(stop.promise)
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt'])
|
||||
f.prepare()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
f.webContents.emit('did-start-navigation', {}, 'about:blank', false, true)
|
||||
stop.resolve(f.outcomes)
|
||||
await expect(paused).rejects.toThrow('renderer_unavailable')
|
||||
expect(f.webContents.send.mock.calls.map(([, value]) => value.phase)).toEqual(['prepare'])
|
||||
})
|
||||
|
||||
it('propagates failed durability without returning success to the exporter', async () => {
|
||||
const f = fixture()
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt'])
|
||||
f.prepare()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
f.respond({ phase: 'error', error: 'disk full' })
|
||||
await expect(paused).rejects.toMatchObject({ message: 'disk full', outcomes: f.outcomes })
|
||||
expect(f.request().phase).toBe('release')
|
||||
})
|
||||
|
||||
it('retains partial stop failures and successful sibling receipts when renderer rollback fails', async () => {
|
||||
const f = fixture()
|
||||
f.outcomes[0].failure = 'terminal_liveness_unavailable'
|
||||
f.outcomes.push({
|
||||
worktreeId: 'sibling',
|
||||
chatFailed: false,
|
||||
receipts: [{ executionHostId: 'runtime:env', stoppedSessionIds: ['remote-chat'] }]
|
||||
})
|
||||
const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt', 'sibling'])
|
||||
f.respond({
|
||||
phase: 'prepare',
|
||||
targets: ['wt', 'sibling'].map((worktreeId) => ({
|
||||
worktreeId,
|
||||
terminalHost: { kind: 'self' },
|
||||
chatHosts: [{ kind: 'self' }]
|
||||
}))
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
f.respond({ phase: 'error', error: 'host_terminal_stop_failed' })
|
||||
await expect(paused).rejects.toMatchObject({ outcomes: f.outcomes })
|
||||
expect(f.outcomes[1]?.receipts[0]?.stoppedSessionIds).toEqual(['remote-chat'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { ipcMain, type BrowserWindow, type IpcMainEvent } from 'electron'
|
||||
import {
|
||||
PROJECT_MOVE_PAUSE_PROTOCOL,
|
||||
projectMovePauseResponseSchema,
|
||||
type ProjectMovePauseRequest,
|
||||
type ProjectMovePauseResponse
|
||||
} from '../../shared/project-move-pause'
|
||||
import type { WorkspaceSleepOutcome, WorkspaceSleepRequest } from '../../shared/workspace-sleep'
|
||||
|
||||
type SleepRuntime = {
|
||||
sleepWorkspaces: (request: WorkspaceSleepRequest) => Promise<WorkspaceSleepOutcome[]>
|
||||
}
|
||||
|
||||
/** Known stop receipts survive a renderer failure so the mover can journal partial progress. */
|
||||
export class ProjectMovePauseError extends Error {
|
||||
constructor(
|
||||
cause: unknown,
|
||||
readonly outcomes: readonly WorkspaceSleepOutcome[]
|
||||
) {
|
||||
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
||||
this.name = 'ProjectMovePauseError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller holds its move gate/fence. Check every returned outcome before export; on rejection,
|
||||
* journal ProjectMovePauseError.outcomes too. Main owns stops; the window confirms persistence.
|
||||
*/
|
||||
export async function pauseWorkspacesForProjectMove(
|
||||
mainWindow: BrowserWindow,
|
||||
runtime: SleepRuntime,
|
||||
worktreeIds: readonly string[]
|
||||
): Promise<WorkspaceSleepOutcome[]> {
|
||||
const ids = [...new Set(worktreeIds)]
|
||||
if (ids.length === 0) {
|
||||
return []
|
||||
}
|
||||
const webContents = mainWindow.webContents
|
||||
if (mainWindow.isDestroyed() || webContents.isDestroyed()) {
|
||||
throw new Error('project_move_pause_renderer_unavailable')
|
||||
}
|
||||
const requestId = randomUUID()
|
||||
let unavailable = false
|
||||
let waiting:
|
||||
| {
|
||||
finish: (response?: ProjectMovePauseResponse, error?: Error) => void
|
||||
}
|
||||
| undefined
|
||||
const onUnavailable = (): void => {
|
||||
unavailable = true
|
||||
waiting?.finish(undefined, new Error('project_move_pause_renderer_unavailable'))
|
||||
}
|
||||
const onNavigation = (
|
||||
_event: Electron.Event,
|
||||
_url: string,
|
||||
inPlace: boolean,
|
||||
mainFrame: boolean
|
||||
): void => {
|
||||
if (mainFrame && !inPlace) {
|
||||
onUnavailable()
|
||||
}
|
||||
}
|
||||
const onResponse = (event: IpcMainEvent, value: unknown): void => {
|
||||
if (event.sender !== webContents) {
|
||||
return
|
||||
}
|
||||
const response = projectMovePauseResponseSchema.safeParse(value)
|
||||
if (!response.success || response.data.requestId !== requestId) {
|
||||
return
|
||||
}
|
||||
waiting?.finish(response.data)
|
||||
}
|
||||
const ask = (
|
||||
request: ProjectMovePauseRequest,
|
||||
timeoutMs: number
|
||||
): Promise<ProjectMovePauseResponse> => {
|
||||
if (unavailable) {
|
||||
return Promise.reject(new Error('project_move_pause_renderer_unavailable'))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (response?: ProjectMovePauseResponse, error?: Error): void => {
|
||||
clearTimeout(timer)
|
||||
waiting = undefined
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else if (response?.phase === 'error') {
|
||||
reject(new Error(response.error))
|
||||
} else if (response?.phase !== request.phase) {
|
||||
reject(new Error('project_move_pause_invalid_phase'))
|
||||
} else {
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => finish(undefined, new Error('project_move_pause_renderer_timeout')),
|
||||
timeoutMs
|
||||
)
|
||||
waiting = { finish }
|
||||
try {
|
||||
webContents.send('ui:projectMovePause', request)
|
||||
} catch {
|
||||
onUnavailable()
|
||||
}
|
||||
})
|
||||
}
|
||||
ipcMain.on('ui:projectMovePauseResponse', onResponse)
|
||||
mainWindow.once('closed', onUnavailable)
|
||||
webContents.once('destroyed', onUnavailable)
|
||||
webContents.once('render-process-gone', onUnavailable)
|
||||
webContents.on('did-start-navigation', onNavigation)
|
||||
const envelope = { requestId, protocol: PROJECT_MOVE_PAUSE_PROTOCOL } as const
|
||||
let outcomes: WorkspaceSleepOutcome[] = []
|
||||
try {
|
||||
const prepared = await ask({ ...envelope, phase: 'prepare', worktreeIds: ids }, 20_000)
|
||||
if (
|
||||
prepared.phase !== 'prepare' ||
|
||||
prepared.targets.length !== ids.length ||
|
||||
new Set(prepared.targets.map((target) => target.worktreeId)).size !== ids.length ||
|
||||
prepared.targets.some((target) => !ids.includes(target.worktreeId))
|
||||
) {
|
||||
throw new Error('project_move_pause_invalid_scope')
|
||||
}
|
||||
if (unavailable) {
|
||||
throw new Error('project_move_pause_renderer_unavailable')
|
||||
}
|
||||
outcomes = await runtime.sleepWorkspaces({
|
||||
targets: prepared.targets,
|
||||
markChatsAsleep: false,
|
||||
projectMovePauseId: requestId
|
||||
})
|
||||
if (
|
||||
outcomes.length !== ids.length ||
|
||||
new Set(outcomes.map((outcome) => outcome.worktreeId)).size !== ids.length ||
|
||||
outcomes.some((outcome) => !ids.includes(outcome.worktreeId))
|
||||
) {
|
||||
throw new Error('project_move_pause_invalid_outcomes')
|
||||
}
|
||||
await ask(
|
||||
{
|
||||
...envelope,
|
||||
phase: 'persist',
|
||||
successfulWorktreeIds: outcomes
|
||||
.filter((outcome) => !outcome.failure)
|
||||
.map((outcome) => outcome.worktreeId)
|
||||
},
|
||||
120_000
|
||||
)
|
||||
return outcomes
|
||||
} catch (error) {
|
||||
throw new ProjectMovePauseError(error, outcomes)
|
||||
} finally {
|
||||
if (!unavailable) {
|
||||
try {
|
||||
webContents.send('ui:projectMovePause', { ...envelope, phase: 'release' })
|
||||
} catch {
|
||||
/* The renderer no longer holds a live reservation. */
|
||||
}
|
||||
}
|
||||
ipcMain.removeListener('ui:projectMovePauseResponse', onResponse)
|
||||
mainWindow.removeListener('closed', onUnavailable)
|
||||
webContents.removeListener('destroyed', onUnavailable)
|
||||
webContents.removeListener('render-process-gone', onUnavailable)
|
||||
webContents.removeListener('did-start-navigation', onNavigation)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,15 @@ export const uiClipboardAndWindowControlsApi = {
|
||||
ipcRenderer.on('ui:workspaceSleep', listener)
|
||||
return () => ipcRenderer.removeListener('ui:workspaceSleep', listener)
|
||||
},
|
||||
onProjectMovePause: (callback) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, request: Parameters<typeof callback>[0]) =>
|
||||
callback(request)
|
||||
ipcRenderer.on('ui:projectMovePause', listener)
|
||||
return () => ipcRenderer.removeListener('ui:projectMovePause', listener)
|
||||
},
|
||||
respondProjectMovePause: (response) => {
|
||||
ipcRenderer.send('ui:projectMovePauseResponse', response)
|
||||
},
|
||||
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
|
||||
callback(data)
|
||||
|
||||
@@ -21,6 +21,10 @@ import type {
|
||||
TerminalPaneRevealRequest
|
||||
} from '../../shared/terminal-pane-reveal'
|
||||
import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation'
|
||||
import type {
|
||||
ProjectMovePauseRequest,
|
||||
ProjectMovePauseResponse
|
||||
} from '../../shared/project-move-pause'
|
||||
import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target'
|
||||
import type {
|
||||
WorktreeDefaultTabsLaunch,
|
||||
@@ -283,6 +287,8 @@ export type UiCommandEventApi = {
|
||||
respondTerminalTabClose: (response: TerminalTabCloseResponse) => void
|
||||
/** Follow main's workspace sleep even when another client requested it. */
|
||||
onWorkspaceSleep: (callback: (notice: WorkspaceSleepNotice) => void) => () => void
|
||||
onProjectMovePause?: (callback: (request: ProjectMovePauseRequest) => void) => () => void
|
||||
respondProjectMovePause?: (response: ProjectMovePauseResponse) => void
|
||||
onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void
|
||||
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
|
||||
onSystemResumed: (callback: () => void) => () => void
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { ProjectMovePauseResponse } from '../../../../shared/project-move-pause'
|
||||
import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const tabsByWorktree: Record<string, { id: string }[]> = {}
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
const worktreesByRepo: Record<string, { id: string; repoId: string; hostId: string }[]> = {}
|
||||
return {
|
||||
state: {
|
||||
activeWorktreeId: null,
|
||||
setActiveWorktree: vi.fn(),
|
||||
shutdownWorktreeBrowsers: vi.fn(),
|
||||
shutdownWorktreeTerminals: vi.fn<AppState['shutdownWorktreeTerminals']>(),
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
worktreesByRepo,
|
||||
detectedWorktreesByRepo: {}
|
||||
},
|
||||
persist: vi.fn(async () => {}),
|
||||
payload: vi.fn(() => ({}))
|
||||
}
|
||||
})
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mocks.state, subscribe: () => () => {} }
|
||||
}))
|
||||
vi.mock('@/lib/worktree-sleep-intent', () => ({
|
||||
clearWorktreeSleepIntent: vi.fn(),
|
||||
markWorktreeSleepIntent: vi.fn(),
|
||||
withWorktreeSleepTeardown: (_id: string, action: () => Promise<void>) => action()
|
||||
}))
|
||||
vi.mock('@/lib/workspace-session', () => ({ buildWorkspaceSessionPayload: mocks.payload }))
|
||||
vi.mock('@/lib/workspace-session-host-persistence', () => ({
|
||||
persistWorkspaceSessionByHost: mocks.persist
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } }))
|
||||
import { createProjectMovePauseFollower } from './project-move-pause-follower'
|
||||
|
||||
function fixture() {
|
||||
const responses: ProjectMovePauseResponse[] = []
|
||||
const follower = createProjectMovePauseFollower((response) => responses.push(response))
|
||||
const requestId = 'move-1'
|
||||
const prepare = (ids = ['wt']) =>
|
||||
follower.request({ requestId, protocol: 1, phase: 'prepare', worktreeIds: ids })
|
||||
const persist = (ids = ['wt']) =>
|
||||
follower.request({ requestId, protocol: 1, phase: 'persist', successfulWorktreeIds: ids })
|
||||
const notice = (phase: 'started' | 'ended', worktreeId = 'wt'): void => {
|
||||
follower.follow(
|
||||
phase === 'started'
|
||||
? { phase, worktreeId, projectMovePauseId: requestId }
|
||||
: {
|
||||
phase,
|
||||
worktreeId,
|
||||
projectMovePauseId: requestId,
|
||||
slept: true,
|
||||
stoppedPtyIds: ['pty'],
|
||||
records: []
|
||||
}
|
||||
)
|
||||
}
|
||||
return { follower, responses, prepare, persist, notice }
|
||||
}
|
||||
|
||||
describe('project move renderer completion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.persist.mockResolvedValue(undefined)
|
||||
mocks.state.shutdownWorktreeTerminals.mockImplementation(async (_id, options) => {
|
||||
await options?.hostStop
|
||||
})
|
||||
mocks.state.tabsByWorktree = {}
|
||||
mocks.state.ptyIdsByTabId = {}
|
||||
mocks.state.worktreesByRepo = {}
|
||||
vi.stubGlobal('window', { api: { session: {} } })
|
||||
})
|
||||
|
||||
it('awaits renderer teardown and durable session save while retaining browsers', async () => {
|
||||
const f = fixture()
|
||||
const teardown = Promise.withResolvers<void>()
|
||||
const saving = Promise.withResolvers<void>()
|
||||
mocks.state.shutdownWorktreeTerminals.mockImplementation(async (_id, options) => {
|
||||
await options?.hostStop
|
||||
await teardown.promise
|
||||
})
|
||||
mocks.persist.mockReturnValue(saving.promise)
|
||||
await f.prepare()
|
||||
const prepared = f.responses[0]
|
||||
if (prepared?.phase !== 'prepare') {
|
||||
throw new Error('prepare failed')
|
||||
}
|
||||
f.notice('started')
|
||||
f.notice('ended')
|
||||
const completion = f.persist()
|
||||
expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled()
|
||||
expect(mocks.persist).not.toHaveBeenCalled()
|
||||
teardown.resolve()
|
||||
await vi.waitFor(() => expect(mocks.persist).toHaveBeenCalledOnce())
|
||||
expect(f.responses.map((response) => response.phase)).toEqual(['prepare'])
|
||||
saving.resolve()
|
||||
await completion
|
||||
expect(f.responses.map((response) => response.phase)).toEqual(['prepare', 'persist'])
|
||||
})
|
||||
|
||||
it('resolves every twin chat host and the terminal owner instead of assuming local', async () => {
|
||||
const f = fixture()
|
||||
const id = 'repo::/wt'
|
||||
mocks.state.worktreesByRepo = {
|
||||
repo: [
|
||||
{ id, repoId: 'repo', hostId: 'local' },
|
||||
{ id, repoId: 'repo', hostId: 'runtime:env' }
|
||||
]
|
||||
}
|
||||
mocks.state.tabsByWorktree = { [id]: [{ id: 'tab' }] }
|
||||
mocks.state.ptyIdsByTabId = { tab: [toRemoteRuntimePtyId('pty', 'env')] }
|
||||
await f.prepare([id])
|
||||
expect(f.responses[0]).toEqual({
|
||||
requestId: 'move-1',
|
||||
protocol: 1,
|
||||
phase: 'prepare',
|
||||
targets: [
|
||||
{
|
||||
worktreeId: id,
|
||||
terminalHost: { kind: 'environment', environmentId: 'env' },
|
||||
chatHosts: [{ kind: 'self' }, { kind: 'environment', environmentId: 'env' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses ambiguous twins before starting a sleep', async () => {
|
||||
const f = fixture()
|
||||
const id = 'repo::/wt'
|
||||
mocks.state.worktreesByRepo = {
|
||||
repo: [
|
||||
{ id, repoId: 'repo', hostId: 'local' },
|
||||
{ id, repoId: 'repo', hostId: 'runtime:env' }
|
||||
]
|
||||
}
|
||||
await f.prepare([id])
|
||||
expect(f.responses[0]?.phase).toBe('error')
|
||||
expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses completion when the follower did not see a supported sleep', async () => {
|
||||
const f = fixture()
|
||||
await f.prepare()
|
||||
await f.persist()
|
||||
expect(f.responses.at(-1)).toMatchObject({
|
||||
phase: 'error',
|
||||
error: 'project_move_pause_missing_completion'
|
||||
})
|
||||
expect(mocks.persist).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports renderer teardown failures instead of persisting and acknowledging success', async () => {
|
||||
const f = fixture()
|
||||
mocks.state.shutdownWorktreeTerminals.mockRejectedValue(new Error('capture failed'))
|
||||
await f.prepare()
|
||||
f.notice('started')
|
||||
f.notice('ended')
|
||||
await f.persist()
|
||||
expect(f.responses.at(-1)).toMatchObject({ phase: 'error', error: 'capture failed' })
|
||||
expect(mocks.persist).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports durable write failure after teardown', async () => {
|
||||
const f = fixture()
|
||||
mocks.persist.mockRejectedValue(new Error('disk full'))
|
||||
await f.prepare()
|
||||
f.notice('started')
|
||||
f.notice('ended')
|
||||
await f.persist()
|
||||
expect(f.responses.at(-1)).toMatchObject({ phase: 'error', error: 'disk full' })
|
||||
})
|
||||
|
||||
it('keeps ordinary user sleep behavior and refuses a superseded move pause', async () => {
|
||||
const f = fixture()
|
||||
await f.prepare()
|
||||
f.notice('started')
|
||||
f.notice('ended')
|
||||
f.follower.follow({ worktreeId: 'wt', phase: 'started' })
|
||||
f.follower.follow({
|
||||
worktreeId: 'wt',
|
||||
phase: 'ended',
|
||||
slept: true,
|
||||
stoppedPtyIds: [],
|
||||
records: []
|
||||
})
|
||||
await f.persist()
|
||||
expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt')
|
||||
expect(f.responses.at(-1)).toMatchObject({
|
||||
phase: 'error',
|
||||
error: 'project_move_pause_sleep_superseded'
|
||||
})
|
||||
})
|
||||
|
||||
it('reserves overlapping scopes until main releases them', async () => {
|
||||
const f = fixture()
|
||||
await f.prepare()
|
||||
const next = {
|
||||
requestId: 'move-2',
|
||||
protocol: 1,
|
||||
phase: 'prepare',
|
||||
worktreeIds: ['wt']
|
||||
} as const
|
||||
await f.follower.request({ ...next, worktreeIds: [...next.worktreeIds] })
|
||||
expect(f.responses.at(-1)).toMatchObject({
|
||||
phase: 'error',
|
||||
error: 'project_move_pause_scope_busy'
|
||||
})
|
||||
await f.follower.request({ requestId: 'move-1', protocol: 1, phase: 'release' })
|
||||
await f.follower.request({ ...next, worktreeIds: [...next.worktreeIds] })
|
||||
expect(f.responses.at(-1)).toMatchObject({ requestId: 'move-2', phase: 'prepare' })
|
||||
})
|
||||
|
||||
it('does not acknowledge a write that main released while it was in flight', async () => {
|
||||
const f = fixture()
|
||||
const saving = Promise.withResolvers<void>()
|
||||
mocks.persist.mockReturnValue(saving.promise)
|
||||
await f.prepare()
|
||||
f.notice('started')
|
||||
f.notice('ended')
|
||||
const completion = f.persist()
|
||||
await vi.waitFor(() => expect(mocks.persist).toHaveBeenCalledOnce())
|
||||
await f.follower.request({ requestId: 'move-1', protocol: 1, phase: 'release' })
|
||||
saving.resolve()
|
||||
await completion
|
||||
expect(f.responses.at(-1)).toMatchObject({
|
||||
phase: 'error',
|
||||
error: 'project_move_pause_released'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
PROJECT_MOVE_PAUSE_PROTOCOL,
|
||||
type ProjectMovePauseRequest,
|
||||
type ProjectMovePauseResponse
|
||||
} from '../../../../shared/project-move-pause'
|
||||
import type { WorkspaceSleepNotice } from '../../../../shared/workspace-sleep'
|
||||
import { useAppStore } from '@/store'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence'
|
||||
import { workspaceSleepTarget } from './workspace-sleep-target'
|
||||
import { followWorkspaceSleep } from './workspace-sleep-follower'
|
||||
|
||||
type Pause = {
|
||||
worktreeIds: Set<string>
|
||||
started: Set<string>
|
||||
ended: Map<string, Promise<Error | null>>
|
||||
error?: Error
|
||||
}
|
||||
|
||||
/** The handshake and sleep follower share one lifetime, so an old follower cannot claim support. */
|
||||
export function createProjectMovePauseFollower(
|
||||
respond: (response: ProjectMovePauseResponse) => void
|
||||
): {
|
||||
request: (request: ProjectMovePauseRequest) => Promise<void>
|
||||
follow: (notice: WorkspaceSleepNotice) => void
|
||||
} {
|
||||
const pauses = new Map<string, Pause>()
|
||||
return {
|
||||
request: async (request) => {
|
||||
const envelope = {
|
||||
requestId: request.requestId,
|
||||
protocol: PROJECT_MOVE_PAUSE_PROTOCOL
|
||||
} as const
|
||||
try {
|
||||
if (request.protocol !== PROJECT_MOVE_PAUSE_PROTOCOL) {
|
||||
throw new Error('project_move_pause_unsupported_protocol')
|
||||
}
|
||||
if (request.phase === 'release') {
|
||||
pauses.delete(request.requestId)
|
||||
return
|
||||
}
|
||||
if (request.phase === 'prepare') {
|
||||
const ids = new Set(request.worktreeIds)
|
||||
if (
|
||||
ids.size === 0 ||
|
||||
ids.size !== request.worktreeIds.length ||
|
||||
pauses.has(request.requestId)
|
||||
) {
|
||||
throw new Error('project_move_pause_invalid_scope')
|
||||
}
|
||||
if (
|
||||
[...pauses.values()].some((pause) => [...ids].some((id) => pause.worktreeIds.has(id)))
|
||||
) {
|
||||
throw new Error('project_move_pause_scope_busy')
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const targets = [...ids].map((id) => {
|
||||
const target = workspaceSleepTarget(state, id)
|
||||
if ('refused' in target) {
|
||||
throw new Error(target.refused)
|
||||
}
|
||||
return target
|
||||
})
|
||||
pauses.set(request.requestId, { worktreeIds: ids, started: new Set(), ended: new Map() })
|
||||
respond({ ...envelope, phase: 'prepare', targets })
|
||||
return
|
||||
}
|
||||
const pause = pauses.get(request.requestId)
|
||||
if (!pause) {
|
||||
throw new Error('project_move_pause_not_prepared')
|
||||
}
|
||||
if (pause.error) {
|
||||
throw pause.error
|
||||
}
|
||||
if (request.successfulWorktreeIds.some((id) => !pause.ended.has(id))) {
|
||||
throw new Error('project_move_pause_missing_completion')
|
||||
}
|
||||
if ([...pause.started].some((id) => !pause.ended.has(id))) {
|
||||
throw new Error('project_move_pause_incomplete_sleep')
|
||||
}
|
||||
const errors = await Promise.all(pause.ended.values())
|
||||
const error = errors.find((failure) => failure !== null)
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (pause.error || pauses.get(request.requestId) !== pause) {
|
||||
throw pause.error ?? new Error('project_move_pause_released')
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
await persistWorkspaceSessionByHost(
|
||||
window.api.session,
|
||||
buildWorkspaceSessionPayload(state),
|
||||
state
|
||||
)
|
||||
if (pause.error || pauses.get(request.requestId) !== pause) {
|
||||
throw pause.error ?? new Error('project_move_pause_released')
|
||||
}
|
||||
respond({ ...envelope, phase: 'persist' })
|
||||
} catch (error) {
|
||||
respond({
|
||||
...envelope,
|
||||
phase: 'error',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
follow: (notice) => {
|
||||
if (notice.phase === 'failed') {
|
||||
followWorkspaceSleep(notice)
|
||||
return
|
||||
}
|
||||
const pause = notice.projectMovePauseId ? pauses.get(notice.projectMovePauseId) : undefined
|
||||
const prepared = pause?.worktreeIds.has(notice.worktreeId) === true
|
||||
for (const other of pauses.values()) {
|
||||
if (other.worktreeIds.has(notice.worktreeId) && (other !== pause || !prepared)) {
|
||||
other.error = new Error('project_move_pause_sleep_superseded')
|
||||
}
|
||||
}
|
||||
if (pause && prepared && notice.phase === 'started') {
|
||||
if (pause.started.has(notice.worktreeId)) {
|
||||
pause.error = new Error('project_move_pause_duplicate_sleep')
|
||||
}
|
||||
pause.started.add(notice.worktreeId)
|
||||
}
|
||||
const completion = followWorkspaceSleep(notice, {
|
||||
keepBrowserTabs: prepared,
|
||||
reportTeardownFailure: prepared
|
||||
})
|
||||
if (pause && prepared && notice.phase === 'ended') {
|
||||
if (!pause.started.has(notice.worktreeId) || pause.ended.has(notice.worktreeId)) {
|
||||
pause.error = new Error('project_move_pause_unexpected_completion')
|
||||
}
|
||||
pause.ended.set(
|
||||
notice.worktreeId,
|
||||
Promise.resolve(completion).then(
|
||||
() => null,
|
||||
(error: unknown) => (error instanceof Error ? error : new Error(String(error)))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { toastWorkspaceSleepOutcome } from './sleep-worktree-flow'
|
||||
type FollowedSleep = {
|
||||
settle: (stop: HostTerminalStop) => void
|
||||
stopWatchingReopen: () => void
|
||||
teardown: Promise<void>
|
||||
teardown: Promise<Error | null>
|
||||
/** The selection to put back if the sleep fails and the user picked nothing meanwhile. */
|
||||
selectionBeforeSleep: WorkspaceSelection | null
|
||||
}
|
||||
@@ -43,7 +43,10 @@ function watchReopenDuringSleep(worktreeId: string): () => void {
|
||||
* guards its panes before any terminal exits (the notice arrives ahead of every exit), and on
|
||||
* `ended` it commits or rolls back those panes and wakes the workspace if the sleep failed.
|
||||
*/
|
||||
export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void {
|
||||
export function followWorkspaceSleep(
|
||||
notice: WorkspaceSleepNotice,
|
||||
options: { keepBrowserTabs?: boolean; reportTeardownFailure?: boolean } = {}
|
||||
): Promise<void> | void {
|
||||
const { worktreeId } = notice
|
||||
if (notice.phase === 'failed') {
|
||||
toastWorkspaceSleepOutcome(notice.outcome)
|
||||
@@ -64,7 +67,9 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void {
|
||||
state.setActiveWorktree(null)
|
||||
restoreSidebarPosition()
|
||||
}
|
||||
state.shutdownWorktreeBrowsers(worktreeId)
|
||||
if (!options.keepBrowserTabs) {
|
||||
state.shutdownWorktreeBrowsers(worktreeId)
|
||||
}
|
||||
let settle!: (stop: HostTerminalStop) => void
|
||||
const hostStop = new Promise<HostTerminalStop>((resolve) => (settle = resolve))
|
||||
const teardown = withWorktreeSleepTeardown(worktreeId, () =>
|
||||
@@ -72,9 +77,13 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void {
|
||||
keepIdentifiers: true,
|
||||
hostStop
|
||||
})
|
||||
).catch((error: unknown) => {
|
||||
console.warn('[sleep-worktree] panes kept awake', { worktreeId, error })
|
||||
})
|
||||
).then(
|
||||
() => null,
|
||||
(error: unknown) => {
|
||||
console.warn('[sleep-worktree] panes kept awake', { worktreeId, error })
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
)
|
||||
followed.set(worktreeId, {
|
||||
settle,
|
||||
teardown,
|
||||
@@ -91,7 +100,7 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void {
|
||||
? { failed: false, records: notice.records }
|
||||
: { failed: true, stoppedPtyIds: notice.stoppedPtyIds ?? [] }
|
||||
)
|
||||
void (entry?.teardown ?? Promise.resolve()).then(() => {
|
||||
return (entry?.teardown ?? Promise.resolve(null)).then((error) => {
|
||||
const state = useAppStore.getState()
|
||||
// A failed sleep leaves the workspace awake and retryable; one the user reopened is theirs.
|
||||
if (!notice.slept || state.activeWorktreeId === worktreeId) {
|
||||
@@ -100,5 +109,8 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void {
|
||||
if (!notice.slept && entry?.selectionBeforeSleep && state.activeWorktreeId === null) {
|
||||
restoreWorkspaceSelection(state, entry.selectionBeforeSleep)
|
||||
}
|
||||
if (error && options.reportTeardownFailure) {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed
|
||||
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { followWorkspaceSleep } from '@/components/sidebar/workspace-sleep-follower'
|
||||
import { createProjectMovePauseFollower } from '@/components/sidebar/project-move-pause-follower'
|
||||
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
|
||||
import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence'
|
||||
import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal'
|
||||
@@ -198,7 +199,19 @@ export function registerMobileAndTerminalCloseIpcBridge(
|
||||
}
|
||||
|
||||
// A sleep main runs, from this window or a phone: the window hides the workspace and its panes.
|
||||
unsubs.push(window.api.ui.onWorkspaceSleep(followWorkspaceSleep))
|
||||
if (window.api.ui.onProjectMovePause && window.api.ui.respondProjectMovePause) {
|
||||
const movePause = createProjectMovePauseFollower(window.api.ui.respondProjectMovePause)
|
||||
unsubs.push(
|
||||
window.api.ui.onProjectMovePause((request) => {
|
||||
void movePause.request(request).catch((error: unknown) => {
|
||||
console.warn('[project-move-pause] renderer reply failed', error)
|
||||
})
|
||||
})
|
||||
)
|
||||
unsubs.push(window.api.ui.onWorkspaceSleep(movePause.follow))
|
||||
} else {
|
||||
unsubs.push(window.api.ui.onWorkspaceSleep(followWorkspaceSleep))
|
||||
}
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onResumeSleepingAgents(({ worktreeId }) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const PROJECT_MOVE_PAUSE_PROTOCOL = 1
|
||||
|
||||
export type ProjectMovePauseRequest = { requestId: string; protocol: 1 } & (
|
||||
| { phase: 'prepare'; worktreeIds: string[] }
|
||||
| { phase: 'persist'; successfulWorktreeIds: string[] }
|
||||
| { phase: 'release' }
|
||||
)
|
||||
|
||||
const host = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('self') }),
|
||||
z.object({ kind: z.literal('environment'), environmentId: z.string().min(1) })
|
||||
])
|
||||
|
||||
export const projectMovePauseResponseSchema = z.discriminatedUnion('phase', [
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
protocol: z.literal(PROJECT_MOVE_PAUSE_PROTOCOL),
|
||||
phase: z.literal('prepare'),
|
||||
targets: z.array(
|
||||
z.object({ worktreeId: z.string(), terminalHost: host, chatHosts: z.array(host) })
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
protocol: z.literal(PROJECT_MOVE_PAUSE_PROTOCOL),
|
||||
phase: z.literal('persist')
|
||||
}),
|
||||
z.object({
|
||||
requestId: z.string(),
|
||||
protocol: z.literal(PROJECT_MOVE_PAUSE_PROTOCOL),
|
||||
phase: z.literal('error'),
|
||||
error: z.string()
|
||||
})
|
||||
])
|
||||
|
||||
export type ProjectMovePauseResponse = z.infer<typeof projectMovePauseResponseSchema>
|
||||
@@ -14,6 +14,8 @@ export type WorkspaceSleepTarget = {
|
||||
|
||||
export type WorkspaceSleepRequest = {
|
||||
targets: WorkspaceSleepTarget[]
|
||||
/** A main-owned project move with a negotiated renderer persistence barrier. */
|
||||
projectMovePauseId?: string
|
||||
/** Only the user's Sleep hides its chats; a project move pauses through the same sleep. */
|
||||
markChatsAsleep?: boolean
|
||||
}
|
||||
@@ -36,10 +38,11 @@ export type WorkspaceSleepOutcome = {
|
||||
|
||||
/** What the window follows of a sleep main runs, whoever asked for it. */
|
||||
export type WorkspaceSleepNotice =
|
||||
| { worktreeId: string; phase: 'started' }
|
||||
| { worktreeId: string; phase: 'started'; projectMovePauseId?: string }
|
||||
| {
|
||||
worktreeId: string
|
||||
phase: 'ended'
|
||||
projectMovePauseId?: string
|
||||
/** The terminals main proved stopped, as the window's PTY ids; null when the stop failed. */
|
||||
stoppedPtyIds: string[] | null
|
||||
slept: boolean
|
||||
|
||||
Reference in New Issue
Block a user