feat(FN-1451): complete Step 2 — resolve hierarchy in dashboard imports
This commit is contained in:
@@ -13,7 +13,7 @@ const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockParseCompanyDirectory = vi.fn();
|
||||
const mockParseCompanyArchive = vi.fn();
|
||||
const mockParseSingleAgentManifest = vi.fn();
|
||||
const mockConvertAgentCompanies = vi.fn();
|
||||
const mockPrepareAgentCompaniesImport = vi.fn();
|
||||
|
||||
class MockAgentCompaniesParseError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -35,7 +35,7 @@ vi.mock("@fusion/core", () => {
|
||||
parseCompanyDirectory: (...args: unknown[]) => mockParseCompanyDirectory(...args),
|
||||
parseCompanyArchive: (...args: unknown[]) => mockParseCompanyArchive(...args),
|
||||
parseSingleAgentManifest: (...args: unknown[]) => mockParseSingleAgentManifest(...args),
|
||||
convertAgentCompanies: (...args: unknown[]) => mockConvertAgentCompanies(...args),
|
||||
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
|
||||
AgentCompaniesParseError: MockAgentCompaniesParseError,
|
||||
};
|
||||
});
|
||||
@@ -106,8 +106,13 @@ describe("POST /api/agents/import", () => {
|
||||
},
|
||||
});
|
||||
|
||||
mockConvertAgentCompanies.mockReturnValue({
|
||||
inputs: [{ name: "YAML Agent", role: "custom", title: "Chief Executive", metadata: { skills: ["review"] } }],
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [{
|
||||
manifestKey: "yaml-agent",
|
||||
aliases: ["yaml-agent"],
|
||||
index: 0,
|
||||
input: { name: "YAML Agent", role: "custom", title: "Chief Executive", metadata: { skills: ["review"] } },
|
||||
}],
|
||||
result: {
|
||||
created: ["YAML Agent"],
|
||||
skipped: [],
|
||||
@@ -138,7 +143,7 @@ describe("POST /api/agents/import", () => {
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockConvertAgentCompanies).toHaveBeenCalledTimes(1);
|
||||
expect(mockPrepareAgentCompaniesImport).toHaveBeenCalledTimes(1);
|
||||
const body = response.body as any;
|
||||
expect(body.created).toHaveLength(1);
|
||||
expect(body.created[0].name).toBe("YAML Agent");
|
||||
@@ -174,6 +179,97 @@ describe("POST /api/agents/import", () => {
|
||||
expect(body.companySlug).toBe("archive-co");
|
||||
});
|
||||
|
||||
it("creates hierarchical agents with resolved parent ids", async () => {
|
||||
const sourceDir = join(testDir, "hierarchy-company");
|
||||
mkdirSync(join(sourceDir, "agents", "ceo"), { recursive: true });
|
||||
writeFileSync(join(sourceDir, "agents", "ceo", "AGENTS.md"), "---\nname: CEO\n---\nLead");
|
||||
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [
|
||||
{
|
||||
manifestKey: "ceo",
|
||||
aliases: ["ceo"],
|
||||
index: 0,
|
||||
input: { name: "CEO", role: "custom" },
|
||||
},
|
||||
{
|
||||
manifestKey: "vp-eng",
|
||||
aliases: ["vp-eng"],
|
||||
index: 1,
|
||||
input: { name: "VP Eng", role: "custom" },
|
||||
reportsTo: { raw: "ceo", deferredManifestKey: "ceo" },
|
||||
},
|
||||
{
|
||||
manifestKey: "staff-eng",
|
||||
aliases: ["staff-eng"],
|
||||
index: 2,
|
||||
input: { name: "Staff Eng", role: "custom" },
|
||||
reportsTo: { raw: "../vp-eng/AGENTS.md", deferredManifestKey: "vp-eng" },
|
||||
},
|
||||
],
|
||||
result: {
|
||||
created: ["CEO", "VP Eng", "Staff Eng"],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await postImport(app, { source: sourceDir });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCreateAgent).toHaveBeenCalledTimes(3);
|
||||
expect(mockCreateAgent.mock.calls[0]?.[0]).toEqual({ name: "CEO", role: "custom" });
|
||||
expect(mockCreateAgent.mock.calls[1]?.[0]).toEqual({
|
||||
name: "VP Eng",
|
||||
role: "custom",
|
||||
reportsTo: "agent-CEO",
|
||||
});
|
||||
expect(mockCreateAgent.mock.calls[2]?.[0]).toEqual({
|
||||
name: "Staff Eng",
|
||||
role: "custom",
|
||||
reportsTo: "agent-VP Eng",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses helper-resolved existing manager ids for partial imports", async () => {
|
||||
mockListAgents.mockResolvedValue([{ id: "agent-ceo", name: "CEO" }]);
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [
|
||||
{
|
||||
manifestKey: "vp-eng",
|
||||
aliases: ["vp-eng"],
|
||||
index: 0,
|
||||
input: { name: "VP Eng", role: "custom" },
|
||||
reportsTo: { raw: "../ceo/AGENTS.md", resolvedAgentId: "agent-ceo" },
|
||||
},
|
||||
],
|
||||
result: {
|
||||
created: ["VP Eng"],
|
||||
skipped: ["CEO"],
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
|
||||
const response = await postImport(app, {
|
||||
manifest: "---\nname: VP Eng\nreportsTo: ../ceo/AGENTS.md\n---\nLead engineering",
|
||||
skipExisting: true,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCreateAgent).toHaveBeenCalledWith({
|
||||
name: "VP Eng",
|
||||
role: "custom",
|
||||
reportsTo: "agent-ceo",
|
||||
});
|
||||
expect(mockPrepareAgentCompaniesImport).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
skipExisting: ["CEO"],
|
||||
existingAgents: [{ id: "agent-ceo", name: "CEO" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-directory source paths", async () => {
|
||||
const filePath = join(testDir, "manifest.md");
|
||||
writeFileSync(filePath, "---\nname: Agent\n---");
|
||||
@@ -211,8 +307,8 @@ describe("POST /api/agents/import", () => {
|
||||
|
||||
it("honors skipExisting and returns skipped agents", async () => {
|
||||
mockListAgents.mockResolvedValue([{ id: "agent-existing", name: "YAML Agent" }]);
|
||||
mockConvertAgentCompanies.mockReturnValue({
|
||||
inputs: [],
|
||||
mockPrepareAgentCompaniesImport.mockReturnValue({
|
||||
items: [],
|
||||
result: {
|
||||
created: [],
|
||||
skipped: ["YAML Agent"],
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
hasIssueBadgeFieldsChanged,
|
||||
} from "./github-webhooks.js";
|
||||
import { createMissionRouter } from "./mission-routes.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
import { getOrCreateProjectStore, invalidateAllGlobalSettingsCaches } from "./project-store-resolver.js";
|
||||
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
||||
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
||||
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
|
||||
@@ -2065,6 +2065,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.put("/settings/global", async (req, res) => {
|
||||
try {
|
||||
const settings = await store.updateGlobalSettings(req.body);
|
||||
// Invalidate global settings caches in all project-scoped stores so the
|
||||
// next GET /settings?projectId=xxx reads fresh values from disk rather
|
||||
// than returning a stale per-project cache.
|
||||
invalidateAllGlobalSettingsCaches();
|
||||
res.json(settings);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -9586,7 +9590,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
parseCompanyDirectory,
|
||||
parseCompanyArchive,
|
||||
parseSingleAgentManifest,
|
||||
convertAgentCompanies,
|
||||
prepareAgentCompaniesImport,
|
||||
AgentCompaniesParseError: _AgentCompaniesParseError,
|
||||
} = await import("@fusion/core");
|
||||
|
||||
@@ -9596,7 +9600,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
const existingAgents = await agentStore.listAgents();
|
||||
const existingNames = new Set(existingAgents.map((a: any) => a.name));
|
||||
const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined;
|
||||
const conversionOptions = {
|
||||
...(skipExisting ? { skipExisting: [...existingNames] } : {}),
|
||||
existingAgents,
|
||||
};
|
||||
|
||||
let pkg: {
|
||||
company?: { name?: string; slug?: string };
|
||||
@@ -9810,26 +9817,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw badRequest("Provide one of: agents (array), source (path), manifest (string), or importSource + companySlug");
|
||||
}
|
||||
|
||||
const { inputs, result } = convertAgentCompanies(pkg as any, conversionOptions);
|
||||
const { items: importItems, result } = prepareAgentCompaniesImport(pkg as any, conversionOptions);
|
||||
const companyName = pkg.company?.name ?? "Unknown";
|
||||
const companySlug = typeof pkg.company?.slug === "string" ? pkg.company.slug : undefined;
|
||||
|
||||
if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
|
||||
if (importItems.length === 0 && result.errors.length === 0 && result.skipped.length === 0) {
|
||||
throw badRequest("No agents found in manifest");
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
const agentPreview = inputs.map((input: any) => ({
|
||||
name: input.name,
|
||||
role: input.role,
|
||||
title: typeof input.title === "string" ? input.title : undefined,
|
||||
icon: typeof input.icon === "string" ? input.icon : undefined,
|
||||
reportsTo: typeof input.reportsTo === "string" ? input.reportsTo : undefined,
|
||||
instructionsText: typeof input.instructionsText === "string"
|
||||
? input.instructionsText.slice(0, 200) + (input.instructionsText.length > 200 ? "..." : "")
|
||||
const agentPreview = importItems.map((item: any) => ({
|
||||
name: item.input.name,
|
||||
role: item.input.role,
|
||||
title: typeof item.input.title === "string" ? item.input.title : undefined,
|
||||
icon: typeof item.input.icon === "string" ? item.input.icon : undefined,
|
||||
reportsTo: item.reportsTo?.resolvedAgentId,
|
||||
instructionsText: typeof item.input.instructionsText === "string"
|
||||
? item.input.instructionsText.slice(0, 200) + (item.input.instructionsText.length > 200 ? "..." : "")
|
||||
: undefined,
|
||||
skills: Array.isArray(input.metadata?.skills)
|
||||
? input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
|
||||
skills: Array.isArray(item.input.metadata?.skills)
|
||||
? item.input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string")
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
@@ -9847,21 +9854,42 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
const created: Array<{ id: string; name: string }> = [];
|
||||
const errors: Array<{ name: string; error: string }> = [...result.errors];
|
||||
const createdAgentIdsByManifestKey = new Map<string, string>();
|
||||
|
||||
for (const input of inputs) {
|
||||
if (!skipExisting && existingNames.has(input.name)) {
|
||||
errors.push({ name: input.name, error: "Agent with this name already exists" });
|
||||
for (const item of importItems) {
|
||||
if (!skipExisting && existingNames.has(item.input.name)) {
|
||||
errors.push({ name: item.input.name, error: "Agent with this name already exists" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const input = {
|
||||
...item.input,
|
||||
...(item.input.metadata ? { metadata: { ...item.input.metadata } } : {}),
|
||||
};
|
||||
|
||||
if (item.reportsTo?.deferredManifestKey) {
|
||||
const resolvedReportsTo = createdAgentIdsByManifestKey.get(item.reportsTo.deferredManifestKey);
|
||||
if (!resolvedReportsTo) {
|
||||
errors.push({
|
||||
name: item.input.name,
|
||||
error: `Could not resolve reportsTo reference "${item.reportsTo.raw}" because the manager was not created`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
input.reportsTo = resolvedReportsTo;
|
||||
} else if (item.reportsTo?.resolvedAgentId) {
|
||||
input.reportsTo = item.reportsTo.resolvedAgentId;
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await agentStore.createAgent(input);
|
||||
created.push({ id: agent.id, name: agent.name });
|
||||
createdAgentIdsByManifestKey.set(item.manifestKey, agent.id);
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
errors.push({ name: input.name, error: err.message });
|
||||
errors.push({ name: item.input.name, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user