feat(cloud-sync): sync .context and an optional read-only mirror from cloud workspaces to this computer

- 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/<repository>/<workspace>, 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
This commit is contained in:
Neil
2026-11-10 20:04:14 -08:00
parent a868970883
commit c267ae3676
40 changed files with 3653 additions and 8 deletions
@@ -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<string, { content: Buffer; mtime: number }>
ignored: Set<string>
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<string, { content: Buffer; mtime: number }>()
const ignored = new Set<string>()
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 <T>(method: string, params: unknown): Promise<T> => {
// 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<unknown> {
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<string, boolean>()
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
}
+178
View File
@@ -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 <T>(
method: string,
params: unknown,
options?: { timeoutMs?: number; signal?: AbortSignal }
): Promise<T> => {
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()
}
}
@@ -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<void> {
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<void> {
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<CloudSyncRemovalPreview> {
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 }
}
@@ -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<LocalStat> {
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<string, IndexEntry>,
pass: {
local: ReadonlyMap<string, LocalStat>
scoped: ReadonlySet<string> | null
downloaded: ReadonlySet<string>
}
): 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<string, IndexEntry>
): Promise<void> {
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<void> {
const dirs = new Set<string>()
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<string, RemoteEntry>
entries: Record<string, IndexEntry>
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<void>
log: (line: string) => void
}
const INDEX_SAVE_EVERY = 200
async function copyOne(input: DownloadInput, path: string, entry: RemoteEntry): Promise<void> {
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<void> {
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<void> => {
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
)
}
@@ -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/<repository>/<workspace>', () => {
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)
})
})
+117
View File
@@ -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/<repository>/<workspace>`, 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<boolean> {
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<string> {
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)
}
+110
View File
@@ -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<string, LocalStat>([
['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<string, LocalStat>([['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<string, LocalStat>([['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<string, LocalStat>([['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<string, LocalStat>([['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)
})
})
+162
View File
@@ -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<string, RemoteEntry>
index: Readonly<Record<string, IndexEntry>>
local: ReadonlyMap<string, LocalStat>
limits: SyncLimits
/** Paths whose remote state is authoritative this pass; others in the index are left alone. */
scope?: ReadonlySet<string>
}): 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 }
}
+307
View File
@@ -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 = <T = unknown>(
method: string,
params: unknown,
options?: { timeoutMs?: number; signal?: AbortSignal }
) => Promise<T>
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<string[]> {
const out: string[] = []
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > CONTEXT_MAX_DEPTH || out.length >= maxFiles) {
return
}
let entries: z.infer<typeof DirEntries>
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<Set<string>> {
const included = new Set<string>()
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<SyncCall, true>()
/** 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<Map<string, RemoteEntry>> {
const out = new Map<string, RemoteEntry>()
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<string, RemoteEntry>,
signal?: AbortSignal
): Promise<void> {
let next = 0
const worker = async (): Promise<void> => {
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
}
@@ -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<void> => {
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/<repository>/<workspace>', 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()
})
})
+310
View File
@@ -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<boolean>
broadcast: (view: CloudSyncView) => void
openPath: (path: string) => Promise<boolean>
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<string, { session: CloudSyncSession; environmentId: string }>()
const states = new Map<string, CloudSyncState>()
const known = new Set<string>()
const archiveCheckedAt = new Map<string, number>()
/** Copies already removed (archived or deleted); nothing to check or remove again. */
const retired = new Set<string>()
let reconciling: Promise<void> | 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<CloudSyncView | null> {
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<void> {
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<CloudSyncChoice>
): Promise<CloudSyncView | null> {
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<void> {
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<CloudSyncRemovalPreview> {
const runtime = findRuntime(workspaceId)
return runtime
? previewRemoval(deps.store, workspaceId, runtime.folder)
: { hasLocalCopy: false, editedHere: 0 }
},
async dispose(): Promise<void> {
disposed = true
await Promise.all([...sessions.keys()].map((workspaceId) => stopSession(workspaceId)))
}
}
}
export type CloudSyncService = ReturnType<typeof createCloudSyncService>
@@ -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<void> }): Promise<void> => {
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')
})
})
+320
View File
@@ -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<void> = Promise.resolve()
let fullPassQueued = false
let watch: WatchSubscription | null = null
let watchRetryMs = WATCH_RETRY_MIN_MS
let watchRetryTimer: ReturnType<typeof setTimeout> | null = null
let safetyTimer: ReturnType<typeof setInterval> | 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<CloudSyncState>): void => {
state = { ...state, ...patch, choice, limits: limitsInfo(choice) }
deps.emit(state)
}
const scope = (): SyncScope => (choice.mirror ? 'workspace' : 'context')
async function loadIndex(): Promise<CloudSyncIndex> {
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<void>): Promise<void> {
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<void> {
if (fullPassQueued) {
return chain
}
fullPassQueued = true
return enqueue(async () => {
fullPassQueued = false
changes.clear()
await fullPass()
})
}
async function fullPass(): Promise<void> {
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<void> {
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<string, RemoteEntry>
paths: Set<string>
scoped: Set<string> | null
truncated: number
}): Promise<void> {
const current = index!
const limits = limitsFor(choice)
await mkdir(deps.folder, { recursive: true })
const local = new Map<string, LocalStat>()
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<void> {
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<void> {
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<void> {
if (!watch) {
void openWatch()
}
return requestFullPass()
},
setChoice(next: CloudSyncChoice): Promise<void> {
choice = next
publish({})
return requestFullPass()
},
/** Stops syncing; the files stay (asleep). */
stop(): Promise<void> {
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<typeof createCloudSyncSession>
+121
View File
@@ -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<typeof IndexSchema>
async function writeJson(path: string, value: unknown): Promise<void> {
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<typeof ChoicesSchema> | null = null
const indexPath = (workspaceId: string): string =>
join(dir, `${createHash('sha256').update(workspaceId).digest('hex').slice(0, 32)}.json`)
async function loadChoices(): Promise<z.infer<typeof ChoicesSchema>> {
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<CloudSyncChoice> {
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<CloudSyncChoice>
): Promise<CloudSyncChoice> {
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<void> {
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<CloudSyncIndex> {
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<void> {
await mkdir(dir, { recursive: true })
await writeJson(indexPath(workspaceId), index)
},
async removeIndex(workspaceId: string): Promise<void> {
await rm(indexPath(workspaceId), { force: true })
}
}
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return null
}
}
export type CloudSyncStore = ReturnType<typeof createCloudSyncStore>
export type { IndexEntry }
+122
View File
@@ -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<WatchSubscription>
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<string>()
let timer: ReturnType<typeof setTimeout> | 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()
}
}
}
@@ -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<typeof setTimeout> | null = null
let beats = 0
@@ -253,6 +261,7 @@ export function installHostedSandbox(
setOrcaCloudActiveAccountResolver(null)
offline.dispose()
setCloudOfflineService(null)
disposeCloudSync()
})
}
@@ -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) {
@@ -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)
}
@@ -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')
})
})
+53 -1
View File
@@ -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<FileStatManyResult> {
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<void> => {
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
@@ -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)
})
]
@@ -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,
+3
View File
@@ -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). */
+19
View File
@@ -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)
}
}
+2
View File
@@ -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,
@@ -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)
@@ -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')}
</DropdownMenuItem>
) : null}
{isCloudSyncAvailable() ? (
<DropdownMenuItem onSelect={() => requestCloudSyncFocus(worktreeId)}>
<HardDriveDownload className="size-3.5" />
{t('orcaCloudSync.menuItem', 'Mirror to this computer…')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem disabled={busy || view.state === 'waking'} onSelect={actions.onArchive}>
<Archive className="size-3.5" />
{t('orcaCloudWorkspace.archive', 'Archive')}
@@ -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 : <CloudWorkspaceSyncSection workspaceId={workspaceId} />}
{showFooter ? (
<div className="flex min-h-6 items-center justify-between gap-2 whitespace-nowrap">
{/* Not a live region: a ticking "ago" would announce itself every few seconds. */}
@@ -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>): 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<CloudSyncView>,
state: CloudDotState = 'running'
): ReturnType<typeof defineFixture> {
// 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 (
<div className="rounded-md border border-border bg-floating-surface p-3">
<CloudWorkspaceStatusPanel
workspaceId={id}
state={state}
entry={entry(state === 'sleeping' ? asleep : running)}
pending={undefined}
actions={actions}
/>
</div>
)
}
})
}
/** 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
})
@@ -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<typeof useTranslate>
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
<React.Fragment key={i}>
{part}
{i < parts.length - 1 ? (
<>
/<wbr />
</>
) : null}
</React.Fragment>
))}
</>
)
}
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<HTMLButtonElement>
}): React.JSX.Element {
const id = useId()
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<label htmlFor={`${id}-switch`} className="block font-medium text-foreground">
{label}
</label>
<p id={`${id}-desc`} className="mt-0.5 text-muted-foreground">
{description}
</p>
</div>
<Switch
ref={switchRef}
id={`${id}-switch`}
aria-describedby={`${id}-desc`}
checked={checked}
onCheckedChange={onCheckedChange}
className="mt-0.5"
/>
</div>
)
}
/** 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<string | null>(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<HTMLElement | null>(null)
const mirrorSwitch = useRef<HTMLButtonElement | null>(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<void> => {
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 (
<section
ref={section}
aria-labelledby={`cloud-sync-${workspaceId}`}
className="flex flex-col gap-2.5 border-t border-border/60 pt-3"
data-cloud-sync-section={workspaceId}
>
<h3 id={`cloud-sync-${workspaceId}`} className="font-medium text-muted-foreground">
{t('orcaCloudSync.title', 'On this computer')}
</h3>
<SyncRow
label={t('orcaCloudSync.contextLabel', '.context folder')}
description={t(
'orcaCloudSync.contextDescription',
'Plans and screenshots from this workspace'
)}
checked={view.choice.context}
onCheckedChange={(next) => void choose({ context: next })}
/>
<SyncRow
switchRef={mirrorSwitch}
label={t('orcaCloudSync.mirrorLabel', 'Mirror to this computer')}
description={t(
'orcaCloudSync.mirrorDescription',
'A read-only copy of the workspace files, kept up to date while it runs. Skips files in .gitignore.'
)}
checked={view.choice.mirror}
onCheckedChange={(next) => void choose({ mirror: next })}
/>
{on ? (
<div className="flex flex-col gap-1.5">
{status ? <p className="tabular-nums text-muted-foreground">{status}</p> : null}
<code
className="block min-w-0 break-words rounded bg-muted/50 px-1.5 py-0.5 font-mono text-[11px] text-foreground"
title={view.displayPath}
data-cloud-sync-path
>
<PathWithBreaks path={view.displayPath} />
</code>
<div className="-ml-2 flex items-center gap-1">
<Button
size="xs"
variant="ghost"
className="text-muted-foreground"
onClick={() => void openCloudSyncFolder(workspaceId)}
>
<FolderOpen />
{t('orcaCloudSync.openFolder', 'Open folder')}
</Button>
<Button
size="xs"
variant="ghost"
className="text-muted-foreground"
onClick={() => {
void navigator.clipboard.writeText(view.openPath).then(() => setCopied(true))
setTimeout(() => setCopied(false), 1500)
}}
>
<Copy />
{copied
? t('orcaCloudSync.copied', 'Copied')
: t('orcaCloudSync.copyPath', 'Copy path')}
</Button>
</div>
</div>
) : null}
{on && view.phase === 'error' ? (
<InlineError
title={t('orcaCloudSync.error', 'Couldn’t sync files. Your files in the cloud are safe.')}
retryLabel={t('orcaCloudSync.tryAgain', 'Try again')}
onRetry={() => void retryCloudSync(workspaceId)}
density="compact"
/>
) : null}
{on && view.editedHere > 0 ? (
<InlineError
tone="neutral"
density="compact"
title={t(
'orcaCloudSync.editedHere',
'{{count}} files changed on this computer. Edits here don’t sync back. The cloud copy replaces them when it changes.',
{
count: view.editedHere,
defaultValue_one:
'{{count}} file changed on this computer. Edits here don’t sync back. The cloud copy replaces it when it changes.'
}
)}
/>
) : null}
{on && (tooLarge || overLimit) ? (
<InlineError
tone="neutral"
density="compact"
title={
overLimit
? t(
'orcaCloudSync.overLimit',
'The mirror is limited to {{size}}. Larger files and anything past the limit aren’t copied.',
{ size: syncSize(t, view.limits.maxBytes) }
)
: t('orcaCloudSync.tooLarge', 'Files over {{size}} aren’t copied.', {
size: syncSize(t, view.limits.maxFileBytes)
})
}
/>
) : null}
<span className="sr-only" role="status" aria-live="polite">
{announcement}
</span>
</section>
)
}
@@ -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<null>): void {
@@ -115,6 +116,8 @@ export function runArchiveWithUndo(
commit: () => Promise<CloudWorkspaceResult<null>>
/** 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<boolean> {
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<readonly { completed: boolean }[] | void> =>
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)
})
@@ -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 (
<>
<DialogTitle className="text-sm">
@@ -68,12 +71,43 @@ export function DeleteCloudWorkspaceHeader({
)}
</div>
) : null}
{localCopy?.hasLocalCopy ? (
<div className="mt-0.5 text-muted-foreground" data-cloud-sync-removal>
{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.')}
</div>
) : null}
</div>
<DeleteCloudSandboxAgentsNote worktree={worktree} />
</>
)
}
/** 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,
+11
View File
@@ -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"
+42 -1
View File
@@ -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"
}
}
@@ -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<string, CloudSyncView>()
const listeners = new Set<() => void>()
const requested = new Set<string>()
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<void> {
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<void> {
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<string>()
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)
)
}
@@ -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/<repository>/<workspace>`). */
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<CloudSyncView | null>
setChoice: (args: {
workspaceId: string
context?: boolean
mirror?: boolean
}) => Promise<CloudSyncView | null>
openFolder: (workspaceId: string) => Promise<{ ok: boolean }>
retry: (workspaceId: string) => Promise<void>
removalPreview: (workspaceId: string) => Promise<CloudSyncRemovalPreview>
onChanged: (listener: (view: CloudSyncView) => void) => () => void
}
+10
View File
@@ -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[] }
@@ -850,6 +850,8 @@
"localBuild.schemaCompatibility",
"localBuild.signatureRequirement",
"localBuild.useButton",
"orcaCloudSync.removal",
"orcaCloudSync.removalEdited",
"plugins.frameOpenExternal.alwaysAllow",
"plugins.frameOpenExternal.cancel",
"plugins.frameOpenExternal.message",
+5
View File
@@ -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(),
+2
View File
@@ -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,