feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
ANALYSIS_TOOL_ALLOWLIST,
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
MAX_ASSIGNMENTS_PER_RUN,
|
||||
MAX_CONCURRENCY,
|
||||
MAX_REVIEW_CYCLES,
|
||||
PROFILE_ROLES,
|
||||
type ProfileRole,
|
||||
type WorkflowConfig,
|
||||
} from "./domain.ts";
|
||||
|
||||
const TOP_LEVEL_KEYS = new Set([
|
||||
"concurrency",
|
||||
"maxAssignmentsPerRun",
|
||||
"maxReviewCycles",
|
||||
"analysisTools",
|
||||
"standardAgents",
|
||||
"worktrees",
|
||||
]);
|
||||
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
|
||||
const STANDARD_AGENT_KEYS = new Set(["enabled", "roles"]);
|
||||
const WORKTREE_KEYS = new Set(["root"]);
|
||||
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export interface LoadWorkflowConfigOptions {
|
||||
cwd: string;
|
||||
projectTrusted: boolean;
|
||||
agentDir?: string;
|
||||
invocationConcurrency?: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
allowed: ReadonlySet<string>,
|
||||
label: string,
|
||||
) {
|
||||
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(
|
||||
`${label}: unknown field${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function integerInRange(
|
||||
value: unknown,
|
||||
label: string,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
) {
|
||||
if (!Number.isSafeInteger(value) || typeof value !== "number") {
|
||||
throw new Error(`${label} must be an integer`);
|
||||
}
|
||||
if (value < minimum || value > maximum) {
|
||||
throw new Error(`${label} must be between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, label: string) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((entry) => typeof entry !== "string")
|
||||
) {
|
||||
throw new Error(`${label} must be an array of strings`);
|
||||
}
|
||||
const normalized = value.map((entry) => entry.trim());
|
||||
if (normalized.some((entry) => entry.length === 0)) {
|
||||
throw new Error(`${label} cannot contain empty values`);
|
||||
}
|
||||
if (new Set(normalized).size !== normalized.length) {
|
||||
throw new Error(`${label} cannot contain duplicate values`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function worktreeRoot(value: unknown, label: string) {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`${label} must be a non-empty relative path`);
|
||||
}
|
||||
const normalized = path.normalize(value.trim());
|
||||
if (
|
||||
path.isAbsolute(normalized) ||
|
||||
normalized === ".." ||
|
||||
normalized.startsWith(`..${path.sep}`)
|
||||
) {
|
||||
throw new Error(`${label} must stay inside the repository`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function cloneConfig(config: WorkflowConfig): WorkflowConfig {
|
||||
return {
|
||||
concurrency: { ...config.concurrency },
|
||||
maxAssignmentsPerRun: config.maxAssignmentsPerRun,
|
||||
maxReviewCycles: config.maxReviewCycles,
|
||||
analysisTools: [...config.analysisTools],
|
||||
standardAgents: {
|
||||
enabled: config.standardAgents.enabled,
|
||||
roles: Object.fromEntries(
|
||||
Object.entries(config.standardAgents.roles).map(([name, roles]) => [
|
||||
name,
|
||||
[...roles],
|
||||
]),
|
||||
),
|
||||
},
|
||||
worktrees: { ...config.worktrees },
|
||||
};
|
||||
}
|
||||
|
||||
function applyConfigLayer(
|
||||
current: WorkflowConfig,
|
||||
value: unknown,
|
||||
label: string,
|
||||
): WorkflowConfig {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(`${label} must contain a JSON object`);
|
||||
}
|
||||
assertKnownKeys(value, TOP_LEVEL_KEYS, label);
|
||||
const next = cloneConfig(current);
|
||||
|
||||
if (value.concurrency !== undefined) {
|
||||
if (!isRecord(value.concurrency)) {
|
||||
throw new Error(`${label}.concurrency must be an object`);
|
||||
}
|
||||
assertKnownKeys(
|
||||
value.concurrency,
|
||||
CONCURRENCY_KEYS,
|
||||
`${label}.concurrency`,
|
||||
);
|
||||
if (value.concurrency.ceiling !== undefined) {
|
||||
const ceiling = integerInRange(
|
||||
value.concurrency.ceiling,
|
||||
`${label}.concurrency.ceiling`,
|
||||
1,
|
||||
MAX_CONCURRENCY,
|
||||
);
|
||||
if (ceiling > current.concurrency.ceiling) {
|
||||
throw new Error(
|
||||
`${label}.concurrency.ceiling cannot raise the current ceiling`,
|
||||
);
|
||||
}
|
||||
next.concurrency.ceiling = ceiling;
|
||||
if (value.concurrency.default === undefined) {
|
||||
next.concurrency.default = Math.min(
|
||||
current.concurrency.default,
|
||||
ceiling,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (value.concurrency.default !== undefined) {
|
||||
next.concurrency.default = integerInRange(
|
||||
value.concurrency.default,
|
||||
`${label}.concurrency.default`,
|
||||
1,
|
||||
next.concurrency.ceiling,
|
||||
);
|
||||
}
|
||||
if (next.concurrency.default > next.concurrency.ceiling) {
|
||||
throw new Error(
|
||||
`${label}.concurrency.default exceeds the effective ceiling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (value.maxAssignmentsPerRun !== undefined) {
|
||||
next.maxAssignmentsPerRun = integerInRange(
|
||||
value.maxAssignmentsPerRun,
|
||||
`${label}.maxAssignmentsPerRun`,
|
||||
1,
|
||||
MAX_ASSIGNMENTS_PER_RUN,
|
||||
);
|
||||
}
|
||||
|
||||
if (value.maxReviewCycles !== undefined) {
|
||||
next.maxReviewCycles = integerInRange(
|
||||
value.maxReviewCycles,
|
||||
`${label}.maxReviewCycles`,
|
||||
1,
|
||||
MAX_REVIEW_CYCLES,
|
||||
);
|
||||
}
|
||||
|
||||
if (value.analysisTools !== undefined) {
|
||||
const requested = stringArray(
|
||||
value.analysisTools,
|
||||
`${label}.analysisTools`,
|
||||
);
|
||||
const invalid = requested.filter(
|
||||
(tool) => !ANALYSIS_TOOL_ALLOWLIST.includes(tool as never),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`${label}.analysisTools contains unsupported tools: ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
next.analysisTools = current.analysisTools.filter((tool) =>
|
||||
requested.includes(tool),
|
||||
);
|
||||
}
|
||||
|
||||
if (value.standardAgents !== undefined) {
|
||||
if (!isRecord(value.standardAgents)) {
|
||||
throw new Error(`${label}.standardAgents must be an object`);
|
||||
}
|
||||
assertKnownKeys(
|
||||
value.standardAgents,
|
||||
STANDARD_AGENT_KEYS,
|
||||
`${label}.standardAgents`,
|
||||
);
|
||||
if (value.standardAgents.enabled !== undefined) {
|
||||
if (typeof value.standardAgents.enabled !== "boolean") {
|
||||
throw new Error(`${label}.standardAgents.enabled must be a boolean`);
|
||||
}
|
||||
next.standardAgents.enabled = value.standardAgents.enabled;
|
||||
}
|
||||
if (value.standardAgents.roles !== undefined) {
|
||||
if (!isRecord(value.standardAgents.roles)) {
|
||||
throw new Error(`${label}.standardAgents.roles must be an object`);
|
||||
}
|
||||
const roles: Record<string, ProfileRole[]> = {};
|
||||
for (const [name, rawRoles] of Object.entries(
|
||||
value.standardAgents.roles,
|
||||
)) {
|
||||
if (!PROFILE_NAME.test(name)) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles agent names must be lowercase kebab case`,
|
||||
);
|
||||
}
|
||||
const requested = stringArray(
|
||||
rawRoles,
|
||||
`${label}.standardAgents.roles.${name}`,
|
||||
);
|
||||
if (requested.length === 0) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles.${name} must not be empty`,
|
||||
);
|
||||
}
|
||||
const invalid = requested.filter(
|
||||
(role) => !PROFILE_ROLES.includes(role as ProfileRole),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles.${name} contains unsupported role ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
roles[name] = requested as ProfileRole[];
|
||||
}
|
||||
next.standardAgents.roles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
if (value.worktrees !== undefined) {
|
||||
if (!isRecord(value.worktrees)) {
|
||||
throw new Error(`${label}.worktrees must be an object`);
|
||||
}
|
||||
assertKnownKeys(value.worktrees, WORKTREE_KEYS, `${label}.worktrees`);
|
||||
if (value.worktrees.root !== undefined) {
|
||||
next.worktrees.root = worktreeRoot(
|
||||
value.worktrees.root,
|
||||
`${label}.worktrees.root`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
async function readOptionalJson(file: string) {
|
||||
let contents: string;
|
||||
try {
|
||||
contents = await readFile(file, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
||||
throw new Error(`${file}: unable to read configuration`, { cause: error });
|
||||
}
|
||||
try {
|
||||
return JSON.parse(contents) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`${file}: invalid JSON`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadWorkflowConfig(
|
||||
options: LoadWorkflowConfigOptions,
|
||||
): Promise<WorkflowConfig> {
|
||||
const agentDir = options.agentDir ?? getAgentDir();
|
||||
let config = cloneConfig(DEFAULT_WORKFLOW_CONFIG);
|
||||
|
||||
const globalFile = path.join(agentDir, "workflow.json");
|
||||
const globalValue = await readOptionalJson(globalFile);
|
||||
if (globalValue !== undefined) {
|
||||
config = applyConfigLayer(config, globalValue, globalFile);
|
||||
}
|
||||
|
||||
if (options.projectTrusted) {
|
||||
const projectFile = path.join(options.cwd, ".pi", "workflow.json");
|
||||
const projectValue = await readOptionalJson(projectFile);
|
||||
if (projectValue !== undefined) {
|
||||
config = applyConfigLayer(config, projectValue, projectFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.invocationConcurrency !== undefined) {
|
||||
config.concurrency.default = integerInRange(
|
||||
options.invocationConcurrency,
|
||||
"invocation concurrency",
|
||||
1,
|
||||
config.concurrency.ceiling,
|
||||
);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
Reference in New Issue
Block a user