Skip to content

Commit 34d7e92

Browse files
committed
refactor(sandbox): enforce preflight schema validation for legacy migration
1 parent 4270d58 commit 34d7e92

9 files changed

Lines changed: 180 additions & 150 deletions

File tree

.agents/design/core/ai/sandbox/user-level-sandbox.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ sessionWorkDirectory = <workspaceRoot>/sessions/<chatId>
5050
- 旧 Workspace 中直接位于 `projects/` 下、名称为 Skill Version ID 的运行时缓存目录不迁移。
5151
- 其他文件保持原相对路径进入目标 session。
5252
- `sessions/<chatId>` 不存在时,使用 staging + rename 提交为该目录。
53-
- `sessions/<chatId>` 已存在时,使用 staging + rename 提交为 `sessions/<chatId>/.old`
54-
- `.old` 是迁移保留目录,不写迁移 marker;目标目录已存在时视为目录安装完成
53+
- `sessions/<chatId>` 已存在时, staging 递归合并进 session;同名文件或类型冲突均以
54+
session 现有内容为准,旧内容直接舍弃
5555
- 单条旧记录安装成功后,同步删除旧 S3 归档并按 `_id` 删除旧实例记录。
5656
- 旧记录仍存在就是该条迁移尚未完成或清理尚未完成的进度依据。
5757

@@ -161,6 +161,12 @@ type SandboxInstance = {
161161

162162
## 6. 迁移设计
163163

164+
管理员迁移入口首先读取原始 Legacy 文档并执行整表结构预检。App 必须具备合法的
165+
`sourceType/sourceId/userId/chatId`,Skill 必须具备合法的 `sourceType/sourceId`,公共字段
166+
和可选配置必须符合 Legacy Zod Schema,且不得残留 beta6 已清理的 `appId/type/metadata.skillId`
167+
任一记录失败时直接拒绝整次任务,错误返回 `_id``sandboxId`、字段路径和原因;预检通过前
168+
不写新表、不连接 Sandbox、不访问 S3,也不产生迁移 Track。
169+
164170
### 6.1 Skill Edit
165171

166172
1. 将旧 Skill Edit 记录复制到新表。
@@ -176,8 +182,9 @@ type SandboxInstance = {
176182
4. 将每条旧 Sandbox 停止并归档;已归档记录直接复用 S3。
177183
5. 在目标 Sandbox 的 `.migration/<oldSandboxId>` 解压 staging。
178184
6. 删除 staging 中直接位于 `projects/` 下且名称为 24 位十六进制 ID 的目录。
179-
7. 目标 session 不存在时 rename staging 到 session;已存在时 rename 到 `.old`
180-
8. 最终目录已存在时视为已安装,校验目录可读后继续清理。
185+
7. 目标 session 不存在时 rename staging 到 session;已存在时递归合并 staging,目录同名
186+
时继续向下合并,文件同名或文件/目录类型冲突时保留 session 现有内容。
187+
8. 重复执行时已迁入的路径会因目标存在而跳过,保持幂等。
181188
9. 同步删除旧归档,确认对象不存在后删除 Legacy 记录。
182189
10. 单次任务读取全部 Legacy 记录;Skill 只迁移表记录,直接并行执行;App 固定并发度
183190
为 5,按 `sourceId + userId` 分组后组间并发、组内按 Chat 顺序串行。
@@ -192,7 +199,6 @@ type SandboxInstance = {
192199
并解除临时 `metadata.migration`
193200
- 重试先将 `failed` 切回 `migrating`,只查询仍存在的 Legacy 记录。
194201
- S3 已不存在但确定性目标目录存在时,直接删除残留 Legacy 记录。
195-
- `.old` 已存在时不覆盖、不生成额外目录,视为目录提交已完成。
196202
- 真实迁移使用全局 Redis lease,避免两个管理员请求并发写同一 staging 目录。
197203
- 全部 Legacy 记录完成后,Migration 状态删除,目标 Sandbox 回归普通生命周期。
198204

@@ -209,7 +215,7 @@ type SandboxInstance = {
209215
- 自动 stop/archive/delete 跳过 Migration 记录。
210216
- Chat 删除不删除共享 Sandbox;App 删除仍删除。
211217
- Skill 迁移保留完整记录且不删除 S3。
212-
- App 迁移覆盖默认目录、`.old` 冲突目录、重试、单条清理和最终状态清理。
218+
- App 迁移覆盖默认目录、新 session 优先的冲突合并、重试、单条清理和最终状态清理。
213219

214220
## 8. TODO
215221

@@ -225,7 +231,7 @@ type SandboxInstance = {
225231
- [x] 调整 Chat 删除、App 删除、自动 stop/archive/delete 生命周期。
226232
- [x] 增加同步 S3 归档删除与存在性检查。
227233
- [x] 实现 Skill Edit Legacy 记录迁移。
228-
- [x] 实现 App Workspace 分组迁移、staging、`.old` 和逐条清理
234+
- [x] 实现 App Workspace 分组迁移、staging、新 session 优先合并和逐条清理
229235
- [x] 编写并运行局部单元测试。
230236
- [x] 运行相关 TypeScript 检查。
231237
- [x] 最后运行全量测试。

packages/global/test/core/ai/sandbox/constants.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ describe('generateSandboxId', () => {
3939
});
4040

4141
describe('generateUserSandboxId', () => {
42-
it('is stable for the same App and user across Chats', () => {
43-
expect(generateUserSandboxId('app1', 'user1')).toBe(generateUserSandboxId('app1', 'user1'));
44-
});
45-
4642
it('changes when App or user changes', () => {
4743
const id = generateUserSandboxId('app1', 'user1');
4844
expect(id).not.toBe(generateUserSandboxId('app2', 'user1'));

packages/service/common/middle/tracks/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,6 @@ export const pushTrack = {
264264
phase: 'failure';
265265
sandboxId: string;
266266
step:
267-
| 'validate_legacy_record'
268267
| 'prepare_app_target'
269268
| 'migrate_skill'
270269
| 'migrate_app'

packages/service/core/ai/sandbox/application/migration.ts

Lines changed: 76 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { pushTrack } from '../../../../common/middle/tracks/utils';
1818
import { getS3SandboxSource } from '../../../../common/s3/sources/sandbox';
1919
import {
2020
MongoLegacySandboxInstance,
21+
LegacySandboxInstanceZodSchema,
2122
type LegacySandboxInstanceSchemaType
2223
} from '../infrastructure/instance/legacySchema';
2324
import {
@@ -48,7 +49,7 @@ type UserSandboxMigrationParams = {
4849
dryRun?: boolean;
4950
};
5051

51-
type LegacyWorkspaceInstallState = 'old' | 'conflict' | 'empty';
52+
type LegacyWorkspaceInstallState = 'conflict' | 'empty';
5253
type 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 是否已经存在。 */
149179
export 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
*/
186214
export 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

Comments
 (0)