From c267ae367617d9ec1a555249b67f5686ee29c744 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 20:04:14 -0800 Subject: [PATCH] feat(cloud-sync): sync .context and an optional read-only mirror from cloud workspaces to this computer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - orcad: files.statMany (batched stat, up to 2,000 paths) for cheap manifests; older images fall back to files.stat - main: per-workspace sync sessions (full pass on start/reconnect/wake, files.watch-driven incremental passes), read-only copies under ~/orca/cloud//, gitignore-aware via files.listAll, bounded (.context 250 MB / 50 MB per file; mirror 2 GB / 100 MB per file / 100k files), never wakes a sandbox, stops when asleep, removes the copy on archive/delete - window: 'On this computer' section in the cloud status panel, '⋯ → Mirror to this computer…', removal line in the archive toast and delete dialog, confirm before removing changed copies --- .../cloud-sync-fake-vm.test-support.ts | 139 +++++++ src/main/cloud-sync/cloud-sync-ipc.ts | 178 +++++++++ src/main/cloud-sync/cloud-sync-local-copy.ts | 105 +++++ .../cloud-sync/cloud-sync-local-writes.ts | 173 +++++++++ src/main/cloud-sync/cloud-sync-paths.test.ts | 75 ++++ src/main/cloud-sync/cloud-sync-paths.ts | 117 ++++++ src/main/cloud-sync/cloud-sync-plan.test.ts | 110 ++++++ src/main/cloud-sync/cloud-sync-plan.ts | 162 ++++++++ src/main/cloud-sync/cloud-sync-remote.ts | 307 +++++++++++++++ .../cloud-sync/cloud-sync-service.test.ts | 164 ++++++++ src/main/cloud-sync/cloud-sync-service.ts | 310 +++++++++++++++ .../cloud-sync/cloud-sync-session.test.ts | 219 +++++++++++ src/main/cloud-sync/cloud-sync-session.ts | 320 +++++++++++++++ src/main/cloud-sync/cloud-sync-store.ts | 121 ++++++ src/main/cloud-sync/cloud-sync-watch.ts | 122 ++++++ .../hosted-sandbox/hosted-sandbox-install.ts | 11 +- .../hosted-workspace-service.ts | 3 + .../runtime-file-command-surface.ts | 2 + .../files/file-access.stat-many.test.ts | 45 +++ src/main/runtime/files/file-access.ts | 54 ++- .../methods/files/files-stat-many-methods.ts | 13 + .../rpc/methods/rpc-method-registry.ts | 2 + src/preload/api-types.ts | 3 + src/preload/api/cloud-sync-bridge.ts | 19 + src/preload/index.ts | 2 + .../orca-cloud/CloudWorkspaceMark.tsx | 10 + .../orca-cloud/CloudWorkspaceMenuItems.tsx | 9 +- .../orca-cloud/CloudWorkspaceStatusPanel.tsx | 3 + .../CloudWorkspaceSyncSection.fixture.tsx | 155 ++++++++ .../orca-cloud/CloudWorkspaceSyncSection.tsx | 367 ++++++++++++++++++ .../orca-cloud/cloud-workspace-actions.ts | 34 +- .../deletion/DeleteCloudWorkspaceParts.tsx | 34 ++ .../src/i18n/en-runtime-required.json | 11 + src/renderer/src/i18n/locales/en.json | 43 +- .../src/lib/orca-cloud/cloud-sync-store.ts | 131 +++++++ src/shared/cloud-sync/cloud-sync-contract.ts | 69 ++++ src/shared/file-access/file-stat-many.ts | 10 + .../languages/protected-copy-keys.json | 2 + src/shared/rpc-contract/files-params.ts | 5 + .../rpc-params-catalog.generated.ts | 2 + 40 files changed, 3653 insertions(+), 8 deletions(-) create mode 100644 src/main/cloud-sync/cloud-sync-fake-vm.test-support.ts create mode 100644 src/main/cloud-sync/cloud-sync-ipc.ts create mode 100644 src/main/cloud-sync/cloud-sync-local-copy.ts create mode 100644 src/main/cloud-sync/cloud-sync-local-writes.ts create mode 100644 src/main/cloud-sync/cloud-sync-paths.test.ts create mode 100644 src/main/cloud-sync/cloud-sync-paths.ts create mode 100644 src/main/cloud-sync/cloud-sync-plan.test.ts create mode 100644 src/main/cloud-sync/cloud-sync-plan.ts create mode 100644 src/main/cloud-sync/cloud-sync-remote.ts create mode 100644 src/main/cloud-sync/cloud-sync-service.test.ts create mode 100644 src/main/cloud-sync/cloud-sync-service.ts create mode 100644 src/main/cloud-sync/cloud-sync-session.test.ts create mode 100644 src/main/cloud-sync/cloud-sync-session.ts create mode 100644 src/main/cloud-sync/cloud-sync-store.ts create mode 100644 src/main/cloud-sync/cloud-sync-watch.ts create mode 100644 src/main/runtime/files/file-access.stat-many.test.ts create mode 100644 src/main/runtime/rpc/methods/files/files-stat-many-methods.ts create mode 100644 src/preload/api/cloud-sync-bridge.ts create mode 100644 src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.fixture.tsx create mode 100644 src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.tsx create mode 100644 src/renderer/src/lib/orca-cloud/cloud-sync-store.ts create mode 100644 src/shared/cloud-sync/cloud-sync-contract.ts create mode 100644 src/shared/file-access/file-stat-many.ts diff --git a/src/main/cloud-sync/cloud-sync-fake-vm.test-support.ts b/src/main/cloud-sync/cloud-sync-fake-vm.test-support.ts new file mode 100644 index 00000000000..369e82a64da --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-fake-vm.test-support.ts @@ -0,0 +1,139 @@ +// An in-memory cloud workspace that answers the orcad file RPCs cloud sync uses, so the real +// session (planner, writes, watch handling) runs against temp folders without a sandbox. +import { z } from 'zod' +import type { SyncCall } from './cloud-sync-remote' + +const ParamsSchema = z.looseObject({ + relativePath: z.string().optional(), + relativePaths: z.array(z.string()).optional(), + candidatePaths: z.array(z.string()).optional(), + offset: z.number().optional(), + length: z.number().optional() +}) +import type { WatchSubscribe } from './cloud-sync-session' + +export const VM_ROOT = '/vercel/sandbox/project' + +export type FakeVm = { + files: Map + ignored: Set + calls: string[] + statManySupported: boolean + asleep: boolean + write: (path: string, content: string, mtime?: number) => void + remove: (path: string) => void + /** Delivers a watch batch to every open subscription. */ + emitWatch: (events: unknown[]) => void + call: SyncCall + subscribe: WatchSubscribe + openWatches: () => number +} + +function codeError(code: string, message = code): Error { + return Object.assign(new Error(message), { code }) +} + +export function createFakeVm(): FakeVm { + const files = new Map() + const ignored = new Set() + const calls: string[] = [] + const watches = new Set<(event: unknown) => void>() + let clock = 1_000 + const vm: FakeVm = { + files, + ignored, + calls, + statManySupported: true, + asleep: false, + write(path, content, mtime) { + files.set(path, { content: Buffer.from(content), mtime: mtime ?? ++clock }) + }, + remove(path) { + files.delete(path) + }, + emitWatch(events) { + for (const listener of watches) { + listener({ type: 'changed', worktree: 'id:ws', events }) + } + }, + openWatches: () => watches.size, + call: async (method: string, params: unknown): Promise => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test double; the session validates every reply with zod, as it does real orcad replies. + return (await answer(method, params)) as T + }, + subscribe: async (_selector, handlers) => { + watches.add(handlers.onEvent) + handlers.onEvent({ type: 'ready' }) + return { close: () => watches.delete(handlers.onEvent) } + } + } + async function answer(method: string, params: unknown): Promise { + calls.push(method) + if (vm.asleep) { + throw codeError('runtime_environment_asleep') + } + const p = ParamsSchema.parse(params) + switch (method) { + case 'worktree.list': + return { worktrees: [{ id: 'repo::ws', path: VM_ROOT }] } + case 'files.readDir': { + const dir = String(p.relativePath) + const names = new Map() + for (const path of files.keys()) { + if (path.startsWith(`${dir}/`)) { + const rest = path.slice(dir.length + 1) + const [head, ...tail] = rest.split('/') + names.set(head!, tail.length > 0 || names.get(head!) === true) + } + } + if (names.size === 0) { + throw new Error(`ENOENT: no such file or directory, scandir '${dir}'`) + } + return [...names].map(([name, isDirectory]) => ({ name, isDirectory, isSymlink: false })) + } + case 'files.listAll': { + const candidates = p.candidatePaths + return [...files.keys()].filter( + (path) => + !ignored.has(path) && + !path.startsWith('.context/') && + (!candidates || candidates.includes(path)) + ) + } + case 'files.statMany': { + if (!vm.statManySupported) { + throw codeError('method_not_found', 'Unknown method: files.statMany') + } + return { + entries: (p.relativePaths ?? []).map((path) => { + const file = files.get(path) + return file ? { size: file.content.length, mtime: file.mtime, type: 'file' } : null + }) + } + } + case 'files.stat': { + const file = files.get(String(p.relativePath)) + if (!file) { + throw new Error('ENOENT: no such file') + } + return { size: file.content.length, mtime: file.mtime, isDirectory: false } + } + case 'files.readChunk': { + const file = files.get(String(p.relativePath)) + if (!file) { + throw new Error('ENOENT: no such file') + } + const offset = Number(p.offset) + const slice = file.content.subarray(offset, offset + Number(p.length)) + return { + contentBase64: slice.toString('base64'), + bytesRead: slice.length, + eof: offset + slice.length >= file.content.length + } + } + default: + throw codeError('method_not_found', `Unknown method: ${method}`) + } + } + return vm +} diff --git a/src/main/cloud-sync/cloud-sync-ipc.ts b/src/main/cloud-sync/cloud-sync-ipc.ts new file mode 100644 index 00000000000..ba378f0e723 --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-ipc.ts @@ -0,0 +1,178 @@ +// Wires cloud → this computer sync into main: the orcad transport (on its own queue lane, so sync +// never competes with the window's reads), lifecycle signals, and the window's IPC. +import { mkdir } from 'node:fs/promises' +import { ipcMain, shell, type BrowserWindow } from 'electron' +import { onEphemeralVmRuntimesChanged } from '../../shared/ephemeral-vm-runtime-store-events' +import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store' +import { ORCA_CLOUD_SYNC_CHANNELS as CH } from '../../shared/cloud-sync/cloud-sync-contract' +import { + callRuntimeEnvironment, + subscribeRuntimeEnvironment +} from '../ipc/runtime-environments/runtime-environment-transport-routing' +import { isCloudWorkspaceArchivedOnControlPlane } from '../hosted-sandbox/hosted-workspace-runtime' +import { subscribeSystemPowerLifecycle } from '../power/system-power-lifecycle' +import type { SyncCall } from './cloud-sync-remote' +import type { WatchSubscribe } from './cloud-sync-session' +import { createCloudSyncService, type CloudSyncService } from './cloud-sync-service' +import { createCloudSyncStore } from './cloud-sync-store' + +const DEFAULT_CALL_TIMEOUT_MS = 60_000 +/** Picks up runtime records that changed without an event, and the archived-elsewhere check. */ +const BEAT_MS = 60_000 + +function callFor(userDataPath: string, environmentId: string): SyncCall { + return async ( + method: string, + params: unknown, + options?: { timeoutMs?: number; signal?: AbortSignal } + ): Promise => { + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + method, + params, + options?.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, + undefined, + undefined, + { lane: 'download', ...(options?.signal ? { signal: options.signal } : {}) } + ) + if (response.ok === true) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result type of the orcad method it calls, and cloud-sync-remote validates every reply with zod before use. + return response.result as T + } + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } +} + +function watchFor(userDataPath: string, environmentId: string): WatchSubscribe { + return async (selector, handlers) => { + let closed = false + const close = (): void => { + if (!closed) { + closed = true + handlers.onClose() + } + } + const subscription = await subscribeRuntimeEnvironment( + userDataPath, + environmentId, + 'files.watch', + { worktree: selector, batchCadence: 'interval' }, + undefined, + { + onEvent: (payload) => { + if (payload.type === 'response') { + if (payload.response.ok === true) { + handlers.onEvent(payload.response.result) + } else { + close() + } + } else if (payload.type === 'error' || payload.type === 'close') { + close() + } + }, + onClose: close + } + ) + return { + close: () => { + // Our own close is not a drop: no retry. + closed = true + subscription.close() + } + } + } +} + +let service: CloudSyncService | null = null + +/** The running sync service (null before install or when Orca Cloud is off). */ +export function getCloudSyncService(): CloudSyncService | null { + return service +} + +export function installCloudSync(deps: { + userDataPath: string + windows: () => readonly BrowserWindow[] +}): () => void { + const store = createCloudSyncStore(deps.userDataPath) + const sync = createCloudSyncService({ + store, + listRuntimes: () => listEphemeralVmRuntimes(deps.userDataPath), + callFor: (environmentId) => callFor(deps.userDataPath, environmentId), + watchFor: (environmentId) => watchFor(deps.userDataPath, environmentId), + isArchived: (workspaceId) => isCloudWorkspaceArchivedOnControlPlane(workspaceId), + broadcast: (view) => { + for (const window of deps.windows()) { + if (!window.isDestroyed()) { + window.webContents.send(CH.changed, view) + } + } + }, + openPath: async (path) => { + // The folder line shows this path; make sure there is a folder to open (empty .context). + await mkdir(path, { recursive: true }) + return (await shell.openPath(path)) === '' + } + }) + service = sync + + for (const channel of [CH.getState, CH.setChoice, CH.openFolder, CH.retry, CH.removalPreview]) { + ipcMain.removeHandler(channel) + } + ipcMain.handle(CH.getState, (_event, workspaceId: unknown) => + typeof workspaceId === 'string' ? sync.getState(workspaceId) : null + ) + ipcMain.handle( + CH.setChoice, + (_event, args: { workspaceId?: unknown; context?: unknown; mirror?: unknown }) => { + if (typeof args?.workspaceId !== 'string') { + return null + } + return sync.setChoice(args.workspaceId, { + ...(typeof args.context === 'boolean' ? { context: args.context } : {}), + ...(typeof args.mirror === 'boolean' ? { mirror: args.mirror } : {}) + }) + } + ) + ipcMain.handle(CH.openFolder, (_event, workspaceId: unknown) => + typeof workspaceId === 'string' ? sync.openFolder(workspaceId) : { ok: false } + ) + ipcMain.handle(CH.retry, (_event, workspaceId: unknown) => + typeof workspaceId === 'string' ? sync.retry(workspaceId) : undefined + ) + ipcMain.handle(CH.removalPreview, (_event, workspaceId: unknown) => + typeof workspaceId === 'string' + ? sync.removalPreview(workspaceId) + : { hasLocalCopy: false, editedHere: 0 } + ) + + const offRuntimes = onEphemeralVmRuntimesChanged(() => void sync.reconcile()) + let sawSuspend = false + const offPower = subscribeSystemPowerLifecycle({ + onSuspend: () => { + sawSuspend = true + }, + // Fires once on subscribe with the current (awake) state; only a real wake resyncs. + onResume: () => { + if (sawSuspend) { + sawSuspend = false + sync.noteSystemResumed() + } + } + }) + const beat = setInterval(() => void sync.reconcile(), BEAT_MS) + beat.unref?.() + void sync.reconcile() + + return () => { + clearInterval(beat) + offRuntimes() + offPower() + for (const channel of [CH.getState, CH.setChoice, CH.openFolder, CH.retry, CH.removalPreview]) { + ipcMain.removeHandler(channel) + } + service = null + void sync.dispose() + } +} diff --git a/src/main/cloud-sync/cloud-sync-local-copy.ts b/src/main/cloud-sync/cloud-sync-local-copy.ts new file mode 100644 index 00000000000..0aa26661193 --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-local-copy.ts @@ -0,0 +1,105 @@ +// A workspace's copy on this computer as a whole: where it is (from the runtime record), what +// removing it would take with it, and removing it (archive, delete, both rows off). +import { rm, rmdir } from 'node:fs/promises' +import { basename, join } from 'node:path' +import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' +import { getEphemeralVmRecipeResultConnection } from '../../shared/ephemeral-vm-recipes' +import type { CloudSyncRemovalPreview } from '../../shared/cloud-sync/cloud-sync-contract' +import { localStatAt, removeCopies } from './cloud-sync-local-writes' +import { SYNC_TEMP_DIR, cloudSyncFolder, isContextPath } from './cloud-sync-paths' +import { isLocalCopyEdited } from './cloud-sync-plan' +import type { CloudSyncStore } from './cloud-sync-store' + +export type CloudRuntime = { + workspaceId: string + environmentId: string + folder: string + projectRoot: string | null + running: boolean + /** Deleted (or being deleted) on this computer. */ + gone: boolean +} + +export function cloudRuntimesFrom( + runtimes: readonly EphemeralVmRuntimeRecord[], + home?: string +): CloudRuntime[] { + const out: CloudRuntime[] = [] + for (const runtime of runtimes) { + if (runtime.recipe?.provider !== 'orca-cloud' || !runtime.workspaceId) { + continue + } + let projectRoot: string | null = null + if (runtime.recipeResult) { + const connection = getEphemeralVmRecipeResultConnection(runtime.recipeResult) + projectRoot = connection.type === 'orca-server' ? (connection.projectRoot ?? null) : null + } + out.push({ + workspaceId: runtime.workspaceId, + environmentId: runtime.runtimeEnvironmentId ?? '', + folder: cloudSyncFolder( + { + repoName: runtime.repoPath ? basename(runtime.repoPath) : undefined, + workspaceName: runtime.workspaceName, + workspaceId: runtime.workspaceId + }, + home + ), + projectRoot, + running: runtime.status === 'running' && Boolean(runtime.runtimeEnvironmentId), + gone: + runtime.status === 'cleaned' || + runtime.status === 'cleanup_pending' || + runtime.status === 'cleanup_failed' + }) + } + return out +} + +/** + * Removes what this computer copied (per its index) and the folders that leaves empty. Files + * someone added here stay, and so does any folder that still holds them. + */ +export async function removeLocalCopy( + store: CloudSyncStore, + workspaceId: string, + folder: string +): Promise { + const index = await store.readIndex(workspaceId, folder) + await removeCopies(folder, Object.keys(index.entries), index.entries) + await rm(join(folder, SYNC_TEMP_DIR), { recursive: true, force: true }).catch(() => undefined) + await rmdir(join(folder, '.context')).catch(() => undefined) + await rmdir(folder).catch(() => undefined) + await rmdir(join(folder, '..')).catch(() => undefined) + await store.removeIndex(workspaceId) +} + +/** Mirror turned off while asleep: the mirrored files go now (they'd only get staler). */ +export async function removeMirroredFiles( + store: CloudSyncStore, + workspaceId: string, + folder: string +): Promise { + const index = await store.readIndex(workspaceId, folder) + const outside = Object.keys(index.entries).filter((path) => !isContextPath(path)) + if (outside.length > 0) { + await removeCopies(folder, outside, index.entries) + await store.writeIndex(workspaceId, index) + } +} + +export async function previewRemoval( + store: CloudSyncStore, + workspaceId: string, + folder: string +): Promise { + const index = await store.readIndex(workspaceId, folder) + let editedHere = 0 + for (const [path, entry] of Object.entries(index.entries)) { + const here = await localStatAt(folder, path) + if (isLocalCopyEdited(entry, here)) { + editedHere++ + } + } + return { hasLocalCopy: Object.keys(index.entries).length > 0, editedHere } +} diff --git a/src/main/cloud-sync/cloud-sync-local-writes.ts b/src/main/cloud-sync/cloud-sync-local-writes.ts new file mode 100644 index 00000000000..9e5bfd66838 --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-local-writes.ts @@ -0,0 +1,173 @@ +// The disk half of a sync pass on this computer: copies are paged into a temp file, made +// read-only, then renamed into place; removals touch only files this computer copied. Nothing +// goes through a symlinked parent, so a planted link can't redirect a write or delete. +import { randomUUID } from 'node:crypto' +import { chmod, lstat, mkdir, open, rename, rm, rmdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { transferRuntimeFile } from '../runtime-downloads/runtime-download-transfer' +import { CONTEXT_DIR, SYNC_TEMP_DIR, ensureSafeParent, hasSafeParents } from './cloud-sync-paths' +import { + isLocalCopyEdited, + type IndexEntry, + type LocalStat, + type RemoteEntry +} from './cloud-sync-plan' +import type { SyncCall } from './cloud-sync-remote' + +const DOWNLOAD_CONCURRENCY = 6 +/** files.readChunk's page size (transferRuntimeFile's default). */ +const CHUNK_BYTES = 512 * 1024 +const READ_ONLY_MODE = 0o444 + +export async function localStatAt(folder: string, relativePath: string): Promise { + if (!(await hasSafeParents(folder, relativePath))) { + return null + } + const info = await lstat(join(folder, relativePath)).catch(() => null) + return info && info.isFile() ? { size: info.size, mtimeMs: info.mtimeMs } : null +} + +/** + * Files and bytes the folder holds, and how many copies were changed here. A full pass checks + * every copy (its stats are in `local`); an incremental pass only re-checks its own paths. + */ +export async function summarizeCopies( + folder: string, + entries: Record, + pass: { + local: ReadonlyMap + scoped: ReadonlySet | null + downloaded: ReadonlySet + } +): Promise<{ files: number; bytes: number; edited: number }> { + let files = 0 + let bytes = 0 + let edited = 0 + for (const [path, entry] of Object.entries(entries)) { + files++ + bytes += entry.ls + if (pass.downloaded.has(path) || (pass.scoped && !pass.scoped.has(path))) { + continue + } + const here = pass.scoped ? await localStatAt(folder, path) : (pass.local.get(path) ?? null) + if (here && isLocalCopyEdited(entry, here)) { + edited++ + } + } + return { files, bytes, edited } +} + +/** Removes copies this computer made (per the index) and the directories they leave empty. */ +export async function removeCopies( + folder: string, + paths: readonly string[], + entries: Record +): Promise { + for (const path of paths) { + // Only files this computer copied are ever removed, and only while they are still files. + if (entries[path] && (await localStatAt(folder, path)) !== null) { + await unlink(join(folder, path)).catch(() => undefined) + } + delete entries[path] + } + await pruneEmptyDirs(folder, paths) +} + +/** Directories emptied by removals go too (never the folder itself or `.context`). */ +export async function pruneEmptyDirs(folder: string, removed: readonly string[]): Promise { + const dirs = new Set() + for (const path of removed) { + const parts = path.split('/') + for (let i = parts.length - 1; i > 0; i--) { + dirs.add(parts.slice(0, i).join('/')) + } + } + for (const dir of [...dirs].sort((a, b) => b.length - a.length)) { + if (dir !== CONTEXT_DIR && (await hasSafeParents(folder, `${dir}/x`))) { + await rmdir(join(folder, dir)).catch(() => undefined) + } + } +} + +export type DownloadInput = { + call: SyncCall + selector: string + folder: string + paths: readonly string[] + remote: ReadonlyMap + entries: Record + maxFileBytes: number + signal: AbortSignal + onProgress: (done: number, total: number) => void + /** Saves the index now and then, so a long first copy resumes instead of restarting. */ + checkpoint: () => Promise + log: (line: string) => void +} + +const INDEX_SAVE_EVERY = 200 + +async function copyOne(input: DownloadInput, path: string, entry: RemoteEntry): Promise { + const temp = join(input.folder, SYNC_TEMP_DIR, randomUUID()) + try { + const handle = await open(temp, 'wx', 0o600) + try { + await transferRuntimeFile({ + call: (method, params, options) => input.call(method, params, options), + worktree: input.selector, + relativePath: path, + sink: handle, + signal: input.signal, + maxBytes: input.maxFileBytes, + // A second page in flight only helps files that have one; small files take one read. + concurrency: entry.size > CHUNK_BYTES ? 2 : 1, + onProgress: () => undefined + }) + } finally { + await handle.close() + } + await chmod(temp, READ_ONLY_MODE) + const target = await ensureSafeParent(input.folder, path) + const existing = await lstat(target).catch(() => null) + if (existing && !existing.isFile()) { + // A directory or link sits where the cloud has a file: never replace what we didn't write. + throw new Error('path_conflict') + } + await rename(temp, target) + const written = await lstat(target) + input.entries[path] = { rs: entry.size, rm: entry.mtime, ls: written.size, lm: written.mtimeMs } + } catch (error) { + await rm(temp, { force: true }).catch(() => undefined) + if (!input.signal.aborted) { + // One file that vanished or changed type mid-copy shouldn't fail the pass. + input.log(`skipped ${path}: ${error instanceof Error ? error.message : String(error)}`) + } + } +} + +export async function downloadCopies(input: DownloadInput): Promise { + await mkdir(join(input.folder, SYNC_TEMP_DIR), { recursive: true }) + let done = 0 + let sinceSave = 0 + let next = 0 + const total = input.paths.length + input.onProgress(done, total) + const worker = async (): Promise => { + while (next < input.paths.length && !input.signal.aborted) { + const path = input.paths[next++]! + const entry = input.remote.get(path) + if (entry) { + await copyOne(input, path, entry) + } + done++ + if (++sinceSave >= INDEX_SAVE_EVERY) { + sinceSave = 0 + await input.checkpoint().catch(() => undefined) + } + input.onProgress(done, total) + } + } + await Promise.all(Array.from({ length: Math.min(DOWNLOAD_CONCURRENCY, total) }, worker)) + await rm(join(input.folder, SYNC_TEMP_DIR), { recursive: true, force: true }).catch( + () => undefined + ) +} diff --git a/src/main/cloud-sync/cloud-sync-paths.test.ts b/src/main/cloud-sync/cloud-sync-paths.test.ts new file mode 100644 index 00000000000..483f6a006d4 --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-paths.test.ts @@ -0,0 +1,75 @@ +import { mkdtemp, mkdir, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + cloudSyncFolder, + displayPath, + ensureSafeParent, + folderSegment, + hasSafeParents, + isSafeSyncPath +} from './cloud-sync-paths' + +const dirs: string[] = [] +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('cloud sync paths', () => { + it('puts a workspace at ~/orca/cloud//', () => { + const folder = cloudSyncFolder( + { repoName: 'Hello-World', workspaceName: 'my-ws', workspaceId: 'abc' }, + '/Users/me' + ) + expect(folder).toBe('/Users/me/orca/cloud/Hello-World/my-ws') + expect(displayPath(folder, '/Users/me')).toBe('~/orca/cloud/Hello-World/my-ws') + }) + + it('cleans names into single visible folder names', () => { + expect(folderSegment('../../etc', 'x')).toBe('-..-etc') + expect(folderSegment('.hidden', 'x')).toBe('hidden') + expect(folderSegment('a/b:c', 'x')).toBe('a-b-c') + expect(folderSegment('‮evil', 'x')).toBe('evil') + expect(folderSegment(' ', 'fallback')).toBe('fallback') + }) + + it('accepts ordinary workspace paths', () => { + for (const path of ['README.md', 'src/a.ts', '.context/plan.md', '.github/workflows/ci.yml']) { + expect(isSafeSyncPath(path)).toBe(true) + } + }) + + it('refuses anything that could land outside the folder or in git internals', () => { + for (const path of [ + '', + '/etc/passwd', + '../x', + 'a/../../x', + 'a//b', + './a', + 'a\\b', + '.git/config', + 'sub/.git/hooks/pre-commit', + '.orca-sync-tmp/x', + 'a\u0000b', + 'a‮b' + ]) { + expect(isSafeSyncPath(path)).toBe(false) + } + }) + + it('never creates or follows a symlinked parent', async () => { + const root = await mkdtemp(join(tmpdir(), 'cloud-sync-paths-')) + const outside = await mkdtemp(join(tmpdir(), 'cloud-sync-outside-')) + dirs.push(root, outside) + await symlink(outside, join(root, 'link')) + await expect(ensureSafeParent(root, 'link/file.txt')).rejects.toThrow('unsafe_parent') + expect(await hasSafeParents(root, 'link/file.txt')).toBe(false) + await mkdir(join(root, 'real')) + expect(await ensureSafeParent(root, 'real/deeper/file.txt')).toBe( + join(root, 'real', 'deeper', 'file.txt') + ) + expect(await hasSafeParents(root, 'real/deeper/file.txt')).toBe(true) + }) +}) diff --git a/src/main/cloud-sync/cloud-sync-paths.ts b/src/main/cloud-sync/cloud-sync-paths.ts new file mode 100644 index 00000000000..9fe0630749e --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-paths.ts @@ -0,0 +1,117 @@ +// Where synced files live on this computer and which cloud paths may be written there. The cloud +// side is not trusted: a path that could land outside the folder, or in a git or sync-internal +// directory, is dropped before anything touches the disk. +import { lstat, mkdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, sep } from 'node:path' + +/** `~/orca/cloud//`, beside the `~/orca/workspaces/…` convention. */ +export const CLOUD_SYNC_BASE_SEGMENTS = ['orca', 'cloud'] as const +export const CONTEXT_DIR = '.context' +/** Temp files for in-flight copies; inside the folder so the final rename stays on one volume. */ +export const SYNC_TEMP_DIR = '.orca-sync-tmp' + +const MAX_SEGMENT_LENGTH = 80 +const MAX_PATH_LENGTH = 1024 +// Bidi overrides and zero-width characters could disguise a name in Finder. +// oxlint-disable-next-line no-control-regex -- control characters are exactly what this rejects +const UNSAFE_CHARS = /[\u0000-\u001f\u007f​-‏‪-‮⁦-⁩]/ + +/** One folder name from a repository or workspace name: printable, no separators, not hidden. */ +export function folderSegment(name: string | undefined, fallback: string): string { + const cleaned = (name ?? '') + .normalize('NFC') + .replace(UNSAFE_CHARS, '') + .replace(/[/\\:]/g, '-') + .replace(/^[.\s]+/, '') + .trim() + .slice(0, MAX_SEGMENT_LENGTH) + .trim() + return cleaned.length > 0 ? cleaned : fallback +} + +export function cloudSyncFolder( + args: { repoName?: string; workspaceName?: string; workspaceId: string }, + home: string = homedir() +): string { + return join( + home, + ...CLOUD_SYNC_BASE_SEGMENTS, + folderSegment(args.repoName, 'workspace'), + folderSegment(args.workspaceName, args.workspaceId.slice(0, 12)) + ) +} + +/** `~/orca/cloud/…` for display. */ +export function displayPath(path: string, home: string = homedir()): string { + return path === home || path.startsWith(home + sep) ? `~${path.slice(home.length)}` : path +} + +/** + * A cloud-relative path that is safe to mirror: forward slashes, no empty/`.`/`..` segments, no + * absolute or drive paths, no control characters, never inside `.git` or this sync's temp dir. + */ +export function isSafeSyncPath(path: string): boolean { + if (typeof path !== 'string' || path.length === 0 || path.length > MAX_PATH_LENGTH) { + return false + } + if (path.startsWith('/') || path.includes('\\') || UNSAFE_CHARS.test(path)) { + return false + } + const segments = path.split('/') + for (const segment of segments) { + if (segment === '' || segment === '.' || segment === '..') { + return false + } + if (segment.length > 255) { + return false + } + } + const first = segments[0]! + if (first === '.git' || first === SYNC_TEMP_DIR || segments.includes('.git')) { + return false + } + return true +} + +export function isContextPath(path: string): boolean { + return path === CONTEXT_DIR || path.startsWith(`${CONTEXT_DIR}/`) +} + +/** Every parent of `relativePath` under `root` exists as a real directory (no links). */ +export async function hasSafeParents(root: string, relativePath: string): Promise { + let current = root + for (const segment of relativePath.split('/').slice(0, -1)) { + current = join(current, segment) + const info = await lstat(current).catch(() => null) + if (!info || info.isSymbolicLink() || !info.isDirectory()) { + return false + } + } + return true +} + +/** + * Creates the parent directories of `relativePath` under `root`, refusing any component that is + * a symlink or not a directory, so a planted link can't redirect a write outside the folder. + */ +export async function ensureSafeParent(root: string, relativePath: string): Promise { + const segments = relativePath.split('/') + let current = root + for (const segment of segments.slice(0, -1)) { + current = join(current, segment) + let info = await lstat(current).catch(() => null) + if (!info) { + await mkdir(current).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'EEXIST') { + throw error + } + }) + info = await lstat(current) + } + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error('unsafe_parent') + } + } + return join(root, ...segments) +} diff --git a/src/main/cloud-sync/cloud-sync-plan.test.ts b/src/main/cloud-sync/cloud-sync-plan.test.ts new file mode 100644 index 00000000000..14f41cab2cd --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-plan.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { isLocalCopyEdited, planSync, type IndexEntry, type LocalStat } from './cloud-sync-plan' + +const limits = { maxFiles: 100, maxBytes: 10_000, maxFileBytes: 1_000 } +const entry = (size: number, mtime: number): IndexEntry => ({ + rs: size, + rm: mtime, + ls: size, + lm: 5 +}) + +describe('planSync', () => { + it('copies new and changed files, skips unchanged ones', () => { + const plan = planSync({ + remote: new Map([ + ['a.txt', { size: 10, mtime: 1 }], + ['b.txt', { size: 20, mtime: 2 }], + ['c.txt', { size: 30, mtime: 9 }] + ]), + index: { 'b.txt': entry(20, 2), 'c.txt': entry(30, 3) }, + local: new Map([ + ['b.txt', { size: 20, mtimeMs: 5 }], + ['c.txt', { size: 30, mtimeMs: 5 }] + ]), + limits + }) + expect(plan.download).toEqual(['a.txt', 'c.txt']) + expect(plan.remove).toEqual([]) + expect(plan.totalFiles).toBe(3) + }) + + it('restores a copy someone deleted on this computer', () => { + const plan = planSync({ + remote: new Map([['a.txt', { size: 10, mtime: 1 }]]), + index: { 'a.txt': entry(10, 1) }, + local: new Map([['a.txt', null]]), + limits + }) + expect(plan.download).toEqual(['a.txt']) + }) + + it('keeps a local edit until the cloud changes that file, and reports it', () => { + const edited = planSync({ + remote: new Map([['a.txt', { size: 10, mtime: 1 }]]), + index: { 'a.txt': entry(10, 1) }, + local: new Map([['a.txt', { size: 12, mtimeMs: 99 }]]), + limits + }) + expect(edited.download).toEqual([]) + expect(edited.editedHere).toEqual(['a.txt']) + const cloudChanged = planSync({ + remote: new Map([['a.txt', { size: 11, mtime: 2 }]]), + index: { 'a.txt': entry(10, 1) }, + local: new Map([['a.txt', { size: 12, mtimeMs: 99 }]]), + limits + }) + expect(cloudChanged.download).toEqual(['a.txt']) + }) + + it('removes copies the cloud no longer has, only within the pass scope', () => { + const plan = planSync({ + remote: new Map(), + index: { 'gone.txt': entry(1, 1), 'other.txt': entry(1, 1) }, + local: new Map(), + limits, + scope: new Set(['gone.txt']) + }) + expect(plan.remove).toEqual(['gone.txt']) + }) + + it('skips files over the per-file limit and stops at the folder limits', () => { + const plan = planSync({ + remote: new Map([ + ['huge.bin', { size: 5_000, mtime: 1 }], + ['a', { size: 600, mtime: 1 }], + ['b', { size: 600, mtime: 1 }], + ['c', { size: 100, mtime: 1 }] + ]), + index: {}, + local: new Map(), + limits: { maxFiles: 10, maxBytes: 1_000, maxFileBytes: 1_000 } + }) + expect(plan.skippedLarge).toBe(1) + // Smallest first: c (100) + a (600) fit; b would pass 1,000. + expect(plan.download).toEqual(['c', 'a']) + expect(plan.overLimit).toBe(1) + }) + + it('counts files already here before new ones, so a full folder never evicts', () => { + const plan = planSync({ + remote: new Map([ + ['kept', { size: 900, mtime: 1 }], + ['new', { size: 200, mtime: 1 }] + ]), + index: { kept: entry(900, 1) }, + local: new Map([['kept', { size: 900, mtimeMs: 5 }]]), + limits: { maxFiles: 10, maxBytes: 1_000, maxFileBytes: 1_000 } + }) + expect(plan.download).toEqual([]) + expect(plan.overLimit).toBe(1) + }) +}) + +describe('isLocalCopyEdited', () => { + it('compares size and whole-millisecond mtime', () => { + expect(isLocalCopyEdited(entry(10, 1), { size: 10, mtimeMs: 5.4 })).toBe(false) + expect(isLocalCopyEdited(entry(10, 1), { size: 10, mtimeMs: 6 })).toBe(true) + expect(isLocalCopyEdited(entry(10, 1), null)).toBe(false) + }) +}) diff --git a/src/main/cloud-sync/cloud-sync-plan.ts b/src/main/cloud-sync/cloud-sync-plan.ts new file mode 100644 index 00000000000..03be8fd9a3e --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-plan.ts @@ -0,0 +1,162 @@ +// What one sync pass does: compares the cloud's manifest with what this computer last wrote and +// what is on disk now. Pure, so the rules (one-way, read-only, bounded) are tested without a VM. + +import type { + CloudSyncLimitsInfo, + CloudSyncPhase, + CloudSyncState +} from '../../shared/cloud-sync/cloud-sync-contract' + +export type RemoteEntry = { size: number; mtime: number } + +/** What this computer wrote for a path: the cloud version it copied and the file it left on disk. */ +export type IndexEntry = { + /** Cloud size and mtime when copied. */ + rs: number + rm: number + /** Local size and mtimeMs right after the copy; a difference later means someone wrote here. */ + ls: number + lm: number +} + +export type LocalStat = { size: number; mtimeMs: number } | null + +/** Which rows are on for a workspace (this member's choice on this computer). */ +export type CloudSyncChoice = { context: boolean; mirror: boolean } + +export type SyncLimits = { + maxFiles: number + maxBytes: number + maxFileBytes: number +} + +// Decimal units, as the window shows them ("2.0 GB", "100 MB"). +export const CONTEXT_LIMITS: SyncLimits = { + maxFiles: 5_000, + maxBytes: 250_000_000, + maxFileBytes: 50_000_000 +} +export const MIRROR_LIMITS: SyncLimits = { + maxFiles: 100_000, + maxBytes: 2_000_000_000, + maxFileBytes: 100_000_000 +} + +export function limitsFor(choice: CloudSyncChoice): SyncLimits { + return choice.mirror ? MIRROR_LIMITS : CONTEXT_LIMITS +} + +export function limitsInfo(choice: CloudSyncChoice): CloudSyncLimitsInfo { + const limits = limitsFor(choice) + return { maxBytes: limits.maxBytes, maxFileBytes: limits.maxFileBytes } +} + +export function emptySyncState( + workspaceId: string, + folder: string, + choice: CloudSyncChoice, + phase: CloudSyncPhase +): CloudSyncState { + return { + workspaceId, + choice, + folder, + phase, + files: 0, + bytes: 0, + progress: null, + lastSyncedAt: null, + editedHere: 0, + skippedLarge: 0, + overLimit: 0, + limits: limitsInfo(choice), + error: null + } +} + +export type SyncPlan = { + /** Paths to copy from the cloud, smallest first so the folder fills in quickly. */ + download: string[] + /** Paths this computer copied earlier that the cloud no longer has (or no longer includes). */ + remove: string[] + /** Copies someone changed on this computer; kept until the cloud changes the same file. */ + editedHere: string[] + /** Cloud files over the per-file limit (not copied). */ + skippedLarge: number + /** Files past the folder's file or byte limit (not copied). */ + overLimit: number + /** Bytes the folder holds once the plan is applied. */ + totalBytes: number + totalFiles: number +} + +export function isLocalCopyEdited(entry: IndexEntry, local: LocalStat): boolean { + return ( + local !== null && + (local.size !== entry.ls || Math.trunc(local.mtimeMs) !== Math.trunc(entry.lm)) + ) +} + +export function planSync(input: { + remote: ReadonlyMap + index: Readonly> + local: ReadonlyMap + limits: SyncLimits + /** Paths whose remote state is authoritative this pass; others in the index are left alone. */ + scope?: ReadonlySet +}): SyncPlan { + const { remote, index, local, limits } = input + const download: string[] = [] + const remove: string[] = [] + const editedHere: string[] = [] + let skippedLarge = 0 + let overLimit = 0 + let totalBytes = 0 + let totalFiles = 0 + + // Files already here count toward the limits first, so a full folder never evicts what it has. + const candidates: [string, RemoteEntry][] = [] + for (const [path, entry] of remote) { + if (entry.size > limits.maxFileBytes) { + skippedLarge++ + continue + } + const known = index[path] + const here = local.get(path) ?? null + if (known && here !== null) { + const sameCloud = known.rs === entry.size && Math.trunc(known.rm) === Math.trunc(entry.mtime) + if (sameCloud) { + if (isLocalCopyEdited(known, here)) { + editedHere.push(path) + } + totalBytes += here.size + totalFiles++ + continue + } + } + candidates.push([path, entry]) + } + candidates.sort((a, b) => a[1].size - b[1].size || (a[0] < b[0] ? -1 : 1)) + for (const [path, entry] of candidates) { + if (totalFiles + 1 > limits.maxFiles || totalBytes + entry.size > limits.maxBytes) { + overLimit++ + // An older copy of a file that no longer fits stays out of date rather than half-current. + if (index[path] && (local.get(path) ?? null) !== null) { + remove.push(path) + } + continue + } + download.push(path) + totalBytes += entry.size + totalFiles++ + } + for (const path of Object.keys(index)) { + if (input.scope && !input.scope.has(path)) { + continue + } + if (!remote.has(path) || remote.get(path)!.size > limits.maxFileBytes) { + remove.push(path) + } + } + return { download, remove, editedHere, skippedLarge, overLimit, totalBytes, totalFiles } +} diff --git a/src/main/cloud-sync/cloud-sync-remote.ts b/src/main/cloud-sync/cloud-sync-remote.ts new file mode 100644 index 00000000000..c125b62364d --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-remote.ts @@ -0,0 +1,307 @@ +// The cloud half of a sync pass, over the workspace's E2EE orcad connection: list what is in +// scope (gitignore-aware), stat it in batches, and page files down. Replies are validated, never +// trusted: the VM is not this process. +import { z } from 'zod' +import { FILE_STAT_MANY_MAX } from '../../shared/file-access/file-stat-many' +import { CONTEXT_DIR, isContextPath, isSafeSyncPath } from './cloud-sync-paths' +import type { RemoteEntry } from './cloud-sync-plan' + +export type SyncCall = ( + method: string, + params: unknown, + options?: { timeoutMs?: number; signal?: AbortSignal } +) => Promise + +export type SyncScope = 'context' | 'workspace' + +const LIST_TIMEOUT_MS = 60_000 +const STAT_TIMEOUT_MS = 60_000 +const CANDIDATE_BATCH = 100 +const FALLBACK_STAT_CONCURRENCY = 12 +const CONTEXT_MAX_DEPTH = 8 + +export class SyncRemoteError extends Error { + constructor( + readonly code: string, + message: string = code + ) { + super(message) + this.name = 'SyncRemoteError' + } +} + +/** Transport answers for a sandbox that is asleep, waking or unreachable right now. */ +const WAITING_CODES = new Set([ + 'runtime_environment_asleep', + 'runtime_environment_waking', + 'runtime_environment_wake_failed', + 'remote_runtime_unavailable', + 'runtime_unavailable' +]) + +/** Not a failure: the next wake starts a fresh session. */ +export function isWaitingError(error: unknown): boolean { + return WAITING_CODES.has(errorCode(error) ?? '') +} + +export function errorCode(error: unknown): string | null { + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code: unknown }).code + return typeof code === 'string' ? code : null + } + return null +} + +const WorktreeRows = z.looseObject({ + worktrees: z + .array(z.looseObject({ id: z.unknown(), path: z.unknown() })) + .optional() + .catch(undefined) +}) +const DirEntries = z.array( + z.looseObject({ name: z.string(), isDirectory: z.boolean(), isSymlink: z.boolean().optional() }) +) +const PathList = z.array(z.string()) +const StatMany = z.object({ + entries: z.array( + z.union([ + z.null(), + z.object({ + size: z.number().nonnegative(), + mtime: z.number(), + type: z.enum(['file', 'directory', 'symlink']) + }), + z.object({ error: z.string() }) + ]) + ) +}) +const SingleStat = z.looseObject({ + size: z.number().nonnegative(), + mtime: z.number(), + isDirectory: z.boolean() +}) + +/** The orcad worktree for this workspace: its selector and its absolute path in the VM. */ +export async function resolveCloudWorktree( + call: SyncCall, + workspaceId: string, + projectRoot: string | null +): Promise<{ selector: string; root: string }> { + const rows = WorktreeRows.parse(await call('worktree.list', {}, { timeoutMs: LIST_TIMEOUT_MS })) + const valid = (rows.worktrees ?? []).filter( + (row): row is { id: string; path: string } => + typeof row.id === 'string' && typeof row.path === 'string' + ) + const found = + valid.find((row) => row.id === workspaceId || row.id.endsWith(`::${workspaceId}`)) ?? + valid.find((row) => projectRoot !== null && row.path === projectRoot) ?? + (valid.length === 1 ? valid[0] : undefined) + if (!found) { + throw new SyncRemoteError('worktree_not_found') + } + return { selector: `id:${found.id}`, root: found.path.replace(/\/+$/, '') } +} + +/** Every regular file under `.context` (it is usually gitignored, so it is walked, not listed). */ +async function listContext( + call: SyncCall, + selector: string, + maxFiles: number, + signal?: AbortSignal +): Promise { + const out: string[] = [] + const walk = async (dir: string, depth: number): Promise => { + if (depth > CONTEXT_MAX_DEPTH || out.length >= maxFiles) { + return + } + let entries: z.infer + try { + entries = DirEntries.parse( + await call( + 'files.readDir', + { worktree: selector, relativePath: dir }, + { timeoutMs: LIST_TIMEOUT_MS, signal } + ) + ) + } catch (error) { + // No .context folder is the common case, not a failure. + if (depth === 0 && /ENOENT|not.?found|no such/i.test(String((error as Error)?.message))) { + return + } + throw error + } + for (const entry of entries) { + if (entry.isSymlink) { + continue + } + const child = `${dir}/${entry.name}` + if (!isSafeSyncPath(child)) { + continue + } + if (entry.isDirectory) { + await walk(child, depth + 1) + } else if (out.length < maxFiles) { + out.push(child) + } + } + } + await walk(CONTEXT_DIR, 0) + return out +} + +/** Paths in scope: `.context` always; the whole workspace (minus .gitignore) for a mirror. */ +export async function listScope( + call: SyncCall, + selector: string, + scope: SyncScope, + maxFiles: number, + signal?: AbortSignal +): Promise<{ paths: string[]; truncated: boolean }> { + const context = await listContext(call, selector, maxFiles, signal) + if (scope === 'context') { + return { paths: context, truncated: context.length >= maxFiles } + } + const listed = PathList.parse( + await call( + 'files.listAll', + { worktree: selector, maxResults: maxFiles + 1 }, + { timeoutMs: LIST_TIMEOUT_MS, signal } + ) + ) + const paths = new Set(context) + for (const path of listed) { + if (isSafeSyncPath(path) && !isContextPath(path)) { + paths.add(path) + } + } + const all = [...paths] + return { paths: all.slice(0, maxFiles), truncated: all.length > maxFiles } +} + +/** + * Of the changed paths a watch reported, the ones a mirror includes: `.context` always, others + * only when the cloud's listing (which honours .gitignore) still has them. + */ +export async function filterIncluded( + call: SyncCall, + selector: string, + scope: SyncScope, + paths: readonly string[], + signal?: AbortSignal +): Promise> { + const included = new Set() + const others: string[] = [] + for (const path of paths) { + if (!isSafeSyncPath(path)) { + continue + } + if (isContextPath(path)) { + included.add(path) + } else if (scope === 'workspace') { + others.push(path) + } + } + for (let i = 0; i < others.length; i += CANDIDATE_BATCH) { + const batch = others.slice(i, i + CANDIDATE_BATCH) + const listed = PathList.parse( + await call( + 'files.listAll', + { worktree: selector, candidatePaths: batch, maxResults: batch.length }, + { timeoutMs: LIST_TIMEOUT_MS, signal } + ) + ) + for (const path of listed) { + if (batch.includes(path)) { + included.add(path) + } + } + } + return included +} + +/** Hosts whose orcad predates `files.statMany` (an older sandbox image). */ +const statManyUnsupported = new WeakMap() + +/** Size and mtime of regular files; absent paths, directories and symlinks are left out. */ +export async function statFiles( + call: SyncCall, + selector: string, + paths: readonly string[], + signal?: AbortSignal +): Promise> { + const out = new Map() + for (let i = 0; i < paths.length; i += FILE_STAT_MANY_MAX) { + const batch = paths.slice(i, i + FILE_STAT_MANY_MAX) + if (!statManyUnsupported.has(call)) { + try { + const reply = StatMany.parse( + await call( + 'files.statMany', + { worktree: selector, relativePaths: batch }, + { timeoutMs: STAT_TIMEOUT_MS, signal } + ) + ) + if (reply.entries.length !== batch.length) { + throw new SyncRemoteError('bad_reply') + } + reply.entries.forEach((entry, index) => { + if (entry && 'type' in entry && entry.type === 'file') { + out.set(batch[index]!, { size: entry.size, mtime: entry.mtime }) + } + }) + continue + } catch (error) { + if (errorCode(error) !== 'method_not_found') { + throw error + } + statManyUnsupported.set(call, true) + } + } + await statOneByOne(call, selector, batch, out, signal) + } + return out +} + +async function statOneByOne( + call: SyncCall, + selector: string, + batch: readonly string[], + out: Map, + signal?: AbortSignal +): Promise { + let next = 0 + const worker = async (): Promise => { + while (next < batch.length) { + const path = batch[next++]! + try { + const stat = SingleStat.parse( + await call( + 'files.stat', + { worktree: selector, relativePath: path }, + { timeoutMs: STAT_TIMEOUT_MS, signal } + ) + ) + if (!stat.isDirectory) { + out.set(path, { size: stat.size, mtime: stat.mtime }) + } + } catch (error) { + // Deleted between listing and stat: simply not in the manifest. + if (!/ENOENT|not.?found|no such/i.test(String((error as Error)?.message))) { + throw error + } + } + } + } + await Promise.all( + Array.from({ length: Math.min(FALLBACK_STAT_CONCURRENCY, batch.length) }, worker) + ) +} + +/** Maps a watch event's absolute VM path to a workspace-relative one (null when outside it). */ +export function relativeToRoot(root: string, absolutePath: unknown): string | null { + if (typeof absolutePath !== 'string') { + return null + } + const prefix = `${root}/` + return absolutePath.startsWith(prefix) ? absolutePath.slice(prefix.length) : null +} diff --git a/src/main/cloud-sync/cloud-sync-service.test.ts b/src/main/cloud-sync/cloud-sync-service.test.ts new file mode 100644 index 00000000000..b7298e28cd8 --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-service.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudSyncView } from '../../shared/cloud-sync/cloud-sync-contract' +import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' +import { createFakeVm, VM_ROOT } from './cloud-sync-fake-vm.test-support' +import { createCloudSyncService } from './cloud-sync-service' +import { createCloudSyncStore } from './cloud-sync-store' + +const dirs: string[] = [] +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +function runtime(status: EphemeralVmRuntimeRecord['status']): EphemeralVmRuntimeRecord { + return { + id: 'rt-1', + recipeId: 'orca-cloud', + recipe: { id: 'orca-cloud', name: 'Orca Cloud', create: 'x', provider: 'orca-cloud' }, + repoPath: '/Users/me/src/Hello-World', + workspaceId: 'ws', + workspaceName: 'my-ws', + runtimeEnvironmentId: 'env-1', + status, + cleanupStatus: 'not_started', + createdAt: 1, + updatedAt: 1, + recipeResult: { + connection: { type: 'orca-server', pairingCode: 'x', projectRoot: VM_ROOT } + } as unknown as EphemeralVmRuntimeRecord['recipeResult'] + } +} + +async function setup() { + const home = await mkdtemp(join(tmpdir(), 'cloud-sync-home-')) + const userData = await mkdtemp(join(tmpdir(), 'cloud-sync-ud-')) + dirs.push(home, userData) + const vm = createFakeVm() + vm.write('.context/plan.md', 'plan') + vm.write('src/a.ts', 'a') + let record = runtime('running') + let archived = false + const views: CloudSyncView[] = [] + const opened: string[] = [] + const service = createCloudSyncService({ + store: createCloudSyncStore(userData), + listRuntimes: () => [record], + callFor: () => vm.call, + watchFor: () => vm.subscribe, + isArchived: async () => archived, + broadcast: (view) => views.push(view), + openPath: async (path) => { + opened.push(path) + return true + }, + home, + log: () => undefined + }) + const folder = join(home, 'orca', 'cloud', 'Hello-World', 'my-ws') + const synced = async (): Promise => { + await vi.waitFor(() => expect(views.at(-1)?.phase).toBe('synced')) + } + return { + vm, + service, + folder, + views, + opened, + synced, + setStatus: (status: EphemeralVmRuntimeRecord['status']) => { + record = runtime(status) + }, + setArchived: (value: boolean) => { + archived = value + } + } +} + +describe('cloud sync service', () => { + it('syncs .context by default into ~/orca/cloud//', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + expect(await readFile(join(t.folder, '.context/plan.md'), 'utf8')).toBe('plan') + const view = await t.service.getState('ws') + expect(view).toMatchObject({ + choice: { context: true, mirror: false }, + displayPath: '~/orca/cloud/Hello-World/my-ws/.context' + }) + await t.service.openFolder('ws') + expect(t.opened).toEqual([join(t.folder, '.context')]) + await t.service.dispose() + }) + + it('opens the workspace folder once the mirror is on', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + const view = await t.service.setChoice('ws', { mirror: true }) + expect(view?.displayPath).toBe('~/orca/cloud/Hello-World/my-ws') + await vi.waitFor(async () => + expect(await readFile(join(t.folder, 'src/a.ts'), 'utf8')).toBe('a') + ) + await t.service.dispose() + }) + + it('asleep: stops, keeps the copy and reports waiting with the last sync', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + t.setStatus('suspended') + await t.service.reconcile() + expect(t.views.at(-1)).toMatchObject({ phase: 'waiting' }) + expect(t.views.at(-1)?.lastSyncedAt).not.toBeNull() + expect(t.vm.openWatches()).toBe(0) + expect(await readFile(join(t.folder, '.context/plan.md'), 'utf8')).toBe('plan') + await t.service.dispose() + }) + + it('archived: removes the copy it made but not files someone added', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + await writeFile(join(t.folder, '.context/mine.txt'), 'mine') + expect(await t.service.removalPreview('ws')).toEqual({ hasLocalCopy: true, editedHere: 0 }) + t.setStatus('suspended') + await t.service.noteArchived('ws') + await expect(stat(join(t.folder, '.context/plan.md'))).rejects.toThrow() + expect(await readFile(join(t.folder, '.context/mine.txt'), 'utf8')).toBe('mine') + await t.service.dispose() + }) + + it('archived elsewhere is found on the slow check while asleep', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + t.setStatus('suspended') + t.setArchived(true) + await t.service.reconcile() + await expect(stat(t.folder)).rejects.toThrow() + await t.service.dispose() + }) + + it('deleted: the whole folder goes', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + t.setStatus('cleaned') + await t.service.reconcile() + await expect(stat(t.folder)).rejects.toThrow() + await t.service.dispose() + }) + + it('turning both off removes the copy', async () => { + const t = await setup() + await t.service.reconcile() + await t.synced() + const view = await t.service.setChoice('ws', { context: false }) + expect(view?.phase).toBe('off') + await expect(stat(join(t.folder, '.context/plan.md'))).rejects.toThrow() + await t.service.dispose() + }) +}) diff --git a/src/main/cloud-sync/cloud-sync-service.ts b/src/main/cloud-sync/cloud-sync-service.ts new file mode 100644 index 00000000000..3b58b8927d0 --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-service.ts @@ -0,0 +1,310 @@ +// Runs cloud → this computer sync for every Orca Cloud workspace this desktop runs. A session +// lives only while the sandbox runs (sync never wakes or holds a sandbox awake: orcad's idle +// verdict ignores connected clients). Asleep: the copy stays readable. Archived or deleted: sync +// stops and the copy this computer made is removed (files someone added here are left alone). +import { join } from 'node:path' +import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes' +import type { + CloudSyncRemovalPreview, + CloudSyncState, + CloudSyncView +} from '../../shared/cloud-sync/cloud-sync-contract' +import { + cloudRuntimesFrom, + previewRemoval, + removeLocalCopy, + removeMirroredFiles, + type CloudRuntime +} from './cloud-sync-local-copy' +import { CONTEXT_DIR, displayPath } from './cloud-sync-paths' +import { limitsInfo, type CloudSyncChoice } from './cloud-sync-plan' +import type { SyncCall } from './cloud-sync-remote' +import { + createCloudSyncSession, + type CloudSyncSession, + type WatchSubscribe +} from './cloud-sync-session' +import type { CloudSyncStore } from './cloud-sync-store' + +/** Archived elsewhere is only visible on the control plane; checked on this slow beat. */ +const ARCHIVE_CHECK_MS = 10 * 60_000 + +export type CloudSyncServiceDeps = { + store: CloudSyncStore + listRuntimes: () => EphemeralVmRuntimeRecord[] + callFor: (environmentId: string) => SyncCall + watchFor: (environmentId: string) => WatchSubscribe + /** Control plane: the workspace is archived (sync stops, the local copy goes). */ + isArchived: (workspaceId: string) => Promise + broadcast: (view: CloudSyncView) => void + openPath: (path: string) => Promise + home?: string + log?: (line: string) => void +} + +function idleState( + runtime: CloudRuntime, + choice: CloudSyncChoice, + previous: CloudSyncState | undefined +): CloudSyncState { + return { + workspaceId: runtime.workspaceId, + choice, + folder: runtime.folder, + phase: !choice.context && !choice.mirror ? 'off' : 'waiting', + files: previous?.files ?? 0, + bytes: previous?.bytes ?? 0, + progress: null, + lastSyncedAt: previous?.lastSyncedAt ?? null, + editedHere: previous?.editedHere ?? 0, + skippedLarge: previous?.skippedLarge ?? 0, + overLimit: previous?.overLimit ?? 0, + limits: limitsInfo(choice), + error: null + } +} + +export function createCloudSyncService(deps: CloudSyncServiceDeps) { + const log = deps.log ?? ((line: string) => console.error(line)) + const sessions = new Map() + const states = new Map() + const known = new Set() + const archiveCheckedAt = new Map() + /** Copies already removed (archived or deleted); nothing to check or remove again. */ + const retired = new Set() + let reconciling: Promise | null = null + let again = false + let disposed = false + + const viewOf = (state: CloudSyncState): CloudSyncView => { + const openPath = + state.choice.mirror || !state.choice.context ? state.folder : join(state.folder, CONTEXT_DIR) + return { ...state, openPath, displayPath: displayPath(openPath, deps.home) } + } + + const publish = (state: CloudSyncState): void => { + states.set(state.workspaceId, state) + deps.broadcast(viewOf(state)) + } + + const findRuntime = (workspaceId: string): CloudRuntime | undefined => + cloudRuntimesFrom(deps.listRuntimes(), deps.home).find( + (candidate) => candidate.workspaceId === workspaceId + ) + + async function stopSession(workspaceId: string): Promise { + const entry = sessions.get(workspaceId) + if (entry) { + sessions.delete(workspaceId) + await entry.session.stop() + } + } + + /** Archived (choices kept for an unarchive) or deleted (choices forgotten): the copy goes. */ + async function retire(runtime: CloudRuntime, reason: 'archived' | 'deleted'): Promise { + if (retired.has(runtime.workspaceId)) { + return + } + retired.add(runtime.workspaceId) + log(`[cloud-sync] ${runtime.workspaceId}: ${reason}; removing the local copy`) + await stopSession(runtime.workspaceId) + await removeLocalCopy(deps.store, runtime.workspaceId, runtime.folder) + if (reason === 'deleted') { + await deps.store.forgetChoice(runtime.workspaceId) + } + archiveCheckedAt.delete(runtime.workspaceId) + publish(idleState(runtime, await deps.store.choice(runtime.workspaceId), undefined)) + } + + function startSession(runtime: CloudRuntime, choice: CloudSyncChoice): void { + const session = createCloudSyncSession({ + workspaceId: runtime.workspaceId, + folder: runtime.folder, + projectRoot: runtime.projectRoot, + call: deps.callFor(runtime.environmentId), + subscribeWatch: deps.watchFor(runtime.environmentId), + store: deps.store, + choice, + emit: (state) => { + if (sessions.get(runtime.workspaceId)?.session === session) { + publish(state) + } + }, + log + }) + sessions.set(runtime.workspaceId, { session, environmentId: runtime.environmentId }) + void session.start() + } + + async function reconcileIdle(runtime: CloudRuntime, choice: CloudSyncChoice): Promise { + await stopSession(runtime.workspaceId) + const previous = states.get(runtime.workspaceId) + const next = idleState(runtime, choice, previous) + // The 60 s beat lands here for every idle workspace: tell the window only what changed. + if (JSON.stringify(previous) !== JSON.stringify(next)) { + publish(next) + } + const at = archiveCheckedAt.get(runtime.workspaceId) + const due = at === undefined || Date.now() - at >= ARCHIVE_CHECK_MS + if (!runtime.running && !retired.has(runtime.workspaceId) && due) { + archiveCheckedAt.set(runtime.workspaceId, Date.now()) + if (await deps.isArchived(runtime.workspaceId).catch(() => false)) { + await retire(runtime, 'archived') + } + } + } + + async function reconcileOnce(): Promise { + const runtimes = cloudRuntimesFrom(deps.listRuntimes(), deps.home) + const present = new Set(runtimes.map((runtime) => runtime.workspaceId)) + // A record that vanished without passing through cleanup: stop, but never delete on a guess + // (a store that failed to read would look the same). + for (const workspaceId of known) { + if (!present.has(workspaceId)) { + await stopSession(workspaceId) + known.delete(workspaceId) + } + } + for (const runtime of runtimes) { + if (disposed) { + return + } + known.add(runtime.workspaceId) + if (runtime.gone) { + await retire(runtime, 'deleted') + continue + } + const choice = await deps.store.choice(runtime.workspaceId) + if (!runtime.running || (!choice.context && !choice.mirror)) { + await reconcileIdle(runtime, choice) + continue + } + archiveCheckedAt.delete(runtime.workspaceId) + // Unarchived and running again: it syncs afresh. + retired.delete(runtime.workspaceId) + const current = sessions.get(runtime.workspaceId) + if (current?.environmentId !== runtime.environmentId) { + await stopSession(runtime.workspaceId) + startSession(runtime, choice) + } + } + } + + /** Coalesces bursts of runtime-store changes into one pass at a time. */ + function reconcile(): Promise { + if (reconciling) { + again = true + return reconciling + } + reconciling = (async () => { + try { + do { + again = false + await reconcileOnce().catch((error: unknown) => + log(`[cloud-sync] reconcile failed: ${error instanceof Error ? error.message : error}`) + ) + } while (again && !disposed) + } finally { + reconciling = null + } + })() + return reconciling + } + + async function viewFor(workspaceId: string): Promise { + const state = states.get(workspaceId) + if (state) { + return viewOf(state) + } + const runtime = findRuntime(workspaceId) + if (!runtime) { + return null + } + const index = await deps.store.readIndex(workspaceId, runtime.folder) + const entries = Object.values(index.entries) + const loaded = { + ...idleState(runtime, await deps.store.choice(workspaceId), undefined), + files: entries.length, + bytes: entries.reduce((sum, entry) => sum + entry.ls, 0), + lastSyncedAt: index.lastSyncedAt + } + states.set(workspaceId, loaded) + return viewOf(loaded) + } + + return { + reconcile, + /** orcad (re)connected for an environment: resync its workspace now. */ + noteConnected(environmentId: string): void { + for (const entry of sessions.values()) { + if (entry.environmentId === environmentId) { + void entry.session.resync() + } + } + void reconcile() + }, + /** Archived from this computer: remove the copy now, not on the next control-plane check. */ + async noteArchived(workspaceId: string): Promise { + const runtime = findRuntime(workspaceId) + if (runtime) { + await retire(runtime, 'archived') + } + }, + /** This computer woke from sleep: connections may have dropped and changes been missed. */ + noteSystemResumed(): void { + for (const entry of sessions.values()) { + void entry.session.resync() + } + void reconcile() + }, + getState: viewFor, + async setChoice( + workspaceId: string, + patch: Partial + ): Promise { + const runtime = findRuntime(workspaceId) + if (!runtime) { + return null + } + const choice = await deps.store.setChoice(workspaceId, patch) + const session = sessions.get(workspaceId) + if (!choice.context && !choice.mirror) { + await stopSession(workspaceId) + await removeLocalCopy(deps.store, workspaceId, runtime.folder) + states.delete(workspaceId) + } else if (session) { + void session.session.setChoice(choice) + } else { + if (!choice.mirror) { + await removeMirroredFiles(deps.store, workspaceId, runtime.folder) + } + states.delete(workspaceId) + } + await reconcile() + return viewFor(workspaceId) + }, + async retry(workspaceId: string): Promise { + const entry = sessions.get(workspaceId) + await (entry ? entry.session.resync() : reconcile()) + }, + async openFolder(workspaceId: string): Promise<{ ok: boolean }> { + const view = await viewFor(workspaceId) + if (!view || view.phase === 'off') { + return { ok: false } + } + return { ok: await deps.openPath(view.openPath) } + }, + async removalPreview(workspaceId: string): Promise { + const runtime = findRuntime(workspaceId) + return runtime + ? previewRemoval(deps.store, workspaceId, runtime.folder) + : { hasLocalCopy: false, editedHere: 0 } + }, + async dispose(): Promise { + disposed = true + await Promise.all([...sessions.keys()].map((workspaceId) => stopSession(workspaceId))) + } + } +} + +export type CloudSyncService = ReturnType diff --git a/src/main/cloud-sync/cloud-sync-session.test.ts b/src/main/cloud-sync/cloud-sync-session.test.ts new file mode 100644 index 00000000000..0c9532ae94e --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-session.test.ts @@ -0,0 +1,219 @@ +import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudSyncState } from '../../shared/cloud-sync/cloud-sync-contract' +import { createFakeVm, VM_ROOT, type FakeVm } from './cloud-sync-fake-vm.test-support' +import { createCloudSyncSession } from './cloud-sync-session' +import { createCloudSyncStore, type CloudSyncChoice } from './cloud-sync-store' + +const dirs: string[] = [] +afterEach(async () => { + vi.useRealTimers() + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function setup(vm: FakeVm, choice: CloudSyncChoice = { context: true, mirror: false }) { + const folder = await mkdtemp(join(tmpdir(), 'cloud-sync-folder-')) + const userData = await mkdtemp(join(tmpdir(), 'cloud-sync-userdata-')) + dirs.push(folder, userData) + const store = createCloudSyncStore(userData) + const states: CloudSyncState[] = [] + const session = createCloudSyncSession({ + workspaceId: 'ws', + folder, + projectRoot: VM_ROOT, + call: vm.call, + subscribeWatch: vm.subscribe, + store, + choice, + emit: (state) => states.push(state) + }) + return { folder, store, states, session, last: () => states.at(-1)! } +} + +const settle = async (session: { resync: () => Promise }): Promise => { + await session.resync() +} + +describe('cloud sync session', () => { + it('copies .context (gitignored, walked) read-only and leaves other files alone by default', async () => { + const vm = createFakeVm() + vm.write('.context/plan.md', '# Plan') + vm.write('.context/shots/one.png', 'PNG') + vm.write('src/index.ts', 'code') + const { folder, session, last } = await setup(vm) + await session.start() + expect(await readFile(join(folder, '.context/plan.md'), 'utf8')).toBe('# Plan') + expect(await readFile(join(folder, '.context/shots/one.png'), 'utf8')).toBe('PNG') + await expect(stat(join(folder, 'src/index.ts'))).rejects.toThrow() + expect((await stat(join(folder, '.context/plan.md'))).mode & 0o222).toBe(0) + expect(last()).toMatchObject({ phase: 'synced', files: 2, editedHere: 0 }) + await session.stop() + }) + + it('mirrors the workspace minus .gitignore, then removes it when the mirror is turned off', async () => { + const vm = createFakeVm() + vm.write('.context/plan.md', 'p') + vm.write('src/index.ts', 'code') + vm.write('dist/bundle.js', 'built') + vm.ignored.add('dist/bundle.js') + const { folder, session, last } = await setup(vm, { context: true, mirror: true }) + await session.start() + expect(await readFile(join(folder, 'src/index.ts'), 'utf8')).toBe('code') + await expect(stat(join(folder, 'dist/bundle.js'))).rejects.toThrow() + expect(last()).toMatchObject({ phase: 'synced', files: 2 }) + await session.setChoice({ context: true, mirror: false }) + await expect(stat(join(folder, 'src/index.ts'))).rejects.toThrow() + // The emptied directory goes too; .context stays. + await expect(stat(join(folder, 'src'))).rejects.toThrow() + expect(await readFile(join(folder, '.context/plan.md'), 'utf8')).toBe('p') + await session.stop() + }) + + it('second pass is incremental: only changed files are read again', async () => { + const vm = createFakeVm() + for (let i = 0; i < 50; i++) { + vm.write(`src/f${i}.ts`, `v${i}`) + } + const { folder, session } = await setup(vm, { context: true, mirror: true }) + await session.start() + vm.calls.length = 0 + vm.write('src/f7.ts', 'changed') + await settle(session) + expect(vm.calls.filter((method) => method === 'files.readChunk')).toHaveLength(1) + expect(vm.calls.filter((method) => method === 'files.statMany')).toHaveLength(1) + expect(await readFile(join(folder, 'src/f7.ts'), 'utf8')).toBe('changed') + await session.stop() + }) + + it('follows the cloud file watch: changes and deletions land without a full pass', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const vm = createFakeVm() + vm.write('.context/plan.md', 'v1') + const { folder, session } = await setup(vm) + await session.start() + vm.calls.length = 0 + vm.write('.context/plan.md', 'v2') + vm.write('.context/new.png', 'img') + vm.emitWatch([ + { kind: 'update', absolutePath: `${VM_ROOT}/.context/plan.md` }, + { kind: 'create', absolutePath: `${VM_ROOT}/.context/new.png` }, + // Outside the workspace: ignored. + { kind: 'update', absolutePath: '/etc/passwd' } + ]) + await vi.advanceTimersByTimeAsync(1_000) + vi.useRealTimers() + await vi.waitFor(async () => + expect(await readFile(join(folder, '.context/new.png'), 'utf8')).toBe('img') + ) + expect(await readFile(join(folder, '.context/plan.md'), 'utf8')).toBe('v2') + // Targeted: no walk of .context, one stat batch, one read per file. + expect(vm.calls).not.toContain('files.readDir') + expect(vm.calls.filter((method) => method === 'files.statMany')).toHaveLength(1) + expect(vm.calls.filter((method) => method === 'files.readChunk')).toHaveLength(2) + await session.stop() + }) + + it('a watch delete removes the copy', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const vm = createFakeVm() + vm.write('.context/a.md', 'a') + vm.write('.context/b.md', 'b') + const { folder, session } = await setup(vm) + await session.start() + vm.remove('.context/a.md') + vm.emitWatch([{ kind: 'delete', absolutePath: `${VM_ROOT}/.context/a.md` }]) + await vi.advanceTimersByTimeAsync(1_000) + vi.useRealTimers() + await vi.waitFor(async () => expect(stat(join(folder, '.context/a.md'))).rejects.toThrow()) + expect(await readFile(join(folder, '.context/b.md'), 'utf8')).toBe('b') + await session.stop() + }) + + it('keeps a forced local edit, reports it, and replaces it when the cloud changes', async () => { + const vm = createFakeVm() + vm.write('.context/plan.md', 'cloud') + const { folder, session, last } = await setup(vm) + await session.start() + const path = join(folder, '.context/plan.md') + await chmod(path, 0o644) + await writeFile(path, 'edited here, longer') + await settle(session) + expect(await readFile(path, 'utf8')).toBe('edited here, longer') + expect(last().editedHere).toBe(1) + vm.write('.context/plan.md', 'cloud v2') + await settle(session) + expect(await readFile(path, 'utf8')).toBe('cloud v2') + expect(last().editedHere).toBe(0) + await session.stop() + }) + + it('never removes files it did not copy', async () => { + const vm = createFakeVm() + vm.write('.context/plan.md', 'p') + const { folder, session } = await setup(vm) + await session.start() + await writeFile(join(folder, '.context/mine.txt'), 'local only') + await settle(session) + expect(await readFile(join(folder, '.context/mine.txt'), 'utf8')).toBe('local only') + await session.stop() + }) + + it('does not write through a symlink planted in the folder', async () => { + const vm = createFakeVm() + vm.write('.context/sub/plan.md', 'p') + const { folder, session } = await setup(vm) + const outside = await mkdtemp(join(tmpdir(), 'cloud-sync-outside-')) + dirs.push(outside) + const { mkdir } = await import('node:fs/promises') + await mkdir(join(folder, '.context')) + await symlink(outside, join(folder, '.context/sub')) + await session.start() + await expect(stat(join(outside, 'plan.md'))).rejects.toThrow() + await session.stop() + }) + + it('falls back to one stat per file on an older sandbox image', async () => { + const vm = createFakeVm() + vm.statManySupported = false + vm.write('.context/a.md', 'a') + vm.write('.context/b.md', 'b') + const { folder, session } = await setup(vm) + await session.start() + expect(vm.calls.filter((method) => method === 'files.stat').length).toBeGreaterThanOrEqual(2) + expect(await readFile(join(folder, '.context/b.md'), 'utf8')).toBe('b') + await session.stop() + }) + + it('reads an asleep sandbox as waiting, not as an error', async () => { + const vm = createFakeVm() + vm.asleep = true + const { session, last } = await setup(vm) + await session.start() + expect(last()).toMatchObject({ phase: 'waiting', error: null }) + await session.stop() + }) + + it('skips files over the per-file limit and says so', async () => { + const vm = createFakeVm() + vm.files.set('.context/huge.mov', { content: Buffer.alloc(51_000_000), mtime: 1 }) + vm.write('.context/plan.md', 'p') + const { folder, session, last } = await setup(vm) + await session.start() + await expect(stat(join(folder, '.context/huge.mov'))).rejects.toThrow() + expect(last()).toMatchObject({ phase: 'synced', files: 1, skippedLarge: 1 }) + await session.stop() + }) + + it('stops cleanly: closes the watch and leaves the files', async () => { + const vm = createFakeVm() + vm.write('.context/plan.md', 'p') + const { folder, session } = await setup(vm) + await session.start() + expect(vm.openWatches()).toBe(1) + await session.stop() + expect(vm.openWatches()).toBe(0) + expect(await readFile(join(folder, '.context/plan.md'), 'utf8')).toBe('p') + }) +}) diff --git a/src/main/cloud-sync/cloud-sync-session.ts b/src/main/cloud-sync/cloud-sync-session.ts new file mode 100644 index 00000000000..4c3407ba75c --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-session.ts @@ -0,0 +1,320 @@ +// One workspace's sync while its sandbox runs: a full pass on start (and after anything that may +// have missed changes: reconnects, watch overflow, this computer waking), then the cloud's file +// watch drives small incremental passes. One-way: cloud → this computer. Copies are written +// read-only; a copy someone changed here anyway is left alone until the cloud changes it. +import { mkdir } from 'node:fs/promises' +import type { CloudSyncState } from '../../shared/cloud-sync/cloud-sync-contract' +import { + downloadCopies, + localStatAt, + removeCopies, + summarizeCopies +} from './cloud-sync-local-writes' +import { isContextPath, isSafeSyncPath } from './cloud-sync-paths' +import { + emptySyncState, + limitsFor, + limitsInfo, + planSync, + type LocalStat, + type RemoteEntry +} from './cloud-sync-plan' +import { + filterIncluded, + isWaitingError, + listScope, + resolveCloudWorktree, + statFiles, + type SyncCall, + type SyncScope +} from './cloud-sync-remote' +import type { CloudSyncChoice, CloudSyncIndex, CloudSyncStore } from './cloud-sync-store' +import { + createChangeBatcher, + readWatchEvent, + type WatchSubscribe, + type WatchSubscription +} from './cloud-sync-watch' + +export type { WatchSubscribe, WatchSubscription } + +/** Safety net for anything the watch missed. */ +const FULL_PASS_INTERVAL_MS = 10 * 60_000 +const WATCH_RETRY_MIN_MS = 5_000 +const WATCH_RETRY_MAX_MS = 5 * 60_000 + +export type CloudSyncSessionDeps = { + workspaceId: string + folder: string + projectRoot: string | null + call: SyncCall + subscribeWatch: WatchSubscribe + store: CloudSyncStore + choice: CloudSyncChoice + emit: (state: CloudSyncState) => void + log?: (line: string) => void + now?: () => number +} + +export function createCloudSyncSession(deps: CloudSyncSessionDeps) { + const now = deps.now ?? Date.now + const log = (line: string): void => deps.log?.(`[cloud-sync] ${deps.workspaceId}: ${line}`) + let choice = deps.choice + let stopped = false + let worktree: { selector: string; root: string } | null = null + let index: CloudSyncIndex | null = null + let chain: Promise = Promise.resolve() + let fullPassQueued = false + let watch: WatchSubscription | null = null + let watchRetryMs = WATCH_RETRY_MIN_MS + let watchRetryTimer: ReturnType | null = null + let safetyTimer: ReturnType | null = null + const abort = new AbortController() + let state: CloudSyncState = emptySyncState(deps.workspaceId, deps.folder, choice, 'starting') + const changes = createChangeBatcher({ + onPaths: (paths) => void enqueue(() => incrementalPass(paths)), + onRescan: () => void requestFullPass(), + now + }) + + const publish = (patch: Partial): void => { + state = { ...state, ...patch, choice, limits: limitsInfo(choice) } + deps.emit(state) + } + + const scope = (): SyncScope => (choice.mirror ? 'workspace' : 'context') + + async function loadIndex(): Promise { + index ??= await deps.store.readIndex(deps.workspaceId, deps.folder) + if (index.lastSyncedAt !== null && state.lastSyncedAt === null) { + publish({ lastSyncedAt: index.lastSyncedAt }) + } + return index + } + + async function ensureWorktree(): Promise<{ selector: string; root: string }> { + worktree ??= await resolveCloudWorktree(deps.call, deps.workspaceId, deps.projectRoot) + return worktree + } + + /** Runs passes one at a time. */ + function enqueue(run: () => Promise): Promise { + chain = chain.then(async () => { + if (stopped) { + return + } + try { + await run() + } catch (error) { + if (stopped) { + return + } + if (isWaitingError(error)) { + // Asleep or waking: not a failure; the next wake starts a fresh session. + publish({ phase: 'waiting', progress: null, error: null }) + return + } + const message = error instanceof Error ? error.message : String(error) + log(message) + publish({ phase: 'error', progress: null, error: message.slice(0, 300) }) + } + }) + return chain + } + + /** A full pass requested while one is queued collapses into it. */ + function requestFullPass(): Promise { + if (fullPassQueued) { + return chain + } + fullPassQueued = true + return enqueue(async () => { + fullPassQueued = false + changes.clear() + await fullPass() + }) + } + + async function fullPass(): Promise { + const { selector } = await ensureWorktree() + const current = await loadIndex() + const limits = limitsFor(choice) + if (state.lastSyncedAt === null) { + publish({ phase: 'starting', error: null }) + } + const listed = await listScope(deps.call, selector, scope(), limits.maxFiles, abort.signal) + const remote = await statFiles(deps.call, selector, listed.paths, abort.signal) + const paths = new Set([...remote.keys(), ...Object.keys(current.entries)]) + await apply({ remote, paths, scoped: null, truncated: listed.truncated ? 1 : 0 }) + } + + async function incrementalPass(paths: string[]): Promise { + const { selector } = await ensureWorktree() + await loadIndex() + const included = await filterIncluded(deps.call, selector, scope(), paths, abort.signal) + const remote = await statFiles(deps.call, selector, [...included], abort.signal) + await apply({ remote, paths: new Set(paths), scoped: new Set(paths), truncated: 0 }) + } + + async function apply(input: { + remote: Map + paths: Set + scoped: Set | null + truncated: number + }): Promise { + const current = index! + const limits = limitsFor(choice) + await mkdir(deps.folder, { recursive: true }) + const local = new Map() + for (const path of input.paths) { + if (isSafeSyncPath(path)) { + local.set(path, await localStatAt(deps.folder, path)) + } + } + // Paths that left scope (mirror turned off) are removed like cloud deletions. + const remote = new Map( + [...input.remote].filter( + ([path]) => isSafeSyncPath(path) && (choice.mirror || isContextPath(path)) + ) + ) + const plan = planSync({ + remote, + index: current.entries, + local, + limits, + ...(input.scoped ? { scope: input.scoped } : {}) + }) + await removeCopies(deps.folder, plan.remove, current.entries) + if (plan.download.length > 0) { + const { selector } = await ensureWorktree() + await downloadCopies({ + call: deps.call, + selector, + folder: deps.folder, + paths: plan.download, + remote, + entries: current.entries, + maxFileBytes: limits.maxFileBytes, + signal: abort.signal, + onProgress: (done, total) => publish({ phase: 'copying', progress: { done, total } }), + checkpoint: () => deps.store.writeIndex(deps.workspaceId, current), + log + }) + } + const { files, bytes, edited } = await summarizeCopies(deps.folder, current.entries, { + local, + scoped: input.scoped, + downloaded: new Set(plan.download) + }) + current.lastSyncedAt = now() + await deps.store.writeIndex(deps.workspaceId, current) + const full = input.scoped === null + publish({ + phase: 'synced', + progress: null, + files, + bytes, + lastSyncedAt: current.lastSyncedAt, + // An incremental pass only re-checks its own paths; the last full pass's counts stand. + editedHere: full ? edited : Math.max(state.editedHere, edited), + skippedLarge: full ? plan.skippedLarge : state.skippedLarge + plan.skippedLarge, + overLimit: full ? plan.overLimit + input.truncated : state.overLimit, + error: null + }) + } + + function onWatchEvent(event: unknown): void { + if (!worktree) { + return + } + const batch = readWatchEvent(event, worktree.root, choice.mirror) + if (batch.kind === 'ready') { + watchRetryMs = WATCH_RETRY_MIN_MS + } else if (batch.kind === 'rescan') { + changes.rescan() + } else if (batch.kind === 'paths') { + changes.add(batch.paths) + } + } + + async function openWatch(): Promise { + if (stopped || watch) { + return + } + try { + const { selector } = await ensureWorktree() + const opened = await deps.subscribeWatch(selector, { + onEvent: onWatchEvent, + onClose: () => { + watch = null + scheduleWatchRetry() + } + }) + if (stopped) { + opened.close() + return + } + watch = opened + } catch (error) { + log(`watch failed: ${error instanceof Error ? error.message : String(error)}`) + scheduleWatchRetry() + } + } + + function scheduleWatchRetry(): void { + if (stopped || watchRetryTimer) { + return + } + // Backs off so an unreachable sandbox never turns into a tight loop. + const delay = watchRetryMs + watchRetryMs = Math.min(WATCH_RETRY_MAX_MS, watchRetryMs * 2) + watchRetryTimer = setTimeout(() => { + watchRetryTimer = null + // Changes made while the watch was down are only found by a full pass. + void requestFullPass().then(openWatch) + }, delay) + watchRetryTimer.unref?.() + } + + return { + async start(): Promise { + publish({ phase: 'starting' }) + await loadIndex() + await requestFullPass() + await openWatch() + safetyTimer = setInterval(() => void requestFullPass(), FULL_PASS_INTERVAL_MS) + safetyTimer.unref?.() + }, + /** This computer woke, or the connection came back: anything may have changed meanwhile. */ + resync(): Promise { + if (!watch) { + void openWatch() + } + return requestFullPass() + }, + setChoice(next: CloudSyncChoice): Promise { + choice = next + publish({}) + return requestFullPass() + }, + /** Stops syncing; the files stay (asleep). */ + stop(): Promise { + stopped = true + abort.abort() + watch?.close() + watch = null + changes.dispose() + if (watchRetryTimer) { + clearTimeout(watchRetryTimer) + } + if (safetyTimer) { + clearInterval(safetyTimer) + } + watchRetryTimer = null + safetyTimer = null + return chain + } + } +} + +export type CloudSyncSession = ReturnType diff --git a/src/main/cloud-sync/cloud-sync-store.ts b/src/main/cloud-sync/cloud-sync-store.ts new file mode 100644 index 00000000000..ce302beae7e --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-store.ts @@ -0,0 +1,121 @@ +// Per-device sync choices and per-workspace indexes under userData. Choices are this member's +// alone (a teammate's toggle never changes them); an index records what this computer copied. +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { z } from 'zod' +import type { CloudSyncChoice, IndexEntry } from './cloud-sync-plan' + +export type { CloudSyncChoice } + +/** `.context` is small and holds what people come back for (plans, screenshots): on by default. */ +export const DEFAULT_SYNC_CHOICE: CloudSyncChoice = { context: true, mirror: false } + +const ChoicesSchema = z.object({ + version: z.literal(1), + workspaces: z.record( + z.string(), + z.object({ context: z.boolean().optional(), mirror: z.boolean().optional() }) + ) +}) + +const IndexEntrySchema = z.object({ + rs: z.number(), + rm: z.number(), + ls: z.number(), + lm: z.number() +}) + +const IndexSchema = z.object({ + version: z.literal(1), + folder: z.string(), + lastSyncedAt: z.number().nullable(), + entries: z.record(z.string(), IndexEntrySchema) +}) + +export type CloudSyncIndex = z.infer + +async function writeJson(path: string, value: unknown): Promise { + const temp = `${path}.${randomUUID()}.tmp` + await writeFile(temp, JSON.stringify(value), { mode: 0o600 }) + await rename(temp, path).catch(async (error) => { + await rm(temp, { force: true }) + throw error + }) +} + +export function createCloudSyncStore(userDataPath: string) { + const dir = join(userDataPath, 'orca-cloud-sync') + const choicesPath = join(dir, 'choices.json') + let choices: z.infer | null = null + + const indexPath = (workspaceId: string): string => + join(dir, `${createHash('sha256').update(workspaceId).digest('hex').slice(0, 32)}.json`) + + async function loadChoices(): Promise> { + if (choices) { + return choices + } + const raw = await readFile(choicesPath, 'utf8').catch(() => null) + const parsed = raw ? ChoicesSchema.safeParse(safeJson(raw)) : null + choices = parsed?.success ? parsed.data : { version: 1, workspaces: {} } + return choices + } + + return { + dir, + async choice(workspaceId: string): Promise { + const saved = (await loadChoices()).workspaces[workspaceId] + return { + context: saved?.context ?? DEFAULT_SYNC_CHOICE.context, + mirror: saved?.mirror ?? DEFAULT_SYNC_CHOICE.mirror + } + }, + async setChoice( + workspaceId: string, + patch: Partial + ): Promise { + const all = await loadChoices() + const next = { ...(await this.choice(workspaceId)), ...patch } + all.workspaces[workspaceId] = next + await mkdir(dir, { recursive: true }) + await writeJson(choicesPath, all) + return next + }, + async forgetChoice(workspaceId: string): Promise { + const all = await loadChoices() + if (all.workspaces[workspaceId]) { + delete all.workspaces[workspaceId] + await mkdir(dir, { recursive: true }) + await writeJson(choicesPath, all) + } + }, + async readIndex(workspaceId: string, folder: string): Promise { + const raw = await readFile(indexPath(workspaceId), 'utf8').catch(() => null) + const parsed = raw ? IndexSchema.safeParse(safeJson(raw)) : null + // An index for another folder (workspace renamed) describes files that aren't there. + if (parsed?.success && parsed.data.folder === folder) { + return parsed.data + } + return { version: 1, folder, lastSyncedAt: null, entries: {} } + }, + async writeIndex(workspaceId: string, index: CloudSyncIndex): Promise { + await mkdir(dir, { recursive: true }) + await writeJson(indexPath(workspaceId), index) + }, + async removeIndex(workspaceId: string): Promise { + await rm(indexPath(workspaceId), { force: true }) + } + } +} + +function safeJson(raw: string): unknown { + try { + return JSON.parse(raw) + } catch { + return null + } +} + +export type CloudSyncStore = ReturnType +export type { IndexEntry } diff --git a/src/main/cloud-sync/cloud-sync-watch.ts b/src/main/cloud-sync/cloud-sync-watch.ts new file mode 100644 index 00000000000..e9c29e5408b --- /dev/null +++ b/src/main/cloud-sync/cloud-sync-watch.ts @@ -0,0 +1,122 @@ +// The cloud's file watch, read for sync: which workspace paths changed, or that a rescan is +// needed (overflow, or a directory event that can stand for many files). Events come from the VM +// and are validated like any other reply. +import { z } from 'zod' +import { isContextPath, isSafeSyncPath } from './cloud-sync-paths' +import { relativeToRoot } from './cloud-sync-remote' + +const ChangeSchema = z.looseObject({ + kind: z.string().optional(), + absolutePath: z.unknown().optional(), + oldAbsolutePath: z.unknown().optional(), + isDirectory: z.boolean().optional() +}) +const WatchEventSchema = z.looseObject({ + type: z.string(), + events: z.array(z.unknown()).optional() +}) + +export type WatchSubscription = { close: () => void } +export type WatchSubscribe = ( + selector: string, + handlers: { onEvent: (event: unknown) => void; onClose: () => void } +) => Promise + +export type WatchBatch = + | { kind: 'ready' } + | { kind: 'rescan' } + | { kind: 'paths'; paths: string[] } + | { kind: 'none' } + +export function readWatchEvent(event: unknown, root: string, mirror: boolean): WatchBatch { + const parsed = WatchEventSchema.safeParse(event) + if (!parsed.success) { + return { kind: 'none' } + } + if (parsed.data.type === 'ready') { + return { kind: 'ready' } + } + if (parsed.data.type !== 'changed' || !parsed.data.events) { + return { kind: 'none' } + } + const paths: string[] = [] + for (const raw of parsed.data.events) { + const change = ChangeSchema.safeParse(raw) + if (!change.success) { + continue + } + if (change.data.kind === 'overflow' || change.data.isDirectory === true) { + return { kind: 'rescan' } + } + for (const candidate of [change.data.absolutePath, change.data.oldAbsolutePath]) { + const rel = relativeToRoot(root, candidate) + if (rel && isSafeSyncPath(rel) && (mirror || isContextPath(rel))) { + paths.push(rel) + } + } + } + return paths.length > 0 ? { kind: 'paths', paths } : { kind: 'none' } +} + +const DEBOUNCE_MS = 800 +const MAX_WAIT_MS = 5_000 +/** More changed paths than this in one burst (a checkout, an install) is cheaper as a full pass. */ +const MAX_PATHS = 400 + +/** Collects changed paths for a short quiet period (bounded wait), then hands them over. */ +export function createChangeBatcher(handlers: { + onPaths: (paths: string[]) => void + onRescan: () => void + now?: () => number +}) { + const now = handlers.now ?? Date.now + const dirty = new Set() + let timer: ReturnType | null = null + let since = 0 + + const cancel = (): void => { + if (timer) { + clearTimeout(timer) + } + timer = null + } + const flush = (): void => { + cancel() + const paths = [...dirty] + dirty.clear() + if (paths.length > 0) { + handlers.onPaths(paths) + } + } + return { + add(paths: readonly string[]): void { + for (const path of paths) { + dirty.add(path) + } + if (dirty.size > MAX_PATHS) { + this.rescan() + return + } + if (timer === null) { + since = now() + } + cancel() + timer = setTimeout(flush, Math.max(0, Math.min(DEBOUNCE_MS, since + MAX_WAIT_MS - now()))) + timer.unref?.() + }, + rescan(): void { + cancel() + dirty.clear() + handlers.onRescan() + }, + /** A full pass covers whatever was waiting. */ + clear(): void { + cancel() + dirty.clear() + }, + dispose(): void { + cancel() + dirty.clear() + } + } +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts index 97de012ff10..bfc062455d5 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-install.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts @@ -50,6 +50,7 @@ import { } from '../ipc/cloud-environments/orca-cloud-environment-handlers' import { installCloudTransferHandlers } from './cloud-transfer' import { installCloudOfflineService, setCloudOfflineService } from './cloud-offline-service' +import { getCloudSyncService, installCloudSync } from '../cloud-sync/cloud-sync-ipc' import { findCloudRuntime, isArchivedOnControlPlane, @@ -147,7 +148,10 @@ export function installHostedSandbox( userDataPath, driver, windows: () => BrowserWindow.getAllWindows(), - onConnected: (runtimeEnvironmentId) => void mcpSync.sync(runtimeEnvironmentId, 'connect'), + onConnected: (runtimeEnvironmentId) => { + void mcpSync.sync(runtimeEnvironmentId, 'connect') + getCloudSyncService()?.noteConnected(runtimeEnvironmentId) + }, onRunningChange: (anyRunning) => usageRefresh.setRunning(anyRunning), activeAccountId }) @@ -205,6 +209,10 @@ export function installHostedSandbox( } }) setCloudOfflineService(offline) + const disposeCloudSync = installCloudSync({ + userDataPath, + windows: () => BrowserWindow.getAllWindows() + }) // Records land after the step answers and wakes can come from any path, so reconcile on a slow beat too. let beat: ReturnType | null = null let beats = 0 @@ -253,6 +261,7 @@ export function installHostedSandbox( setOrcaCloudActiveAccountResolver(null) offline.dispose() setCloudOfflineService(null) + disposeCloudSync() }) } diff --git a/src/main/hosted-sandbox/hosted-workspace-service.ts b/src/main/hosted-sandbox/hosted-workspace-service.ts index b001469bbe6..0706896c744 100644 --- a/src/main/hosted-sandbox/hosted-workspace-service.ts +++ b/src/main/hosted-sandbox/hosted-workspace-service.ts @@ -22,6 +22,7 @@ import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-p import { toReady } from './hosted-sandbox-driver' import { noteWakeTimeline } from './cloud-wake-timeline' import { createHostedRuntimeEnvironmentCall } from './hosted-runtime-environment-call' +import { getCloudSyncService } from '../cloud-sync/cloud-sync-ipc' import { NOT_CLOUD, createOomTracker, @@ -270,6 +271,8 @@ export function installHostedWorkspaceService(deps: { [args.workspaceId], 'The workspace was archived before this was sent.' ) + // The archive toast said so: the copy synced to this computer goes now, not on the next check. + void getCloudSyncService()?.noteArchived(args.workspaceId) deps.syncSessions() return { ok: true, value: null } } catch (error) { diff --git a/src/main/runtime/construction/runtime-file-command-surface.ts b/src/main/runtime/construction/runtime-file-command-surface.ts index 87e3ca3f822..9f3e050ec16 100644 --- a/src/main/runtime/construction/runtime-file-command-surface.ts +++ b/src/main/runtime/construction/runtime-file-command-surface.ts @@ -22,6 +22,7 @@ export type RuntimeFileCommandSurface = Pick< | 'listRuntimeMarkdownDocuments' | 'pathsExistRuntimeFiles' | 'statRuntimeFile' + | 'statManyRuntimeFiles' > & Pick< RuntimeFileReads, @@ -115,6 +116,7 @@ export function installRuntimeFileCommandSurface( listRuntimeMarkdownDocuments: fileAccess.listRuntimeMarkdownDocuments.bind(fileAccess), pathsExistRuntimeFiles: fileAccess.pathsExistRuntimeFiles.bind(fileAccess), statRuntimeFile: fileAccess.statRuntimeFile.bind(fileAccess), + statManyRuntimeFiles: fileAccess.statManyRuntimeFiles.bind(fileAccess), stagedFileSaves: stagedFileSaveCommands } satisfies RuntimeFileCommandSurface) } diff --git a/src/main/runtime/files/file-access.stat-many.test.ts b/src/main/runtime/files/file-access.stat-many.test.ts new file mode 100644 index 00000000000..e5d2bd7e120 --- /dev/null +++ b/src/main/runtime/files/file-access.stat-many.test.ts @@ -0,0 +1,45 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { RuntimeFileAccess } from './file-access' +import { createRuntimeFileHost } from './runtime-file-host.test-fixture' + +const dirs: string[] = [] +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +describe('files.statMany (statManyRuntimeFiles)', () => { + it('stats a batch in order: files, directories and absent paths', async () => { + const root = await mkdtemp(join(tmpdir(), 'stat-many-')) + dirs.push(root) + await mkdir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.ts'), 'hello') + const { host, store } = createRuntimeFileHost({ path: root }) + // The local provider authorizes paths against the registered repositories. + Object.assign(store, { + getRepos: () => [{ id: 'repo-1', path: root, displayName: 'repo', kind: 'git' }] + }) + const access = new RuntimeFileAccess(host) + const { entries } = await access.statManyRuntimeFiles('wt-1', [ + 'src/a.ts', + 'src', + 'missing.txt' + ]) + expect(entries[0]).toMatchObject({ size: 5, type: 'file', mtime: expect.any(Number) }) + expect(entries[1]).toMatchObject({ type: 'directory' }) + expect(entries[2]).toBeNull() + }) + + it('refuses batches over the limit', async () => { + const { host } = createRuntimeFileHost({ path: '/repo' }) + const access = new RuntimeFileAccess(host) + await expect( + access.statManyRuntimeFiles( + 'wt-1', + Array.from({ length: 2001 }, (_, i) => `f${i}`) + ) + ).rejects.toThrow('Invalid stat batch') + }) +}) diff --git a/src/main/runtime/files/file-access.ts b/src/main/runtime/files/file-access.ts index 1ec963b2319..519ea01171f 100644 --- a/src/main/runtime/files/file-access.ts +++ b/src/main/runtime/files/file-access.ts @@ -9,6 +9,11 @@ import { type PathExistenceResult } from '../../../shared/file-access/path-existence-batch' import { readRuntimeFilePathExistence } from './file-path-existence' +import { + FILE_STAT_MANY_MAX, + type FileStatManyEntry, + type FileStatManyResult +} from '../../../shared/file-access/file-stat-many' import { isMobileBinaryPath, isMobileMarkdownPath, @@ -18,7 +23,10 @@ import { import type { RuntimeFileCommandHost } from './file-command-host' import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation' import { joinWorktreeRelativePath } from './paths/runtime-relative-paths' -import { isENOENT } from '../../ipc/filesystem/access/filesystem-path-containment' +import { + isENOENT, + isPathAbsentError +} from '../../ipc/filesystem/access/filesystem-path-containment' import { requireRuntimeFileProvider, resolveFileExplorerPath, @@ -28,6 +36,8 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/exec import type { FileStat } from '../../providers/filesystem-provider-contract' import { assertRuntimeReadWslDistroRunning } from '../integrations/git/runtime-wsl-background-read-gate' +const STAT_MANY_CONCURRENCY = 32 + type RuntimeFileAccessHost = Pick< RuntimeFileCommandHost, 'requireStore' | 'resolveRuntimeFileTarget' | 'openFile' | 'openDiff' @@ -141,6 +151,48 @@ export class RuntimeFileAccess { return readRuntimeFilePathExistence(targets, this.host) } + /** Stat of many workspace paths in one call (cloud file sync manifests). */ + async statManyRuntimeFiles( + worktreeSelector: string, + relativePaths: string[] + ): Promise { + if (!Array.isArray(relativePaths) || relativePaths.length > FILE_STAT_MANY_MAX) { + throw new Error('Invalid stat batch') + } + const targets = await resolveFileExplorerPaths(this.host, worktreeSelector, relativePaths) + if (targets.length === 0) { + return { entries: [] } + } + await assertRuntimeReadWslDistroRunning(targets[0]) + const provider = requireRuntimeFileProvider(targets[0], this.host) + // Links are followed like every files.* read; one pointing outside the workspace fails the + // later chunk read's containment check, so it is never copied. + const statOne = provider.stat.bind(provider) + const entries: FileStatManyEntry[] = Array.from({ length: targets.length }, () => null) + let next = 0 + const worker = async (): Promise => { + while (next < targets.length) { + const index = next++ + try { + const stats = await statOne(targets[index].path) + entries[index] = { + size: stats.size, + mtime: stats.mtimeMs ?? stats.mtime, + type: stats.type + } + } catch (error) { + entries[index] = isPathAbsentError(error) + ? null + : { error: error instanceof Error ? error.message.slice(0, 200) : 'stat_failed' } + } + } + } + await Promise.all( + Array.from({ length: Math.min(STAT_MANY_CONCURRENCY, targets.length) }, worker) + ) + return { entries } + } + async statRuntimeFile( worktreeSelector: string, relativePath: string diff --git a/src/main/runtime/rpc/methods/files/files-stat-many-methods.ts b/src/main/runtime/rpc/methods/files/files-stat-many-methods.ts new file mode 100644 index 00000000000..226d33abb2c --- /dev/null +++ b/src/main/runtime/rpc/methods/files/files-stat-many-methods.ts @@ -0,0 +1,13 @@ +import { defineMethod } from '../../core' +import { FileStatMany } from '../../../../../shared/rpc-contract/files-params' + +/** Batched stat for cloud file sync manifests (one call instead of one per file). */ +export const FILE_STAT_MANY_METHODS = [ + defineMethod({ + name: 'files.statMany', + permission: 'workspace', + params: FileStatMany, + handler: async (params, { runtime }) => + runtime.statManyRuntimeFiles(params.worktree, params.relativePaths) + }) +] diff --git a/src/main/runtime/rpc/methods/rpc-method-registry.ts b/src/main/runtime/rpc/methods/rpc-method-registry.ts index 8f6474dded0..d6867656788 100644 --- a/src/main/runtime/rpc/methods/rpc-method-registry.ts +++ b/src/main/runtime/rpc/methods/rpc-method-registry.ts @@ -35,6 +35,7 @@ import { COMPUTER_METHODS } from './computer/computer' import { SESSION_TAB_METHODS } from './session-tabs/session-tabs' import { NATIVE_CHAT_METHODS } from './native-chat/native-chat' import { FILE_METHODS } from './files/files' +import { FILE_STAT_MANY_METHODS } from './files/files-stat-many-methods' import { GIT_METHODS } from './source-control/git/git' import { GITHUB_METHODS } from './source-control/github/github' import { GITLAB_METHODS } from './source-control/gitlab/gitlab' @@ -140,6 +141,7 @@ export const ALL_RPC_METHODS = [ ...(ORCA_LAYOUT_OWNER_IS_MAIN ? [...LAYOUT_APPLY_METHODS, ...LAYOUT_VIEW_REPORT_METHODS] : []), ...NATIVE_CHAT_METHODS, ...FILE_METHODS, + ...FILE_STAT_MANY_METHODS, ...GIT_METHODS, ...GITHUB_METHODS, ...GITLAB_METHODS, diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index df68073a9b3..50890285c3e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -74,6 +74,7 @@ import type { SpeechApi } from './api/speech-api' import type { SshApi } from './api/ssh-api' import type { PlainSshTerminalApi } from './api/plain-ssh-terminal-bridge' import type { RuntimeDownloadsApi } from './api/runtime-downloads-bridge' +import type { CloudSyncApi } from '../shared/cloud-sync/cloud-sync-contract' import type { LegacyRelayBridgeApi } from './api/legacy-relay-bridge-api' import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api' import type { UiCommandEventApi } from './api/ui-command-event-api' @@ -185,6 +186,8 @@ export type PreloadApi = { plainSsh?: PlainSshTerminalApi /** Desktop only: streams files and folders from a paired runtime host to this machine. */ runtimeDownloads?: RuntimeDownloadsApi + /** Desktop only: Orca Cloud workspace files synced to this computer (.context and the mirror). */ + cloudSync?: CloudSyncApi /** Desktop only: the API keys Orca hands to agent launches. */ agentApiKeys?: AgentApiKeysApi /** Desktop only: Orca Cloud API keys, org settings and routines (hosted builds). */ diff --git a/src/preload/api/cloud-sync-bridge.ts b/src/preload/api/cloud-sync-bridge.ts new file mode 100644 index 00000000000..2100350cf82 --- /dev/null +++ b/src/preload/api/cloud-sync-bridge.ts @@ -0,0 +1,19 @@ +import { ipcRenderer } from 'electron' +import { + ORCA_CLOUD_SYNC_CHANNELS as CH, + type CloudSyncApi, + type CloudSyncView +} from '../../shared/cloud-sync/cloud-sync-contract' + +export const cloudSyncApi: CloudSyncApi = { + getState: (workspaceId) => ipcRenderer.invoke(CH.getState, workspaceId), + setChoice: (args) => ipcRenderer.invoke(CH.setChoice, args), + openFolder: (workspaceId) => ipcRenderer.invoke(CH.openFolder, workspaceId), + retry: (workspaceId) => ipcRenderer.invoke(CH.retry, workspaceId), + removalPreview: (workspaceId) => ipcRenderer.invoke(CH.removalPreview, workspaceId), + onChanged: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, view: CloudSyncView) => listener(view) + ipcRenderer.on(CH.changed, handler) + return () => ipcRenderer.removeListener(CH.changed, handler) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 97df80b930c..3926fecc021 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -92,6 +92,7 @@ import { cursorAccountsApi } from './api/cursor-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge' import { runtimeDownloadsApi } from './api/runtime-downloads-bridge' +import { cloudSyncApi } from './api/cloud-sync-bridge' import { agentApiKeysApi } from './api/agent-api-keys-bridge' import { cloudApiBridge } from './api/cloud-api-bridge' import { cloudSecretsApi } from './api/cloud-secrets-bridge' @@ -209,6 +210,7 @@ const api = { ssh: sshApi, plainSsh: plainSshTerminalApi, runtimeDownloads: runtimeDownloadsApi, + cloudSync: cloudSyncApi, agentApiKeys: agentApiKeysApi, cloudApi: cloudApiBridge, cloudSecrets: cloudSecretsApi, diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceMark.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMark.tsx index 8de188f9fd3..de65a66dbfc 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceMark.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceMark.tsx @@ -8,6 +8,7 @@ import { cloudStateLabel } from './cloud-state-copy' import { CloudWorkspaceStatusPanel } from './CloudWorkspaceStatusPanel' import { useCloudStatusPolling, useCloudWorkspaceView } from './use-cloud-workspace-view' import { useCloudPanelActions } from './use-cloud-panel-actions' +import { onCloudSyncFocusRequest } from '@/lib/orca-cloud/cloud-sync-store' const PANEL_POLL_MS = 5_000 const HOVER_OPEN_MS = 250 @@ -67,6 +68,15 @@ function CloudWorkspaceMarkInner({ timer.current = setTimeout(() => setOpen(null), HOVER_CLOSE_MS) } } + // "Mirror to this computer…" in the workspace menu opens this panel on its sync section. + useEffect( + () => + onCloudSyncFocusRequest(workspaceId, () => { + clearTimer() + setOpen('pinned') + }), + [workspaceId, clearTimer] + ) useCloudStatusPolling(workspaceId, PANEL_POLL_MS, open !== null) const close = useCallback(() => setOpen(null), []) const actions = useCloudPanelActions(workspaceId, close) diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx index 6b112a7fbc6..4a5281c0cfb 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceMenuItems.tsx @@ -1,7 +1,8 @@ import React from 'react' -import { Archive, ArchiveRestore, RotateCw } from 'lucide-react' +import { Archive, ArchiveRestore, HardDriveDownload, RotateCw } from 'lucide-react' import { DropdownMenuItem } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' +import { isCloudSyncAvailable, requestCloudSyncFocus } from '@/lib/orca-cloud/cloud-sync-store' import { useCloudWorkspaceView } from './use-cloud-workspace-view' import { useCloudPanelActions } from './use-cloud-panel-actions' @@ -48,6 +49,12 @@ function CloudMenuRows({ {t('orcaCloudWorkspace.restartSandbox', 'Restart sandbox')} ) : null} + {isCloudSyncAvailable() ? ( + requestCloudSyncFocus(worktreeId)}> + + {t('orcaCloudSync.menuItem', 'Mirror to this computer…')} + + ) : null} {t('orcaCloudWorkspace.archive', 'Archive')} diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx index 4a2bad51a26..c765dec34cb 100644 --- a/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceStatusPanel.tsx @@ -37,6 +37,7 @@ import { CloudStateDot } from './CloudStateDot' import { CloudSecretsMissingNotice } from '../cloud-secrets-ask/CloudSecretsMissingNotice' import { CloudAgentsAndSecretsLink } from '../agent-host-panel/CloudAgentsAndSecretsLink' import { cloudStateDetail, cloudStateLabel } from './cloud-state-copy' +import { CloudWorkspaceSyncSection } from './CloudWorkspaceSyncSection' export type CloudPanelActions = { onWake: () => void @@ -325,6 +326,8 @@ export function CloudWorkspaceStatusPanel({ ) : null} + {state === 'archived' ? null : } + {showFooter ? (
{/* Not a live region: a ticking "ago" would announce itself every few seconds. */} diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.fixture.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.fixture.tsx new file mode 100644 index 00000000000..f2c121b860a --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.fixture.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { seedCloudSyncView } from '@/lib/orca-cloud/cloud-sync-store' +import type { CloudStatusEntry } from '@/lib/orca-cloud/orca-cloud-workspace-store' +import type { CloudDotState } from '@/lib/orca-cloud/orca-cloud-workspace-state' +import { + ORCA_CLOUD_SANDBOX_SPEC, + type HostedWorkspaceStatus +} from '../../../../shared/hosted-sandbox/hosted-sandbox-wire' +import type { CloudSyncView } from '../../../../shared/cloud-sync/cloud-sync-contract' +import { CloudWorkspaceStatusPanel } from './CloudWorkspaceStatusPanel' + +const noop = (): void => {} +const actions = { onWake: noop, onRestart: noop, onArchive: noop, onUnarchive: noop, onSleep: noop } + +const running: HostedWorkspaceStatus = { + state: 'ready', + metrics: { + cpuPercent: 37, + cpus: ORCA_CLOUD_SANDBOX_SPEC.vcpus, + load1: 1.2, + memTotalBytes: ORCA_CLOUD_SANDBOX_SPEC.memoryGb * 1e9, + memUsedBytes: 5.1e9, + diskTotalBytes: 34.3e9, + diskUsedBytes: 6.2e9, + uptimeSeconds: 7_420, + oomKills: 0, + lastOomVictim: null, + orcadAlive: true + }, + uptimeMs: 2 * 3_600_000 + 3 * 60_000, + usage: { totalMs: 14.6 * 3_600_000, centsPerHour: 100, costCents: 1460 }, + archive: null, + checkedAt: Date.now() +} +const asleep: HostedWorkspaceStatus = { + ...running, + state: 'sleeping', + metrics: null, + uptimeMs: null +} + +const entry = (status: HostedWorkspaceStatus): CloudStatusEntry => ({ + status, + loading: false, + error: null, + fetchedAt: Date.now() - 20_000 +}) + +const FOLDER = '/Users/me/orca/cloud/Hello-World/fix-login-redirect' + +function view(id: string, over: Partial): CloudSyncView { + const choice = over.choice ?? { context: true, mirror: false } + const openPath = choice.mirror ? FOLDER : `${FOLDER}/.context` + return { + workspaceId: id, + choice, + folder: FOLDER, + phase: 'synced', + files: 12, + bytes: 640_000, + progress: null, + lastSyncedAt: Date.now() - 8_000, + editedHere: 0, + skippedLarge: 0, + overLimit: 0, + limits: choice.mirror + ? { maxBytes: 2_000_000_000, maxFileBytes: 100_000_000 } + : { maxBytes: 250_000_000, maxFileBytes: 50_000_000 }, + error: null, + openPath, + displayPath: openPath.replace('/Users/me', '~'), + ...over + } +} + +function panel( + id: string, + sync: Partial, + state: CloudDotState = 'running' +): ReturnType { + // Seeded once, outside render: the section reads it through the same store as the live app. + seedCloudSyncView(view(id, sync)) + return defineFixture({ + width: 300, + render: () => { + return ( +
+ +
+ ) + } + }) +} + +/** Default: `.context` on, mirror off. */ +export const contextSynced = panel('fx-context', {}) +export const contextSyncing = panel('fx-context-start', { + phase: 'starting', + files: 0, + bytes: 0, + lastSyncedAt: null +}) +export const mirrorCopying = panel('fx-mirror-copying', { + choice: { context: true, mirror: true }, + phase: 'copying', + progress: { done: 120, total: 1_204 } +}) +export const mirrorSynced = panel('fx-mirror', { + choice: { context: true, mirror: true }, + files: 1_204, + bytes: 38_400_000, + lastSyncedAt: Date.now() - 2 * 60_000 +}) +export const asleepWaiting = panel( + 'fx-asleep', + { + choice: { context: true, mirror: true }, + phase: 'waiting', + files: 1_204, + bytes: 38_400_000, + lastSyncedAt: Date.now() - 63 * 60_000 + }, + 'sleeping' +) +export const editedHere = panel('fx-edited', { + choice: { context: true, mirror: true }, + files: 1_204, + bytes: 38_400_000, + editedHere: 3 +}) +export const overLimit = panel('fx-limit', { + choice: { context: true, mirror: true }, + files: 61_000, + bytes: 1_990_000_000, + overLimit: 812, + skippedLarge: 2 +}) +export const syncError = panel('fx-error', { + phase: 'error', + error: 'worktree_not_found' +}) +export const bothOff = panel('fx-off', { + choice: { context: false, mirror: false }, + phase: 'off', + files: 0, + bytes: 0, + lastSyncedAt: null +}) diff --git a/src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.tsx b/src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.tsx new file mode 100644 index 00000000000..9b1ac7bcbe0 --- /dev/null +++ b/src/renderer/src/components/orca-cloud/CloudWorkspaceSyncSection.tsx @@ -0,0 +1,367 @@ +// "On this computer" in a cloud workspace's status panel: `.context` sync (on by default) and the +// read-only mirror (off by default), where the files are, and how current they are. Quiet by +// design: no toasts; progress is announced politely and at most every ~10 s. +import React, { useContext, useEffect, useId, useRef, useState } from 'react' +import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context' +import { Copy, FolderOpen } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { Switch } from '@/components/ui/switch' +import { useNow } from '@/hooks/use-now' +import { getIntlLocale } from '@/i18n/i18n' +import { useTranslate } from '@/i18n/use-translate' +import { + openCloudSyncFolder, + retryCloudSync, + setCloudSyncChoice, + takeCloudSyncFocus, + useCloudSync, + useCloudSyncFocusRequest +} from '@/lib/orca-cloud/cloud-sync-store' +import type { CloudSyncView } from '../../../../shared/cloud-sync/cloud-sync-contract' + +const ANNOUNCE_EVERY_MS = 10_000 +const JUST_NOW_MS = 60_000 + +type T = ReturnType + +function formatCount(value: number): string { + return new Intl.NumberFormat(getIntlLocale()).format(value) +} + +/** Round sizes for prose and the status line ("2 GB", "1.4 GB", "38 MB", "640 KB"). */ +export function syncSize(t: T, bytes: number): string { + const format = (value: number, digits: number): string => + new Intl.NumberFormat(getIntlLocale(), { maximumFractionDigits: digits }).format(value) + if (bytes >= 1e9) { + return t('orcaCloudSync.gb', '{{value}} GB', { value: format(bytes / 1e9, 1) }) + } + if (bytes >= 1e6) { + return t('orcaCloudSync.mb', '{{value}} MB', { value: format(bytes / 1e6, 0) }) + } + return t('orcaCloudSync.kb', '{{value}} KB', { + value: format(Math.max(bytes > 0 ? 1 : 0, bytes / 1e3), 0) + }) +} + +/** Breaks a path only after "/", so folder names stay whole when it wraps. */ +function PathWithBreaks({ path }: { path: string }): React.JSX.Element { + const parts = path.split('/') + return ( + <> + {parts.map((part, i) => ( + // oxlint-disable-next-line react/no-array-index-key -- path segments can repeat; their order is the identity + + {part} + {i < parts.length - 1 ? ( + <> + / + + ) : null} + + ))} + + ) +} + +function ago(t: T, at: number, now: number): string { + const minutes = Math.floor((now - at) / JUST_NOW_MS) + if (minutes < 1) { + return t('orcaCloudSync.justNow', 'just now') + } + // Whole minutes, then whole hours: a status line that ticks seconds reads as noise. + return minutes < 60 + ? t('orcaCloudSync.minutesAgo', '{{count}} min ago', { count: minutes }) + : t('orcaCloudSync.hoursAgo', '{{count}} h ago', { count: Math.floor(minutes / 60) }) +} + +/** The status line under the switches. */ +export function cloudSyncStatusLine(view: CloudSyncView, t: T, now: number): string | null { + if (view.phase === 'off') { + return null + } + if (view.phase === 'copying' && view.progress) { + return t('orcaCloudSync.copying', 'Copying {{done}} of {{total}} files…', { + done: formatCount(view.progress.done), + total: formatCount(view.progress.total) + }) + } + if (view.phase === 'waiting') { + return view.lastSyncedAt + ? t('orcaCloudSync.waitingSynced', 'Waits until the workspace wakes · synced {{ago}}', { + ago: ago(t, view.lastSyncedAt, now) + }) + : t('orcaCloudSync.waiting', 'Waits until the workspace wakes') + } + if (view.phase === 'starting' || (view.phase === 'copying' && !view.progress)) { + return t('orcaCloudSync.syncing', 'Syncing…') + } + if (view.phase === 'synced' && view.lastSyncedAt) { + const values = { + files: t('orcaCloudSync.fileCount', '{{n}} files', { + count: view.files, + n: formatCount(view.files), + defaultValue_one: '{{n}} file' + }), + size: syncSize(t, view.bytes), + ago: ago(t, view.lastSyncedAt, now) + } + // The cap was hit: say so instead of claiming a full copy. + return view.overLimit > 0 + ? t( + 'orcaCloudSync.syncedToLimit', + 'Synced up to the limit · {{files}} · {{size}} · {{ago}}', + values + ) + : t('orcaCloudSync.synced', 'Synced · {{files}} · {{size}} · {{ago}}', values) + } + return null +} + +function SyncRow({ + label, + description, + checked, + onCheckedChange, + switchRef +}: { + label: string + description: string + checked: boolean + onCheckedChange: (next: boolean) => void + switchRef?: React.Ref +}): React.JSX.Element { + const id = useId() + return ( +
+
+ +

