Skip to content

Commit 04ab639

Browse files
fix(ocm-cli): chunked mirror upload and macOS metadata exclusion (#238)
1 parent 0597bf6 commit 04ab639

8 files changed

Lines changed: 777 additions & 320 deletions

File tree

backend/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { createAuth } from './auth'
3535
import { createAuthMiddleware } from './auth/middleware'
3636
import { createPromptTemplateRoutes } from './routes/prompt-templates'
3737
import { createInternalRoutes } from './routes/internal'
38+
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
3839
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
3940
import { sseAggregator } from './services/sse-aggregator'
4041
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
@@ -245,6 +246,7 @@ try {
245246
logger.info('Workspace directories initialized')
246247

247248
await cleanupExpiredCache()
249+
await sweepStaleUploadSessions()
248250

249251
await ensureDefaultConfigExists()
250252
await backfillOpenCodeModelStateFromFile()
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { spawn } from 'child_process'
2+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, statSync } from 'fs'
3+
import * as fsp from 'fs/promises'
4+
import { createReadStream } from 'fs'
5+
import { dirname, join } from 'path'
6+
import { pipeline } from 'stream/promises'
7+
import { randomUUID } from 'crypto'
8+
import { getReposPath } from '@opencode-manager/shared/config/env'
9+
10+
export const MIRROR_CHUNK_SIZE = 8 * 1024 * 1024
11+
const STALE_UPLOAD_MS = 24 * 60 * 60 * 1000
12+
13+
export interface UploadMeta {
14+
uploadId: string
15+
repoId: number
16+
fullPath: string
17+
created: boolean
18+
createdRepoId?: number
19+
force: boolean
20+
startedAt: number
21+
}
22+
23+
export function getStagingRoot(): string {
24+
return join(getReposPath(), '.ocm-staging')
25+
}
26+
27+
export function getUploadsRoot(): string {
28+
return join(getStagingRoot(), 'uploads')
29+
}
30+
31+
export function getUploadDir(uploadId: string): string {
32+
return join(getUploadsRoot(), uploadId)
33+
}
34+
35+
export function getPartsDir(uploadId: string): string {
36+
return join(getUploadDir(uploadId), 'parts')
37+
}
38+
39+
export function getMetaPath(uploadId: string): string {
40+
return join(getUploadDir(uploadId), 'meta.json')
41+
}
42+
43+
export function getPartPath(uploadId: string, index: number): string {
44+
return join(getPartsDir(uploadId), `${index}.bin`)
45+
}
46+
47+
export async function createUploadSession(meta: Omit<UploadMeta, 'uploadId' | 'startedAt'>): Promise<UploadMeta> {
48+
const uploadId = randomUUID()
49+
mkdirSync(getPartsDir(uploadId), { recursive: true })
50+
const full: UploadMeta = { ...meta, uploadId, startedAt: Date.now() }
51+
await fsp.writeFile(getMetaPath(uploadId), JSON.stringify(full), 'utf-8')
52+
return full
53+
}
54+
55+
export async function readUploadMeta(uploadId: string): Promise<UploadMeta | null> {
56+
try {
57+
const raw = await fsp.readFile(getMetaPath(uploadId), 'utf-8')
58+
return JSON.parse(raw) as UploadMeta
59+
} catch {
60+
return null
61+
}
62+
}
63+
64+
export async function deleteUploadSession(uploadId: string): Promise<void> {
65+
await fsp.rm(getUploadDir(uploadId), { recursive: true, force: true }).catch(() => {})
66+
}
67+
68+
export async function sweepStaleUploadSessions(now = Date.now(), ttlMs = STALE_UPLOAD_MS): Promise<void> {
69+
const root = getUploadsRoot()
70+
if (!existsSync(root)) return
71+
let entries: string[]
72+
try {
73+
entries = readdirSync(root)
74+
} catch {
75+
return
76+
}
77+
for (const id of entries) {
78+
const meta = await readUploadMeta(id)
79+
if (!meta || now - meta.startedAt > ttlMs) {
80+
await deleteUploadSession(id)
81+
}
82+
}
83+
}
84+
85+
export interface ExtractResult {
86+
extractedRoot: string
87+
staging: string
88+
}
89+
90+
export async function extractPartsToStaging(uploadId: string, totalParts: number, gzip: boolean): Promise<ExtractResult> {
91+
const stagingParent = getStagingRoot()
92+
mkdirSync(stagingParent, { recursive: true })
93+
const staging = mkdtempSync(join(stagingParent, 'recv-'))
94+
95+
const tarArgs = ['-x', '-f', '-', '-C', staging]
96+
if (gzip) tarArgs.unshift('-z')
97+
const child = spawn('tar', tarArgs, { stdio: ['pipe', 'pipe', 'pipe'] })
98+
99+
const stderrChunks: Buffer[] = []
100+
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
101+
102+
const tarDone = new Promise<void>((resolve, reject) => {
103+
child.on('close', (code) => {
104+
if (code === 0) resolve()
105+
else {
106+
const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
107+
reject(new Error(`tar exited with code ${code}${stderr ? `: ${stderr}` : ''}`))
108+
}
109+
})
110+
child.on('error', reject)
111+
})
112+
113+
try {
114+
for (let i = 0; i < totalParts; i++) {
115+
const partPath = getPartPath(uploadId, i)
116+
if (!existsSync(partPath)) {
117+
throw new Error(`missing part ${i} for upload ${uploadId}`)
118+
}
119+
await pipeline(createReadStream(partPath), child.stdin, { end: i === totalParts - 1 })
120+
}
121+
await tarDone
122+
} catch (err) {
123+
if (!child.killed) child.kill('SIGKILL')
124+
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {})
125+
throw err
126+
}
127+
128+
let extractedRoot = staging
129+
const entries = readdirSync(staging)
130+
if (entries.length === 1) {
131+
const candidate = join(staging, entries[0]!)
132+
try {
133+
if (statSync(candidate).isDirectory()) {
134+
extractedRoot = candidate
135+
}
136+
} catch { /* ignore */ }
137+
}
138+
139+
return { extractedRoot, staging }
140+
}
141+
142+
export interface SwapResult {
143+
backupDir?: string
144+
}
145+
146+
export async function atomicSwapIntoPlace(extractedRoot: string, fullPath: string): Promise<SwapResult> {
147+
await fsp.mkdir(dirname(fullPath), { recursive: true })
148+
149+
let backupDir: string | undefined
150+
if (existsSync(fullPath)) {
151+
backupDir = `${fullPath}.ocm-old-${Date.now()}-${Math.random().toString(36).slice(2)}`
152+
await fsp.rename(fullPath, backupDir)
153+
}
154+
155+
try {
156+
await fsp.rename(extractedRoot, fullPath)
157+
} catch (err) {
158+
if (backupDir) {
159+
await fsp.rename(backupDir, fullPath).catch(() => {})
160+
}
161+
throw err
162+
}
163+
164+
return { backupDir }
165+
}
166+
167+
export async function discardBackup(backupDir: string | undefined): Promise<void> {
168+
if (!backupDir) return
169+
await fsp.rm(backupDir, { recursive: true, force: true }).catch(() => {})
170+
}
171+
172+
export async function restoreBackup(fullPath: string, backupDir: string | undefined): Promise<void> {
173+
if (!backupDir) return
174+
if (existsSync(fullPath)) {
175+
await fsp.rm(fullPath, { recursive: true, force: true }).catch(() => {})
176+
}
177+
await fsp.rename(backupDir, fullPath).catch(() => {})
178+
}

0 commit comments

Comments
 (0)