@axiom-lattice/gateway 2.1.111 → 2.1.113
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +13 -13
- package/CHANGELOG.md +20 -0
- package/dist/index.js +2118 -319
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1948 -145
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -6
- package/src/controllers/__tests__/workflow-tracking.test.ts +113 -0
- package/src/controllers/connections.ts +7 -0
- package/src/controllers/eval.ts +44 -4
- package/src/controllers/export-import.ts +166 -0
- package/src/controllers/run.ts +30 -4
- package/src/controllers/workflow-tracking.ts +105 -11
- package/src/export_registrations/a2a-api-key.registration.ts +89 -0
- package/src/export_registrations/agent.registration.ts +80 -0
- package/src/export_registrations/binding.registration.ts +106 -0
- package/src/export_registrations/channel-installation.registration.ts +88 -0
- package/src/export_registrations/collection.registration.ts +85 -0
- package/src/export_registrations/connection.registration.ts +97 -0
- package/src/export_registrations/database-config.registration.ts +94 -0
- package/src/export_registrations/eval.registration.ts +334 -0
- package/src/export_registrations/index.ts +31 -0
- package/src/export_registrations/mcp-config.registration.ts +97 -0
- package/src/export_registrations/menu.registration.ts +91 -0
- package/src/export_registrations/metrics-config.registration.ts +94 -0
- package/src/export_registrations/skill.registration.ts +88 -0
- package/src/export_registrations/task.registration.ts +99 -0
- package/src/index.ts +18 -8
- package/src/routes/index.ts +42 -1
- package/src/services/ExportImportService.ts +296 -0
- package/src/services/__tests__/ExportImportService.test.ts +130 -0
- package/src/services/eval-runner.ts +63 -55
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExportableEntityDefinition, ExportableEntity, ImportPreviewResult } from '@axiom-lattice/core';
|
|
2
|
+
import { getStoreLattice } from '@axiom-lattice/core';
|
|
3
|
+
import type { SkillStore } from '@axiom-lattice/protocols';
|
|
4
|
+
|
|
5
|
+
function getStore(): SkillStore {
|
|
6
|
+
return getStoreLattice('default', 'skill').store as unknown as SkillStore;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const skillRegistration: ExportableEntityDefinition = {
|
|
10
|
+
entityType: 'skill',
|
|
11
|
+
label: 'Skill(技能)',
|
|
12
|
+
category: 'core',
|
|
13
|
+
dependsOn: [],
|
|
14
|
+
cascadeParents: [],
|
|
15
|
+
|
|
16
|
+
async listForExport(tenantId: string): Promise<ExportableEntity[]> {
|
|
17
|
+
const store = getStore();
|
|
18
|
+
const skills = await store.getAllSkills(tenantId);
|
|
19
|
+
return skills.map((s) => ({
|
|
20
|
+
_exportId: '',
|
|
21
|
+
data: {
|
|
22
|
+
id: s.id,
|
|
23
|
+
name: s.name,
|
|
24
|
+
description: s.description,
|
|
25
|
+
license: s.license,
|
|
26
|
+
compatibility: s.compatibility,
|
|
27
|
+
metadata: s.metadata,
|
|
28
|
+
content: s.content,
|
|
29
|
+
subSkills: s.subSkills,
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async previewImport(
|
|
35
|
+
tenantId: string,
|
|
36
|
+
entities: ExportableEntity[],
|
|
37
|
+
): Promise<ImportPreviewResult> {
|
|
38
|
+
const store = getStore();
|
|
39
|
+
const existing = await store.getAllSkills(tenantId);
|
|
40
|
+
const existingIds = new Set(existing.map((s) => s.id));
|
|
41
|
+
|
|
42
|
+
const conflicts: ImportPreviewResult['conflicts'] = [];
|
|
43
|
+
const insertions: ImportPreviewResult['insertions'] = [];
|
|
44
|
+
|
|
45
|
+
for (const entity of entities) {
|
|
46
|
+
const id = entity.data.id as string;
|
|
47
|
+
if (existingIds.has(id)) {
|
|
48
|
+
conflicts.push({
|
|
49
|
+
_exportId: entity._exportId,
|
|
50
|
+
entityType: 'skill',
|
|
51
|
+
conflictType: 'id_exists',
|
|
52
|
+
existingId: id,
|
|
53
|
+
existingName: (entity.data.name as string) || id,
|
|
54
|
+
});
|
|
55
|
+
} else {
|
|
56
|
+
insertions.push({
|
|
57
|
+
_exportId: entity._exportId,
|
|
58
|
+
entityType: 'skill',
|
|
59
|
+
name: (entity.data.name as string) || id,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { conflicts, insertions, errors: [] };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async applyImport(tenantId, entity, resolution) {
|
|
68
|
+
const store = getStore();
|
|
69
|
+
const data = entity.data as Record<string, unknown>;
|
|
70
|
+
const id = resolution.action === 'rename' && resolution.newId ? resolution.newId : data.id as string;
|
|
71
|
+
|
|
72
|
+
if (resolution.action === 'overwrite') {
|
|
73
|
+
try { await store.deleteSkill(tenantId, id); } catch { /* may not exist */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await store.createSkill(tenantId, id, {
|
|
77
|
+
name: data.name as string,
|
|
78
|
+
description: data.description as string,
|
|
79
|
+
license: data.license as string | undefined,
|
|
80
|
+
compatibility: data.compatibility as string | undefined,
|
|
81
|
+
metadata: data.metadata as Record<string, string> | undefined,
|
|
82
|
+
content: data.content as string | undefined,
|
|
83
|
+
subSkills: data.subSkills as string[] | undefined,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { newId: id };
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ExportableEntityDefinition, ExportableEntity, ImportPreviewResult } from '@axiom-lattice/core';
|
|
2
|
+
import { getStoreLattice } from '@axiom-lattice/core';
|
|
3
|
+
import type { TaskStore } from '@axiom-lattice/protocols';
|
|
4
|
+
|
|
5
|
+
function getStore(): TaskStore {
|
|
6
|
+
return getStoreLattice('default', 'task').store as unknown as TaskStore;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const taskRegistration: ExportableEntityDefinition = {
|
|
10
|
+
entityType: 'task',
|
|
11
|
+
label: 'Task(任务)',
|
|
12
|
+
category: 'core',
|
|
13
|
+
dependsOn: ['agent'],
|
|
14
|
+
cascadeParents: [],
|
|
15
|
+
referenceFields: { ownerId: 'agent', parentId: 'task' },
|
|
16
|
+
|
|
17
|
+
async listForExport(tenantId: string): Promise<ExportableEntity[]> {
|
|
18
|
+
const store = getStore();
|
|
19
|
+
const tasks = await store.list({ tenantId, limit: 10000, offset: 0 });
|
|
20
|
+
return tasks.map((t) => ({
|
|
21
|
+
_exportId: '',
|
|
22
|
+
data: {
|
|
23
|
+
id: t.id,
|
|
24
|
+
title: t.title,
|
|
25
|
+
description: t.description,
|
|
26
|
+
status: t.status,
|
|
27
|
+
priority: t.priority,
|
|
28
|
+
dueDate: t.dueDate,
|
|
29
|
+
ownerType: t.ownerType,
|
|
30
|
+
ownerId: t.ownerId,
|
|
31
|
+
metadata: t.metadata,
|
|
32
|
+
parentId: t.parentId,
|
|
33
|
+
sourceId: t.sourceId,
|
|
34
|
+
context: t.context,
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async previewImport(
|
|
40
|
+
tenantId: string,
|
|
41
|
+
entities: ExportableEntity[],
|
|
42
|
+
): Promise<ImportPreviewResult> {
|
|
43
|
+
const store = getStore();
|
|
44
|
+
const existing = await store.list({ tenantId, limit: 10000, offset: 0 });
|
|
45
|
+
const existingIds = new Set(existing.map((t) => t.id));
|
|
46
|
+
|
|
47
|
+
const conflicts: ImportPreviewResult['conflicts'] = [];
|
|
48
|
+
const insertions: ImportPreviewResult['insertions'] = [];
|
|
49
|
+
|
|
50
|
+
for (const entity of entities) {
|
|
51
|
+
const d = entity.data as Record<string, unknown>;
|
|
52
|
+
const id = d.id as string;
|
|
53
|
+
if (existingIds.has(id)) {
|
|
54
|
+
conflicts.push({
|
|
55
|
+
_exportId: entity._exportId,
|
|
56
|
+
entityType: 'task',
|
|
57
|
+
conflictType: 'id_exists',
|
|
58
|
+
existingId: id,
|
|
59
|
+
existingName: (d.title as string) || id,
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
insertions.push({
|
|
63
|
+
_exportId: entity._exportId,
|
|
64
|
+
entityType: 'task',
|
|
65
|
+
name: (d.title as string) || id,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { conflicts, insertions, errors: [] };
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async applyImport(tenantId, entity, resolution) {
|
|
74
|
+
const store = getStore();
|
|
75
|
+
const data = entity.data as Record<string, unknown>;
|
|
76
|
+
const id = resolution.action === 'rename' && resolution.newId ? resolution.newId : data.id as string;
|
|
77
|
+
|
|
78
|
+
if (resolution.action === 'overwrite') {
|
|
79
|
+
try { await store.delete(tenantId, id); } catch { /* may not exist */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await store.create({
|
|
83
|
+
tenantId,
|
|
84
|
+
ownerType: (data.ownerType as 'user' | 'agent') || 'user',
|
|
85
|
+
ownerId: data.ownerId as string || '',
|
|
86
|
+
title: data.title as string,
|
|
87
|
+
description: data.description as string | undefined,
|
|
88
|
+
status: data.status as 'pending' | 'in_progress' | 'completed' | 'cancelled' | undefined,
|
|
89
|
+
priority: data.priority as 'low' | 'medium' | 'high' | undefined,
|
|
90
|
+
dueDate: data.dueDate as string | undefined,
|
|
91
|
+
metadata: data.metadata as Record<string, unknown> | undefined,
|
|
92
|
+
parentId: data.parentId as string | undefined,
|
|
93
|
+
sourceId: data.sourceId as string | undefined,
|
|
94
|
+
context: data.context as Record<string, unknown> | undefined,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return { newId: id };
|
|
98
|
+
},
|
|
99
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -8,14 +8,16 @@ import path from "path";
|
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
9
|
import { registerLatticeRoutes } from "./routes";
|
|
10
10
|
import { registerResourceRoutes } from "./routes/resource-routes";
|
|
11
|
+
import { registerAllBuiltinEntities } from "./export_registrations";
|
|
11
12
|
import type { ChannelInstallationStore, BindingRegistry } from "@axiom-lattice/protocols";
|
|
12
13
|
import { MessageRouter } from "./router/MessageRouter";
|
|
13
14
|
import { ChannelAdapterRegistry } from "./channels/registry";
|
|
14
15
|
import { createDeduplicationMiddleware, createRateLimitMiddleware, createAuditLoggerMiddleware } from "./router/middlewares";
|
|
15
|
-
import { setBindingRegistry, setMenuRegistry } from "@axiom-lattice/core";
|
|
16
|
+
import { setBindingRegistry, setMenuRegistry, setEvalRunService } from "@axiom-lattice/core";
|
|
16
17
|
import { larkChannelAdapter } from "./channels/lark/LarkChannelAdapter";
|
|
17
18
|
import { extractUserFromAuthHeader } from "./controllers/auth";
|
|
18
19
|
import { setChannelControllerDeps } from "./controllers/channel-installations";
|
|
20
|
+
import { evalRunner } from "./services/eval-runner";
|
|
19
21
|
import { configureSwagger } from "./swagger";
|
|
20
22
|
import {
|
|
21
23
|
setQueueServiceType,
|
|
@@ -387,6 +389,8 @@ const start = async (config?: LatticeGatewayConfig) => {
|
|
|
387
389
|
});
|
|
388
390
|
}
|
|
389
391
|
|
|
392
|
+
setEvalRunService(evalRunner);
|
|
393
|
+
|
|
390
394
|
// Initialize MenuRegistry for custom menu items
|
|
391
395
|
try {
|
|
392
396
|
const menuStore = getStoreLattice("default", "menu").store;
|
|
@@ -396,6 +400,9 @@ const start = async (config?: LatticeGatewayConfig) => {
|
|
|
396
400
|
// Menu store optional — custom menus won't work without it
|
|
397
401
|
}
|
|
398
402
|
|
|
403
|
+
// Register export/import entity types before routes
|
|
404
|
+
registerAllBuiltinEntities();
|
|
405
|
+
|
|
399
406
|
// Register all routes (channel routes only active if channelDeps is set)
|
|
400
407
|
// Wire controller deps so enable/disable/delete can manage live connections
|
|
401
408
|
if (channelDeps?.router) {
|
|
@@ -466,13 +473,16 @@ const start = async (config?: LatticeGatewayConfig) => {
|
|
|
466
473
|
}
|
|
467
474
|
|
|
468
475
|
// Restore agent instances with pending messages (fire-and-forget to avoid blocking startup)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
+
// Temporarily disabled: do not auto-consume pending messages on startup.
|
|
477
|
+
if (process.env.AXIOM_RESTORE_PENDING_ON_STARTUP === "true") {
|
|
478
|
+
agentInstanceManager.restore()
|
|
479
|
+
.then((stats) => {
|
|
480
|
+
logger.info(`Agent recovery complete: ${stats.restored} threads restored, ${stats.errors} errors`);
|
|
481
|
+
})
|
|
482
|
+
.catch((error) => {
|
|
483
|
+
logger.error("Agent recovery failed", { error });
|
|
484
|
+
});
|
|
485
|
+
}
|
|
476
486
|
|
|
477
487
|
// Restore MCP server connections that were connected before shutdown
|
|
478
488
|
restoreMcpConnections().catch((error) => {
|
package/src/routes/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import * as dataQueryController from "../controllers/data-query";
|
|
|
19
19
|
import * as workflowTrackingController from "../controllers/workflow-tracking";
|
|
20
20
|
import * as paController from "../controllers/personal-assistant";
|
|
21
21
|
import * as tasksController from "../controllers/tasks";
|
|
22
|
+
import * as exportImportController from "../controllers/export-import";
|
|
22
23
|
import { middlewareTypesRoutes } from "../controllers/middleware-types";
|
|
23
24
|
import {
|
|
24
25
|
createRunSchema,
|
|
@@ -510,7 +511,9 @@ export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { rout
|
|
|
510
511
|
workflowTrackingController.getAllWorkflowRuns
|
|
511
512
|
);
|
|
512
513
|
|
|
513
|
-
app.get
|
|
514
|
+
app.get<{
|
|
515
|
+
Querystring: { page?: string; pageSize?: string };
|
|
516
|
+
}>(
|
|
514
517
|
"/api/workflows/inbox",
|
|
515
518
|
workflowTrackingController.getInboxItems
|
|
516
519
|
);
|
|
@@ -546,6 +549,11 @@ export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { rout
|
|
|
546
549
|
Params: { runId: string };
|
|
547
550
|
}>("/api/workflows/runs/:runId/tasks", workflowTrackingController.getRunTasks);
|
|
548
551
|
|
|
552
|
+
// Abort a running workflow — stops graph + all sub-agents
|
|
553
|
+
app.post<{
|
|
554
|
+
Params: { runId: string };
|
|
555
|
+
}>("/api/workflows/runs/:runId/abort", workflowTrackingController.abortWorkflowRun);
|
|
556
|
+
|
|
549
557
|
// 简化的 Inbox 任务回复接口(后台 streaming 执行,立即返回)
|
|
550
558
|
app.post<{
|
|
551
559
|
Body: { runId: string; answers: Array<{ questionIndex: number; selectedOptions: string[] }> };
|
|
@@ -568,4 +576,37 @@ export const registerLatticeRoutes = (app: FastifyInstance, channelDeps?: { rout
|
|
|
568
576
|
"/api/assistants/:assistant_id/threads/:thread_id/pending-messages/:message_id",
|
|
569
577
|
removePendingMessageHandler
|
|
570
578
|
);
|
|
579
|
+
|
|
580
|
+
// Export/Import endpoints
|
|
581
|
+
app.get("/api/tenants/exportable-types", exportImportController.getExportableTypes);
|
|
582
|
+
|
|
583
|
+
app.post<{ Params: { tenantId: string }; Body: { entityTypes: string[]; entityIds?: Record<string, string[]> } }>(
|
|
584
|
+
"/api/tenants/:tenantId/export",
|
|
585
|
+
exportImportController.exportConfig,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
app.post<{ Params: { tenantId: string }; Body: { entityTypes: string[]; entityIds?: Record<string, string[]> } }>(
|
|
589
|
+
"/api/tenants/:tenantId/export/confirm",
|
|
590
|
+
exportImportController.exportConfigConfirm,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
app.post<{ Params: { tenantId: string }; Body: { entityTypes: string[] } }>(
|
|
594
|
+
"/api/tenants/:tenantId/export/entities",
|
|
595
|
+
exportImportController.previewEntities,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
app.get<{ Params: { tenantId: string; jobId: string } }>(
|
|
599
|
+
"/api/tenants/:tenantId/export/:jobId/download",
|
|
600
|
+
exportImportController.downloadExport,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
app.post<{ Params: { tenantId: string }; Body: { bundle: any } }>(
|
|
604
|
+
"/api/tenants/:tenantId/import/preview",
|
|
605
|
+
exportImportController.importPreview,
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
app.post<{ Params: { tenantId: string }; Body: { bundle: any; resolutions: Record<string, any> } }>(
|
|
609
|
+
"/api/tenants/:tenantId/import/apply",
|
|
610
|
+
exportImportController.importApply,
|
|
611
|
+
);
|
|
571
612
|
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core orchestration service for tenant configuration export and import.
|
|
3
|
+
*
|
|
4
|
+
* @module @axiom-lattice/gateway/services/ExportImportService
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { ExportableEntityRegistry, DependencyResolver, IdRemapper } from '@axiom-lattice/core';
|
|
8
|
+
import type {
|
|
9
|
+
ExportBundle,
|
|
10
|
+
ExportableEntity,
|
|
11
|
+
ImportPreviewResult,
|
|
12
|
+
Resolution,
|
|
13
|
+
ImportApplyResult,
|
|
14
|
+
ImportEntityResult,
|
|
15
|
+
} from '@axiom-lattice/core';
|
|
16
|
+
import { randomUUID } from 'crypto';
|
|
17
|
+
|
|
18
|
+
interface JobEntry {
|
|
19
|
+
bundle: ExportBundle;
|
|
20
|
+
tenantId: string;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Ephemeral in-memory job store (jobs expire after TTL). */
|
|
25
|
+
const exportJobs = new Map<string, JobEntry>();
|
|
26
|
+
|
|
27
|
+
/** TTL: 1 hour */
|
|
28
|
+
const JOB_TTL_MS = 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
function cleanExpiredJobs(): void {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
for (const [id, entry] of exportJobs) {
|
|
33
|
+
if (now - entry.createdAt > JOB_TTL_MS) {
|
|
34
|
+
exportJobs.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ExportImportService {
|
|
40
|
+
private registry = ExportableEntityRegistry.getInstance();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Preview an export: check which types would be auto-included, which dependencies are missing.
|
|
44
|
+
*/
|
|
45
|
+
async exportPreview(
|
|
46
|
+
tenantId: string,
|
|
47
|
+
selectedTypes: string[],
|
|
48
|
+
): Promise<{ missingDependencies: string[]; cascadeAdditions: string[] }> {
|
|
49
|
+
const allDefs = this.registry.getAll();
|
|
50
|
+
|
|
51
|
+
const expanded = DependencyResolver.expandCascade(allDefs, selectedTypes);
|
|
52
|
+
const cascadeAdditions = expanded.filter((t) => !selectedTypes.includes(t));
|
|
53
|
+
|
|
54
|
+
const deps = DependencyResolver.computeDependencies(allDefs, expanded);
|
|
55
|
+
const registeredTypes = new Set(allDefs.map((d) => d.entityType));
|
|
56
|
+
const missingDependencies = deps.missing.filter((t) => registeredTypes.has(t));
|
|
57
|
+
|
|
58
|
+
return { missingDependencies, cascadeAdditions };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate a full ExportBundle for the given tenant and entity types.
|
|
63
|
+
* Stores the bundle in-memory and returns a jobId for download.
|
|
64
|
+
*/
|
|
65
|
+
async export(
|
|
66
|
+
tenantId: string,
|
|
67
|
+
entityTypes: string[],
|
|
68
|
+
entityIds?: Record<string, string[]>,
|
|
69
|
+
): Promise<{ jobId: string; bundle: ExportBundle }> {
|
|
70
|
+
const allDefs = this.registry.getAll();
|
|
71
|
+
const expanded = DependencyResolver.expandCascade(allDefs, entityTypes);
|
|
72
|
+
const finalTypes = [...new Set([...expanded, ...entityTypes])];
|
|
73
|
+
|
|
74
|
+
const dependencyOrder = DependencyResolver.resolveOrder(
|
|
75
|
+
finalTypes.map((t) => this.registry.get(t)),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const entities: Record<string, ExportableEntity[]> = {};
|
|
79
|
+
let exportCounter = 0;
|
|
80
|
+
|
|
81
|
+
for (const type of dependencyOrder) {
|
|
82
|
+
const def = this.registry.get(type);
|
|
83
|
+
let raw = await def.listForExport(tenantId);
|
|
84
|
+
|
|
85
|
+
if (entityIds && entityIds[type] && entityIds[type].length > 0) {
|
|
86
|
+
const idSet = new Set(entityIds[type]);
|
|
87
|
+
raw = raw.filter((e) => idSet.has(e.data.id as string));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
entities[type] = raw.map((e) => ({
|
|
91
|
+
...e,
|
|
92
|
+
_exportId: `exp-${type}-${String(++exportCounter).padStart(3, '0')}`,
|
|
93
|
+
data: this.sanitizeEntityData(e.data),
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Build reverse map: raw entity ID → _exportId
|
|
98
|
+
const rawIdToExportId = new Map<string, string>();
|
|
99
|
+
for (const [, entityList] of Object.entries(entities)) {
|
|
100
|
+
for (const entity of entityList) {
|
|
101
|
+
const rawId = entity.data.id as string;
|
|
102
|
+
if (rawId) {
|
|
103
|
+
rawIdToExportId.set(rawId, entity._exportId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Rewrite reference fields to @type/exportId format
|
|
109
|
+
for (const [type, entityList] of Object.entries(entities)) {
|
|
110
|
+
const def = this.registry.get(type);
|
|
111
|
+
const refFields = def.referenceFields || {};
|
|
112
|
+
for (const entity of entityList) {
|
|
113
|
+
for (const [fieldName, refEntityType] of Object.entries(refFields)) {
|
|
114
|
+
const rawValue = entity.data[fieldName];
|
|
115
|
+
if (typeof rawValue === 'string' && rawValue) {
|
|
116
|
+
const exportId = rawIdToExportId.get(rawValue);
|
|
117
|
+
if (exportId) {
|
|
118
|
+
entity.data[fieldName] = `@${refEntityType}/${exportId}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bundle: ExportBundle = {
|
|
126
|
+
version: 1,
|
|
127
|
+
exportedAt: new Date().toISOString(),
|
|
128
|
+
sourceTenantId: tenantId,
|
|
129
|
+
entities,
|
|
130
|
+
dependencyOrder,
|
|
131
|
+
_warning: 'This file contains plaintext secrets (passwords, API keys, tokens). Store it securely and delete after import.',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const jobId = randomUUID();
|
|
135
|
+
exportJobs.set(jobId, { bundle, tenantId, createdAt: Date.now() });
|
|
136
|
+
|
|
137
|
+
return { jobId, bundle };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Retrieve a previously generated export bundle by jobId with tenant validation.
|
|
142
|
+
*/
|
|
143
|
+
getExportJob(jobId: string, tenantId: string): ExportBundle | undefined {
|
|
144
|
+
cleanExpiredJobs();
|
|
145
|
+
const entry = exportJobs.get(jobId);
|
|
146
|
+
if (!entry || entry.tenantId !== tenantId) return undefined;
|
|
147
|
+
return entry.bundle;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Preview import: validate bundle and detect conflicts.
|
|
152
|
+
*/
|
|
153
|
+
async previewImport(
|
|
154
|
+
tenantId: string,
|
|
155
|
+
bundle: ExportBundle,
|
|
156
|
+
): Promise<ImportPreviewResult> {
|
|
157
|
+
this.validateBundle(bundle);
|
|
158
|
+
|
|
159
|
+
const allConflicts: ImportPreviewResult['conflicts'] = [];
|
|
160
|
+
const allInsertions: ImportPreviewResult['insertions'] = [];
|
|
161
|
+
const allErrors: ImportPreviewResult['errors'] = [];
|
|
162
|
+
|
|
163
|
+
for (const [entityType, entities] of Object.entries(bundle.entities)) {
|
|
164
|
+
if (entities.length === 0) continue;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const def = this.registry.get(entityType);
|
|
168
|
+
const result = await def.previewImport(tenantId, entities);
|
|
169
|
+
|
|
170
|
+
allConflicts.push(...result.conflicts);
|
|
171
|
+
allInsertions.push(...result.insertions);
|
|
172
|
+
allErrors.push(...result.errors);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
for (const entity of entities) {
|
|
175
|
+
allErrors.push({
|
|
176
|
+
_exportId: entity._exportId,
|
|
177
|
+
entityType,
|
|
178
|
+
error: `Preview failed: ${(err as Error).message}`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { conflicts: allConflicts, insertions: allInsertions, errors: allErrors };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Apply import: create entities in dependency order with ID remapping.
|
|
189
|
+
*/
|
|
190
|
+
async applyImport(
|
|
191
|
+
tenantId: string,
|
|
192
|
+
bundle: ExportBundle,
|
|
193
|
+
resolutions: Record<string, Resolution>,
|
|
194
|
+
): Promise<ImportApplyResult> {
|
|
195
|
+
this.validateBundle(bundle);
|
|
196
|
+
|
|
197
|
+
const results: ImportEntityResult[] = [];
|
|
198
|
+
const idMap: Record<string, string> = {};
|
|
199
|
+
// Track skill old id → new id for agent graphDefinition implicit refs
|
|
200
|
+
const skillIdRemap: Record<string, string> = {};
|
|
201
|
+
|
|
202
|
+
for (const entityType of bundle.dependencyOrder) {
|
|
203
|
+
const entities = bundle.entities[entityType];
|
|
204
|
+
if (!entities || entities.length === 0) continue;
|
|
205
|
+
|
|
206
|
+
const def = this.registry.get(entityType);
|
|
207
|
+
const remapper = new IdRemapper(idMap);
|
|
208
|
+
|
|
209
|
+
for (const entity of entities) {
|
|
210
|
+
const resolution = resolutions[entity._exportId];
|
|
211
|
+
|
|
212
|
+
if (resolution?.action === 'skip') {
|
|
213
|
+
results.push({ _exportId: entity._exportId, entityType, status: 'skipped' });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const remappedData = remapper.remapReferences(entity.data) as Record<string, unknown>;
|
|
219
|
+
|
|
220
|
+
// Remap implicit skill references in agent graphDefinition.skillIds
|
|
221
|
+
if (entityType === 'agent' && Object.keys(skillIdRemap).length > 0) {
|
|
222
|
+
const skillRemapper = new IdRemapper({});
|
|
223
|
+
remappedData.graphDefinition = skillRemapper.remapSkillIds(
|
|
224
|
+
(remappedData.graphDefinition as Record<string, unknown>) || {},
|
|
225
|
+
skillIdRemap,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (resolution?.action === 'rename' && resolution.newId) {
|
|
230
|
+
remappedData.id = resolution.newId;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const entityWithRemappedData = { ...entity, data: remappedData };
|
|
234
|
+
const { newId } = await def.applyImport(
|
|
235
|
+
tenantId,
|
|
236
|
+
entityWithRemappedData,
|
|
237
|
+
resolution ?? { _exportId: entity._exportId, action: 'overwrite' },
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
if (newId) {
|
|
241
|
+
idMap[entity._exportId] = newId;
|
|
242
|
+
// Track skill ID renames for agent graphDefinition remapping
|
|
243
|
+
if (entityType === 'skill' && newId) {
|
|
244
|
+
const oldSkillId = remappedData.id as string;
|
|
245
|
+
if (oldSkillId !== newId) {
|
|
246
|
+
skillIdRemap[oldSkillId] = newId;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
results.push({
|
|
250
|
+
_exportId: entity._exportId,
|
|
251
|
+
entityType,
|
|
252
|
+
status: resolution?.action === 'overwrite' ? 'updated' : 'created',
|
|
253
|
+
newId,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch (err) {
|
|
257
|
+
results.push({
|
|
258
|
+
_exportId: entity._exportId,
|
|
259
|
+
entityType,
|
|
260
|
+
status: 'failed',
|
|
261
|
+
error: (err as Error).message,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { results, idMap };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* List all registered exportable types (for frontend).
|
|
272
|
+
*/
|
|
273
|
+
listExportableTypes() {
|
|
274
|
+
return this.registry.listTypes();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private validateBundle(bundle: ExportBundle): void {
|
|
278
|
+
if (bundle.version !== 1) {
|
|
279
|
+
throw new Error(`Unsupported bundle version: ${bundle.version}. Expected: 1`);
|
|
280
|
+
}
|
|
281
|
+
if (!bundle.entities || typeof bundle.entities !== 'object') {
|
|
282
|
+
throw new Error('Invalid bundle: missing entities');
|
|
283
|
+
}
|
|
284
|
+
if (!Array.isArray(bundle.dependencyOrder)) {
|
|
285
|
+
throw new Error('Invalid bundle: missing dependencyOrder');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private sanitizeEntityData(data: Record<string, unknown>): Record<string, unknown> {
|
|
290
|
+
const sanitized = { ...data };
|
|
291
|
+
delete sanitized.tenantId;
|
|
292
|
+
delete sanitized.createdAt;
|
|
293
|
+
delete sanitized.updatedAt;
|
|
294
|
+
return sanitized;
|
|
295
|
+
}
|
|
296
|
+
}
|