mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
'ab'
|
||||
]) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+34
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user