feat: add core registry schemas
This commit is contained in:
1
docs/schemas/personas/.gitkeep
Normal file
1
docs/schemas/personas/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
docs/schemas/templates/.gitkeep
Normal file
1
docs/schemas/templates/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -9,9 +9,10 @@
|
|||||||
"pnpm": ">=9.0.0 <10"
|
"pnpm": ">=9.0.0 <10"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b",
|
"build": "tsc -b && pnpm -r --if-present build",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/migrate.ts",
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
|
"db:seed": "tsx scripts/seed.ts",
|
||||||
"devflow": "tsx apps/cli/src/index.ts",
|
"devflow": "tsx apps/cli/src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm,cjs --dts",
|
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
|
"yaml": "2.6.1",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,47 @@ describe("content hashing", () => {
|
|||||||
expect(left).toMatch(/^[a-f0-9]{64}$/);
|
expect(left).toMatch(/^[a-f0-9]{64}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves own __proto__ keys while canonicalizing objects", () => {
|
||||||
|
const withProtoKey = JSON.parse('{"__proto__":{"x":1},"a":2}') as unknown;
|
||||||
|
|
||||||
|
expect(canonicalize(withProtoKey)).toBe('{"__proto__":{"x":1},"a":2}');
|
||||||
|
expect(hash(withProtoKey)).not.toBe(hash({ a: 2 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects hidden object keys that would be ignored by JSON rendering", () => {
|
||||||
|
const withSymbol = { a: 1, [Symbol("x")]: 2 };
|
||||||
|
const withHidden = { a: 1 };
|
||||||
|
Object.defineProperty(withHidden, "hidden", { value: 2, enumerable: false });
|
||||||
|
|
||||||
|
expect(() => canonicalize(withSymbol)).toThrow(/non-enumerable or symbol/);
|
||||||
|
expect(() => canonicalize(withHidden)).toThrow(/non-enumerable or symbol/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-index array object keys that would be ignored by JSON rendering", () => {
|
||||||
|
const withStringKey = [1] as number[] & { extra?: number };
|
||||||
|
withStringKey.extra = 2;
|
||||||
|
const withSymbol = [1] as unknown[];
|
||||||
|
Object.defineProperty(withSymbol, Symbol("x"), { value: 2, enumerable: true });
|
||||||
|
|
||||||
|
expect(() => canonicalize(withStringKey)).toThrow(/non-index array/);
|
||||||
|
expect(() => canonicalize(withSymbol)).toThrow(/non-index array/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the shortest round-trippable number literals without plus signs", () => {
|
||||||
|
expect(canonicalize([100, 1000, 11000, 123000, 1e20, 1e21, 0.000001, 0.0000001])).toBe(
|
||||||
|
"[100,1e3,11e3,123e3,1e20,1e21,1e-6,1e-7]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects values that are not JSON-safe", () => {
|
it("rejects values that are not JSON-safe", () => {
|
||||||
|
const sparse = Array<number>(3);
|
||||||
|
sparse[0] = 1;
|
||||||
|
sparse[2] = 3;
|
||||||
|
|
||||||
expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow(
|
expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow(
|
||||||
/non-plain object/,
|
/non-plain object/,
|
||||||
);
|
);
|
||||||
expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/);
|
expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/);
|
||||||
|
expect(() => canonicalize(sparse)).toThrow(/sparse array/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function renderCanonical(value: JsonValue): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return JSON.stringify(value);
|
return renderCanonicalNumber(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@@ -37,7 +37,17 @@ function assertJsonValue(value: unknown): JsonValue {
|
|||||||
Array.isArray(value)
|
Array.isArray(value)
|
||||||
) {
|
) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) => assertJsonValue(item));
|
assertOnlyArrayIndexKeys(value);
|
||||||
|
const arrayValue: JsonValue[] = [];
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
if (!(index in value)) {
|
||||||
|
throw new TypeError(`Cannot canonicalize sparse array at index ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayValue.push(assertJsonValue(value[index]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return arrayValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
@@ -57,7 +67,8 @@ function assertJsonValue(value: unknown): JsonValue {
|
|||||||
throw new TypeError("Cannot canonicalize non-plain object");
|
throw new TypeError("Cannot canonicalize non-plain object");
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectValue: Record<string, JsonValue> = {};
|
assertOnlyEnumerableStringKeys(value);
|
||||||
|
const objectValue: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
|
||||||
|
|
||||||
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
if (childValue === undefined) {
|
if (childValue === undefined) {
|
||||||
@@ -72,3 +83,183 @@ function assertJsonValue(value: unknown): JsonValue {
|
|||||||
|
|
||||||
throw new TypeError(`Cannot canonicalize ${typeof value}`);
|
throw new TypeError(`Cannot canonicalize ${typeof value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertOnlyEnumerableStringKeys(value: object) {
|
||||||
|
const enumerableStringKeys = Object.keys(value);
|
||||||
|
const ownKeys = Reflect.ownKeys(value);
|
||||||
|
const hasOnlyEnumerableStringKeys =
|
||||||
|
ownKeys.length === enumerableStringKeys.length &&
|
||||||
|
ownKeys.every(
|
||||||
|
(key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasOnlyEnumerableStringKeys) {
|
||||||
|
throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertOnlyArrayIndexKeys(value: unknown[]) {
|
||||||
|
for (const key of Reflect.ownKeys(value)) {
|
||||||
|
if (key === "length") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
|
||||||
|
throw new TypeError("Cannot canonicalize non-index array object keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Number(key);
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
|
||||||
|
throw new TypeError("Cannot canonicalize non-index array object keys");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCanonicalNumber(value: number): string {
|
||||||
|
if (Object.is(value, -0) || value === 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
addNumberCandidate(candidates, value, value.toString());
|
||||||
|
const jsonCandidate = JSON.stringify(value);
|
||||||
|
if (jsonCandidate !== undefined) {
|
||||||
|
addNumberCandidate(candidates, value, jsonCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let precision = 1; precision <= 17; precision += 1) {
|
||||||
|
addNumberCandidate(candidates, value, value.toPrecision(precision));
|
||||||
|
addNumberCandidate(candidates, value, value.toExponential(precision - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [best] = [...candidates].sort(
|
||||||
|
(left, right) => left.length - right.length || compareCodeUnits(left, right),
|
||||||
|
);
|
||||||
|
if (!best) {
|
||||||
|
throw new TypeError(`Cannot canonicalize number ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNumberCandidate(candidates: Set<string>, value: number, raw: string) {
|
||||||
|
const candidate = normalizeNumberLiteral(raw);
|
||||||
|
for (const equivalent of expandNumberLiteral(candidate)) {
|
||||||
|
if (Number(equivalent) === value) {
|
||||||
|
candidates.add(equivalent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareCodeUnits(left: string, right: string) {
|
||||||
|
if (left < right) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left > right) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumberLiteral(raw: string): string {
|
||||||
|
const [mantissaText, exponentText] = raw.toLowerCase().split("e");
|
||||||
|
const mantissa = normalizeDecimal(mantissaText ?? "");
|
||||||
|
if (exponentText === undefined) {
|
||||||
|
return mantissa;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exponent = normalizeExponent(exponentText);
|
||||||
|
if (exponent === "0") {
|
||||||
|
return mantissa;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${mantissa}e${exponent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDecimal(raw: string): string {
|
||||||
|
if (!raw.includes(".")) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = raw.replace(/0+$/, "").replace(/\.$/, "");
|
||||||
|
if (trimmed === "-0") {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExponent(raw: string): string {
|
||||||
|
const sign = raw.startsWith("-") ? "-" : "";
|
||||||
|
const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, "");
|
||||||
|
if (unsigned === "") {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sign}${unsigned}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandNumberLiteral(raw: string): string[] {
|
||||||
|
const parsed = parseNumberLiteral(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
return [raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
const plain = renderPlainDecimal(parsed);
|
||||||
|
const candidates = new Set([plain]);
|
||||||
|
|
||||||
|
for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) {
|
||||||
|
const exponent = parsed.power + parsed.digits.length - integerDigits;
|
||||||
|
if (exponent === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mantissa =
|
||||||
|
integerDigits === parsed.digits.length
|
||||||
|
? parsed.digits
|
||||||
|
: `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`;
|
||||||
|
candidates.add(`${parsed.sign}${mantissa}e${exponent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberLiteral(raw: string) {
|
||||||
|
const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, sign, integerPart, fractionalPart = "", exponentPart] = match;
|
||||||
|
let digits = `${integerPart}${fractionalPart}`;
|
||||||
|
let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length;
|
||||||
|
digits = digits.replace(/^0+/, "");
|
||||||
|
if (digits === "") {
|
||||||
|
return { sign: "", digits: "0", power: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthBeforeTrailingTrim = digits.length;
|
||||||
|
digits = digits.replace(/0+$/, "");
|
||||||
|
power += lengthBeforeTrailingTrim - digits.length;
|
||||||
|
|
||||||
|
return { sign: sign ?? "", digits, power };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) {
|
||||||
|
if (parsed.digits === "0") {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.power >= 0) {
|
||||||
|
return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointIndex = parsed.digits.length + parsed.power;
|
||||||
|
if (pointIndex > 0) {
|
||||||
|
return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ export * from "./config.js";
|
|||||||
export * from "./enums.js";
|
export * from "./enums.js";
|
||||||
export * from "./errors.js";
|
export * from "./errors.js";
|
||||||
export * from "./hash.js";
|
export * from "./hash.js";
|
||||||
|
export * from "./persona.js";
|
||||||
export * from "./prompt-envelope.js";
|
export * from "./prompt-envelope.js";
|
||||||
|
export * from "./registry-loader.js";
|
||||||
export * from "./run-event.js";
|
export * from "./run-event.js";
|
||||||
|
export * from "./template.js";
|
||||||
|
export * from "./version.js";
|
||||||
|
|||||||
147
packages/core/src/persona.ts
Normal file
147
packages/core/src/persona.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { Backend, Capability, RiskLevel } from "./enums.js";
|
||||||
|
import { hash } from "./hash.js";
|
||||||
|
import { DbIntVersion } from "./version.js";
|
||||||
|
|
||||||
|
export type JsonValue =
|
||||||
|
| null
|
||||||
|
| boolean
|
||||||
|
| number
|
||||||
|
| string
|
||||||
|
| JsonValue[]
|
||||||
|
| { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export type JsonObject = { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export const JsonObject: z.ZodType<JsonObject, z.ZodTypeDef, unknown> = z.lazy(() =>
|
||||||
|
z
|
||||||
|
.custom<Record<string, unknown>>(isPlainJsonRecordInput, {
|
||||||
|
message: "expected plain JSON object",
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
for (const key of Reflect.ownKeys(value)) {
|
||||||
|
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "expected plain JSON object",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSafeJsonObjectKey(key)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "reserved object key",
|
||||||
|
path: [key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pipe(z.record(z.string(), JsonValue)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const JsonValue: z.ZodType<JsonValue, z.ZodTypeDef, unknown> = z.lazy(() =>
|
||||||
|
z.union([z.null(), z.boolean(), z.number().finite(), z.string(), JsonArray, JsonObject]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const JsonArray: z.ZodType<JsonValue[], z.ZodTypeDef, unknown> = z.lazy(() =>
|
||||||
|
z
|
||||||
|
.custom<unknown[]>(Array.isArray, { message: "expected JSON array" })
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
for (const key of Reflect.ownKeys(value)) {
|
||||||
|
if (key === "length") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "expected JSON array",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = Number(key);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(index) ||
|
||||||
|
index < 0 ||
|
||||||
|
index >= value.length ||
|
||||||
|
String(index) !== key
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "expected JSON array",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pipe(z.array(JsonValue)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Persona = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
version: DbIntVersion,
|
||||||
|
backend: Backend,
|
||||||
|
capabilities: z.array(Capability),
|
||||||
|
maxRiskLevel: RiskLevel,
|
||||||
|
allowedRoles: z.array(z.string().min(1)).optional(),
|
||||||
|
promptConfig: z
|
||||||
|
.object({
|
||||||
|
systemPrompt: z.string().optional(),
|
||||||
|
instructionsPrelude: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default({})
|
||||||
|
.transform((value) => {
|
||||||
|
const promptConfig: { systemPrompt?: string; instructionsPrelude?: string } = {};
|
||||||
|
if (value.systemPrompt !== undefined) {
|
||||||
|
promptConfig.systemPrompt = value.systemPrompt;
|
||||||
|
}
|
||||||
|
if (value.instructionsPrelude !== undefined) {
|
||||||
|
promptConfig.instructionsPrelude = value.instructionsPrelude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return promptConfig;
|
||||||
|
}),
|
||||||
|
modelConfig: JsonObject.default({}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type Persona = z.infer<typeof Persona>;
|
||||||
|
|
||||||
|
export function personaHash(persona: Persona | undefined): string {
|
||||||
|
if (!persona) {
|
||||||
|
throw new TypeError("persona is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashSubject = {
|
||||||
|
name: persona.name,
|
||||||
|
version: persona.version,
|
||||||
|
capabilities: persona.capabilities,
|
||||||
|
backend: persona.backend,
|
||||||
|
maxRiskLevel: persona.maxRiskLevel,
|
||||||
|
promptConfig: persona.promptConfig,
|
||||||
|
modelConfig: persona.modelConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
return hash(
|
||||||
|
persona.allowedRoles === undefined
|
||||||
|
? hashSubject
|
||||||
|
: { ...hashSubject, allowedRoles: persona.allowedRoles },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeJsonObjectKey(key: string) {
|
||||||
|
return key !== "__proto__" && key !== "constructor" && key !== "prototype";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainJsonRecordInput(value: unknown) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
}
|
||||||
297
packages/core/src/registry-loader.test.ts
Normal file
297
packages/core/src/registry-loader.test.ts
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Persona } from "./persona.js";
|
||||||
|
import {
|
||||||
|
assertNoReferencedRegistryDeletions,
|
||||||
|
buildRegistrySeedPlan,
|
||||||
|
loadPersonaFiles,
|
||||||
|
loadTemplateFiles,
|
||||||
|
personaHash,
|
||||||
|
templateHash,
|
||||||
|
} from "./registry-loader.js";
|
||||||
|
|
||||||
|
function makeRoot() {
|
||||||
|
return mkdtempSync(join(tmpdir(), "devflow-registry-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("registry loader", () => {
|
||||||
|
it("loads versioned persona YAML files and computes stable hashes", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dir = join(root, "personas");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "fake_developer@1.yaml"),
|
||||||
|
[
|
||||||
|
"name: fake_developer",
|
||||||
|
"version: 1",
|
||||||
|
"backend: fake",
|
||||||
|
"capabilities:",
|
||||||
|
" - code_edit",
|
||||||
|
"maxRiskLevel: medium",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [entry] = loadPersonaFiles(dir);
|
||||||
|
|
||||||
|
expect(entry?.name).toBe("fake_developer");
|
||||||
|
expect(entry?.version).toBe(1);
|
||||||
|
expect(entry?.path).toBe(realpathSync(join(dir, "fake_developer@1.yaml")));
|
||||||
|
expect(entry?.hash).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(entry?.hash).toBe(personaHash(entry?.definition));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-canonical template filenames and filename identity mismatches", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dir = join(root, "templates");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "development@1.yaml"),
|
||||||
|
[
|
||||||
|
"name: development",
|
||||||
|
"version: 1",
|
||||||
|
"roles:",
|
||||||
|
" - id: implementer",
|
||||||
|
" requiredCapabilities:",
|
||||||
|
" - code_edit",
|
||||||
|
"phases:",
|
||||||
|
" - key: spec",
|
||||||
|
" title: Spec",
|
||||||
|
" risk: low",
|
||||||
|
" roles:",
|
||||||
|
" - implementer",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "development@01.yaml"),
|
||||||
|
["name: development", "version: 1", "roles: []", "phases: []"].join("\n"),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "actual_name@2.yml"),
|
||||||
|
["name: actual_name", "version: 2", "roles: []", "phases: []"].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => loadTemplateFiles(dir)).toThrow(/registry filename/);
|
||||||
|
|
||||||
|
const validDir = join(root, "valid-templates");
|
||||||
|
mkdirSync(validDir);
|
||||||
|
writeFileSync(join(validDir, "development@1.yaml"), readDevelopmentTemplate());
|
||||||
|
const [entry] = loadTemplateFiles(validDir);
|
||||||
|
|
||||||
|
expect(entry?.hash).toBe(templateHash(entry?.definition));
|
||||||
|
|
||||||
|
const mismatchDir = join(root, "mismatched-templates");
|
||||||
|
mkdirSync(mismatchDir);
|
||||||
|
writeFileSync(join(mismatchDir, "wrong@1.yaml"), readDevelopmentTemplate());
|
||||||
|
|
||||||
|
expect(() => loadTemplateFiles(mismatchDir)).toThrow(/identity mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects registry versions outside the database integer range", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const personaDir = join(root, "personas");
|
||||||
|
mkdirSync(personaDir);
|
||||||
|
writeFileSync(
|
||||||
|
join(personaDir, "fake@2147483648.yaml"),
|
||||||
|
[
|
||||||
|
"name: fake",
|
||||||
|
"version: 2147483648",
|
||||||
|
"backend: fake",
|
||||||
|
"capabilities: []",
|
||||||
|
"maxRiskLevel: low",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => loadPersonaFiles(personaDir)).toThrow(/less than or equal/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown template and persona keys instead of silently stripping them", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const personaDir = join(root, "personas");
|
||||||
|
const templateDir = join(root, "templates");
|
||||||
|
mkdirSync(personaDir);
|
||||||
|
mkdirSync(templateDir);
|
||||||
|
writeFileSync(
|
||||||
|
join(personaDir, "fake@1.yaml"),
|
||||||
|
[
|
||||||
|
"name: fake",
|
||||||
|
"version: 1",
|
||||||
|
"backend: fake",
|
||||||
|
"capabilities: []",
|
||||||
|
"maxRiskLevel: low",
|
||||||
|
"typo: accepted",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(templateDir, "development@1.yaml"),
|
||||||
|
["name: development", "version: 1", "roles: []", "phases: []", "typo: accepted"].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => loadPersonaFiles(personaDir)).toThrow(/Unrecognized key/);
|
||||||
|
expect(() => loadTemplateFiles(templateDir)).toThrow(/Unrecognized key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects persona model config values that cannot be content-hashed as JSON", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const personaDir = join(root, "personas");
|
||||||
|
mkdirSync(personaDir);
|
||||||
|
writeFileSync(
|
||||||
|
join(personaDir, "fake@1.yaml"),
|
||||||
|
[
|
||||||
|
"name: fake",
|
||||||
|
"version: 1",
|
||||||
|
"backend: fake",
|
||||||
|
"capabilities: []",
|
||||||
|
"maxRiskLevel: low",
|
||||||
|
"modelConfig:",
|
||||||
|
" temperature: .nan",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => loadPersonaFiles(personaDir)).toThrow(/finite|number|expected plain JSON object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects persona model config keys that would mutate object prototypes", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const personaDir = join(root, "personas");
|
||||||
|
mkdirSync(personaDir);
|
||||||
|
writeFileSync(
|
||||||
|
join(personaDir, "fake@1.yaml"),
|
||||||
|
[
|
||||||
|
"name: fake",
|
||||||
|
"version: 1",
|
||||||
|
"backend: fake",
|
||||||
|
"capabilities: []",
|
||||||
|
"maxRiskLevel: low",
|
||||||
|
"modelConfig:",
|
||||||
|
' "__proto__":',
|
||||||
|
" x: 1",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => loadPersonaFiles(personaDir)).toThrow(/reserved object key/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-plain programmatic model config objects", () => {
|
||||||
|
class ModelConfig {
|
||||||
|
readonly inherited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
Persona.parse({
|
||||||
|
name: "fake",
|
||||||
|
version: 1,
|
||||||
|
backend: "fake",
|
||||||
|
capabilities: [],
|
||||||
|
maxRiskLevel: "low",
|
||||||
|
modelConfig: new ModelConfig(),
|
||||||
|
}),
|
||||||
|
).toThrow(/expected plain JSON object/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects programmatic model config arrays with non-index keys", () => {
|
||||||
|
const array = [1] as unknown[] & { extra?: number };
|
||||||
|
array.extra = 2;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
Persona.parse({
|
||||||
|
name: "fake",
|
||||||
|
version: 1,
|
||||||
|
backend: "fake",
|
||||||
|
capabilities: [],
|
||||||
|
maxRiskLevel: "low",
|
||||||
|
modelConfig: { array },
|
||||||
|
}),
|
||||||
|
).toThrow(/expected JSON array/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects symlinked registry files", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const dir = join(root, "personas");
|
||||||
|
mkdirSync(dir);
|
||||||
|
const target = join(root, "target.yaml");
|
||||||
|
writeFileSync(
|
||||||
|
target,
|
||||||
|
["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
symlinkSync(target, join(dir, "fake@1.yaml"));
|
||||||
|
|
||||||
|
expect(() => loadPersonaFiles(dir)).toThrow(/not a symlink/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds seed actions and fails on published hash mismatch", () => {
|
||||||
|
const root = makeRoot();
|
||||||
|
const personaDir = join(root, "personas");
|
||||||
|
mkdirSync(personaDir);
|
||||||
|
writeFileSync(
|
||||||
|
join(personaDir, "fake@1.yaml"),
|
||||||
|
["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [entry] = loadPersonaFiles(personaDir);
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error("expected persona registry entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(buildRegistrySeedPlan([entry], [])).toEqual({
|
||||||
|
unchanged: [],
|
||||||
|
inserts: [entry],
|
||||||
|
missingReferenced: [],
|
||||||
|
missingUnreferenced: [],
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
buildRegistrySeedPlan(
|
||||||
|
[entry],
|
||||||
|
[{ name: "fake", version: 1, hash: "different", referencedByRun: false }],
|
||||||
|
),
|
||||||
|
).toThrow(/published registry entry was modified/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports published registry rows that no longer have YAML files", () => {
|
||||||
|
const plan = buildRegistrySeedPlan(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ name: "unused", version: 1, hash: "abc", referencedByRun: false },
|
||||||
|
{ name: "referenced", version: 1, hash: "def", referencedByRun: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plan).toEqual({
|
||||||
|
inserts: [],
|
||||||
|
missingReferenced: [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }],
|
||||||
|
missingUnreferenced: [{ name: "unused", version: 1, hash: "abc", referencedByRun: false }],
|
||||||
|
unchanged: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects referenced published registry deletions", () => {
|
||||||
|
const plan = buildRegistrySeedPlan(
|
||||||
|
[],
|
||||||
|
[{ name: "referenced", version: 1, hash: "def", referencedByRun: true }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => assertNoReferencedRegistryDeletions("persona", plan)).toThrow(/referenced@1/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readDevelopmentTemplate() {
|
||||||
|
return [
|
||||||
|
"name: development",
|
||||||
|
"version: 1",
|
||||||
|
"roles:",
|
||||||
|
" - id: implementer",
|
||||||
|
" requiredCapabilities:",
|
||||||
|
" - code_edit",
|
||||||
|
"phases:",
|
||||||
|
" - key: spec",
|
||||||
|
" title: Spec",
|
||||||
|
" risk: low",
|
||||||
|
" roles:",
|
||||||
|
" - implementer",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
152
packages/core/src/registry-loader.ts
Normal file
152
packages/core/src/registry-loader.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
|
import { Persona, personaHash } from "./persona.js";
|
||||||
|
import { Template, templateHash } from "./template.js";
|
||||||
|
|
||||||
|
export interface RegistryEntry<TDefinition> {
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
hash: string;
|
||||||
|
definition: TDefinition;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishedRegistryRow {
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
hash: string;
|
||||||
|
referencedByRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrySeedPlan<TDefinition> {
|
||||||
|
inserts: RegistryEntry<TDefinition>[];
|
||||||
|
missingReferenced: PublishedRegistryRow[];
|
||||||
|
missingUnreferenced: PublishedRegistryRow[];
|
||||||
|
unchanged: RegistryEntry<TDefinition>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegistryKind = "persona" | "template";
|
||||||
|
|
||||||
|
export function loadPersonaFiles(directory: string): RegistryEntry<Persona>[] {
|
||||||
|
return loadVersionedYamlFiles(directory, Persona, personaHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTemplateFiles(directory: string): RegistryEntry<Template>[] {
|
||||||
|
return loadVersionedYamlFiles(directory, Template, templateHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegistrySeedPlan<TDefinition>(
|
||||||
|
entries: RegistryEntry<TDefinition>[],
|
||||||
|
publishedRows: PublishedRegistryRow[],
|
||||||
|
): RegistrySeedPlan<TDefinition> {
|
||||||
|
const publishedByIdentity = new Map(
|
||||||
|
publishedRows.map((row) => [identityKey(row.name, row.version), row]),
|
||||||
|
);
|
||||||
|
const entriesByIdentity = new Map(
|
||||||
|
entries.map((entry) => [identityKey(entry.name, entry.version), entry]),
|
||||||
|
);
|
||||||
|
const inserts: RegistryEntry<TDefinition>[] = [];
|
||||||
|
const missingReferenced: PublishedRegistryRow[] = [];
|
||||||
|
const missingUnreferenced: PublishedRegistryRow[] = [];
|
||||||
|
const unchanged: RegistryEntry<TDefinition>[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const published = publishedByIdentity.get(identityKey(entry.name, entry.version));
|
||||||
|
if (!published) {
|
||||||
|
inserts.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (published.hash !== entry.hash) {
|
||||||
|
throw new Error(
|
||||||
|
`A published registry entry was modified in place: ${entry.name}@${entry.version}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unchanged.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const published of publishedRows) {
|
||||||
|
if (entriesByIdentity.has(identityKey(published.name, published.version))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (published.referencedByRun !== false) {
|
||||||
|
missingReferenced.push(published);
|
||||||
|
} else {
|
||||||
|
missingUnreferenced.push(published);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserts, missingReferenced, missingUnreferenced, unchanged };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNoReferencedRegistryDeletions<TDefinition>(
|
||||||
|
kind: RegistryKind,
|
||||||
|
plan: RegistrySeedPlan<TDefinition>,
|
||||||
|
) {
|
||||||
|
if (plan.missingReferenced.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const identities = plan.missingReferenced.map((entry) => `${entry.name}@${entry.version}`);
|
||||||
|
throw new Error(
|
||||||
|
`Cannot delete published ${kind} registry files referenced by runs: ${identities.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { personaHash, templateHash };
|
||||||
|
|
||||||
|
function loadVersionedYamlFiles<TDefinition extends { name: string; version: number }>(
|
||||||
|
directory: string,
|
||||||
|
schema: { parse(value: unknown): TDefinition },
|
||||||
|
hashDefinition: (definition: TDefinition) => string,
|
||||||
|
): RegistryEntry<TDefinition>[] {
|
||||||
|
return readdirSync(directory)
|
||||||
|
.filter((fileName) => {
|
||||||
|
if (fileName.endsWith(".yml")) {
|
||||||
|
throw new Error(`Invalid registry filename ${fileName}; expected <name>@<version>.yaml`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileName.endsWith(".yaml");
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.map((fileName) => {
|
||||||
|
const path = join(directory, fileName);
|
||||||
|
if (lstatSync(path).isSymbolicLink()) {
|
||||||
|
throw new Error(`Registry filename ${fileName} must be a regular YAML file, not a symlink`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalPath = realpathSync(path);
|
||||||
|
const definition = schema.parse(parse(readFileSync(path, "utf8")));
|
||||||
|
assertFilenameIdentity(fileName, definition);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: definition.name,
|
||||||
|
version: definition.version,
|
||||||
|
hash: hashDefinition(definition),
|
||||||
|
definition,
|
||||||
|
path: canonicalPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFilenameIdentity(fileName: string, definition: { name: string; version: number }) {
|
||||||
|
const match = basename(fileName).match(/^(.+)@([1-9]\d*)\.yaml$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid registry filename ${fileName}; expected <name>@<version>.yaml`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, name, versionText] = match;
|
||||||
|
if (name !== definition.name || versionText !== String(definition.version)) {
|
||||||
|
throw new Error(
|
||||||
|
`Registry filename identity mismatch for ${fileName}: expected ${definition.name}@${definition.version}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityKey(name: string, version: number) {
|
||||||
|
return `${name}@${version}`;
|
||||||
|
}
|
||||||
82
packages/core/src/template.test.ts
Normal file
82
packages/core/src/template.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { Template, templateHash } from "./template.js";
|
||||||
|
|
||||||
|
describe("template schema", () => {
|
||||||
|
it("rejects duplicate role ids", () => {
|
||||||
|
expect(() =>
|
||||||
|
Template.parse({
|
||||||
|
name: "development",
|
||||||
|
version: 1,
|
||||||
|
roles: [
|
||||||
|
{ id: "implementer", requiredCapabilities: ["code_edit"] },
|
||||||
|
{ id: "implementer", requiredCapabilities: ["code_review"] },
|
||||||
|
],
|
||||||
|
phases: [],
|
||||||
|
}),
|
||||||
|
).toThrow(/Duplicate role id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate phase keys and unknown phase roles", () => {
|
||||||
|
expect(() =>
|
||||||
|
Template.parse({
|
||||||
|
name: "development",
|
||||||
|
version: 1,
|
||||||
|
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
|
||||||
|
phases: [
|
||||||
|
{ key: "spec", title: "Spec", risk: "low", roles: ["implementer"] },
|
||||||
|
{ key: "spec", title: "Spec again", risk: "low", roles: ["missing"] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toThrow(/Duplicate phase key|Unknown phase role/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hashes schema-accepted templates with explicit undefined optional fields", () => {
|
||||||
|
const parsed = Template.parse({
|
||||||
|
name: "development",
|
||||||
|
version: 1,
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: "implementer",
|
||||||
|
requiredCapabilities: ["code_edit"],
|
||||||
|
diversity: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
key: "spec",
|
||||||
|
title: "Spec",
|
||||||
|
risk: "low",
|
||||||
|
roles: ["implementer"],
|
||||||
|
expectedArtifact: undefined,
|
||||||
|
timeoutMs: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(templateHash(parsed)).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats explicit false diversity as the same hash as omitted diversity", () => {
|
||||||
|
const withoutDiversity = Template.parse({
|
||||||
|
name: "development",
|
||||||
|
version: 1,
|
||||||
|
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
|
||||||
|
phases: [{ key: "spec", title: "Spec", risk: "low", roles: ["implementer"] }],
|
||||||
|
});
|
||||||
|
const withFalseDiversity = Template.parse({
|
||||||
|
name: "development",
|
||||||
|
version: 1,
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
id: "implementer",
|
||||||
|
requiredCapabilities: ["code_edit"],
|
||||||
|
diversity: { requireDifferentBackends: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
phases: [{ key: "spec", title: "Spec", risk: "low", roles: ["implementer"] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(templateHash(withFalseDiversity)).toBe(templateHash(withoutDiversity));
|
||||||
|
});
|
||||||
|
});
|
||||||
119
packages/core/src/template.ts
Normal file
119
packages/core/src/template.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { Backend, Capability, RiskLevel } from "./enums.js";
|
||||||
|
import { hash } from "./hash.js";
|
||||||
|
import { DbIntVersion } from "./version.js";
|
||||||
|
|
||||||
|
export const TemplatePhase = z
|
||||||
|
.object({
|
||||||
|
key: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
risk: RiskLevel,
|
||||||
|
roles: z.array(z.string().min(1)),
|
||||||
|
expectedArtifact: z
|
||||||
|
.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
schema: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
gates: z.array(z.string().min(1)).default([]),
|
||||||
|
timeoutMs: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const TemplateRole = z
|
||||||
|
.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
requiredCapabilities: z.array(Capability),
|
||||||
|
preferredBackends: z.array(Backend).default([]),
|
||||||
|
count: z.number().int().min(1).default(1),
|
||||||
|
diversity: z
|
||||||
|
.object({
|
||||||
|
requireDifferentBackends: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const Template = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
version: DbIntVersion,
|
||||||
|
roles: z.array(TemplateRole),
|
||||||
|
phases: z.array(TemplatePhase),
|
||||||
|
defaultGates: z.array(z.string().min(1)).default([]),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((template, context) => {
|
||||||
|
const roleIds = new Set<string>();
|
||||||
|
for (const [index, role] of template.roles.entries()) {
|
||||||
|
if (roleIds.has(role.id)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate role id ${role.id}`,
|
||||||
|
path: ["roles", index, "id"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
roleIds.add(role.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseKeys = new Set<string>();
|
||||||
|
for (const [index, phase] of template.phases.entries()) {
|
||||||
|
if (phaseKeys.has(phase.key)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Duplicate phase key ${phase.key}`,
|
||||||
|
path: ["phases", index, "key"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
phaseKeys.add(phase.key);
|
||||||
|
|
||||||
|
for (const [roleIndex, roleId] of phase.roles.entries()) {
|
||||||
|
if (!roleIds.has(roleId)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: `Unknown phase role ${roleId}`,
|
||||||
|
path: ["phases", index, "roles", roleIndex],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TemplatePhase = z.infer<typeof TemplatePhase>;
|
||||||
|
export type TemplateRole = z.infer<typeof TemplateRole>;
|
||||||
|
export type Template = z.infer<typeof Template>;
|
||||||
|
|
||||||
|
export function templateHash(template: Template | undefined): string {
|
||||||
|
if (!template) {
|
||||||
|
throw new TypeError("template is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash({
|
||||||
|
name: template.name,
|
||||||
|
version: template.version,
|
||||||
|
roles: template.roles.map((role) => ({
|
||||||
|
id: role.id,
|
||||||
|
requiredCapabilities: role.requiredCapabilities,
|
||||||
|
preferredBackends: role.preferredBackends,
|
||||||
|
count: role.count,
|
||||||
|
...(role.diversity?.requireDifferentBackends === true ? { diversity: role.diversity } : {}),
|
||||||
|
})),
|
||||||
|
phases: template.phases.map((phase) => ({
|
||||||
|
key: phase.key,
|
||||||
|
title: phase.title,
|
||||||
|
risk: phase.risk,
|
||||||
|
roles: phase.roles,
|
||||||
|
gates: phase.gates,
|
||||||
|
...(phase.expectedArtifact === undefined ? {} : { expectedArtifact: phase.expectedArtifact }),
|
||||||
|
...(phase.timeoutMs === undefined ? {} : { timeoutMs: phase.timeoutMs }),
|
||||||
|
})),
|
||||||
|
gates: template.defaultGates,
|
||||||
|
capabilitiesRequired: template.roles.map((role) => ({
|
||||||
|
roleId: role.id,
|
||||||
|
requiredCapabilities: role.requiredCapabilities,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
3
packages/core/src/version.ts
Normal file
3
packages/core/src/version.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const DbIntVersion = z.number().int().positive().max(2_147_483_647);
|
||||||
9
packages/core/tsconfig.build.json
Normal file
9
packages/core/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm,cjs --dts",
|
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
|
||||||
"typecheck": "tsc -b --noEmit",
|
"typecheck": "tsc -b --noEmit",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
9
packages/db/tsconfig.build.json
Normal file
9
packages/db/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@@ -44,7 +44,7 @@ importers:
|
|||||||
version: 2.1.6
|
version: 2.1.6
|
||||||
tsup:
|
tsup:
|
||||||
specifier: 8.3.5
|
specifier: 8.3.5
|
||||||
version: 8.3.5(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)
|
version: 8.3.5(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1)
|
||||||
tsx:
|
tsx:
|
||||||
specifier: 4.19.2
|
specifier: 4.19.2
|
||||||
version: 4.19.2
|
version: 4.19.2
|
||||||
@@ -53,11 +53,38 @@ importers:
|
|||||||
version: 5.6.3
|
version: 5.6.3
|
||||||
vite:
|
vite:
|
||||||
specifier: 6.0.3
|
specifier: 6.0.3
|
||||||
version: 6.0.3(@types/node@22.10.2)(tsx@4.19.2)
|
version: 6.0.3(@types/node@22.10.2)(tsx@4.19.2)(yaml@2.6.1)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 2.1.8
|
specifier: 2.1.8
|
||||||
version: 2.1.8(@types/node@22.10.2)
|
version: 2.1.8(@types/node@22.10.2)
|
||||||
|
|
||||||
|
apps/cli:
|
||||||
|
dependencies:
|
||||||
|
commander:
|
||||||
|
specifier: 12.1.0
|
||||||
|
version: 12.1.0
|
||||||
|
dotenv:
|
||||||
|
specifier: 17.4.2
|
||||||
|
version: 17.4.2
|
||||||
|
pg:
|
||||||
|
specifier: 8.20.0
|
||||||
|
version: 8.20.0
|
||||||
|
zod:
|
||||||
|
specifier: 3.24.1
|
||||||
|
version: 3.24.1
|
||||||
|
|
||||||
|
packages/core:
|
||||||
|
dependencies:
|
||||||
|
dotenv:
|
||||||
|
specifier: 17.4.2
|
||||||
|
version: 17.4.2
|
||||||
|
yaml:
|
||||||
|
specifier: 2.6.1
|
||||||
|
version: 2.6.1
|
||||||
|
zod:
|
||||||
|
specifier: 3.24.1
|
||||||
|
version: 3.24.1
|
||||||
|
|
||||||
packages/db:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
@@ -2058,6 +2085,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
yaml@2.6.1:
|
||||||
|
resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
zod@3.24.1:
|
zod@3.24.1:
|
||||||
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
|
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
|
||||||
|
|
||||||
@@ -3219,12 +3251,13 @@ snapshots:
|
|||||||
|
|
||||||
pirates@4.0.7: {}
|
pirates@4.0.7: {}
|
||||||
|
|
||||||
postcss-load-config@6.0.1(postcss@8.5.14)(tsx@4.19.2):
|
postcss-load-config@6.0.1(postcss@8.5.14)(tsx@4.19.2)(yaml@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
postcss: 8.5.14
|
postcss: 8.5.14
|
||||||
tsx: 4.19.2
|
tsx: 4.19.2
|
||||||
|
yaml: 2.6.1
|
||||||
|
|
||||||
postcss@8.5.14:
|
postcss@8.5.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3383,7 +3416,7 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
tsup@8.3.5(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3):
|
tsup@8.3.5(postcss@8.5.14)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
bundle-require: 5.1.0(esbuild@0.24.2)
|
bundle-require: 5.1.0(esbuild@0.24.2)
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
@@ -3393,7 +3426,7 @@ snapshots:
|
|||||||
esbuild: 0.24.2
|
esbuild: 0.24.2
|
||||||
joycon: 3.1.1
|
joycon: 3.1.1
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
postcss-load-config: 6.0.1(postcss@8.5.14)(tsx@4.19.2)
|
postcss-load-config: 6.0.1(postcss@8.5.14)(tsx@4.19.2)(yaml@2.6.1)
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
rollup: 4.60.3
|
rollup: 4.60.3
|
||||||
source-map: 0.8.0-beta.0
|
source-map: 0.8.0-beta.0
|
||||||
@@ -3455,7 +3488,7 @@ snapshots:
|
|||||||
'@types/node': 22.10.2
|
'@types/node': 22.10.2
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
vite@6.0.3(@types/node@22.10.2)(tsx@4.19.2):
|
vite@6.0.3(@types/node@22.10.2)(tsx@4.19.2)(yaml@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.24.2
|
esbuild: 0.24.2
|
||||||
postcss: 8.5.14
|
postcss: 8.5.14
|
||||||
@@ -3464,6 +3497,7 @@ snapshots:
|
|||||||
'@types/node': 22.10.2
|
'@types/node': 22.10.2
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
tsx: 4.19.2
|
tsx: 4.19.2
|
||||||
|
yaml: 2.6.1
|
||||||
|
|
||||||
vitest@2.1.8(@types/node@22.10.2):
|
vitest@2.1.8(@types/node@22.10.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3531,4 +3565,6 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
|
yaml@2.6.1: {}
|
||||||
|
|
||||||
zod@3.24.1: {}
|
zod@3.24.1: {}
|
||||||
|
|||||||
165
scripts/seed.ts
Normal file
165
scripts/seed.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Persona,
|
||||||
|
type PublishedRegistryRow,
|
||||||
|
type RegistryEntry,
|
||||||
|
type Template,
|
||||||
|
assertNoReferencedRegistryDeletions,
|
||||||
|
buildRegistrySeedPlan,
|
||||||
|
loadPersonaFiles,
|
||||||
|
loadTemplateFiles,
|
||||||
|
} from "../packages/core/src/index.js";
|
||||||
|
import { createDbClient } from "../packages/db/src/client.js";
|
||||||
|
import {
|
||||||
|
agentPersonas,
|
||||||
|
runBindings,
|
||||||
|
runs,
|
||||||
|
workflowTemplates,
|
||||||
|
} from "../packages/db/src/schema/index.js";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("DATABASE_URL is required to seed registries");
|
||||||
|
}
|
||||||
|
|
||||||
|
const personasDirectory = fileURLToPath(new URL("../docs/schemas/personas", import.meta.url));
|
||||||
|
const templatesDirectory = fileURLToPath(new URL("../docs/schemas/templates", import.meta.url));
|
||||||
|
const client = createDbClient(databaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const personas = loadRegistryDirectory(personasDirectory, loadPersonaFiles);
|
||||||
|
const templates = loadRegistryDirectory(templatesDirectory, loadTemplateFiles);
|
||||||
|
const result = {
|
||||||
|
personas: await seedPersonas(personas),
|
||||||
|
templates: await seedTemplates(templates),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRegistryDirectory<TDefinition>(
|
||||||
|
directory: string,
|
||||||
|
load: (directory: string) => RegistryEntry<TDefinition>[],
|
||||||
|
) {
|
||||||
|
if (!existsSync(directory)) {
|
||||||
|
throw new Error(`Registry directory does not exist: ${directory}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return load(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedPersonas(entries: RegistryEntry<Persona>[]) {
|
||||||
|
const existing = await client.db
|
||||||
|
.select({
|
||||||
|
id: agentPersonas.id,
|
||||||
|
name: agentPersonas.name,
|
||||||
|
version: agentPersonas.version,
|
||||||
|
hash: agentPersonas.hash,
|
||||||
|
})
|
||||||
|
.from(agentPersonas);
|
||||||
|
const publishedRows: PublishedRegistryRow[] = [];
|
||||||
|
|
||||||
|
for (const row of existing) {
|
||||||
|
const reference = await client.db
|
||||||
|
.select({ id: runBindings.id })
|
||||||
|
.from(runBindings)
|
||||||
|
.where(or(eq(runBindings.personaId, row.id), eq(runBindings.personaHash, row.hash)))
|
||||||
|
.limit(1);
|
||||||
|
publishedRows.push({ ...row, referencedByRun: reference.length > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = buildRegistrySeedPlan(entries, publishedRows);
|
||||||
|
assertNoReferencedRegistryDeletions("persona", plan);
|
||||||
|
await deleteUnreferencedPersonas(plan.missingUnreferenced);
|
||||||
|
for (const entry of plan.inserts) {
|
||||||
|
await client.db.insert(agentPersonas).values({
|
||||||
|
name: entry.name,
|
||||||
|
version: entry.version,
|
||||||
|
hash: entry.hash,
|
||||||
|
definition: entry.definition,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarizePlan(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTemplates(entries: RegistryEntry<Template>[]) {
|
||||||
|
const existing = await client.db
|
||||||
|
.select({
|
||||||
|
id: workflowTemplates.id,
|
||||||
|
name: workflowTemplates.name,
|
||||||
|
version: workflowTemplates.version,
|
||||||
|
hash: workflowTemplates.hash,
|
||||||
|
})
|
||||||
|
.from(workflowTemplates);
|
||||||
|
const publishedRows: PublishedRegistryRow[] = [];
|
||||||
|
|
||||||
|
for (const row of existing) {
|
||||||
|
const reference = await client.db
|
||||||
|
.select({ id: runs.id })
|
||||||
|
.from(runs)
|
||||||
|
.where(or(eq(runs.templateId, row.id), eq(runs.templateHash, row.hash)))
|
||||||
|
.limit(1);
|
||||||
|
publishedRows.push({ ...row, referencedByRun: reference.length > 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = buildRegistrySeedPlan(entries, publishedRows);
|
||||||
|
assertNoReferencedRegistryDeletions("template", plan);
|
||||||
|
await deleteUnreferencedTemplates(plan.missingUnreferenced);
|
||||||
|
for (const entry of plan.inserts) {
|
||||||
|
await client.db.insert(workflowTemplates).values({
|
||||||
|
name: entry.name,
|
||||||
|
version: entry.version,
|
||||||
|
hash: entry.hash,
|
||||||
|
definition: entry.definition,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return summarizePlan(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePlan<TDefinition>(plan: ReturnType<typeof buildRegistrySeedPlan<TDefinition>>) {
|
||||||
|
return {
|
||||||
|
deleted: plan.missingUnreferenced.length,
|
||||||
|
inserted: plan.inserts.length,
|
||||||
|
unchanged: plan.unchanged.length,
|
||||||
|
missingReferenced: plan.missingReferenced.map((entry) => `${entry.name}@${entry.version}`),
|
||||||
|
missingUnreferenced: plan.missingUnreferenced.map((entry) => `${entry.name}@${entry.version}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUnreferencedPersonas(entries: PublishedRegistryRow[]) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
await client.db
|
||||||
|
.delete(agentPersonas)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentPersonas.name, entry.name),
|
||||||
|
eq(agentPersonas.version, entry.version),
|
||||||
|
eq(agentPersonas.hash, entry.hash),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUnreferencedTemplates(entries: PublishedRegistryRow[]) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
await client.db
|
||||||
|
.delete(workflowTemplates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workflowTemplates.name, entry.name),
|
||||||
|
eq(workflowTemplates.version, entry.version),
|
||||||
|
eq(workflowTemplates.hash, entry.hash),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user