Skip to content

Commit 325b6a2

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

9 files changed

Lines changed: 126 additions & 95 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 记录复制到新表。

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: 48 additions & 40 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 {
@@ -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+
}
105117

106-
const isLegacySkillInstance = (doc: LegacySandboxInstanceSchemaType) =>
107-
doc.sourceType === ChatSourceTypeEnum.skillEdit || doc.type === SandboxTypeEnum.editDebug;
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+
}
126+
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 记录。
@@ -299,35 +329,25 @@ const runLegacySandboxMigration = async (
299329
): Promise<UserSandboxMigrationResult> => {
300330
const dryRun = params.dryRun ?? true;
301331
const migrationStartedAt = Date.now();
302-
const legacyDocs = await MongoLegacySandboxInstance.find({})
332+
const rawLegacyDocs = await MongoLegacySandboxInstance.collection
333+
.find({})
303334
.sort({ lastActiveAt: 1, _id: 1 })
304-
.lean<LegacySandboxInstanceSchemaType[]>();
335+
.toArray();
336+
const legacyDocs = parseLegacySandboxInstances(rawLegacyDocs);
305337
const skillItems: ResolvedLegacySkill[] = [];
306338
const appItems: ResolvedLegacyApp[] = [];
307-
const failures: UserSandboxMigrationFailure[] = [];
308339

309340
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' });
341+
if (doc.sourceType === ChatSourceTypeEnum.skillEdit) {
342+
skillItems.push({ doc, sourceId: doc.sourceId });
314343
continue;
315344
}
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-
}
345+
appItems.push({
346+
doc,
347+
sourceId: doc.sourceId,
348+
userId: doc.userId,
349+
chatId: doc.chatId
350+
});
331351
}
332352

