Add explicit skill selection to company portability
This commit is contained in:
parent
14ee364190
commit
5f2b1b63c2
6 changed files with 29 additions and 13 deletions
|
|
@ -85,16 +85,17 @@ function normalizeSelector(input: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
|
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||||
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false };
|
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false, skills: false };
|
||||||
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
||||||
const include = {
|
const include = {
|
||||||
company: values.includes("company"),
|
company: values.includes("company"),
|
||||||
agents: values.includes("agents"),
|
agents: values.includes("agents"),
|
||||||
projects: values.includes("projects"),
|
projects: values.includes("projects"),
|
||||||
issues: values.includes("issues"),
|
issues: values.includes("issues") || values.includes("tasks"),
|
||||||
|
skills: values.includes("skills"),
|
||||||
};
|
};
|
||||||
if (!include.company && !include.agents && !include.projects && !include.issues) {
|
if (!include.company && !include.agents && !include.projects && !include.issues && !include.skills) {
|
||||||
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues");
|
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
||||||
}
|
}
|
||||||
return include;
|
return include;
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +338,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
.description("Export a company into a portable markdown package")
|
.description("Export a company into a portable markdown package")
|
||||||
.argument("<companyId>", "Company ID")
|
.argument("<companyId>", "Company ID")
|
||||||
.requiredOption("--out <path>", "Output directory")
|
.requiredOption("--out <path>", "Output directory")
|
||||||
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents")
|
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
|
||||||
.option("--skills <values>", "Comma-separated skill slugs/keys to export")
|
.option("--skills <values>", "Comma-separated skill slugs/keys to export")
|
||||||
.option("--projects <values>", "Comma-separated project shortnames/ids to export")
|
.option("--projects <values>", "Comma-separated project shortnames/ids to export")
|
||||||
.option("--issues <values>", "Comma-separated issue identifiers/ids to export")
|
.option("--issues <values>", "Comma-separated issue identifiers/ids to export")
|
||||||
|
|
@ -390,7 +391,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
.command("import")
|
.command("import")
|
||||||
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
||||||
.requiredOption("--from <pathOrUrl>", "Source path or URL")
|
.requiredOption("--from <pathOrUrl>", "Source path or URL")
|
||||||
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents")
|
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
|
||||||
.option("--target <mode>", "Target mode: new | existing")
|
.option("--target <mode>", "Target mode: new | existing")
|
||||||
.option("-C, --company-id <id>", "Existing target company ID")
|
.option("-C, --company-id <id>", "Existing target company ID")
|
||||||
.option("--new-company-name <name>", "Name override for --target new")
|
.option("--new-company-name <name>", "Name override for --target new")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ export interface CompanyPortabilityInclude {
|
||||||
agents: boolean;
|
agents: boolean;
|
||||||
projects: boolean;
|
projects: boolean;
|
||||||
issues: boolean;
|
issues: boolean;
|
||||||
|
skills: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityEnvInput {
|
export interface CompanyPortabilityEnvInput {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export const portabilityIncludeSchema = z
|
||||||
agents: z.boolean().optional(),
|
agents: z.boolean().optional(),
|
||||||
projects: z.boolean().optional(),
|
projects: z.boolean().optional(),
|
||||||
issues: z.boolean().optional(),
|
issues: z.boolean().optional(),
|
||||||
|
skills: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|
||||||
|
|
@ -119,6 +120,7 @@ export const portabilityManifestSchema = z.object({
|
||||||
agents: z.boolean(),
|
agents: z.boolean(),
|
||||||
projects: z.boolean(),
|
projects: z.boolean(),
|
||||||
issues: z.boolean(),
|
issues: z.boolean(),
|
||||||
|
skills: z.boolean(),
|
||||||
}),
|
}),
|
||||||
company: portabilityCompanyManifestEntrySchema.nullable(),
|
company: portabilityCompanyManifestEntrySchema.nullable(),
|
||||||
agents: z.array(portabilityAgentManifestEntrySchema),
|
agents: z.array(portabilityAgentManifestEntrySchema),
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ describe("company portability routes", () => {
|
||||||
});
|
});
|
||||||
mockCompanyPortabilityService.previewExport.mockResolvedValue({
|
mockCompanyPortabilityService.previewExport.mockResolvedValue({
|
||||||
rootPath: "paperclip",
|
rootPath: "paperclip",
|
||||||
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
|
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
|
||||||
files: {},
|
files: {},
|
||||||
fileInventory: [],
|
fileInventory: [],
|
||||||
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },
|
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },
|
||||||
|
|
|
||||||
|
|
@ -666,7 +666,8 @@ describe("company portability", () => {
|
||||||
collisionStrategy: "rename",
|
collisionStrategy: "rename",
|
||||||
}, "user-1");
|
}, "user-1");
|
||||||
|
|
||||||
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, {
|
const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
|
||||||
|
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
|
||||||
onConflict: "replace",
|
onConflict: "replace",
|
||||||
});
|
});
|
||||||
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
|
@ -812,7 +813,8 @@ describe("company portability", () => {
|
||||||
expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1");
|
expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1");
|
||||||
expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported");
|
expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported");
|
||||||
expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active");
|
expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active");
|
||||||
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, {
|
const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
|
||||||
|
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
|
||||||
onConflict: "rename",
|
onConflict: "rename",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
|
||||||
agents: true,
|
agents: true,
|
||||||
projects: false,
|
projects: false,
|
||||||
issues: false,
|
issues: false,
|
||||||
|
skills: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
|
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
|
||||||
|
|
@ -561,6 +562,7 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
|
||||||
agents: input?.agents ?? DEFAULT_INCLUDE.agents,
|
agents: input?.agents ?? DEFAULT_INCLUDE.agents,
|
||||||
projects: input?.projects ?? DEFAULT_INCLUDE.projects,
|
projects: input?.projects ?? DEFAULT_INCLUDE.projects,
|
||||||
issues: input?.issues ?? DEFAULT_INCLUDE.issues,
|
issues: input?.issues ?? DEFAULT_INCLUDE.issues,
|
||||||
|
skills: input?.skills ?? DEFAULT_INCLUDE.skills,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1193,6 +1195,7 @@ function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: stri
|
||||||
agents: filtered.manifest.agents.length > 0,
|
agents: filtered.manifest.agents.length > 0,
|
||||||
projects: filtered.manifest.projects.length > 0,
|
projects: filtered.manifest.projects.length > 0,
|
||||||
issues: filtered.manifest.issues.length > 0,
|
issues: filtered.manifest.issues.length > 0,
|
||||||
|
skills: filtered.manifest.skills.length > 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
|
|
@ -1656,6 +1659,7 @@ function buildManifestFromPackageFiles(
|
||||||
agents: true,
|
agents: true,
|
||||||
projects: projectPaths.length > 0,
|
projects: projectPaths.length > 0,
|
||||||
issues: taskPaths.length > 0,
|
issues: taskPaths.length > 0,
|
||||||
|
skills: skillPaths.length > 0,
|
||||||
},
|
},
|
||||||
company: {
|
company: {
|
||||||
path: resolvedCompanyPath,
|
path: resolvedCompanyPath,
|
||||||
|
|
@ -2051,6 +2055,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
|
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
|
||||||
? true
|
? true
|
||||||
: input.include?.issues,
|
: input.include?.issues,
|
||||||
|
skills: input.skills && input.skills.length > 0 ? true : input.include?.skills,
|
||||||
});
|
});
|
||||||
const company = await companies.getById(companyId);
|
const company = await companies.getById(companyId);
|
||||||
if (!company) throw notFound("Company not found");
|
if (!company) throw notFound("Company not found");
|
||||||
|
|
@ -2063,7 +2068,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
||||||
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||||
const companySkillRows = await companySkills.listFull(companyId);
|
const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
|
||||||
if (include.agents) {
|
if (include.agents) {
|
||||||
const skipped = allAgentRows.length - liveAgentRows.length;
|
const skipped = allAgentRows.length - liveAgentRows.length;
|
||||||
if (skipped > 0) {
|
if (skipped > 0) {
|
||||||
|
|
@ -2464,6 +2469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
agents: resolved.manifest.agents.length > 0,
|
agents: resolved.manifest.agents.length > 0,
|
||||||
projects: resolved.manifest.projects.length > 0,
|
projects: resolved.manifest.projects.length > 0,
|
||||||
issues: resolved.manifest.issues.length > 0,
|
issues: resolved.manifest.issues.length > 0,
|
||||||
|
skills: resolved.manifest.skills.length > 0,
|
||||||
};
|
};
|
||||||
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
||||||
resolved.warnings.unshift(...warnings);
|
resolved.warnings.unshift(...warnings);
|
||||||
|
|
@ -2497,6 +2503,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
agents: resolved.manifest.agents.length > 0,
|
agents: resolved.manifest.agents.length > 0,
|
||||||
projects: resolved.manifest.projects.length > 0,
|
projects: resolved.manifest.projects.length > 0,
|
||||||
issues: resolved.manifest.issues.length > 0,
|
issues: resolved.manifest.issues.length > 0,
|
||||||
|
skills: resolved.manifest.skills.length > 0,
|
||||||
};
|
};
|
||||||
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
|
||||||
resolved.warnings.unshift(...warnings);
|
resolved.warnings.unshift(...warnings);
|
||||||
|
|
@ -2559,6 +2566,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
agents: requestedInclude.agents && manifest.agents.length > 0,
|
agents: requestedInclude.agents && manifest.agents.length > 0,
|
||||||
projects: requestedInclude.projects && manifest.projects.length > 0,
|
projects: requestedInclude.projects && manifest.projects.length > 0,
|
||||||
issues: requestedInclude.issues && manifest.issues.length > 0,
|
issues: requestedInclude.issues && manifest.issues.length > 0,
|
||||||
|
skills: requestedInclude.skills && manifest.skills.length > 0,
|
||||||
};
|
};
|
||||||
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
||||||
if (mode === "agent_safe" && collisionStrategy === "replace") {
|
if (mode === "agent_safe" && collisionStrategy === "replace") {
|
||||||
|
|
@ -3019,9 +3027,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
existingProjectSlugToId.set(existing.urlKey, existing.id);
|
existingProjectSlugToId.set(existing.urlKey, existing.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const importedSkills = await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
|
const importedSkills = include.skills || include.agents
|
||||||
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
|
? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
|
||||||
});
|
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
const desiredSkillRefMap = new Map<string, string>();
|
const desiredSkillRefMap = new Map<string, string>();
|
||||||
for (const importedSkill of importedSkills) {
|
for (const importedSkill of importedSkills) {
|
||||||
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);
|
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue