@@ -18,6 +18,7 @@ import { pushTrack } from '../../../../common/middle/tracks/utils';
1818import { getS3SandboxSource } from '../../../../common/s3/sources/sandbox' ;
1919import {
2020 MongoLegacySandboxInstance ,
21+ LegacySandboxInstanceZodSchema ,
2122 type LegacySandboxInstanceSchemaType
2223} from '../infrastructure/instance/legacySchema' ;
2324import {
@@ -48,7 +49,7 @@ type UserSandboxMigrationParams = {
4849 dryRun ?: boolean ;
4950} ;
5051
51- type LegacyWorkspaceInstallState = 'old' | ' conflict' | 'empty' ;
52+ type LegacyWorkspaceInstallState = 'conflict' | 'empty' ;
5253type LegacySandboxCleanupStep =
5354 | 'delete_sandbox'
5455 | 'delete_volume'
@@ -100,11 +101,40 @@ type ResolvedLegacyApp = {
100101 chatId : string ;
101102} ;
102103
103- const resolveLegacySourceId = ( doc : LegacySandboxInstanceSchemaType ) =>
104- doc . sourceId || doc . appId || doc . metadata ?. skillId || undefined ;
104+ /**
105+ * 在产生任何迁移副作用前校验整张 Legacy 表。
106+ */
107+ const parseLegacySandboxInstances = ( rawDocs : unknown [ ] ) : LegacySandboxInstanceSchemaType [ ] => {
108+ const parsedDocs : LegacySandboxInstanceSchemaType [ ] = [ ] ;
109+ const failures : string [ ] = [ ] ;
110+
111+ for ( const rawDoc of rawDocs ) {
112+ const parsed = LegacySandboxInstanceZodSchema . safeParse ( rawDoc ) ;
113+ if ( parsed . success ) {
114+ parsedDocs . push ( parsed . data ) ;
115+ continue ;
116+ }
117+
118+ const doc = rawDoc && typeof rawDoc === 'object' ? ( rawDoc as Record < string , unknown > ) : { } ;
119+ const issues = parsed . error . issues
120+ . map ( ( issue ) => `${ issue . path . join ( '.' ) || '<root>' } : ${ issue . message } ` )
121+ . join ( '; ' ) ;
122+ failures . push (
123+ `_id=${ String ( doc . _id ?? 'unknown' ) } , sandboxId=${ String ( doc . sandboxId ?? 'unknown' ) } [${ issues } ]`
124+ ) ;
125+ }
105126
106- const isLegacySkillInstance = ( doc : LegacySandboxInstanceSchemaType ) =>
107- doc . sourceType === ChatSourceTypeEnum . skillEdit || doc . type === SandboxTypeEnum . editDebug ;
127+ if ( failures . length > 0 ) {
128+ throw new Error (
129+ [
130+ `Legacy Sandbox preflight validation failed for ${ failures . length } record(s)` ,
131+ ...failures
132+ ] . join ( '\n' )
133+ ) ;
134+ }
135+
136+ return parsedDocs ;
137+ } ;
108138
109139/**
110140 * 检查同一 App + User 是否仍有未迁移的 Legacy 记录。
@@ -145,7 +175,7 @@ const toLegacyResource = (doc: LegacySandboxInstanceSchemaType): SandboxResource
145175 metadata : doc . metadata
146176} ) ;
147177
148- /** 检查旧 Workspace 在当前 Chat 目标目录中的提交状态 。 */
178+ /** 检查目标 session 是否已经存在 。 */
149179export async function inspectLegacyWorkspaceInstallState ( params : {
150180 target : Awaited < ReturnType < typeof getSandboxClient > > ;
151181 chatId : string ;
@@ -156,12 +186,9 @@ export async function inspectLegacyWorkspaceInstallState(params: {
156186 joinSandboxPath ( workspaceRoot , 'sessions' ) ,
157187 getSandboxSessionPathSegment ( chatId )
158188 ) ;
159- const oldDirectory = joinSandboxPath ( sessionWorkDirectory , '.old' ) ;
160189 const checkResult = await target . provider . execute (
161190 [
162- `if [ -d ${ shellQuote ( oldDirectory ) } ]; then` ,
163- " printf '%s' old" ,
164- `elif [ -d ${ shellQuote ( sessionWorkDirectory ) } ]; then` ,
191+ `if [ -d ${ shellQuote ( sessionWorkDirectory ) } ]; then` ,
165192 " printf '%s' conflict" ,
166193 'else' ,
167194 " printf '%s' empty" ,
@@ -174,14 +201,15 @@ export async function inspectLegacyWorkspaceInstallState(params: {
174201 }
175202
176203 const state = checkResult . stdout . trim ( ) ;
177- if ( state === 'old' || state === ' conflict' || state === 'empty' ) return state ;
204+ if ( state === 'conflict' || state === 'empty' ) return state ;
178205 throw new Error ( `Unexpected migration target state: ${ state || 'empty output' } ` ) ;
179206}
180207
181208/**
182209 * 把一条旧 Workspace 归档安装到目标用户级 Sandbox。
183210 *
184- * session 已存在视为冲突并提交到 `.old`;`.old` 已存在视为此前已提交。
211+ * session 不存在时直接提交 staging;已存在时递归合并,所有同名路径以 session
212+ * 现有内容为准。
185213 */
186214export async function installLegacyWorkspaceArchive ( params : {
187215 target : Awaited < ReturnType < typeof getSandboxClient > > ;
@@ -195,28 +223,25 @@ export async function installLegacyWorkspaceArchive(params: {
195223 joinSandboxPath ( workspaceRoot , 'sessions' ) ,
196224 getSandboxSessionPathSegment ( chatId )
197225 ) ;
198- const oldDirectory = joinSandboxPath ( sessionWorkDirectory , '.old' ) ;
199226 const stagingName = createHash ( 'sha256' ) . update ( legacySandboxId ) . digest ( 'hex' ) . slice ( 0 , 40 ) ;
200227 const stagingDirectory = joinSandboxPath (
201228 joinSandboxPath ( workspaceRoot , '.migration' ) ,
202229 stagingName
203230 ) ;
204- const targetState = await inspectLegacyWorkspaceInstallState ( { target, chatId } ) ;
205- if ( targetState === 'old' ) return ;
206-
207231 await restoreSandboxWorkspaceArchiveForMigration ( {
208232 sandbox : target . provider ,
209233 workDirectory : stagingDirectory ,
210234 sandboxId : legacySandboxId ,
211235 archiveBody
212236 } ) ;
213237
214- const finalDirectory = targetState === 'conflict' ? oldDirectory : sessionWorkDirectory ;
238+ const sourceDirectory = stagingDirectory ;
215239 const commitResult = await target . provider . execute (
216240 [
217- `staging=${ shellQuote ( stagingDirectory ) } ` ,
218- `target=${ shellQuote ( finalDirectory ) } ` ,
219- `projects="$staging/projects"` ,
241+ 'set -e' ,
242+ `source=${ shellQuote ( sourceDirectory ) } ` ,
243+ `target=${ shellQuote ( sessionWorkDirectory ) } ` ,
244+ 'projects="$source/projects"' ,
220245 'if [ -d "$projects" ]; then' ,
221246 ' find "$projects" -mindepth 1 -maxdepth 1 -type d -exec sh -c \'' ,
222247 ' for dir; do' ,
@@ -226,16 +251,31 @@ export async function installLegacyWorkspaceArchive(params: {
226251 ' done' ,
227252 " ' sh {} +" ,
228253 'fi' ,
254+ 'merge_without_overwrite() {' ,
255+ ' source_root=$1' ,
256+ ' target_root=$2' ,
257+ ' for source_path in "$source_root"/* "$source_root"/.[!.]* "$source_root"/..?*; do' ,
258+ ' [ -e "$source_path" ] || [ -L "$source_path" ] || continue' ,
259+ ' name=${source_path##*/}' ,
260+ ' target_path="$target_root/$name"' ,
261+ ' if [ ! -e "$target_path" ] && [ ! -L "$target_path" ]; then' ,
262+ ' mv -- "$source_path" "$target_path"' ,
263+ ' elif [ -d "$source_path" ] && [ ! -L "$source_path" ] && [ -d "$target_path" ] && [ ! -L "$target_path" ]; then' ,
264+ ' merge_without_overwrite "$source_path" "$target_path"' ,
265+ ' fi' ,
266+ ' done' ,
267+ '}' ,
229268 'if [ -d "$target" ]; then' ,
230- ' rm -rf -- "$staging"' ,
269+ ' merge_without_overwrite "$source" "$target"' ,
270+ ' rm -rf -- "$source"' ,
231271 ' exit 0' ,
232272 'fi' ,
233273 'if [ -e "$target" ]; then' ,
234274 ' echo "Migration target exists but is not a directory" >&2' ,
235275 ' exit 1' ,
236276 'fi' ,
237277 'mkdir -p "$(dirname "$target")"' ,
238- 'mv -- "$staging " "$target"' ,
278+ 'mv -- "$source " "$target"' ,
239279 'test -d "$target"'
240280 ] . join ( '\n' ) ,
241281 { timeoutMs : MIGRATION_COMMAND_TIMEOUT_MS , maxOutputBytes : 8 * 1024 }
@@ -299,35 +339,25 @@ const runLegacySandboxMigration = async (
299339) : Promise < UserSandboxMigrationResult > => {
300340 const dryRun = params . dryRun ?? true ;
301341 const migrationStartedAt = Date . now ( ) ;
302- const legacyDocs = await MongoLegacySandboxInstance . find ( { } )
342+ const rawLegacyDocs = await MongoLegacySandboxInstance . collection
343+ . find ( { } )
303344 . sort ( { lastActiveAt : 1 , _id : 1 } )
304- . lean < LegacySandboxInstanceSchemaType [ ] > ( ) ;
345+ . toArray ( ) ;
346+ const legacyDocs = parseLegacySandboxInstances ( rawLegacyDocs ) ;
305347 const skillItems : ResolvedLegacySkill [ ] = [ ] ;
306348 const appItems : ResolvedLegacyApp [ ] = [ ] ;
307- const failures : UserSandboxMigrationFailure [ ] = [ ] ;
308349
309350 for ( const doc of legacyDocs ) {
310- const sourceId = resolveLegacySourceId ( doc ) ;
311- if ( isLegacySkillInstance ( doc ) ) {
312- if ( sourceId ) skillItems . push ( { doc, sourceId } ) ;
313- else failures . push ( { sandboxId : doc . sandboxId , error : 'Legacy Skill sourceId is missing' } ) ;
351+ if ( doc . sourceType === ChatSourceTypeEnum . skillEdit ) {
352+ skillItems . push ( { doc, sourceId : doc . sourceId } ) ;
314353 continue ;
315354 }
316- if ( doc . sourceType && doc . sourceType !== ChatSourceTypeEnum . app ) {
317- failures . push ( {
318- sandboxId : doc . sandboxId ,
319- error : `Unsupported Legacy Sandbox sourceType: ${ doc . sourceType } `
320- } ) ;
321- continue ;
322- }
323- if ( sourceId && doc . userId && doc . chatId ) {
324- appItems . push ( { doc, sourceId, userId : doc . userId , chatId : doc . chatId } ) ;
325- } else {
326- failures . push ( {
327- sandboxId : doc . sandboxId ,
328- error : 'Legacy App sourceId, userId or chatId is missing'
329- } ) ;
330- }
355+ appItems . push ( {
356+ doc,
357+ sourceId : doc . sourceId ,
358+ userId : doc . userId ,
359+ chatId : doc . chatId
360+ } ) ;
331361 }
332362
333363 const appGroups = new Map < string , ResolvedLegacyApp [ ] > ( ) ;
@@ -344,8 +374,8 @@ const runLegacySandboxMigration = async (
344374 migratedAppCount : 0 ,
345375 appGroupCount : appGroups . size ,
346376 completedAppGroupCount : 0 ,
347- failedCount : failures . length ,
348- failures
377+ failedCount : 0 ,
378+ failures : [ ]
349379 } ;
350380 const trackCompleted = ( ) =>
351381 recordMigrationTrack ( {
@@ -358,18 +388,6 @@ const runLegacySandboxMigration = async (
358388 } ) ;
359389
360390 await recordMigrationTrack ( { runId, phase : 'started' , dryRun } ) ;
361- await Promise . all (
362- failures . map ( ( failure ) =>
363- recordMigrationTrack ( {
364- runId,
365- phase : 'failure' ,
366- dryRun,
367- sandboxId : failure . sandboxId ,
368- step : 'validate_legacy_record' ,
369- error : failure . error
370- } )
371- )
372- ) ;
373391 if ( dryRun ) {
374392 await trackCompleted ( ) ;
375393 return result ;
@@ -452,11 +470,6 @@ const runLegacySandboxMigration = async (
452470 target,
453471 chatId : item . chatId
454472 } ) ;
455- if ( targetState === 'old' ) {
456- await cleanupMigratedLegacyAppInstance ( item . doc ) ;
457- result . migratedAppCount += 1 ;
458- continue ;
459- }
460473 const archiveSource = getS3SandboxSource ( ) ;
461474 const archiveExists = await archiveSource . isWorkspaceArchiveExists ( {
462475 sandboxId : item . doc . sandboxId
0 commit comments