diff --git a/.changeset/fix-expert-scope-versioned-keys.md b/.changeset/fix-expert-scope-versioned-keys.md new file mode 100644 index 00000000..bc9cb9ce --- /dev/null +++ b/.changeset/fix-expert-scope-versioned-keys.md @@ -0,0 +1,8 @@ +--- +"perstack": patch +"create-expert": patch +"@perstack/runtime": patch +"@perstack/core": patch +--- + +Fix getExpertScope to strip @version/@tag suffix from coordinator keys, preventing false "out-of-scope delegate" validation errors for versioned expert keys diff --git a/packages/core/src/schemas/expert.test.ts b/packages/core/src/schemas/expert.test.ts index 7146eb3e..125dccd1 100644 --- a/packages/core/src/schemas/expert.test.ts +++ b/packages/core/src/schemas/expert.test.ts @@ -153,4 +153,38 @@ describe("@perstack/core: expertSchema", () => { }), ).toThrow(ZodError) }) + + it("accepts valid delegation with versioned keys", () => { + const result = expertSchema.parse({ + key: "game-producer@1.0.0", + name: "game-producer", + version: "1.0.0", + instruction: "Test instruction", + delegates: ["@game-producer/designer@1.0.0"], + }) + expect(result.delegates).toEqual(["@game-producer/designer@1.0.0"]) + }) + + it("accepts valid delegation with tagged keys (draft ref)", () => { + const result = expertSchema.parse({ + key: "weather-reporter@lho140o3ie8b", + name: "weather-reporter", + version: "0.0.0", + instruction: "Test instruction", + delegates: ["@weather-reporter/data-fetcher@lho140o3ie8b"], + }) + expect(result.delegates).toEqual(["@weather-reporter/data-fetcher@lho140o3ie8b"]) + }) + + it("rejects out-of-scope delegation with versioned keys", () => { + expect(() => + expertSchema.parse({ + key: "game-producer@1.0.0", + name: "game-producer", + version: "1.0.0", + instruction: "Test instruction", + delegates: ["@other-scope/expert@1.0.0"], + }), + ).toThrow(ZodError) + }) }) diff --git a/packages/core/src/utils/expert-type.test.ts b/packages/core/src/utils/expert-type.test.ts index b10234fa..4135fc24 100644 --- a/packages/core/src/utils/expert-type.test.ts +++ b/packages/core/src/utils/expert-type.test.ts @@ -31,6 +31,18 @@ describe("getExpertScope", () => { it("extracts scope from delegate name", () => { expect(getExpertScope("@game-producer/designer")).toBe("game-producer") }) + + it("strips version suffix from coordinator key", () => { + expect(getExpertScope("game-producer@1.0.0")).toBe("game-producer") + }) + + it("strips tag suffix from coordinator key", () => { + expect(getExpertScope("game-producer@lho140o3ie8b")).toBe("game-producer") + }) + + it("extracts scope from versioned delegate key", () => { + expect(getExpertScope("@game-producer/designer@1.0.0")).toBe("game-producer") + }) }) describe("getExpertShortName", () => { @@ -41,6 +53,14 @@ describe("getExpertShortName", () => { it("extracts short name from delegate name", () => { expect(getExpertShortName("@game-producer/designer")).toBe("designer") }) + + it("strips version suffix from coordinator key", () => { + expect(getExpertShortName("game-producer@1.0.0")).toBe("game-producer") + }) + + it("strips version suffix from delegate key", () => { + expect(getExpertShortName("@game-producer/designer@1.0.0")).toBe("designer") + }) }) describe("validateDelegation", () => { @@ -62,6 +82,28 @@ describe("validateDelegation", () => { }) }) + describe("valid cases with versioned keys", () => { + it("versioned coordinator -> versioned own scope delegate", () => { + expect(validateDelegation("game-producer@1.0.0", "@game-producer/designer@1.0.0")).toBeNull() + }) + + it("versioned coordinator -> versioned other coordinator", () => { + expect(validateDelegation("game-producer@1.0.0", "other-coordinator@2.0.0")).toBeNull() + }) + + it("versioned delegate -> versioned sibling delegate", () => { + expect( + validateDelegation("@game-producer/designer@1.0.0", "@game-producer/programmer@1.0.0"), + ).toBeNull() + }) + + it("tagged coordinator -> tagged own scope delegate", () => { + expect( + validateDelegation("game-producer@draft-ref-id", "@game-producer/designer@draft-ref-id"), + ).toBeNull() + }) + }) + describe("invalid cases", () => { it("self-delegation (coordinator)", () => { expect(validateDelegation("game-producer", "game-producer")).toBe( @@ -93,6 +135,20 @@ describe("validateDelegation", () => { ) }) }) + + describe("invalid cases with versioned keys", () => { + it("versioned coordinator -> versioned out-of-scope delegate", () => { + expect(validateDelegation("game-producer@1.0.0", "@other-scope/expert@1.0.0")).toBe( + 'Expert "game-producer@1.0.0" cannot delegate to out-of-scope delegate "@other-scope/expert@1.0.0"', + ) + }) + + it("versioned delegate -> versioned own coordinator", () => { + expect(validateDelegation("@game-producer/designer@1.0.0", "game-producer@1.0.0")).toBe( + 'Delegate "@game-producer/designer@1.0.0" cannot delegate to its own coordinator "game-producer@1.0.0"', + ) + }) + }) }) describe("validateAllDelegations", () => { diff --git a/packages/core/src/utils/expert-type.ts b/packages/core/src/utils/expert-type.ts index 3de34291..b968219e 100644 --- a/packages/core/src/utils/expert-type.ts +++ b/packages/core/src/utils/expert-type.ts @@ -14,8 +14,11 @@ export function isDelegateExpert(expertName: string): boolean { /** * Returns the scope of an expert. + * Handles both bare names and versioned keys (with @version/@tag suffix). * - Coordinator "game-producer" -> "game-producer" + * - Coordinator "game-producer@1.0.0" -> "game-producer" * - Delegate "@game-producer/designer" -> "game-producer" + * - Delegate "@game-producer/designer@1.0.0" -> "game-producer" */ export function getExpertScope(expertName: string): string { if (isDelegateExpert(expertName)) { @@ -23,20 +26,27 @@ export function getExpertScope(expertName: string): string { const slashIndex = withoutAt.indexOf("/") return slashIndex === -1 ? withoutAt : withoutAt.slice(0, slashIndex) } - return expertName + const atIndex = expertName.indexOf("@") + return atIndex === -1 ? expertName : expertName.slice(0, atIndex) } /** * Returns the short name of an expert. + * Handles both bare names and versioned keys (with @version/@tag suffix). * - Coordinator "game-producer" -> "game-producer" + * - Coordinator "game-producer@1.0.0" -> "game-producer" * - Delegate "@game-producer/designer" -> "designer" + * - Delegate "@game-producer/designer@1.0.0" -> "designer" */ export function getExpertShortName(expertName: string): string { if (isDelegateExpert(expertName)) { const slashIndex = expertName.indexOf("/") - return slashIndex === -1 ? expertName : expertName.slice(slashIndex + 1) + const shortName = slashIndex === -1 ? expertName : expertName.slice(slashIndex + 1) + const atIndex = shortName.indexOf("@") + return atIndex === -1 ? shortName : shortName.slice(0, atIndex) } - return expertName + const atIndex = expertName.indexOf("@") + return atIndex === -1 ? expertName : expertName.slice(0, atIndex) } /** @@ -64,7 +74,11 @@ export function validateDelegation(source: string, target: string): string | nul } // A delegate cannot delegate to its own coordinator - if (isDelegateExpert(source) && isCoordinatorExpert(target) && target === sourceScope) { + if ( + isDelegateExpert(source) && + isCoordinatorExpert(target) && + getExpertScope(target) === sourceScope + ) { return `Delegate "${source}" cannot delegate to its own coordinator "${target}"` }