333353
const appGroups = new Map<string, ResolvedLegacyApp[]>();
@@ -344,8 +364,8 @@ const runLegacySandboxMigration = async (
344364
migratedAppCount: 0,
345365
appGroupCount: appGroups.size,
346366
completedAppGroupCount: 0,
347-
failedCount: failures.length,
348-
failures
367+
failedCount: 0,
368+
failures: []
349369
};
350370
const trackCompleted = () =>
351371
recordMigrationTrack({
@@ -358,18 +378,6 @@ const runLegacySandboxMigration = async (
358378
});
359379

360380
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-
);
373381
if (dryRun) {
374382
await trackCompleted();
375383
return result;

packages/service/core/ai/sandbox/infrastructure/instance/legacySchema.ts

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,56 @@
66
import { connectionMongo, getMongoModel } from '../../../../../common/mongo';
77
import { SandboxStatusEnum, SandboxTypeEnum } from '@fastgpt/global/core/ai/sandbox/constants';
88
import { ChatSourceTypeEnum } from '@fastgpt/global/core/chat/constants';
9-
import type {
10-
SandboxInstanceSchemaType,
11-
SandboxProviderType,
12-
SandboxStorageType
9+
import {
10+
SandboxLimitSchema,
11+
SandboxMetadataSchema,
12+
SandboxProviderSchema,
13+
SandboxStorageSchema,
14+
SharedSandboxStatusSchema
1315
} from '../../type';
16+
import z from 'zod';
1417

1518
const { Schema } = connectionMongo;
1619

1720
export const legacyCollectionName = 'agent_sandbox_instances';
1821

19-
export type LegacySandboxInstanceSchemaType = {
20-
_id: unknown;
21-
provider: SandboxProviderType;
22-
sandboxId: string;
23-
appId?: string | null;
24-
sourceType?: ChatSourceTypeEnum | null;
25-
sourceId?: string | null;
26-
userId?: string | null;
27-
chatId?: string | null;
28-
type?: SandboxTypeEnum | null;
29-
status: SandboxInstanceSchemaType['status'];
30-
lastActiveAt: Date;
31-
createdAt?: Date;
32-
limit?: SandboxInstanceSchemaType['limit'];
33-
storage?: SandboxStorageType | null;
34-
metadata?: NonNullable<SandboxInstanceSchemaType['metadata']> & {
35-
skillId?: string;
36-
};
37-
};
22+
const LegacySandboxMetadataSchema = SandboxMetadataSchema.extend({
23+
skillId: z.never().optional()
24+
}).passthrough();
25+
26+
const LegacySandboxCommonSchema = z
27+
.object({
28+
_id: z.unknown().refine((value) => value !== null && value !== undefined, {
29+
message: '_id is required'
30+
}),
31+
provider: SandboxProviderSchema,
32+
sandboxId: z.string().min(1),
33+
sourceId: z.string().min(1),
34+
status: SharedSandboxStatusSchema,
35+
lastActiveAt: z.date(),
36+
createdAt: z.date().optional(),
37+
limit: SandboxLimitSchema.optional(),
38+
storage: SandboxStorageSchema.nullish(),
39+
metadata: LegacySandboxMetadataSchema.optional(),
40+
appId: z.never().optional(),
41+
type: z.never().optional()
42+
})
43+
.passthrough();
44+
45+
/** beta6 归一化完成后,用户级 Sandbox 迁移允许读取的 Legacy 记录结构。 */
46+
export const LegacySandboxInstanceZodSchema = z.discriminatedUnion('sourceType', [
47+
LegacySandboxCommonSchema.extend({
48+
sourceType: z.literal(ChatSourceTypeEnum.app),
49+
userId: z.string().min(1),
50+
chatId: z.string().min(1)
51+
}),
52+
LegacySandboxCommonSchema.extend({
53+
sourceType: z.literal(ChatSourceTypeEnum.skillEdit),
54+
userId: z.string().nullish(),
55+
chatId: z.string().nullish()
56+
})
57+
]);
58+
export type LegacySandboxInstanceSchemaType = z.infer<typeof LegacySandboxInstanceZodSchema>;
3859

3960
const LegacySandboxInstanceSchema = new Schema(
4061
{

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

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ describe('user-level sandbox migration', () => {
132132
expect(target.provider.execute).toHaveBeenCalledTimes(1);
133133
});
134134

135-
it('dry-run groups valid App records and reports invalid Legacy ownership', async () => {
135+
it('dry-run groups valid App records and rejects an invalid Legacy structure', async () => {
136136
await MongoLegacySandboxInstance.collection.insertMany([
137137
{
138138
provider: 'opensandbox',
@@ -153,14 +153,6 @@ describe('user-level sandbox migration', () => {
153153
chatId: 'chat-2',
154154
status: 'stopped',
155155
lastActiveAt: new Date()
156-
},
157-
{
158-
provider: 'opensandbox',
159-
sandboxId: 'migration-test-invalid',
160-
sourceType: ChatSourceTypeEnum.app,
161-
sourceId: 'app-2',
162-
status: 'stopped',
163-
lastActiveAt: new Date()
164156
}
165157
]);
166158

@@ -169,31 +161,46 @@ describe('user-level sandbox migration', () => {
169161
expect(result.legacyAppCount).toBeGreaterThanOrEqual(2);
170162
expect(result.appGroupCount).toBeGreaterThanOrEqual(1);
171163
expect(result.migratedAppCount).toBe(0);
172-
expect(result.failures).toContainEqual(
173-
expect.objectContaining({ sandboxId: 'migration-test-invalid' })
174-
);
164+
expect(result.failures).toEqual([]);
175165

176166
const migrationTracks = trackMocks.userSandboxMigration.mock.calls.map(([data]) => data);
177-
expect(migrationTracks).toHaveLength(3);
167+
expect(migrationTracks).toHaveLength(2);
178168
expect(migrationTracks[0]).toMatchObject({
179169
phase: 'started',
180170
dryRun: true
181171
});
182172
expect(Object.keys(migrationTracks[0]).sort()).toEqual(['dryRun', 'phase', 'runId']);
183173
expect(migrationTracks[1]).toMatchObject({
184-
phase: 'failure',
185-
step: 'validate_legacy_record',
186-
sandboxId: 'migration-test-invalid'
187-
});
188-
expect(migrationTracks[2]).toMatchObject({
189174
phase: 'completed',
190175
dryRun: true,
191176
successCount: expect.any(Number),
192177
durationMs: expect.any(Number),
193178
failureCount: expect.any(Number)
194179
});
195180
expect(migrationTracks.some(({ phase }) => phase === 'progress')).toBe(false);
196-
expect(migrationTracks[2].runId).toBe(migrationTracks[0].runId);
181+
expect(migrationTracks[1].runId).toBe(migrationTracks[0].runId);
182+
183+
await MongoLegacySandboxInstance.collection.insertOne({
184+
provider: 'opensandbox',
185+
sandboxId: 'migration-test-invalid',
186+
sourceType: ChatSourceTypeEnum.app,
187+
sourceId: 'app-2',
188+
status: 'stopped',
189+
lastActiveAt: new Date()
190+
});
191+
trackMocks.userSandboxMigration.mockClear();
192+
193+
const error = await migrateLegacySandboxesToUserLevel({ dryRun: true }).catch(
194+
(error: unknown) => error
195+
);
196+
expect(error).toBeInstanceOf(Error);
197+
expect((error as Error).message).toContain(
198+
'Legacy Sandbox preflight validation failed for 1 record(s)'
199+
);
200+
expect((error as Error).message).toContain('sandboxId=migration-test-invalid');
201+
expect((error as Error).message).toContain('userId');
202+
expect((error as Error).message).toContain('chatId');
203+
expect(trackMocks.userSandboxMigration).not.toHaveBeenCalled();
197204
});
198205

199206
it('keeps migration protection while normalized or raw Legacy App records remain', async () => {

packages/service/test/core/ai/sandbox/infrastructure/instance/model.test.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@ describe('MongoSandboxInstance model', () => {
3030

3131
const doc = new MongoSandboxInstance(mockInstance);
3232
expect(doc.sandboxId).toBe(mockInstance.sandboxId);
33-
expect(doc).not.toHaveProperty('appId');
34-
expect(doc).not.toHaveProperty('chatId');
35-
expect(doc).not.toHaveProperty('type');
3633
expect(doc.status).toBe('running');
3734
expect(doc?.provider).toBe('opensandbox');
3835
});

packages/service/test/core/ai/sandbox/infrastructure/instance/repository.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe('sandbox instance repository', () => {
3838
await MongoSandboxInstance.deleteMany({ sandboxId: new RegExp(`^${prefix}`) });
3939
});
4040

41-
it('upserts one App record without persisting Chat-level fields', async () => {
41+
it('upserts one App record per logical App user', async () => {
4242
const sourceId = `${prefix}app-${getNanoid()}`;
4343
const userId = `${prefix}user-${getNanoid()}`;
4444
const sandboxId = `${prefix}${getNanoid()}`;
@@ -61,9 +61,6 @@ describe('sandbox instance repository', () => {
6161

6262
const stored = await MongoSandboxInstance.findOne({ sandboxId }).lean();
6363
expect(stored).toMatchObject({ sourceType: 'app', sourceId, userId, status: 'running' });
64-
expect(stored).not.toHaveProperty('chatId');
65-
expect(stored).not.toHaveProperty('appId');
66-
expect(stored).not.toHaveProperty('type');
6764
expect(await MongoSandboxInstance.countDocuments({ sourceType: 'app', sourceId, userId })).toBe(
6865
1
6966
);

packages/service/test/core/workflow/dispatch/ai/agent/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@ describe('dispatchRunAgent user context', () => {
457457
expect(sandboxReadyBeforeLoop).toBe(true);
458458
const writeFiles = sandboxWriteFilesMock.mock.calls[0][0];
459459
expect(writeFiles.map((file: { path: string }) => file.path)).toEqual([
460-
'user_files/current.pdf'
460+
'/workspace/user_files/current.pdf'
461461
]);
462462
const loopInput = runAgentLoopMock.mock.calls[0][0].input;
463463
expect(loopInput.systemPrompt).not.toContain('pwd: /workspace');

0 commit comments

Comments
 (0)