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 { return value !== null && typeof value === "object" && !Array.isArray(value); } function assertKnownKeys( value: Record, allowed: ReadonlySet, 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 = {}; 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 { 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; }