feat(FN-1451): complete Step 2 — resolve hierarchy in dashboard imports

This commit is contained in:
gsxdsm
2026-04-13 12:51:59 -07:00
parent 6b112e97f6
commit b1c90013da
2 changed files with 150 additions and 26 deletions

View File

@@ -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"],

View File

@@ -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 });
}
}