Files
harness-tools/extensions/pi-workflow/config.ts
T

322 lines
9.1 KiB
TypeScript

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, "pi-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", "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;
}