fix(cloud): review fixes for Apply changes again and late continue (staged+unstaged re-apply, untracked files never lost, no archive with leftovers, atomic writes, per-transfer lock and worktree check, after-restart offer; one continue loop per pane, late reopen verifies session or offers Continue)

This commit is contained in:
Neil
2026-11-09 18:59:48 -08:00
parent 1f51735ac4
commit bbb035b5a4
19 changed files with 714 additions and 163 deletions
@@ -11,7 +11,7 @@ import {
loadPersistedTransfer,
persistTransfer
} from './cloud-transfer-apply'
import { sweepCloudTransfers } from './cloud-transfer'
import { listPendingCloudTransfers, sweepCloudTransfers } from './cloud-transfer'
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], {
@@ -91,9 +91,13 @@ describe('Apply changes again', () => {
it('reports none for an empty patch and parses git apply conflict paths', async () => {
const { repo, dir } = setup()
expect((await applyPatchIdempotent(repo, join(dir, 'staged.patch'), true, join(dir, 'u'))).outcome).toBe('none')
expect(
conflictPathsFromGitApply('error: patch failed: src/x.ts:12\nerror: src/x.ts: patch does not apply\nerror: gone.txt: does not exist in working directory\n')
(await applyPatchIdempotent(repo, join(dir, 'staged.patch'), true, join(dir, 'u'))).outcome
).toBe('none')
expect(
conflictPathsFromGitApply(
'error: patch failed: src/x.ts:12\nerror: src/x.ts: patch does not apply\nerror: gone.txt: does not exist in working directory\n'
)
).toEqual(['src/x.ts', 'gone.txt'])
})
@@ -107,4 +111,84 @@ describe('Apply changes again', () => {
expect(existsSync(entry.dir)).toBe(true)
expect(await loadPersistedTransfer(join(root, 'nope'))).toBeNull()
})
it('re-applies safely when staged and unstaged changes touch the same file', async () => {
const { repo, dir, entry, homes } = setup()
// Cloud side: a.txt staged as "one staged", then edited further (unstaged) to "one staged + more".
writeFileSync(join(repo, 'a.txt'), 'one staged\n')
git(repo, 'add', 'a.txt')
writeFileSync(join(dir, 'staged.patch'), git(repo, 'diff', '--cached', '--binary'))
writeFileSync(join(repo, 'a.txt'), 'one staged\nmore\n')
writeFileSync(join(dir, 'unstaged.patch'), git(repo, 'diff', '--binary'))
git(repo, 'reset', '-q', '--hard')
const first = await applyTransferToWorktree(entry, repo, homes)
expect(first).toMatchObject({
appliedStaged: true,
appliedUnstaged: true,
leftoverPatchDir: null
})
expect(readFileSync(join(repo, 'a.txt'), 'utf8')).toBe('one staged\nmore\n')
// A retry (lost reply, or "Apply changes again") must recognise both as already there.
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'staged.patch'), git(repo, 'diff', '--cached', '--binary'))
writeFileSync(join(dir, 'unstaged.patch'), git(repo, 'diff', '--binary'))
const again = await applyTransferToWorktree(entry, repo, homes)
expect(again).toMatchObject({
appliedStaged: true,
appliedUnstaged: true,
leftoverPatchDir: null,
conflicts: []
})
expect(git(repo, 'diff', '--cached', '--name-only').trim()).toBe('a.txt')
expect(readFileSync(join(repo, 'a.txt'), 'utf8')).toBe('one staged\nmore\n')
})
it('keeps untracked files it could not write (a different file is here) and names them', async () => {
const { root, repo, dir, entry, homes } = setup()
const src = join(root, 'untracked-src')
mkdirSync(join(src, 'notes'), { recursive: true })
writeFileSync(join(src, 'notes', 'todo.md'), 'cloud todo\n')
writeFileSync(join(src, 'new.txt'), 'cloud new\n')
execFileSync('tar', ['-czf', join(dir, 'untracked.tgz'), '-C', src, 'notes', 'new.txt'], {
env: { ...process.env, COPYFILE_DISABLE: '1' }
})
mkdirSync(join(repo, 'notes'))
writeFileSync(join(repo, 'notes', 'todo.md'), 'my local todo\n')
const result = await applyTransferToWorktree(entry, repo, homes)
expect(readFileSync(join(repo, 'notes', 'todo.md'), 'utf8')).toBe('my local todo\n')
expect(readFileSync(join(repo, 'new.txt'), 'utf8')).toBe('cloud new\n')
expect(result.skippedUntracked).toEqual(['notes/todo.md'])
expect(result.leftoverPatchDir).toBe(join(dir, 'unapplied'))
expect(readFileSync(join(dir, 'unapplied', 'untracked-not-written.txt'), 'utf8')).toBe(
'notes/todo.md\n'
)
expect(existsSync(join(dir, 'unapplied', 'untracked.tgz'))).toBe(true)
// No temp files left behind next to the written ones.
expect(execFileSync('ls', ['-A', join(repo, 'notes')], { encoding: 'utf8' }).trim()).toBe(
'todo.md'
)
// Once the local file is moved aside, Apply changes again writes it; identical files count as done.
writeFileSync(join(repo, 'notes', 'todo.md'), 'cloud todo\n')
const again = await applyTransferToWorktree(entry, repo, homes)
expect(again.skippedUntracked).toEqual([])
expect(again.leftoverPatchDir).toBeNull()
})
it('lists transfers left part-applied, for the after-restart offer', async () => {
const { root, repo, entry } = setup()
const transfers = join(root, 'transfers')
const id = '11111111-2222-3333-4444-555555555555'
const dir = join(transfers, id)
mkdirSync(dir, { recursive: true })
await persistTransfer({ ...entry, dir }, repo)
mkdirSync(join(transfers, 'not-a-transfer'))
expect(await listPendingCloudTransfers(transfers)).toEqual([
{
transferId: id,
workspaceId: 'ws-1',
worktreePath: repo,
leftoverDir: join(dir, 'unapplied')
}
])
})
})
@@ -44,7 +44,12 @@ export async function loadPersistedTransfer(dir: string): Promise<Prepared | nul
JSON.parse(await readFile(join(dir, TRANSFER_STATE_FILE), 'utf8'))
)
return parsed.success
? { dir, manifest: parsed.data.manifest, repoPath: parsed.data.repoPath, workspaceId: parsed.data.workspaceId }
? {
dir,
manifest: parsed.data.manifest,
repoPath: parsed.data.repoPath,
workspaceId: parsed.data.workspaceId
}
: null
} catch {
return null
@@ -68,9 +73,10 @@ export function conflictPathsFromGitApply(stderr: string): string[] {
const paths = new Set<string>()
for (const line of stderr.split('\n')) {
const failed = /^error: patch failed: (.+?):\d+$/.exec(line.trim())
const other = /^error: (.+?): (?:does not exist in (?:index|working directory)|already exists in (?:index|working directory)|patch does not apply)$/.exec(
line.trim()
)
const other =
/^error: (.+?): (?:does not exist in (?:index|working directory)|already exists in (?:index|working directory)|patch does not apply|does not match index)$/.exec(
line.trim()
)
const path = failed?.[1] ?? other?.[1]
if (path) {
paths.add(path.slice(0, 1024))
@@ -94,7 +100,10 @@ export async function applyPatchIdempotent(
}
const flags = ['--whitespace=nowarn', ...(index ? ['--index'] : [])]
const saved = join(leftovers, patch.split('/').at(-1)!)
if ((await gitOk(worktreePath, ['apply', '--check', '-R', ...flags, patch])).ok) {
// "Already applied" for the staged patch is judged on the index alone (--cached): the worktree may
// also carry the unstaged patch on the same files, which --index would read as "does not match".
const reverse = ['--whitespace=nowarn', ...(index ? ['--cached'] : [])]
if ((await gitOk(worktreePath, ['apply', '--check', '-R', ...reverse, patch])).ok) {
await rm(saved, { force: true })
return { outcome: 'already', conflicts: [] }
}
@@ -117,8 +126,18 @@ export async function applyTransferToWorktree(
): Promise<CloudTransferFinished> {
await persistTransfer(entry, worktreePath)
const leftovers = join(entry.dir, 'unapplied')
const staged = await applyPatchIdempotent(worktreePath, join(entry.dir, 'staged.patch'), true, leftovers)
const unstaged = await applyPatchIdempotent(worktreePath, join(entry.dir, 'unstaged.patch'), false, leftovers)
const staged = await applyPatchIdempotent(
worktreePath,
join(entry.dir, 'staged.patch'),
true,
leftovers
)
const unstaged = await applyPatchIdempotent(
worktreePath,
join(entry.dir, 'unstaged.patch'),
false,
leftovers
)
let skippedUntracked: string[] = []
if ((await fileSize(join(entry.dir, 'untracked.tgz'))) > 0) {
// Inflate with a cap (no gzip bombs), then extract without clobbering anything already there.
@@ -128,8 +147,21 @@ export async function applyTransferToWorktree(
maxBytes: CLOUD_TRANSFER_MAX_BYTES,
noClobber: true
})
skippedUntracked = extracted.skipped.slice(0, 200)
await rm(untrackedTar, { force: true })
// Files that differ from what is here were not written: keep the archive and name them, so
// nothing is lost and the folder (and "Apply changes again") stays.
skippedUntracked = [...extracted.existing, ...extracted.skipped].slice(0, 200)
if (extracted.existing.length > 0) {
await mkdir(leftovers, { recursive: true })
await cp(join(entry.dir, 'untracked.tgz'), join(leftovers, 'untracked.tgz'))
await writeFile(
join(leftovers, 'untracked-not-written.txt'),
`${extracted.existing.join('\n')}\n`
)
} else {
await rm(join(leftovers, 'untracked.tgz'), { force: true })
await rm(join(leftovers, 'untracked-not-written.txt'), { force: true })
}
}
const claudeSessions = await installSessions(
join(entry.dir, 'claude'),
@@ -0,0 +1,80 @@
// "Move to this computer": transfer folders left on disk. Prepared transfers live only in memory, so
// a folder found at startup is abandoned unless an apply was attempted on it (then it is kept, and
// offered for "Apply changes again", for 30 days).
import { readdir, readFile, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { CloudTransferPending } from '../../shared/hosted-sandbox/cloud-transfer-api'
import {
PersistedTransferSchema,
TRANSFER_STATE_FILE,
loadPersistedTransfer
} from './cloud-transfer-apply'
const LEFTOVER_TTL_MS = 30 * 86_400_000
/**
* Prepared transfers live only in memory, so anything on disk at startup is abandoned: removed,
* except saved patches that did not apply (the user's work), which are kept for 30 days.
*/
export async function sweepCloudTransfers(root: string, now: number): Promise<number> {
let removed = 0
for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
if (!entry.isDirectory()) {
continue
}
const dir = join(root, entry.name)
const leftovers = await stat(join(dir, 'unapplied')).catch(() => null)
if (leftovers && now - leftovers.mtimeMs < LEFTOVER_TTL_MS) {
continue
}
// An apply was attempted but did not finish: keep it so "Apply changes again" still works.
if (await appliedAttemptWithin(dir, now)) {
continue
}
await rm(dir, { recursive: true, force: true })
removed++
}
return removed
}
export async function persistedWorktreePath(dir: string): Promise<string | null> {
try {
const parsed = PersistedTransferSchema.safeParse(
JSON.parse(await readFile(join(dir, TRANSFER_STATE_FILE), 'utf8'))
)
return parsed.success ? (parsed.data.worktreePath ?? null) : null
} catch {
return null
}
}
async function appliedAttemptWithin(dir: string, now: number): Promise<boolean> {
const info = await stat(join(dir, TRANSFER_STATE_FILE)).catch(() => null)
if (!info || now - info.mtimeMs >= LEFTOVER_TTL_MS) {
return false
}
return (await persistedWorktreePath(dir)) !== null
}
/** Transfers whose apply was attempted but left something behind (shown after a restart). */
export async function listPendingCloudTransfers(root: string): Promise<CloudTransferPending[]> {
const pending: CloudTransferPending[] = []
for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) {
continue
}
const dir = join(root, entry.name)
const transfer = await loadPersistedTransfer(dir)
const worktreePath = await persistedWorktreePath(dir)
if (!transfer || !worktreePath) {
continue
}
pending.push({
transferId: entry.name,
workspaceId: transfer.workspaceId,
worktreePath,
leftoverDir: join(dir, 'unapplied')
})
}
return pending
}
@@ -105,7 +105,8 @@ describe('safe extraction of VM archives', () => {
const result = await safeExtractTar(archive, dest, { maxBytes: 1024, noClobber: true })
expect(existsSync(join(outside, 'pwned'))).toBe(false)
expect(readFileSync(join(dest, 'keep.txt'), 'utf8')).toBe('mine')
expect(result.skipped).toEqual(['linked/pwned', 'keep.txt'])
expect(result.skipped).toEqual(['linked/pwned'])
expect(result.existing).toEqual(['keep.txt'])
})
it('inflates gzip only up to the cap (gzip bomb)', async () => {
+61 -8
View File
@@ -3,7 +3,8 @@
// or climbs out of the destination are skipped; total bytes and entry counts are capped, and gzip is
// inflated with a byte cap (no gzip bombs).
import { createReadStream, createWriteStream } from 'node:fs'
import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises'
import { link, lstat, mkdir, open, readFile, realpath, rename, rm, unlink } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { dirname, isAbsolute, join, normalize, sep } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Transform } from 'node:stream'
@@ -11,7 +12,17 @@ import { createGunzip } from 'node:zlib'
const BLOCK = 512
export type TarExtractResult = { files: number; dirs: number; bytes: number; skipped: string[] }
export type TarExtractResult = {
files: number
dirs: number
bytes: number
/** Entries refused for safety (links, devices, escapes, symlinked ancestors). */
skipped: string[]
/** `noClobber`: a different file was already at the path; the transferred one was not written. */
existing: string[]
/** `noClobber`: the same content was already at the path (an earlier attempt wrote it). */
identical: number
}
/** True when any existing ancestor of `relative` under `root` (or the entry itself) is a symlink. */
async function throughSymlink(root: string, relative: string): Promise<boolean> {
@@ -100,7 +111,14 @@ export async function safeExtractTar(
await mkdir(destination, { recursive: true })
const root = await realpath(destination)
const handle = await open(tarPath, 'r')
const result: TarExtractResult = { files: 0, dirs: 0, bytes: 0, skipped: [] }
const result: TarExtractResult = {
files: 0,
dirs: 0,
bytes: 0,
skipped: [],
existing: [],
identical: 0
}
const header = Buffer.alloc(BLOCK)
let offset = 0
let longName: string | null = null
@@ -173,14 +191,17 @@ export async function safeExtractTar(
await mkdir(dirname(target), { recursive: true })
// Never write through a symlink an earlier step (or the user) left at the path.
const existing = await lstat(target).catch(() => null)
if (existing && (options.noClobber || !existing.isFile())) {
if (existing && !existing.isFile()) {
result.skipped.push(rawName.slice(0, 200))
offset += padded
continue
}
// Keep the executable bit (scripts); never setuid/setgid or group/world-writable.
const executable = (readOctal(header, 100, 8) & 0o111) !== 0
const out = await open(target, 'w', executable ? 0o755 : 0o644)
// Written to a fresh temp name first ('wx': never an existing path), then put in place, so a
// failure mid-write never leaves a partial file at the real path.
const temp = `${target}.orca-transfer-${randomBytes(6).toString('hex')}`
const out = await open(temp, 'wx', executable ? 0o755 : 0o644)
try {
let remaining = size
let position = offset
@@ -195,11 +216,37 @@ export async function safeExtractTar(
remaining -= read.bytesRead
position += read.bytesRead
}
} finally {
await out.close()
} catch (error) {
await out.close().catch(() => undefined)
await unlink(temp).catch(() => undefined)
throw error
}
await out.close()
offset += padded
if (existing && options.noClobber) {
if (existing.size === size && (await sameContent(temp, target))) {
result.identical++
} else {
result.existing.push(relative)
}
await unlink(temp)
continue
}
if (options.noClobber) {
// link() fails if something appeared at the path meanwhile: atomic no-clobber.
try {
await link(temp, target)
} catch {
result.existing.push(relative)
await unlink(temp)
continue
}
await unlink(temp)
} else {
await rename(temp, target)
}
result.files++
offset += padded
continue
}
} finally {
await handle.close()
@@ -227,3 +274,9 @@ export async function gunzipCapped(
}
return total
}
async function sameContent(a: string, b: string): Promise<boolean> {
// Only reached for files of equal size; transfers are capped, so reading both is bounded.
const [left, right] = await Promise.all([readFile(a), readFile(b)])
return left.equals(right)
}
+49 -52
View File
@@ -3,7 +3,7 @@
// in the VM, downloads and imports the branch; finish (after the window created the local worktree
// through its ordinary create path) applies changes and installs remapped agent sessions.
import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises'
import { mkdir, rm, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { ipcMain, type BrowserWindow } from 'electron'
@@ -14,15 +14,19 @@ import {
} from '../../shared/hosted-sandbox/cloud-transfer-pack'
import { safeExtractTar } from './cloud-transfer-tar'
import {
PersistedTransferSchema,
TRANSFER_STATE_FILE,
applyTransferToWorktree,
loadPersistedTransfer,
persistTransfer
} from './cloud-transfer-apply'
import {
listPendingCloudTransfers,
persistedWorktreePath,
sweepCloudTransfers
} from './cloud-transfer-pending'
import {
ORCA_CLOUD_TRANSFER_CHANNELS as CH,
type CloudTransferFinished,
type CloudTransferPending,
type CloudTransferPhase,
type CloudTransferPrepared,
type CloudTransferProgressEvent,
@@ -41,6 +45,7 @@ import { createHostedRuntimeEnvironmentCall } from './hosted-runtime-environment
import { findCloudRuntime } from './hosted-workspace-service'
export { importBundle, installSessions, pickCloudWorktree } from './cloud-transfer-steps'
export { listPendingCloudTransfers, sweepCloudTransfers } from './cloud-transfer-pending'
export function installCloudTransferHandlers(deps: {
userDataPath: string
@@ -50,6 +55,13 @@ export function installCloudTransferHandlers(deps: {
codexHome?: string
}): () => void {
const prepared = new Map<string, Prepared>()
// One apply at a time per transfer (finish and "Apply changes again" share it).
const applying = new Set<string>()
const busy = <T>(): CloudTransferResult<T> => ({
ok: false,
code: 'busy',
message: 'Changes from this move are already being applied.'
})
const claudeHome = deps.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
const codexHome = deps.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), '.codex')
const emit = (event: Omit<CloudTransferProgressEvent, 'at'>): void => {
@@ -78,6 +90,7 @@ export function installCloudTransferHandlers(deps: {
ipcMain.removeHandler(CH.prepare)
ipcMain.removeHandler(CH.finish)
ipcMain.removeHandler(CH.reapply)
ipcMain.removeHandler(CH.listPending)
ipcMain.handle(
CH.prepare,
async (
@@ -191,9 +204,16 @@ export function installCloudTransferHandlers(deps: {
}
const step = (phase: CloudTransferPhase): void =>
emit({ workspaceId: entry.workspaceId, transferId: args.transferId, phase })
if (applying.has(args.transferId)) {
return busy()
}
applying.add(args.transferId)
try {
step('applying')
const finished = await applyTransferToWorktree(entry, args.worktreePath, { claudeHome, codexHome })
const finished = await applyTransferToWorktree(entry, args.worktreePath, {
claudeHome,
codexHome
})
// Kept in memory while something is left to apply, so "Apply changes again" can retry.
if (!finished.leftoverPatchDir) {
prepared.delete(args.transferId)
@@ -202,6 +222,8 @@ export function installCloudTransferHandlers(deps: {
return { ok: true, value: finished }
} catch (error) {
return fail(entry.workspaceId, args.transferId, error)
} finally {
applying.delete(args.transferId)
}
}
)
@@ -216,16 +238,27 @@ export function installCloudTransferHandlers(deps: {
if (!/^[0-9a-f-]{36}$/i.test(args.transferId)) {
return { ok: false, code: 'not_found', message: 'That transfer is no longer available.' }
}
const entry =
prepared.get(args.transferId) ??
(await loadPersistedTransfer(join(deps.userDataPath, 'cloud-transfers', args.transferId)))
const worktreeOk = await stat(args.worktreePath).then((s) => s.isDirectory(), () => false)
if (!entry || !worktreeOk) {
const dir = join(deps.userDataPath, 'cloud-transfers', args.transferId)
const entry = prepared.get(args.transferId) ?? (await loadPersistedTransfer(dir))
const recorded = await persistedWorktreePath(dir)
const worktreeOk = await stat(args.worktreePath).then(
(s) => s.isDirectory(),
() => false
)
// Only the worktree this transfer was first applied to: never another one the window names.
if (!entry || !worktreeOk || (recorded !== null && recorded !== args.worktreePath)) {
return { ok: false, code: 'not_found', message: 'That transfer is no longer available.' }
}
if (applying.has(args.transferId)) {
return busy()
}
applying.add(args.transferId)
try {
emit({ workspaceId: entry.workspaceId, transferId: args.transferId, phase: 'applying' })
const finished = await applyTransferToWorktree(entry, args.worktreePath, { claudeHome, codexHome })
const finished = await applyTransferToWorktree(entry, args.worktreePath, {
claudeHome,
codexHome
})
if (finished.leftoverPatchDir) {
prepared.set(args.transferId, entry)
} else {
@@ -235,54 +268,18 @@ export function installCloudTransferHandlers(deps: {
return { ok: true, value: finished }
} catch (error) {
return fail(entry.workspaceId, args.transferId, error)
} finally {
applying.delete(args.transferId)
}
}
)
ipcMain.handle(CH.listPending, async (): Promise<CloudTransferPending[]> =>
listPendingCloudTransfers(join(deps.userDataPath, 'cloud-transfers'))
)
return () => {
ipcMain.removeHandler(CH.prepare)
ipcMain.removeHandler(CH.finish)
ipcMain.removeHandler(CH.reapply)
}
}
const LEFTOVER_TTL_MS = 30 * 86_400_000
/**
* Prepared transfers live only in memory, so anything on disk at startup is abandoned: removed,
* except saved patches that did not apply (the user's work), which are kept for 30 days.
*/
export async function sweepCloudTransfers(root: string, now: number): Promise<number> {
let removed = 0
for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) {
if (!entry.isDirectory()) {
continue
}
const dir = join(root, entry.name)
const leftovers = await stat(join(dir, 'unapplied')).catch(() => null)
if (leftovers && now - leftovers.mtimeMs < LEFTOVER_TTL_MS) {
continue
}
// An apply was attempted but did not finish: keep it so "Apply changes again" still works.
if (await appliedAttemptWithin(dir, now)) {
continue
}
await rm(dir, { recursive: true, force: true })
removed++
}
return removed
}
async function appliedAttemptWithin(dir: string, now: number): Promise<boolean> {
const info = await stat(join(dir, TRANSFER_STATE_FILE)).catch(() => null)
if (!info || now - info.mtimeMs >= LEFTOVER_TTL_MS) {
return false
}
try {
const parsed = PersistedTransferSchema.safeParse(
JSON.parse(await readFile(join(dir, TRANSFER_STATE_FILE), 'utf8'))
)
return parsed.success && parsed.data.worktreePath !== undefined
} catch {
return false
ipcMain.removeHandler(CH.listPending)
}
}
@@ -34,6 +34,7 @@ export const orcaCloudWorkspaceApi = {
},
prepareTransferToLocal: (args) => ipcRenderer.invoke(TRANSFER.prepare, args),
reapplyTransferToLocal: (args) => ipcRenderer.invoke(TRANSFER.reapply, args),
listPendingTransfers: () => ipcRenderer.invoke(TRANSFER.listPending),
finishTransferToLocal: (args) => ipcRenderer.invoke(TRANSFER.finish, args),
onTransferProgress: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, event: CloudTransferProgressEvent): void =>
@@ -83,7 +83,8 @@ export function CloudMoveToComputerView({
},
{
key: 'changes',
node: known
node:
known && known.uncommittedChanges > 0
? t(
'orcaCloudMove.confirm.changesCount',
'{{count}} uncommitted changes, plus untracked files',
@@ -2,11 +2,25 @@
// leaves those panes alone. Entries older than the memory window are pruned when a wake settles.
const lastUserInputByPane = new Map<string, number>()
const lastUserInputByWorkspace = new Map<string, number>()
const USER_INPUT_MEMORY_MS = 30 * 60_000
/** Any person's input reached this pane (layout leaf). Called for every admitted keystroke. */
export function noteUserInputForPane(paneId: string, at = Date.now()): void {
export function noteUserInputForPane(
paneId: string,
at = Date.now(),
workspaceId?: string | null
): void {
lastUserInputByPane.set(paneId, at)
if (workspaceId) {
lastUserInputByWorkspace.set(workspaceId, at)
}
}
/** True when a person's input reached any pane of this workspace since `since`. */
export function workspaceHadUserInputSince(workspaceId: string, since: number): boolean {
const at = lastUserInputByWorkspace.get(workspaceId)
return at !== undefined && at >= since
}
/** True when a person's input reached this pane since `since` (wake typing included). */
@@ -16,13 +30,16 @@ export function paneHadUserInputSince(paneId: string, since: number): boolean {
}
export function pruneUserInput(now = Date.now()): void {
for (const [paneId, at] of lastUserInputByPane) {
if (now - at > USER_INPUT_MEMORY_MS) {
lastUserInputByPane.delete(paneId)
for (const map of [lastUserInputByPane, lastUserInputByWorkspace]) {
for (const [key, at] of map) {
if (now - at > USER_INPUT_MEMORY_MS) {
map.delete(key)
}
}
}
}
export function resetPaneUserInputForTests(): void {
lastUserInputByPane.clear()
lastUserInputByWorkspace.clear()
}
@@ -12,7 +12,11 @@ import { applyWakeInput } from './wake-input-text'
import { noteUserInputForPane, pruneUserInput, resetPaneUserInputForTests } from './pane-user-input'
export { WAKE_INPUT_CAP, applyWakeInput } from './wake-input-text'
export { noteUserInputForPane, paneHadUserInputSince } from './pane-user-input'
export {
noteUserInputForPane,
paneHadUserInputSince,
workspaceHadUserInputSince
} from './pane-user-input'
const FLUSH_POLL_MS = 100
/** A wake nobody settles stops holding typing after this, and the typing is dropped. */
export const WAKE_INPUT_MAX_HOLD_MS = 90_000
@@ -156,7 +160,7 @@ function pump(now = Date.now()): void {
}
try {
pane.deliver(text)
noteUserInputForPane(paneId, now)
noteUserInputForPane(paneId, now, workspaceId)
for (const listener of deliveryListeners) {
listener({ workspaceId, paneId, submitted })
}
@@ -186,7 +186,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void {
session.pane.terminal,
(data, wasUserInput) => {
if (wasUserInput) {
noteUserInputForPane(wakeInputKey)
noteUserInputForPane(wakeInputKey, Date.now(), session.deps.worktreeId)
}
// Typing into a workspace whose server is waking is accepted now and delivered, in order,
// once this pane is attached to the woken terminal.
+13 -1
View File
@@ -26709,6 +26709,18 @@
"failedLocalNext": "Use Move again from the sandbox panel to bring them over. Your files are safe in the cloud workspace.",
"conflicts": "Changed here since: {{files}}",
"failedLocalReapply": "Apply changes again to bring them over. Nothing you changed here is overwritten, and your files are safe in the cloud workspace.",
"reapply": "Apply changes again"
"reapply": "Apply changes again",
"pending": {
"title": "Some changes from a move weren’t applied",
"detail": "They are saved on this computer. Nothing you changed here is overwritten.",
"stillLeft": "Some changes still conflict with edits made here. They stay saved.",
"done": "All changes applied"
}
},
"orcaCloud": {
"continueOffer": {
"title": "An agent was interrupted by the restart",
"action": "Continue"
}
}
}
@@ -0,0 +1,35 @@
// A reopened pane the continue logic won't type into on its own (a late reopen whose session or
// activity can't be confirmed): one click sends `continue`, once per interrupted turn.
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { getPtyIdForPaneKey } from '@/hooks/pane-key-pty-id'
import { sendBracketedPasteToRunningAgent } from '@/lib/agent-session/messaging/paste/agent-paste-draft'
import { INTERRUPTED_TURN_CONTINUE_PROMPT } from '../../../../../shared/agent-launch/startup/tui-agent-resume-startup'
import {
isInterruptedTurnContinued,
markInterruptedTurnContinued
} from '@/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup'
export function offerInterruptedTurnContinue(paneKey: string, turnKey: string): void {
toast(translate('orcaCloud.continueOffer.title', 'An agent was interrupted by the restart'), {
id: `orca-continue-offer-${turnKey}`,
duration: 60_000,
action: {
label: translate('orcaCloud.continueOffer.action', 'Continue'),
onClick: () => {
const ptyId = getPtyIdForPaneKey(paneKey)
if (!ptyId || isInterruptedTurnContinued(turnKey)) {
return
}
void sendBracketedPasteToRunningAgent({
ptyId,
content: INTERRUPTED_TURN_CONTINUE_PROMPT
}).then((sent) => {
if (sent) {
markInterruptedTurnContinued(turnKey)
}
})
}
}
})
}
@@ -145,25 +145,98 @@ describe('interrupted turn continue', () => {
expect(await continueInterruptedTurns('ws', never.deps)).toBe(0)
})
it('waits for a closed pane to be reopened (agent view reopened after the restart), then continues', async () => {
state.agentStatusByPaneKey = { 'tab-a:1': working('tab-a:1', 'claude') }
/** The pane opens 10 min after the restart; then the relaunched agent reports `report`. */
async function lateReopen(report: Record<string, unknown>, extra: Record<string, unknown> = {}) {
state.agentStatusByPaneKey = {
'tab-a:1': working('tab-a:1', 'claude', {
providerSession: { id: 'sess-1' },
terminalHandle: 'old'
})
}
armInterruptedTurnsForHostRestart('ws', NOW)
// The pane is closed for 10 minutes (well past the 90s relaunch window), then reopened.
let clock = NOW
const send = vi.fn(async (_args: { ptyId: string; prompt: string }) => true)
const reopenAt = NOW + 10 * 60_000
const submit = vi.fn(async (_args: { ptyId: string; content: string }) => true)
const offer = vi.fn()
const sent = await continueInterruptedTurns('ws', {
now: () => clock,
sleep: async (ms: number) => {
clock += ms
if (clock >= reopenAt + 3_000) {
state.agentStatusByPaneKey = {
'tab-a:1': working('tab-a:1', 'claude', {
updatedAt: reopenAt + 1_000,
terminalHandle: 'new',
...report
})
}
}
},
ptyIdFor: () => (clock - NOW >= 10 * 60_000 ? 'pty-reopened' : null),
ptyIdFor: () => (clock >= reopenAt ? 'pty-reopened' : null),
send: vi.fn(async () => false),
submit,
agentInForeground: async () => true,
offer,
...extra
})
return { sent, submit, offer }
}
it('continues a pane reopened long after the restart when the same session came back', async () => {
const { sent, submit, offer } = await lateReopen({
providerSession: { id: 'sess-1' },
state: 'done'
})
expect(sent).toBe(1)
expect(submit).toHaveBeenCalledTimes(1)
expect(submit.mock.calls[0]![0]).toMatchObject({ ptyId: 'pty-reopened', content: 'continue' })
expect(offer).not.toHaveBeenCalled()
})
it('offers Continue instead of typing when a late reopen shows a different session', async () => {
const { sent, submit, offer } = await lateReopen({
providerSession: { id: 'sess-NEW' },
state: 'done'
})
expect(sent).toBe(0)
expect(submit).not.toHaveBeenCalled()
expect(offer).toHaveBeenCalledTimes(1)
})
it('offers Continue instead of typing when the person was active in the workspace since', async () => {
const { noteUserInputForPane } =
await import('@/components/terminal-pane/input/wake-input-buffer')
noteUserInputForPane('some-other-pane', NOW + 60_000, 'ws')
const { sent, submit, offer } = await lateReopen({
providerSession: { id: 'sess-1' },
state: 'done'
})
expect(sent).toBe(0)
expect(submit).not.toHaveBeenCalled()
expect(offer).toHaveBeenCalledTimes(1)
})
it('a re-arm stops the older wait loop, so continue is never sent twice', async () => {
state.agentStatusByPaneKey = { 'tab-a:1': working('tab-a:1', 'claude') }
armInterruptedTurnsForHostRestart('ws', NOW)
let clock = NOW
const send = vi.fn(async () => true)
const first = continueInterruptedTurns('ws', {
now: () => clock,
sleep: async (ms: number) => {
clock += ms
// A second restart arms the pane again while the first loop is still waiting.
if (clock === NOW + 5_000) {
armInterruptedTurnsForHostRestart('ws', clock)
}
},
ptyIdFor: () => (clock >= NOW + 20_000 ? 'pty' : null),
send,
submit: vi.fn(async () => true),
agentInForeground: async () => true
})
expect(sent).toBe(1)
expect(send).toHaveBeenCalledTimes(1)
expect(send.mock.calls[0]![0]).toMatchObject({ ptyId: 'pty-reopened', prompt: 'continue' })
expect(await first).toBe(0)
expect(send).not.toHaveBeenCalled()
})
it('gives up on a pane that is never reopened while the turn is fresh', async () => {
@@ -6,6 +6,7 @@
// a sleep: those are the restarts that cut a working turn. (A pane's sleeping record is no signal
// here: it can outlive the wake that resumed the agent.)
import { useAppStore } from '@/store'
import { offerInterruptedTurnContinue } from './interrupted-turn-continue-offer'
import { getPtyIdForPaneKey } from '@/hooks/pane-key-pty-id'
import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-session/messaging/paste/agent-followup-delivery'
import { sendBracketedPasteToRunningAgent } from '@/lib/agent-session/messaging/paste/agent-paste-draft'
@@ -19,7 +20,10 @@ import {
isInterruptedTurnContinued,
markInterruptedTurnContinued
} from '@/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup'
import { paneHadUserInputSince } from '@/components/terminal-pane/input/wake-input-buffer'
import {
paneHadUserInputSince,
workspaceHadUserInputSince
} from '@/components/terminal-pane/input/wake-input-buffer'
import { inspectRuntimeTerminalProcess } from '@/runtime/hosted-terminals/inspection/runtime-terminal-process-inspection'
import {
isAgentForegroundWrapperProcess,
@@ -27,6 +31,9 @@ import {
} from '../../../../../shared/agent-status/process/agent-process-recognition'
type ArmedTurn = {
workspaceId: string
/** The agent session that was working: a late relaunch must resume this one, not a new one. */
providerSessionId: string | null
paneKey: string
agent: TuiAgent
turnKey: string
@@ -35,6 +42,10 @@ type ArmedTurn = {
terminalHandle: string | null
/** The pane's PTY when armed (the relaunch may reuse the id; it is only a hint). */
ptyIdAtArm: string | null
/** This pane's arm generation; a newer arm or a disarm makes this loop stale. */
generation?: number
/** Set by continueInterruptedTurns: the restart this turn was armed for has settled. */
settledAt?: number
}
/** How long after its pane is open a relaunched agent may take to come up. */
@@ -50,6 +61,14 @@ const CLOSED_PANE_POLL_MS = 5_000
const SETTLE_AFTER_REPORT_MS = 2_000
const armedByWorkspace = new Map<string, ArmedTurn[]>()
/** Bumped by every arm/disarm of a pane: an older wait loop for that pane stops at its next step. */
const paneGeneration = new Map<string, number>()
const bumpPane = (paneKey: string): number => {
const next = (paneGeneration.get(paneKey) ?? 0) + 1
paneGeneration.set(paneKey, next)
return next
}
/** A relaunch first seen this long after the restart is "late": the person reopened the pane. */
/** Dev builds only: what the continue logic decided, for e2e drivers (window.__orcaContinueTrace). */
const devTrace: string[] | null = import.meta.env?.DEV && typeof window !== 'undefined' ? [] : null
@@ -70,6 +89,7 @@ type StatusEntry = {
tabId?: string
paneKey: string
terminalHandle?: string
providerSession?: { id?: string }
}
function paneWorkspaceId(entry: StatusEntry, tabWorkspace: Map<string, string>): string | null {
@@ -102,6 +122,8 @@ export function armInterruptedTurnsForHostRestart(workspaceId: string, now = Dat
continue
}
armed.push({
workspaceId,
providerSessionId: entry.providerSession?.id ?? null,
paneKey: entry.paneKey,
agent,
turnKey: interruptedTurnKey(entry, entry.paneKey),
@@ -113,6 +135,12 @@ export function armInterruptedTurnsForHostRestart(workspaceId: string, now = Dat
trace(
`arm ${workspaceId.slice(-12)} ${armed.map((turn) => `${turn.agent}:${turn.paneKey.slice(-4)}`).join(',')}`
)
for (const turn of armedByWorkspace.get(workspaceId) ?? []) {
bumpPane(turn.paneKey)
}
for (const turn of armed) {
turn.generation = bumpPane(turn.paneKey)
}
if (armed.length > 0) {
armedByWorkspace.set(workspaceId, armed)
} else {
@@ -123,6 +151,9 @@ export function armInterruptedTurnsForHostRestart(workspaceId: string, now = Dat
/** The restart failed or was abandoned: forget what was armed. */
export function disarmInterruptedTurns(workspaceId: string): void {
for (const turn of armedByWorkspace.get(workspaceId) ?? []) {
bumpPane(turn.paneKey)
}
armedByWorkspace.delete(workspaceId)
}
@@ -159,9 +190,18 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise<boolean
return false
}
const freshUntil = turn.armedAt + AGENT_STATUS_STALE_AFTER_MS
const settledAt = turn.settledAt ?? deps.now()
// Starts when the pane is (re)opened: only then can its agent relaunch and read input.
let relaunchDeadline: number | null = null
const stale = (): boolean =>
turn.generation !== undefined && paneGeneration.get(turn.paneKey) !== turn.generation
const userTookOver = (): boolean =>
isInterruptedTurnContinued(turn.turnKey) ||
paneHadUserInputSince(leafOf(turn.paneKey), turn.armedAt)
while (deps.now() < Math.min(freshUntil, relaunchDeadline ?? freshUntil)) {
if (stale()) {
return false
}
if (
continuedOnItsOwn(turn.paneKey, turn.armedAt) ||
// A cold-restore resume already carried `continue`, or a person typed into the pane since.
@@ -178,6 +218,20 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise<boolean
continue
}
relaunchDeadline ??= deps.now() + RELAUNCH_WAIT_MS
// Reopened long after the restart settled: the person may only want to read. Continue only the
// same agent session, and only if nobody has done anything in the workspace since; otherwise
// leave an offer instead of typing into it.
if (deps.now() - settledAt > RELAUNCH_WAIT_MS) {
if (!relaunchReport(turn)) {
// Can't tell which session came back until the relaunched agent reports.
await deps.sleep(POLL_MS)
continue
}
if (!lateContinueAllowed(turn)) {
deps.offer?.(turn)
return false
}
}
// The relaunched agent already reported (its hooks only run inside the agent), so its TUI is
// up and reading input: submit once it has settled and owns the terminal. Some agents (Codex)
// resume idle and never emit the paste-readiness signal the generic path waits for.
@@ -189,7 +243,8 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise<boolean
if (
deps.now() - reported.updatedAt >= SETTLE_AFTER_REPORT_MS &&
(await deps.agentInForeground(ptyId, turn.agent)) &&
!isInterruptedTurnContinued(turn.turnKey) &&
!stale() &&
!userTookOver() &&
(await deps.submit({
ptyId,
content: INTERRUPTED_TURN_CONTINUE_PROMPT
@@ -199,7 +254,7 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise<boolean
markInterruptedTurnContinued(turn.turnKey)
return true
}
} else {
} else if (!stale() && !userTookOver()) {
const sent = await deps.send({
ptyId,
agent: turn.agent,
@@ -217,6 +272,16 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise<boolean
return false
}
/** A late relaunch: the same agent session (resume, not a new chat) and no activity since. */
function lateContinueAllowed(turn: ArmedTurn): boolean {
if (workspaceHadUserInputSince(turn.workspaceId, turn.armedAt)) {
return false
}
const report = relaunchReport(turn)
const sessionId = report?.providerSession?.id ?? null
return turn.providerSessionId !== null && sessionId === turn.providerSessionId
}
type ContinueDeps = {
now: () => number
sleep: (ms: number) => Promise<void>
@@ -225,6 +290,8 @@ type ContinueDeps = {
submit: typeof sendBracketedPasteToRunningAgent
/** The relaunched agent (not a shell) is the terminal's foreground process. */
agentInForeground: (ptyId: string, agent: TuiAgent) => Promise<boolean>
/** Late reopen that may not be typed into: offer "Continue" instead. */
offer?: (turn: ArmedTurn) => void
}
async function agentOwnsTerminal(ptyId: string, agent: TuiAgent): Promise<boolean> {
@@ -245,7 +312,8 @@ const defaultDeps: ContinueDeps = {
ptyIdFor: getPtyIdForPaneKey,
send: sendFollowupPromptWhenAgentReady,
submit: sendBracketedPasteToRunningAgent,
agentInForeground: agentOwnsTerminal
agentInForeground: agentOwnsTerminal,
offer: (turn) => offerInterruptedTurnContinue(turn.paneKey, turn.turnKey)
}
/** The host is back: send `continue` to every armed agent once its relaunched TUI reads input. */
@@ -259,6 +327,10 @@ export async function continueInterruptedTurns(
return 0
}
trace(`continue ${workspaceId.slice(-12)} ${armed.length}`)
const settledAt = deps.now()
for (const turn of armed) {
turn.settledAt = settledAt
}
const results = await Promise.all(
armed.map((turn) =>
continueOne(turn, deps)
@@ -275,4 +347,5 @@ export async function continueInterruptedTurns(
export function resetInterruptedTurnContinueForTests(): void {
armedByWorkspace.clear()
sentTurns.clear()
paneGeneration.clear()
}
@@ -1,4 +1,5 @@
// Window side of Orca Cloud's session lifecycle: cap warnings, rollovers and idle sleep from main.
import { offerPendingCloudTransfers } from '@/lib/orca-cloud-transfer-flow'
import {
armInterruptedTurnsForHostRestart,
continueInterruptedTurns,
@@ -46,6 +47,8 @@ function restartNow(workspaceId: string): Promise<HostedSessionRestartResult> {
}
export function installOrcaCloudSessionEvents(): () => void {
// Moves left part-applied before a restart: offer "Apply changes again" once the window is up.
void offerPendingCloudTransfers().catch(() => 0)
const api = window.api?.ephemeralVm
if (!api?.onCloudSessionWarning || !api.onCloudIdleSleepRequested) {
return () => {}
@@ -32,6 +32,21 @@ export const MOVE_TIMEOUT_MS = 15 * 60_000
let inFlightRun: number | null = null
let runCounter = 0
/** Shared with "Apply changes again": one move or re-apply at a time. */
export function claimMoveRun(): number | null {
if (inFlightRun !== null) {
return null
}
inFlightRun = ++runCounter
return inFlightRun
}
export function releaseMoveRun(run: number): void {
if (inFlightRun === run) {
inFlightRun = null
}
}
export async function runOrcaCloudTransferToLocal(
workspaceId: string,
name: string,
@@ -178,6 +193,8 @@ export async function runOrcaCloudTransferToLocal(
}
noteCloudWorkspaceMoved(workspaceId, Date.now(), worktree.id)
const hidden = isHidden()
// Never archive while something is left to apply: the cloud copy is the other place it lives.
const archiveNow = archiveAfter && !finished.value.leftoverPatchDir
setCloudMoveState({
stage: 'done',
workspaceId,
@@ -186,14 +203,14 @@ export async function runOrcaCloudTransferToLocal(
prepared: prepared.value,
finished: finished.value,
elapsedMs: Date.now() - startedAt,
archivedAfter: archiveAfter,
archivedAfter: archiveNow,
hidden
})
// Hidden: the user kept working elsewhere; the notice offers Open instead of jumping there.
if (!hidden) {
activateAndRevealWorktree(worktree.id)
}
if (archiveAfter) {
if (archiveNow) {
options.onArchive?.(workspaceId, name)
}
} catch (error) {
@@ -221,71 +238,4 @@ export async function runOrcaCloudTransferToLocal(
}
}
/**
* "Apply changes again": re-applies the saved changes and chats onto the SAME local worktree after
* a partial or failed apply (never creates another one). Main skips what is already there and never
* forces a patch over edits made here since; whatever still conflicts stays saved and is reported.
*/
export async function reapplyOrcaCloudTransfer(args: {
workspaceId: string
name: string
localWorktreeId: string
prepared: CloudTransferPrepared
worktreePath: string
archiveAfter: boolean
}): Promise<void> {
const api = window.api?.orcaCloudWorkspace
if (!api?.reapplyTransferToLocal || inFlightRun !== null) {
return
}
const run = ++runCounter
inFlightRun = run
const startedAt = Date.now()
const base = { workspaceId: args.workspaceId, name: args.name, archiveAfter: args.archiveAfter }
setCloudMoveState({
...base,
stage: 'running',
step: 'applying',
skipped: ['waking', 'packing', 'downloading', 'creating'],
startedAt,
bytes: null,
totalBytes: null,
hidden: false
})
try {
const result = await api.reapplyTransferToLocal({
transferId: args.prepared.transferId,
worktreePath: args.worktreePath
})
if (!result.ok) {
throw new Error(result.message)
}
noteCloudWorkspaceMoved(args.workspaceId, Date.now(), args.localWorktreeId)
setCloudMoveState({
stage: 'done',
workspaceId: args.workspaceId,
name: args.name,
localWorktreeId: args.localWorktreeId,
prepared: args.prepared,
finished: result.value,
elapsedMs: Date.now() - startedAt,
archivedAfter: false,
hidden: false
})
} catch (error) {
console.error('[orca-cloud] apply changes again failed', error)
setCloudMoveState({
...base,
stage: 'failed',
step: 'applying',
timedOut: false,
localWorktreeId: args.localWorktreeId,
retry: { prepared: args.prepared, worktreePath: args.worktreePath },
hidden: false
})
} finally {
if (inFlightRun === run) {
inFlightRun = null
}
}
}
export { offerPendingCloudTransfers, reapplyOrcaCloudTransfer } from './orca-cloud-transfer-reapply'
@@ -0,0 +1,122 @@
// "Apply changes again" for "Move to this computer": re-applies a move's saved changes onto the SAME
// local worktree after a partial or failed apply, and offers it again after an app restart.
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { noteCloudWorkspaceMoved, setCloudMoveState } from '@/lib/orca-cloud/orca-cloud-move-store'
import type { CloudTransferPrepared } from '../../../shared/hosted-sandbox/cloud-transfer-api'
import { claimMoveRun, releaseMoveRun } from './orca-cloud-transfer-flow'
/**
* "Apply changes again": re-applies the saved changes and chats onto the SAME local worktree after
* a partial or failed apply (never creates another one). Main skips what is already there and never
* forces a patch over edits made here since; whatever still conflicts stays saved and is reported.
*/
export async function reapplyOrcaCloudTransfer(args: {
workspaceId: string
name: string
localWorktreeId: string
prepared: CloudTransferPrepared
worktreePath: string
archiveAfter: boolean
}): Promise<void> {
const api = window.api?.orcaCloudWorkspace
const run = api?.reapplyTransferToLocal ? claimMoveRun() : null
if (!api || run === null) {
return
}
const startedAt = Date.now()
const base = { workspaceId: args.workspaceId, name: args.name, archiveAfter: args.archiveAfter }
setCloudMoveState({
...base,
stage: 'running',
step: 'applying',
skipped: ['waking', 'packing', 'downloading', 'creating'],
startedAt,
bytes: null,
totalBytes: null,
hidden: false
})
try {
const result = await api.reapplyTransferToLocal({
transferId: args.prepared.transferId,
worktreePath: args.worktreePath
})
if (!result.ok) {
throw new Error(result.message)
}
noteCloudWorkspaceMoved(args.workspaceId, Date.now(), args.localWorktreeId)
setCloudMoveState({
stage: 'done',
workspaceId: args.workspaceId,
name: args.name,
localWorktreeId: args.localWorktreeId,
prepared: args.prepared,
finished: result.value,
elapsedMs: Date.now() - startedAt,
archivedAfter: false,
hidden: false
})
} catch (error) {
console.error('[orca-cloud] apply changes again failed', error)
setCloudMoveState({
...base,
stage: 'failed',
step: 'applying',
timedOut: false,
localWorktreeId: args.localWorktreeId,
retry: { prepared: args.prepared, worktreePath: args.worktreePath },
hidden: false
})
} finally {
releaseMoveRun(run)
}
}
/**
* After an app restart: moves whose changes were not all applied are kept on disk. Offer
* "Apply changes again" for each (same local worktree; never a new one).
*/
export async function offerPendingCloudTransfers(): Promise<number> {
const api = window.api?.orcaCloudWorkspace
if (!api?.listPendingTransfers || !api.reapplyTransferToLocal) {
return 0
}
const pending = await api.listPendingTransfers().catch(() => [])
for (const transfer of pending) {
toast.warning(
translate('orcaCloudMove.pending.title', 'Some changes from a move weren’t applied'),
{
id: `orca-cloud-move-pending-${transfer.transferId}`,
description: translate(
'orcaCloudMove.pending.detail',
'They are saved on this computer. Nothing you changed here is overwritten.'
),
duration: Infinity,
action: {
label: translate('orcaCloudMove.reapply', 'Apply changes again'),
onClick: () =>
void api
.reapplyTransferToLocal({
transferId: transfer.transferId,
worktreePath: transfer.worktreePath
})
.then((result) => {
if (!result.ok) {
toast.error(result.message)
} else if (result.value.leftoverPatchDir) {
toast.warning(
translate(
'orcaCloudMove.pending.stillLeft',
'Some changes still conflict with edits made here. They stay saved.'
)
)
} else {
toast.success(translate('orcaCloudMove.pending.done', 'All changes applied'))
}
})
}
}
)
}
return pending.length
}
@@ -52,6 +52,16 @@ export type CloudTransferFinished = {
skippedUntracked?: string[]
}
/** A move whose changes were not all applied; kept on disk so "Apply changes again" can retry. */
export type CloudTransferPending = {
transferId: string
/** The cloud workspace it was moved from. */
workspaceId: string
/** The local worktree it was applied to. */
worktreePath: string
leftoverDir: string
}
export type CloudTransferResult<T> =
| { ok: true; value: T }
| { ok: false; code: string; message: string }
@@ -70,6 +80,8 @@ export type OrcaCloudTransferApi = {
transferId: string
worktreePath: string
}) => Promise<CloudTransferResult<CloudTransferFinished>>
/** Moves with changes still to apply, kept across app restarts. */
listPendingTransfers: () => Promise<CloudTransferPending[]>
onTransferProgress: (callback: (event: CloudTransferProgressEvent) => void) => () => void
}
@@ -77,5 +89,6 @@ export const ORCA_CLOUD_TRANSFER_CHANNELS = {
prepare: 'orcaCloudWorkspace:prepareTransferToLocal',
finish: 'orcaCloudWorkspace:finishTransferToLocal',
reapply: 'orcaCloudWorkspace:reapplyTransferToLocal',
listPending: 'orcaCloudWorkspace:listPendingTransfers',
progress: 'orcaCloudWorkspace:transferProgress'
} as const