feat(FN-1451): complete Step 1 — add core hierarchy import planning

This commit is contained in:
gsxdsm
2026-04-13 12:49:29 -07:00
parent 0a1def0cc7
commit 6b112e97f6
3 changed files with 462 additions and 15 deletions

View File

@@ -9,6 +9,7 @@ import {
AgentCompaniesParseError, AgentCompaniesParseError,
agentManifestToAgentCreateInput, agentManifestToAgentCreateInput,
convertAgentCompanies, convertAgentCompanies,
prepareAgentCompaniesImport,
mapRoleToCapability, mapRoleToCapability,
parseAgentManifest, parseAgentManifest,
parseCompanyArchive, parseCompanyArchive,
@@ -386,6 +387,96 @@ name: Zip CEO
}); });
}); });
it("prepares imports with manager-first ordering and deferred hierarchy refs", () => {
const { items, result } = prepareAgentCompaniesImport({
company: { name: "Example" },
agents: [
{ name: "IC", reportsTo: "../vp-eng/AGENTS.md" },
{ name: "CEO", slug: "ceo" },
{ name: "VP Eng", slug: "vp-eng", reportsTo: "ceo" },
],
teams: [],
projects: [],
tasks: [],
});
expect(items.map((item) => item.input.name)).toEqual(["CEO", "VP Eng", "IC"]);
expect(items[0]).not.toHaveProperty("reportsTo");
expect(items[1]?.reportsTo).toEqual({
raw: "ceo",
deferredManifestKey: "ceo",
});
expect(items[2]?.reportsTo).toEqual({
raw: "../vp-eng/AGENTS.md",
deferredManifestKey: "vp-eng",
});
expect(result.errors).toEqual([]);
});
it("resolves existing manager refs by slug, path, and agent id", () => {
const existingAgents = [
{
id: "agent-ceo01",
name: "Chief Executive Officer",
metadata: { agentCompaniesSlug: "ceo" },
},
];
const { items, result } = prepareAgentCompaniesImport(
{
company: { name: "Example" },
agents: [
{ name: "Ops Lead", reportsTo: "ceo" },
{ name: "QA Lead", reportsTo: "../ceo/AGENTS.md" },
{ name: "Staff Eng", reportsTo: "agent-ceo01" },
],
teams: [],
projects: [],
tasks: [],
},
{ existingAgents },
);
expect(items.map((item) => item.input.reportsTo)).toEqual([
"agent-ceo01",
"agent-ceo01",
"agent-ceo01",
]);
expect(result.errors).toEqual([]);
});
it("keeps unresolved internal refs out of the import plan", () => {
const { items, result } = prepareAgentCompaniesImport({
company: { name: "Example" },
agents: [{ name: "Worker", reportsTo: "unknown-manager" }],
teams: [],
projects: [],
tasks: [],
});
expect(items).toEqual([]);
expect(result).toEqual({
created: [],
skipped: [],
errors: [
{
name: "Worker",
error:
'Could not resolve reportsTo reference "unknown-manager" to an imported or existing Fusion agent',
},
],
});
});
it("stores the manifest slug in metadata for future hierarchy resolution", () => {
const input = agentManifestToAgentCreateInput({
name: "CEO",
slug: "ceo",
});
expect(input.metadata).toEqual({ agentCompaniesSlug: "ceo" });
});
it("defaults to custom role when no skills are present", () => { it("defaults to custom role when no skills are present", () => {
const input = agentManifestToAgentCreateInput({ name: "Generalist" }); const input = agentManifestToAgentCreateInput({ name: "Generalist" });
expect(input.role).toBe("custom"); expect(input.role).toBe("custom");

View File

@@ -40,6 +40,226 @@ const VALID_ROLES: Set<string> = new Set([
"custom", "custom",
]); ]);
function slugifyAgentReference(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
}
function normalizeReference(value: string): string {
return value.trim().toLowerCase();
}
function normalizePathReference(value: string): string {
const normalized = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
const withoutFile = normalized.replace(/\/AGENTS\.md$/i, "").replace(/\.md$/i, "");
return withoutFile.replace(/^\.\//, "").replace(/\/$/, "").toLowerCase();
}
function extractPathBasename(value: string): string | undefined {
const normalized = normalizePathReference(value);
if (!normalized.includes("/")) {
return undefined;
}
const segments = normalized.split("/").filter(Boolean);
return segments.at(-1);
}
function looksLikeFusionAgentId(value: string): boolean {
return /^agent-[a-z0-9-]+$/i.test(value.trim());
}
function pushAlias(aliases: Set<string>, value: string | undefined | null): void {
if (typeof value !== "string") {
return;
}
const normalized = normalizeReference(value);
if (normalized.length > 0) {
aliases.add(normalized);
}
const slug = slugifyAgentReference(value);
if (slug.length > 0) {
aliases.add(slug);
}
const pathRef = normalizePathReference(value);
if (pathRef !== normalized && pathRef.length > 0) {
aliases.add(pathRef);
}
const basename = extractPathBasename(value);
if (basename) {
aliases.add(basename);
const basenameSlug = slugifyAgentReference(basename);
if (basenameSlug.length > 0) {
aliases.add(basenameSlug);
}
}
}
function collectAgentManifestAliases(agent: AgentManifest): string[] {
const aliases = new Set<string>();
pushAlias(aliases, agent.slug);
pushAlias(aliases, agent.name);
pushAlias(aliases, agent.title);
return [...aliases];
}
function collectExistingAgentAliases(agent: {
id: string;
name: string;
title?: string;
metadata?: Record<string, unknown>;
}): string[] {
const aliases = new Set<string>();
pushAlias(aliases, agent.id);
pushAlias(aliases, agent.name);
pushAlias(aliases, agent.title);
const metadataSlug = agent.metadata?.agentCompaniesSlug;
if (typeof metadataSlug === "string") {
pushAlias(aliases, metadataSlug);
}
return [...aliases];
}
function addAliases(
index: Map<string, Set<string>>,
ownerKey: string,
aliases: Iterable<string>,
): void {
for (const alias of aliases) {
const bucket = index.get(alias) ?? new Set<string>();
bucket.add(ownerKey);
index.set(alias, bucket);
}
}
function resolveUniqueAlias(
aliasIndex: Map<string, Set<string>>,
reference: string,
): { value?: string; ambiguous?: true } {
const aliases = new Set<string>();
pushAlias(aliases, reference);
const matches = new Set<string>();
for (const alias of aliases) {
for (const match of aliasIndex.get(alias) ?? []) {
matches.add(match);
}
}
if (matches.size === 1) {
return { value: [...matches][0] };
}
if (matches.size > 1) {
return { ambiguous: true };
}
return {};
}
function createUniqueManifestKey(baseKey: string, usedKeys: Set<string>): string {
let candidate = baseKey;
let counter = 2;
while (usedKeys.has(candidate)) {
candidate = `${baseKey}#${counter}`;
counter += 1;
}
usedKeys.add(candidate);
return candidate;
}
function topologicallySortImportPlanItems(
items: PreparedAgentCompaniesImportItem[],
): {
orderedItems: PreparedAgentCompaniesImportItem[];
cycleErrors: Array<{ name: string; error: string }>;
} {
const byKey = new Map(items.map((item) => [item.manifestKey, item]));
const indegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
for (const item of items) {
indegree.set(item.manifestKey, 0);
}
for (const item of items) {
const deferredKey = item.reportsTo?.deferredManifestKey;
if (!deferredKey || !byKey.has(deferredKey)) {
continue;
}
indegree.set(item.manifestKey, (indegree.get(item.manifestKey) ?? 0) + 1);
const downstream = dependents.get(deferredKey) ?? [];
downstream.push(item.manifestKey);
dependents.set(deferredKey, downstream);
}
const ready = items
.filter((item) => (indegree.get(item.manifestKey) ?? 0) === 0)
.sort((a, b) => a.index - b.index);
const orderedItems: PreparedAgentCompaniesImportItem[] = [];
while (ready.length > 0) {
const current = ready.shift();
if (!current) {
continue;
}
orderedItems.push(current);
for (const dependentKey of dependents.get(current.manifestKey) ?? []) {
const nextDegree = (indegree.get(dependentKey) ?? 0) - 1;
indegree.set(dependentKey, nextDegree);
if (nextDegree === 0) {
const dependent = byKey.get(dependentKey);
if (dependent) {
ready.push(dependent);
ready.sort((a, b) => a.index - b.index);
}
}
}
}
const cycleErrors = items
.filter((item) => !orderedItems.some((ordered) => ordered.manifestKey === item.manifestKey))
.sort((a, b) => a.index - b.index)
.map((item) => ({
name: item.input.name,
error: `Could not resolve reportsTo hierarchy for ${item.input.name} because the import graph contains a cycle involving "${item.reportsTo?.raw ?? "unknown"}"`,
}));
return { orderedItems, cycleErrors };
}
export interface PreparedAgentCompaniesImportItem {
manifestKey: string;
aliases: string[];
input: AgentCreateInput;
index: number;
reportsTo?: {
raw: string;
resolvedAgentId?: string;
deferredManifestKey?: string;
};
}
export interface PreparedAgentCompaniesImportResult {
items: PreparedAgentCompaniesImportItem[];
result: AgentCompaniesImportResult;
}
/** /**
* Map a role string to a Fusion agent capability. * Map a role string to a Fusion agent capability.
* Unknown roles fall back to "custom". * Unknown roles fall back to "custom".
@@ -291,6 +511,9 @@ export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCrea
if (Array.isArray(agent.metadata?.sources) && agent.metadata.sources.length > 0) { if (Array.isArray(agent.metadata?.sources) && agent.metadata.sources.length > 0) {
metadata.sources = agent.metadata.sources; metadata.sources = agent.metadata.sources;
} }
if (typeof agent.slug === "string" && agent.slug.trim().length > 0) {
metadata.agentCompaniesSlug = agent.slug.trim();
}
return { return {
name: agent.name, name: agent.name,
@@ -311,34 +534,162 @@ export function agentManifestToAgentCreateInput(agent: AgentManifest): AgentCrea
}; };
} }
export function convertAgentCompanies( export function prepareAgentCompaniesImport(
pkg: AgentCompaniesPackage, pkg: AgentCompaniesPackage,
options?: { skipExisting?: string[] }, options?: {
): { inputs: AgentCreateInput[]; result: AgentCompaniesImportResult } { skipExisting?: string[];
existingAgents?: Array<{
id: string;
name: string;
title?: string;
metadata?: Record<string, unknown>;
}>;
},
): PreparedAgentCompaniesImportResult {
const existingNames = new Set(options?.skipExisting ?? []); const existingNames = new Set(options?.skipExisting ?? []);
const inputs: AgentCreateInput[] = []; const existingAliasIndex = new Map<string, Set<string>>();
const existingAgentsById = new Map(
(options?.existingAgents ?? []).map((agent) => [agent.id, agent]),
);
for (const agent of options?.existingAgents ?? []) {
addAliases(existingAliasIndex, agent.id, collectExistingAgentAliases(agent));
}
const plannedAgents: Array<PreparedAgentCompaniesImportItem & { manifest: AgentManifest }> = [];
const pendingAgents: Array<PreparedAgentCompaniesImportItem & { manifest: AgentManifest }> = [];
const manifestAliasIndex = new Map<string, Set<string>>();
const usedManifestKeys = new Set<string>();
const result: AgentCompaniesImportResult = { const result: AgentCompaniesImportResult = {
created: [], created: [],
skipped: [], skipped: [],
errors: [], errors: [],
}; };
for (const agent of pkg.agents) { for (const [index, agent] of pkg.agents.entries()) {
if (existingNames.has(agent.name)) { if (existingNames.has(agent.name)) {
result.skipped.push(agent.name); result.skipped.push(agent.name);
continue; continue;
} }
try { const aliases = collectAgentManifestAliases(agent);
inputs.push(agentManifestToAgentCreateInput(agent)); const baseKey = aliases[0] ?? createUniqueManifestKey(`agent-${index + 1}`, usedManifestKeys);
result.created.push(agent.name); const manifestKey = createUniqueManifestKey(baseKey, usedManifestKeys);
} catch (error) {
result.errors.push({ const planned = {
name: agent.name, manifest: agent,
error: (error as Error).message, manifestKey,
}); aliases,
} input: agentManifestToAgentCreateInput(agent),
index,
};
addAliases(manifestAliasIndex, manifestKey, aliases);
plannedAgents.push(planned);
} }
return { inputs, result }; for (const planned of plannedAgents) {
const rawReportsTo = typeof planned.manifest.reportsTo === "string"
? planned.manifest.reportsTo.trim()
: undefined;
if (!rawReportsTo) {
delete planned.input.reportsTo;
pendingAgents.push(planned);
result.created.push(planned.input.name);
continue;
}
const existingMatch = resolveUniqueAlias(existingAliasIndex, rawReportsTo);
if (existingMatch.ambiguous) {
result.errors.push({
name: planned.input.name,
error: `reportsTo reference "${rawReportsTo}" is ambiguous among existing Fusion agents`,
});
continue;
}
if (existingMatch.value) {
planned.input.reportsTo = existingMatch.value;
planned.reportsTo = {
raw: rawReportsTo,
resolvedAgentId: existingMatch.value,
};
pendingAgents.push(planned);
result.created.push(planned.input.name);
continue;
}
const manifestMatch = resolveUniqueAlias(manifestAliasIndex, rawReportsTo);
if (manifestMatch.ambiguous) {
result.errors.push({
name: planned.input.name,
error: `reportsTo reference "${rawReportsTo}" matches multiple imported agents`,
});
continue;
}
if (manifestMatch.value) {
if (manifestMatch.value === planned.manifestKey) {
result.errors.push({
name: planned.input.name,
error: `reportsTo reference "${rawReportsTo}" resolves to the agent itself`,
});
continue;
}
delete planned.input.reportsTo;
planned.reportsTo = {
raw: rawReportsTo,
deferredManifestKey: manifestMatch.value,
};
pendingAgents.push(planned);
result.created.push(planned.input.name);
continue;
}
if (looksLikeFusionAgentId(rawReportsTo) && !existingAgentsById.has(rawReportsTo)) {
planned.input.reportsTo = rawReportsTo;
planned.reportsTo = {
raw: rawReportsTo,
resolvedAgentId: rawReportsTo,
};
pendingAgents.push(planned);
result.created.push(planned.input.name);
continue;
}
result.errors.push({
name: planned.input.name,
error: `Could not resolve reportsTo reference "${rawReportsTo}" to an imported or existing Fusion agent`,
});
}
const { orderedItems, cycleErrors } = topologicallySortImportPlanItems(pendingAgents);
for (const error of cycleErrors) {
result.errors.push(error);
result.created = result.created.filter((name) => name !== error.name);
}
return {
items: orderedItems.map(({ manifest: _manifest, ...item }) => item),
result,
};
}
export function convertAgentCompanies(
pkg: AgentCompaniesPackage,
options?: {
skipExisting?: string[];
existingAgents?: Array<{
id: string;
name: string;
title?: string;
metadata?: Record<string, unknown>;
}>;
},
): { inputs: AgentCreateInput[]; result: AgentCompaniesImportResult } {
const { items, result } = prepareAgentCompaniesImport(pkg, options);
return {
inputs: items.map((item) => item.input),
result,
};
} }

View File

@@ -444,9 +444,14 @@ export {
parseCompanyArchive, parseCompanyArchive,
mapRoleToCapability, mapRoleToCapability,
agentManifestToAgentCreateInput, agentManifestToAgentCreateInput,
prepareAgentCompaniesImport,
convertAgentCompanies, convertAgentCompanies,
AgentCompaniesParseError, AgentCompaniesParseError,
} from "./agent-companies-parser.js"; } from "./agent-companies-parser.js";
export type {
PreparedAgentCompaniesImportItem,
PreparedAgentCompaniesImportResult,
} from "./agent-companies-parser.js";
// ── Agent Companies Exporter ────────────────────────────── // ── Agent Companies Exporter ──────────────────────────────