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"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"build": "tsc -b && pnpm -r --if-present build",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/migrate.ts",
|
||||
"db:seed": "tsx scripts/seed.ts",
|
||||
"devflow": "tsx apps/cli/src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"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",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "17.4.2",
|
||||
"yaml": "2.6.1",
|
||||
"zod": "3.24.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,47 @@ describe("content hashing", () => {
|
||||
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", () => {
|
||||
const sparse = Array<number>(3);
|
||||
sparse[0] = 1;
|
||||
sparse[2] = 3;
|
||||
|
||||
expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow(
|
||||
/non-plain object/,
|
||||
);
|
||||
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") {
|
||||
return JSON.stringify(value);
|
||||
return renderCanonicalNumber(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
@@ -37,7 +37,17 @@ function assertJsonValue(value: unknown): JsonValue {
|
||||
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;
|
||||
@@ -57,7 +67,8 @@ function assertJsonValue(value: unknown): JsonValue {
|
||||
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>)) {
|
||||
if (childValue === undefined) {
|
||||
@@ -72,3 +83,183 @@ function assertJsonValue(value: unknown): JsonValue {
|
||||
|
||||
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 "./errors.js";
|
||||
export * from "./hash.js";
|
||||
export * from "./persona.js";
|
||||
export * from "./prompt-envelope.js";
|
||||
export * from "./registry-loader.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",
|
||||
"types": "./dist/index.d.ts",
|
||||
"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",
|
||||
"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
|
||||
tsup:
|
||||
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:
|
||||
specifier: 4.19.2
|
||||
version: 4.19.2
|
||||
@@ -53,11 +53,38 @@ importers:
|
||||
version: 5.6.3
|
||||
vite:
|
||||
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:
|
||||
specifier: 2.1.8
|
||||
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:
|
||||
dependencies:
|
||||
drizzle-orm:
|
||||
@@ -2058,6 +2085,11 @@ packages:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
yaml@2.6.1:
|
||||
resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==}
|
||||
engines: {node: '>= 14'}
|
||||
hasBin: true
|
||||
|
||||
zod@3.24.1:
|
||||
resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==}
|
||||
|
||||
@@ -3219,12 +3251,13 @@ snapshots:
|
||||
|
||||
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:
|
||||
lilconfig: 3.1.3
|
||||
optionalDependencies:
|
||||
postcss: 8.5.14
|
||||
tsx: 4.19.2
|
||||
yaml: 2.6.1
|
||||
|
||||
postcss@8.5.14:
|
||||
dependencies:
|
||||
@@ -3383,7 +3416,7 @@ snapshots:
|
||||
|
||||
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:
|
||||
bundle-require: 5.1.0(esbuild@0.24.2)
|
||||
cac: 6.7.14
|
||||
@@ -3393,7 +3426,7 @@ snapshots:
|
||||
esbuild: 0.24.2
|
||||
joycon: 3.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
|
||||
rollup: 4.60.3
|
||||
source-map: 0.8.0-beta.0
|
||||
@@ -3455,7 +3488,7 @@ snapshots:
|
||||
'@types/node': 22.10.2
|
||||
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:
|
||||
esbuild: 0.24.2
|
||||
postcss: 8.5.14
|
||||
@@ -3464,6 +3497,7 @@ snapshots:
|
||||
'@types/node': 22.10.2
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.19.2
|
||||
yaml: 2.6.1
|
||||
|
||||
vitest@2.1.8(@types/node@22.10.2):
|
||||
dependencies:
|
||||
@@ -3531,4 +3565,6 @@ snapshots:
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
yaml@2.6.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