257 lines
7.0 KiB
TypeScript
257 lines
7.0 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,
|
|
type WorkflowConfig,
|
|
} from "./domain.ts";
|
|
|
|
const TOP_LEVEL_KEYS = new Set([
|
|
"concurrency",
|
|
"maxAssignmentsPerRun",
|
|
"maxReviewCycles",
|
|
"analysisTools",
|
|
"worktrees",
|
|
]);
|
|
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
|
|
const WORKTREE_KEYS = new Set(["root"]);
|
|
|
|
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],
|
|
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.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;
|
|
}
|