+ {description} +

+
+ +
+ ) +} + +/** Polite, throttled announcements: progress at most every ~10 s; done and errors at once. */ +function useAnnouncement(view: CloudSyncView | null, t: T): string { + const [message, setMessage] = useState('') + const lastAt = useRef(0) + const lastPhase = useRef(null) + useEffect(() => { + if (!view) { + return + } + const phaseChanged = lastPhase.current !== view.phase + lastPhase.current = view.phase + const now = Date.now() + if (view.phase === 'copying' && view.progress) { + if (now - lastAt.current >= ANNOUNCE_EVERY_MS) { + lastAt.current = now + setMessage(cloudSyncStatusLine(view, t, now) ?? '') + } + } else if (phaseChanged && view.phase === 'synced') { + lastAt.current = 0 + setMessage(t('orcaCloudSync.announceSynced', 'Files synced to this computer')) + } else if (phaseChanged && view.phase === 'error') { + setMessage(t('orcaCloudSync.error', 'Couldn’t sync files. Your files in the cloud are safe.')) + } + }, [view, t]) + return message +} + +export function CloudWorkspaceSyncSection({ + workspaceId, + view: injected +}: { + workspaceId: string + /** Fixtures pass a view; the panel reads the live one. */ + view?: CloudSyncView | null +}): React.JSX.Element | null { + const t = useTranslate() + const live = useCloudSync(workspaceId) + const view = injected === undefined ? live : injected + const now = useNow(30_000) + const section = useRef(null) + const mirrorSwitch = useRef(null) + const focusRequested = useCloudSyncFocusRequest(workspaceId) + const [copied, setCopied] = useState(false) + const announcement = useAnnouncement(view, t) + const confirm = useContext(ConfirmationDialogContext) + + /** Turning a row off removes copies; changed ones are only removed after asking (as archive/delete). */ + const choose = async (patch: { context?: boolean; mirror?: boolean }): Promise => { + if (!view) { + return + } + const next = { ...view.choice, ...patch } + const removes = (view.choice.mirror && !next.mirror) || (!next.context && !next.mirror) + if (removes && view.editedHere > 0 && confirm) { + const ok = await confirm({ + title: t('orcaCloudSync.removeConfirmTitle', 'Remove the copy on this computer?'), + description: t( + 'orcaCloudSync.removalEdited', + '{{count}} changed files on this computer will be removed.', + { + count: view.editedHere, + defaultValue_one: '{{count}} changed file on this computer will be removed.' + } + ), + confirmLabel: t('orcaCloudSync.removeCopy', 'Remove copy'), + confirmVariant: 'destructive', + initialFocus: 'cancel' + }) + if (!ok) { + return + } + } + await setCloudSyncChoice(workspaceId, patch) + } + + useEffect(() => { + if (!focusRequested || !view) { + return + } + // After the popover's own open-focus, which runs once this effect has. + const frame = requestAnimationFrame(() => { + if (takeCloudSyncFocus(workspaceId)) { + section.current?.scrollIntoView({ block: 'nearest' }) + mirrorSwitch.current?.focus() + } + }) + return () => cancelAnimationFrame(frame) + }, [focusRequested, view, workspaceId]) + + if (!view) { + return null + } + const on = view.choice.context || view.choice.mirror + const status = cloudSyncStatusLine(view, t, now) + const tooLarge = view.skippedLarge > 0 + const overLimit = view.overLimit > 0 + + return ( +
+

+ {t('orcaCloudSync.title', 'On this computer')} +

+ void choose({ context: next })} + /> + void choose({ mirror: next })} + /> + + {on ? ( +
+ {status ?

{status}

: null} + + + +
+ + +
+
+ ) : null} + + {on && view.phase === 'error' ? ( + void retryCloudSync(workspaceId)} + density="compact" + /> + ) : null} + {on && view.editedHere > 0 ? ( + + ) : null} + {on && (tooLarge || overLimit) ? ( + + ) : null} + + {announcement} + +
+ ) +} diff --git a/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts b/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts index 6f5029deae4..bdc15ee45c4 100644 --- a/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts +++ b/src/renderer/src/components/orca-cloud/cloud-workspace-actions.ts @@ -23,6 +23,7 @@ import { isCloudWorkspaceDeleting, registerQueuedCloudArchive } from '@/lib/orca-cloud/pending-cloud-archive' +import { cloudSyncRemovalPreview } from '@/lib/orca-cloud/cloud-sync-store' /** Codes map to plain copy; raw control-plane messages never reach a toast. */ function failureToast(title: string, result: CloudWorkspaceResult): void { @@ -115,6 +116,8 @@ export function runArchiveWithUndo( commit: () => Promise> /** False while the workspace is being deleted: its failure is not worth a toast. */ reportFailure?: () => boolean + /** The copy synced to this computer, which goes with the archive (said in the toast). */ + localCopy?: { hasLocalCopy: boolean; editedHere: number } } ): Promise { return new Promise((resolve) => { @@ -156,10 +159,7 @@ export function runArchiveWithUndo( { id: toastId, duration: options.undoMs ?? ARCHIVE_UNDO_MS, - description: translate( - 'orcaCloudWorkspace.archivedDetail', - 'It goes to sleep and stays browsable: PRs, last changes and files.' - ), + description: archivedDetail(options.localCopy), action: { label: translate('orcaCloudWorkspace.undo', 'Undo'), onClick: cancel @@ -169,6 +169,30 @@ export function runArchiveWithUndo( }) } +function archivedDetail(localCopy?: { hasLocalCopy: boolean; editedHere: number }): string { + if (localCopy?.editedHere) { + return translate( + 'orcaCloudWorkspace.archivedDetailLocalEdits', + 'It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed files on this computer will be removed.', + { + count: localCopy.editedHere, + defaultValue_one: + 'It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed file on this computer will be removed.' + } + ) + } + if (localCopy?.hasLocalCopy) { + return translate( + 'orcaCloudWorkspace.archivedDetailLocalCopy', + 'It goes to sleep and stays browsable: PRs, last changes and files. The copy on this computer is removed too.' + ) + } + return translate( + 'orcaCloudWorkspace.archivedDetail', + 'It goes to sleep and stays browsable: PRs, last changes and files.' + ) +} + /** Sidebar archive: the ordinary Sleep, then the control-plane archive, behind the Undo toast. */ export async function runArchiveCloudWorkspace( workspaceId: string, @@ -178,8 +202,10 @@ export async function runArchiveCloudWorkspace( // for a workspace that is going away, which is not worth an error toast. const sleep = (id: string): Promise => isCloudWorkspaceDeleting(id) ? Promise.resolve() : runSleepWorktree(id) + const localCopy = await cloudSyncRemovalPreview(workspaceId).catch(() => undefined) await runArchiveWithUndo(workspaceId, { ...options, + ...(localCopy ? { localCopy } : {}), commit: () => archiveCloudWorkspace(workspaceId, { sleep }), reportFailure: () => !isCloudWorkspaceDeleting(workspaceId) }) diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteCloudWorkspaceParts.tsx b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteCloudWorkspaceParts.tsx index b598300b42f..cfdd27fea90 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteCloudWorkspaceParts.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteCloudWorkspaceParts.tsx @@ -2,7 +2,9 @@ // Archive instead, and deletes an asleep sandbox without waking it or a Force step (this dialog is // the confirmation that its uncommitted changes go with it). import type React from 'react' +import { useEffect, useState } from 'react' import { Archive, Loader2, Trash2 } from 'lucide-react' +import { cloudSyncRemovalPreview } from '@/lib/orca-cloud/cloud-sync-store' import { Button } from '@/components/ui/button' import { DialogDescription, DialogTitle } from '@/components/ui/dialog' import { useTranslate } from '@/i18n/use-translate' @@ -35,6 +37,7 @@ export function DeleteCloudWorkspaceHeader({ .filter(Boolean) .join(' · ') const running = view.state === 'running' + const localCopy = useCloudSyncRemovalPreview(worktree.id) return ( <> @@ -68,12 +71,43 @@ export function DeleteCloudWorkspaceHeader({ )}
) : null} + {localCopy?.hasLocalCopy ? ( +
+ {localCopy.editedHere > 0 + ? t( + 'orcaCloudSync.removalEdited', + '{{count}} changed files on this computer will be removed.', + { + count: localCopy.editedHere, + defaultValue_one: '{{count}} changed file on this computer will be removed.' + } + ) + : t('orcaCloudSync.removal', 'The copy on this computer is removed too.')} +
+ ) : null} ) } +/** What this computer synced from the workspace, for the line that says it goes too. */ +function useCloudSyncRemovalPreview( + workspaceId: string +): { hasLocalCopy: boolean; editedHere: number } | null { + const [preview, setPreview] = useState<{ hasLocalCopy: boolean; editedHere: number } | null>(null) + useEffect(() => { + let live = true + void cloudSyncRemovalPreview(workspaceId) + .then((next) => live && setPreview(next)) + .catch(() => undefined) + return () => { + live = false + } + }, [workspaceId]) + return preview +} + export function DeleteCloudWorkspaceFooter({ isDeleting, onCancel, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 57070f86d45..03503d3b16a 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2336,6 +2336,15 @@ "untracked_other": "{{count}} untracked files" } }, + "orcaCloudSync": { + "ago": "{{ago}} ago", + "editedHere_one": "{{count}} file changed on this computer. Edits here don’t sync back. The cloud copy replaces it when it changes.", + "editedHere_other": "{{count}} files changed on this computer. Edits here don’t sync back. The cloud copy replaces them when it changes.", + "fileCount_one": "{{n}} file", + "fileCount_other": "{{n}} files", + "removalEdited_one": "{{count}} changed file on this computer will be removed.", + "removalEdited_other": "{{count}} changed files on this computer will be removed." + }, "orcaCloudWorkspace": { "archived": { "files": "{{count}} files", @@ -2344,6 +2353,8 @@ "moreFiles_one": "{{count}} more file not shown", "moreFiles_other": "{{count}} more files not shown" }, + "archivedDetailLocalEdits_one": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed file on this computer will be removed.", + "archivedDetailLocalEdits_other": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed files on this computer will be removed.", "detail": { "sleepsWhenIdleMinutes_one": "Sleeps in {{count}} minute if idle", "sleepsWhenIdleMinutes_other": "Sleeps in {{count}} minutes if idle" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cd109e0d6fe..69f86626fdc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27031,7 +27031,10 @@ }, "terminal": { "asleepHistoryKept": "This workspace is asleep. Your terminal history is kept." - } + }, + "archivedDetailLocalEdits_one": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed file on this computer will be removed.", + "archivedDetailLocalEdits_other": "It goes to sleep and stays browsable: PRs, last changes and files. {{count}} changed files on this computer will be removed.", + "archivedDetailLocalCopy": "It goes to sleep and stays browsable: PRs, last changes and files. The copy on this computer is removed too." }, "cloudAgentSetup": { "loading": "Checking what agents can use…", @@ -28064,5 +28067,43 @@ "signInHere": "{{agent}} isn’t signed in for this workspace.", "pausedFix": "Orca account sign-in is paused. Sign in for this workspace; the sign-in stays with this workspace.", "notOfferedFix": "Signs in for each cloud workspace for now. Sign in for this workspace; the sign-in stays with this workspace." + }, + "orcaCloudSync": { + "menuItem": "Mirror to this computer…", + "kb": "{{value}} KB", + "justNow": "just now", + "ago": "{{ago}} ago", + "copying": "Copying {{done}} of {{total}} files…", + "waitingSynced": "Waits until the workspace wakes · synced {{ago}}", + "waiting": "Waits until the workspace wakes", + "syncing": "Syncing…", + "synced": "Synced · {{files}} · {{size}} · {{ago}}", + "fileCount_one": "{{n}} file", + "fileCount_other": "{{n}} files", + "announceSynced": "Files synced to this computer", + "error": "Couldn’t sync files. Your files in the cloud are safe.", + "title": "On this computer", + "contextLabel": ".context folder", + "contextDescription": "Plans and screenshots from this workspace", + "mirrorLabel": "Mirror to this computer", + "mirrorDescription": "A read-only copy of the workspace files, kept up to date while it runs. Skips files in .gitignore.", + "openFolder": "Open folder", + "copied": "Copied", + "copyPath": "Copy path", + "tryAgain": "Try again", + "editedHere_one": "{{count}} file changed on this computer. Edits here don’t sync back. The cloud copy replaces it when it changes.", + "editedHere_other": "{{count}} files changed on this computer. Edits here don’t sync back. The cloud copy replaces them when it changes.", + "overLimit": "The mirror is limited to {{size}}. Larger files and anything past the limit aren’t copied.", + "tooLarge": "Files over {{size}} aren’t copied.", + "removalEdited_one": "{{count}} changed file on this computer will be removed.", + "removalEdited_other": "{{count}} changed files on this computer will be removed.", + "removal": "The copy on this computer is removed too.", + "gb": "{{value}} GB", + "mb": "{{value}} MB", + "minutesAgo": "{{count}} min ago", + "hoursAgo": "{{count}} h ago", + "syncedToLimit": "Synced up to the limit · {{files}} · {{size}} · {{ago}}", + "removeConfirmTitle": "Remove the copy on this computer?", + "removeCopy": "Remove copy" } } diff --git a/src/renderer/src/lib/orca-cloud/cloud-sync-store.ts b/src/renderer/src/lib/orca-cloud/cloud-sync-store.ts new file mode 100644 index 00000000000..3c7d04ed0e7 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/cloud-sync-store.ts @@ -0,0 +1,131 @@ +// The window's view of cloud → this computer sync: one entry per workspace, filled on demand and +// kept current by main's change events (one listener for the whole window). +import { useEffect, useSyncExternalStore } from 'react' +import type { CloudSyncApi, CloudSyncView } from '../../../../shared/cloud-sync/cloud-sync-contract' + +const views = new Map() +const listeners = new Set<() => void>() +const requested = new Set() +let unsubscribe: (() => void) | null = null + +function api(): CloudSyncApi | null { + return typeof window !== 'undefined' ? (window.api?.cloudSync ?? null) : null +} + +function notify(): void { + for (const listener of listeners) { + listener() + } +} + +function put(view: CloudSyncView | null): void { + if (view) { + views.set(view.workspaceId, view) + notify() + } +} + +function ensureSubscribed(): void { + const bridge = api() + if (unsubscribe || !bridge) { + return + } + unsubscribe = bridge.onChanged(put) +} + +function load(workspaceId: string): void { + const bridge = api() + if (!bridge || requested.has(workspaceId)) { + return + } + requested.add(workspaceId) + void bridge + .getState(workspaceId) + .then(put) + .catch(() => requested.delete(workspaceId)) +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +/** Null until main answers, and in builds without the bridge (web, older preloads). */ +export function useCloudSync(workspaceId: string): CloudSyncView | null { + useEffect(() => { + ensureSubscribed() + load(workspaceId) + }, [workspaceId]) + return useSyncExternalStore(subscribe, () => views.get(workspaceId) ?? null) +} + +export function isCloudSyncAvailable(): boolean { + return api() !== null +} + +export async function setCloudSyncChoice( + workspaceId: string, + patch: { context?: boolean; mirror?: boolean } +): Promise { + const bridge = api() + const current = views.get(workspaceId) + if (current) { + // Optimistic: the switch moves at once; main's answer (or change event) settles it. + put({ ...current, choice: { ...current.choice, ...patch } }) + } + put((await bridge?.setChoice({ workspaceId, ...patch })) ?? null) +} + +export function openCloudSyncFolder(workspaceId: string): Promise<{ ok: boolean }> { + return api()?.openFolder(workspaceId) ?? Promise.resolve({ ok: false }) +} + +export function retryCloudSync(workspaceId: string): Promise { + return api()?.retry(workspaceId) ?? Promise.resolve() +} + +export function cloudSyncRemovalPreview( + workspaceId: string +): Promise<{ hasLocalCopy: boolean; editedHere: number }> { + return ( + api()?.removalPreview(workspaceId) ?? Promise.resolve({ hasLocalCopy: false, editedHere: 0 }) + ) +} + +/** Fixtures and tests: seed a view without main. */ +export function seedCloudSyncView(view: CloudSyncView): void { + requested.add(view.workspaceId) + put(view) +} + +/** "Mirror to this computer…" from the workspace menu: the popover opens on the sync section. */ +const focusRequests = new Set() +const focusListeners = new Set<() => void>() +export function requestCloudSyncFocus(workspaceId: string): void { + focusRequests.add(workspaceId) + for (const listener of focusListeners) { + listener() + } +} +export function takeCloudSyncFocus(workspaceId: string): boolean { + return focusRequests.delete(workspaceId) +} +/** Calls `listener` when the menu asks for this workspace's sync section (an event, not state). */ +export function onCloudSyncFocusRequest(workspaceId: string, listener: () => void): () => void { + const wrapped = (): void => { + if (focusRequests.has(workspaceId)) { + listener() + } + } + focusListeners.add(wrapped) + return () => focusListeners.delete(wrapped) +} +export function useCloudSyncFocusRequest(workspaceId: string): boolean { + return useSyncExternalStore( + (listener) => { + focusListeners.add(listener) + return () => focusListeners.delete(listener) + }, + () => focusRequests.has(workspaceId) + ) +} diff --git a/src/shared/cloud-sync/cloud-sync-contract.ts b/src/shared/cloud-sync/cloud-sync-contract.ts new file mode 100644 index 00000000000..18b64fb89d2 --- /dev/null +++ b/src/shared/cloud-sync/cloud-sync-contract.ts @@ -0,0 +1,69 @@ +// Cloud → this computer file sync for Orca Cloud workspaces: `.context` (on by default) and an +// optional read-only mirror of the whole workspace. Main owns the sync; the window shows state. + +export const ORCA_CLOUD_SYNC_CHANNELS = { + getState: 'orcaCloudSync:getState', + setChoice: 'orcaCloudSync:setChoice', + openFolder: 'orcaCloudSync:openFolder', + retry: 'orcaCloudSync:retry', + removalPreview: 'orcaCloudSync:removalPreview', + changed: 'orcaCloudSync:changed' +} as const + +export type CloudSyncChoiceState = { context: boolean; mirror: boolean } + +export type CloudSyncPhase = + /** Both rows off: nothing on this computer. */ + | 'off' + /** First pass in flight, nothing copied yet. */ + | 'starting' + | 'copying' + | 'synced' + /** The sandbox is asleep (or not reachable from here): the copy stays, sync resumes on wake. */ + | 'waiting' + | 'error' + +export type CloudSyncLimitsInfo = { maxBytes: number; maxFileBytes: number } + +export type CloudSyncState = { + workspaceId: string + choice: CloudSyncChoiceState + /** Absolute folder (`~/orca/cloud//`). */ + folder: string + phase: CloudSyncPhase + files: number + bytes: number + progress: { done: number; total: number } | null + lastSyncedAt: number | null + /** Copies changed on this computer (they don't sync back). */ + editedHere: number + /** Cloud files over the per-file limit. */ + skippedLarge: number + /** Files past the folder's limit. */ + overLimit: number + limits: CloudSyncLimitsInfo + /** English detail for "Copy log"; the UI shows the glossary sentence. */ + error: string | null +} + +export type CloudSyncView = CloudSyncState & { + /** What "Open folder" opens and the folder line shows (`~/…`). */ + openPath: string + displayPath: string +} + +/** What archive/delete would remove from this computer (for the confirmation line). */ +export type CloudSyncRemovalPreview = { hasLocalCopy: boolean; editedHere: number } + +export type CloudSyncApi = { + getState: (workspaceId: string) => Promise + setChoice: (args: { + workspaceId: string + context?: boolean + mirror?: boolean + }) => Promise + openFolder: (workspaceId: string) => Promise<{ ok: boolean }> + retry: (workspaceId: string) => Promise + removalPreview: (workspaceId: string) => Promise + onChanged: (listener: (view: CloudSyncView) => void) => () => void +} diff --git a/src/shared/file-access/file-stat-many.ts b/src/shared/file-access/file-stat-many.ts new file mode 100644 index 00000000000..8e068663dbd --- /dev/null +++ b/src/shared/file-access/file-stat-many.ts @@ -0,0 +1,10 @@ +/** One `files.statMany` call stats at most this many paths (a manifest page for cloud file sync). */ +export const FILE_STAT_MANY_MAX = 2000 + +/** Stat of one path; `null` when it is absent, `{ error }` when the host refused or failed. */ +export type FileStatManyEntry = + | { size: number; mtime: number; type: 'file' | 'directory' | 'symlink' } + | { error: string } + | null + +export type FileStatManyResult = { entries: FileStatManyEntry[] } diff --git a/src/shared/plugins/languages/protected-copy-keys.json b/src/shared/plugins/languages/protected-copy-keys.json index 5eddf3aea22..02e991399da 100644 --- a/src/shared/plugins/languages/protected-copy-keys.json +++ b/src/shared/plugins/languages/protected-copy-keys.json @@ -850,6 +850,8 @@ "localBuild.schemaCompatibility", "localBuild.signatureRequirement", "localBuild.useButton", + "orcaCloudSync.removal", + "orcaCloudSync.removalEdited", "plugins.frameOpenExternal.alwaysAllow", "plugins.frameOpenExternal.cancel", "plugins.frameOpenExternal.message", diff --git a/src/shared/rpc-contract/files-params.ts b/src/shared/rpc-contract/files-params.ts index 95da45fa297..acae0622852 100644 --- a/src/shared/rpc-contract/files-params.ts +++ b/src/shared/rpc-contract/files-params.ts @@ -1,4 +1,5 @@ import { PATH_EXISTENCE_BATCH_MAX } from '../file-access/path-existence-batch' +import { FILE_STAT_MANY_MAX } from '../file-access/file-stat-many' import { FILE_READ_DIR_MAX_PAGE_ENTRIES } from '../file-listing/file-dir-page' import { z } from 'zod' import { QUICK_OPEN_REMOTE_QUERY_MAX_CODE_UNITS } from '../quick-open/quick-open-path-search' @@ -9,6 +10,10 @@ export const FilePathsExist = WorktreeSelector.extend({ relativePaths: z.array(z.string()).max(PATH_EXISTENCE_BATCH_MAX) }) +export const FileStatMany = WorktreeSelector.extend({ + relativePaths: z.array(z.string().min(1).max(4096)).min(1).max(FILE_STAT_MANY_MAX) +}) + export const FilePathSearch = WorktreeSelector.extend({ allowLegacyIncludeIgnored: z.boolean().optional(), includeIgnored: z.boolean().optional(), diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index c2ecacbccc1..23222dbf2de 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -264,6 +264,7 @@ import { FilePathsExist, FileReadChunk, FileSearch, + FileStatMany, FileTreePath, FileUnwatch, ResolveTerminalPath, @@ -1123,6 +1124,7 @@ export const RPC_PARAMS_BY_METHOD = { 'files.search': FileSearch, 'files.searchPaths': FilePathSearch, 'files.stat': FileTreePath, + 'files.statMany': FileStatMany, 'files.statTerminalArtifact': TerminalArtifactFile, 'files.unwatch': FileUnwatch, 'files.watch': FileWatch,