feat: add persona binding algorithm

This commit is contained in:
chungyeong
2026-05-10 00:31:18 +09:00
parent 4a7fc94f5c
commit 0d90cd97b6
6 changed files with 1066 additions and 48 deletions

View File

@@ -1465,6 +1465,8 @@ Human required:
- `artifact_timeout_exhausted` - `artifact_timeout_exhausted`
- `destructive_command_blocked` - `destructive_command_blocked`
- `secret_access_blocked` - `secret_access_blocked`
- `backend_unavailable`
- `no_eligible_persona`
- `writeset_conflict` - `writeset_conflict`
- `merge_conflict` - `merge_conflict`
- `objective_not_met` - `objective_not_met`

View File

@@ -0,0 +1,429 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import { type BindingOverride, bindTemplatePersonas } from "./binding.js";
import type { BackendConfig } from "./config.js";
import { DevflowError } from "./errors.js";
import { hash } from "./hash.js";
import { Persona } from "./persona.js";
import { personaHash } from "./persona.js";
import { Template } from "./template.js";
const enabledBackends: BackendConfig[] = [
{ id: "fake", enabled: true },
{ id: "claude", enabled: true, binaryPath: process.execPath },
{ id: "codex", enabled: true, binaryPath: process.execPath },
];
describe("binding algorithm", () => {
it("auto-selects deterministically by preferred backend, version, name, then hash", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["claude", "fake"],
},
],
}),
personas: [
persona({ name: "fake_v9", version: 9, backend: "fake", capabilities: ["code_edit"] }),
persona({ name: "claude_v1", version: 1, backend: "claude", capabilities: ["code_edit"] }),
persona({
name: "claude_v2_b",
version: 2,
backend: "claude",
capabilities: ["code_edit"],
}),
persona({
name: "claude_v2_a",
version: 2,
backend: "claude",
capabilities: ["code_edit"],
}),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings).toHaveLength(1);
expect(result.bindings[0]?.roleId).toBe("implementer");
expect(result.bindings[0]?.persona.name).toBe("claude_v2_a");
});
it("falls back to non-preferred personas only when preferred personas fail eligibility", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["claude"],
},
],
}),
personas: [
persona({ name: "claude_reader", backend: "claude", capabilities: ["code_review"] }),
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings[0]?.persona.name).toBe("fake_implementer");
});
it("does not fall back when preferred personas only fail allowed role checks", () => {
expect(() =>
bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["claude"],
},
],
}),
personas: [
persona({
name: "claude_restricted",
backend: "claude",
capabilities: ["code_edit"],
allowedRoles: ["reviewer"],
}),
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
}),
).toThrow(/no_eligible_persona/);
});
it("fails instead of falling back when the selected preferred backend is unavailable", () => {
expect(() =>
bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["codex"],
},
],
}),
personas: [
persona({ name: "codex_implementer", backend: "codex", capabilities: ["code_edit"] }),
persona({ name: "fake_implementer", backend: "fake", capabilities: ["code_edit"] }),
],
templateHash: "template-hash",
availableBackends: [{ id: "fake", enabled: true }],
}),
).toThrow(/backend_unavailable/);
});
it("classifies binding failures as human-required DevflowError instances", () => {
try {
bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["codex"],
},
],
}),
personas: [persona({ name: "codex_implementer", backend: "codex" })],
templateHash: "template-hash",
availableBackends: [{ id: "codex", enabled: true }],
});
} catch (error) {
expect(error).toBeInstanceOf(DevflowError);
expect((error as DevflowError).class).toBe("human_required");
expect((error as DevflowError).code).toBe("backend_unavailable");
return;
}
throw new Error("expected binding to fail");
});
it("treats absolute backend paths as process-start resolved registry entries", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "implementer",
requiredCapabilities: ["code_edit"],
preferredBackends: ["codex"],
},
],
}),
personas: [persona({ name: "codex_implementer", backend: "codex" })],
templateHash: "template-hash",
availableBackends: [{ id: "codex", enabled: true, binaryPath: "/process/start/codex" }],
});
expect(result.bindings[0]?.backend).toBe("codex");
});
it("supports persona overrides and backend-constraining overrides", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
],
overrides: { roles: { reviewer: { backend: "codex" } } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings[0]?.persona.name).toBe("codex_reviewer");
const swappedPersona = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [{ id: "reviewer", requiredCapabilities: ["code_review"] }],
}),
personas: [
persona({ name: "alpha_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "beta_reviewer", backend: "fake", capabilities: ["code_review"] }),
],
overrides: { roles: { reviewer: { persona: "beta_reviewer" } } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(swappedPersona.bindings[0]?.persona.name).toBe("beta_reviewer");
});
it("rejects typo override keys and normalizes explicit undefined override fields", () => {
const base = {
runId: "run-1",
template: template(),
personas: [persona({ name: "fake_implementer", backend: "fake" })],
templateHash: "template-hash",
availableBackends: enabledBackends,
};
expect(() =>
bindTemplatePersonas({
...base,
overrides: {
roles: {
implementer: { backned: "codex" } as unknown as BindingOverride,
},
},
}),
).toThrow(/Unrecognized key/);
expect(
bindTemplatePersonas({
...base,
overrides: {
roles: {
implementer: {
persona: undefined,
backend: undefined,
} as unknown as BindingOverride,
},
},
}).bindings[0]?.persona.name,
).toBe("fake_implementer");
expect(() =>
bindTemplatePersonas({
...base,
overrides: {
roles: {
implmenter: { backend: "fake" },
},
},
}),
).toThrow(/unknown override role/);
});
it("computes binding hashes from the locked hash subject", () => {
const override = { backend: "fake" as const };
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "reviewer",
requiredCapabilities: ["code_review"],
count: 2,
},
],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
],
overrides: { roles: { "reviewer#1": override } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
const binding = result.bindings.find((candidate) => candidate.roleId === "reviewer#1");
const selectedPersonaHash = personaHash(binding?.persona);
expect(binding?.bindingHash).toBe(
hash({
runId: "run-1",
roleId: "reviewer#1",
templateHash: "template-hash",
personaHash: selectedPersonaHash,
backend: "fake",
override,
}),
);
});
it("expands counted roles and enforces backend diversity after overrides", () => {
const result = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "reviewer",
requiredCapabilities: ["code_review"],
count: 2,
diversity: { requireDifferentBackends: true },
},
],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
],
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(result.bindings.map((binding) => binding.roleId)).toEqual(["reviewer#0", "reviewer#1"]);
expect(new Set(result.bindings.map((binding) => binding.backend)).size).toBe(2);
const constrainedSecondInstance = bindTemplatePersonas({
runId: "run-1",
template: template({
roles: [
{
id: "reviewer",
requiredCapabilities: ["code_review"],
count: 2,
diversity: { requireDifferentBackends: true },
},
],
}),
personas: [
persona({ name: "fake_reviewer", backend: "fake", capabilities: ["code_review"] }),
persona({ name: "codex_reviewer", backend: "codex", capabilities: ["code_review"] }),
],
overrides: { roles: { "reviewer#1": { backend: "fake" } } },
templateHash: "template-hash",
availableBackends: enabledBackends,
});
expect(constrainedSecondInstance.bindings.map((binding) => binding.backend)).toEqual([
"codex",
"fake",
]);
});
it("rejects unavailable backends, missing capabilities, role restrictions, and risk overflow", () => {
const base = {
runId: "run-1",
templateHash: "template-hash",
availableBackends: [{ id: "fake", enabled: true }] satisfies BackendConfig[],
};
expect(() =>
bindTemplatePersonas({
...base,
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
personas: [persona({ name: "disabled", backend: "codex", capabilities: ["code_edit"] })],
}),
).toThrow(/backend_unavailable/);
expect(() =>
bindTemplatePersonas({
...base,
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
personas: [persona({ name: "reviewer", backend: "fake", capabilities: ["code_review"] })],
}),
).toThrow(/no_eligible_persona/);
expect(() =>
bindTemplatePersonas({
...base,
template: template({ roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }] }),
personas: [
persona({
name: "restricted",
backend: "fake",
capabilities: ["code_edit"],
allowedRoles: ["reviewer"],
}),
],
}),
).toThrow(/no_eligible_persona/);
expect(() =>
bindTemplatePersonas({
...base,
template: template({
roles: [{ id: "implementer", requiredCapabilities: ["code_edit"] }],
phases: [{ key: "danger", title: "Danger", risk: "high", roles: ["implementer"] }],
}),
personas: [
persona({
name: "medium_only",
backend: "fake",
capabilities: ["code_edit"],
maxRiskLevel: "medium",
}),
],
}),
).toThrow(/no_eligible_persona/);
});
});
function template(input: Partial<z.input<typeof Template>> = {}): Template {
const roles = input.roles ?? [{ id: "implementer", requiredCapabilities: ["code_edit"] }];
const phases = input.phases ?? [
{ key: "spec", title: "Spec", risk: "low", roles: [roles[0]?.id ?? "implementer"] },
];
return Template.parse({
name: input.name ?? "development",
version: input.version ?? 1,
roles,
phases,
defaultGates: input.defaultGates ?? [],
});
}
function persona(
input: Partial<z.input<typeof Persona>> & Pick<z.input<typeof Persona>, "name">,
): Persona {
return Persona.parse({
name: input.name,
version: input.version ?? 1,
backend: input.backend ?? "fake",
capabilities: input.capabilities ?? ["code_edit"],
maxRiskLevel: input.maxRiskLevel ?? "high",
promptConfig: input.promptConfig ?? {},
modelConfig: input.modelConfig ?? {},
...(input.allowedRoles === undefined ? {} : { allowedRoles: input.allowedRoles }),
});
}

View File

@@ -0,0 +1,384 @@
import { isAbsolute } from "node:path";
import { z } from "zod";
import type { BackendConfig } from "./config.js";
import { Backend } from "./enums.js";
import { DevflowError } from "./errors.js";
import { hash } from "./hash.js";
import { type Persona, personaHash } from "./persona.js";
import type { Template, TemplateRole } from "./template.js";
const riskRank = { low: 0, medium: 1, high: 2 } as const;
export const BindingOverride = z
.object({
persona: z.string().optional(),
backend: Backend.optional(),
})
.strict();
export const BindingOverrides = z
.object({
roles: z.record(BindingOverride).default({}),
})
.strict();
export type BindingOverride = z.infer<typeof BindingOverride>;
export type BindingOverrides = z.infer<typeof BindingOverrides>;
export interface BindTemplatePersonasInput {
runId: string;
template: Template;
personas: Persona[];
templateHash: string;
availableBackends: readonly BackendConfig[];
overrides?: Partial<BindingOverrides>;
}
export interface RoleBinding {
roleId: string;
templateRoleId: string;
persona: Persona;
personaHash: string;
backend: Backend;
bindingHash: string;
}
export interface BindingResult {
bindings: RoleBinding[];
}
export function bindTemplatePersonas(input: BindTemplatePersonasInput): BindingResult {
const overrides = BindingOverrides.parse(input.overrides ?? {});
assertOverrideRoleKeys(input.template, overrides);
const bindings: RoleBinding[] = [];
for (const role of input.template.roles) {
const assignments = selectRoleAssignments(input, role, overrides);
for (const assignment of assignments) {
const { roleId, override, candidate } = assignment;
const personaHashValue = personaHash(candidate);
bindings.push({
roleId,
templateRoleId: role.id,
persona: candidate,
personaHash: personaHashValue,
backend: candidate.backend,
bindingHash: hash({
runId: input.runId,
roleId,
templateHash: input.templateHash,
personaHash: personaHashValue,
backend: candidate.backend,
override,
}),
});
}
}
return { bindings };
}
interface RoleAssignment {
roleId: string;
override: BindingOverride;
candidate: Persona;
}
function selectRoleAssignments(
input: BindTemplatePersonasInput,
role: TemplateRole,
overrides: BindingOverrides,
): RoleAssignment[] {
const instances = roleInstances(role).map((roleId) => ({
roleId,
override: normalizeOverride(overrides.roles[roleId] ?? overrides.roles[role.id]),
}));
const candidateLists = instances.map((instance) => ({
...instance,
candidates: candidatesForRoleInstance(input, role, instance.override),
}));
for (const list of candidateLists) {
if (list.candidates.length === 0) {
throw noEligiblePersona(list.roleId);
}
}
const assignments = assignCandidates(
candidateLists,
role.diversity?.requireDifferentBackends === true,
);
if (assignments === undefined) {
throw noEligiblePersona(role.id, "diversity failed");
}
return instances.map((instance) => {
const candidate = assignments.get(instance.roleId);
if (candidate === undefined) {
throw noEligiblePersona(instance.roleId);
}
return { ...instance, candidate };
});
}
function roleInstances(role: TemplateRole): string[] {
if (role.count === 1) {
return [role.id];
}
return Array.from({ length: role.count }, (_, index) => `${role.id}#${index}`);
}
function assertOverrideRoleKeys(template: Template, overrides: BindingOverrides) {
const validRoleIds = new Set<string>();
for (const role of template.roles) {
validRoleIds.add(role.id);
if (role.count > 1) {
for (let index = 0; index < role.count; index += 1) {
validRoleIds.add(`${role.id}#${index}`);
}
}
}
for (const roleId of Object.keys(overrides.roles)) {
if (!validRoleIds.has(roleId)) {
throw noEligiblePersona(roleId, "unknown override role");
}
}
}
interface CandidateList {
roleId: string;
override?: BindingOverride | undefined;
candidates: Persona[];
}
function candidatesForRoleInstance(
input: BindTemplatePersonasInput,
role: TemplateRole,
override: BindingOverride | undefined,
): Persona[] {
const normalizedOverride = normalizeOverride(override);
const candidates: Persona[] = [];
const sortedCandidates = sortCandidates(input.personas, role)
.filter((persona) =>
normalizedOverride.persona === undefined ? true : persona.name === normalizedOverride.persona,
)
.filter((persona) =>
normalizedOverride.backend === undefined
? true
: persona.backend === normalizedOverride.backend,
);
const selectableCandidates = hasOverrideConstraint(normalizedOverride)
? sortedCandidates
: applyPreferredFallbackRule(sortedCandidates, role, input.template);
for (const candidate of selectableCandidates) {
if (!isEligible(candidate, role, input.template)) {
continue;
}
if (!isBackendAvailable(candidate.backend, input.availableBackends)) {
if (candidates.length === 0) {
throw backendUnavailable(candidate.backend);
}
continue;
}
candidates.push(candidate);
}
return candidates;
}
function hasOverrideConstraint(override: BindingOverride) {
return override.persona !== undefined || override.backend !== undefined;
}
function applyPreferredFallbackRule(
candidates: Persona[],
role: TemplateRole,
template: Template,
): Persona[] {
if (role.preferredBackends.length === 0) {
return candidates;
}
const preferredCandidates = candidates.filter((candidate) =>
role.preferredBackends.includes(candidate.backend),
);
if (preferredCandidates.length === 0) {
return candidates;
}
const allPreferredFailCapabilityOrRisk = preferredCandidates.every(
(candidate) => !capabilitiesCovered(candidate, role) || !riskCovered(candidate, role, template),
);
return allPreferredFailCapabilityOrRisk ? candidates : preferredCandidates;
}
function assignCandidates(
candidateLists: CandidateList[],
requireDifferentBackends: boolean,
): Map<string, Persona> | undefined {
return assignCandidatesAt(candidateLists, requireDifferentBackends, 0, new Map(), new Set());
}
function assignCandidatesAt(
candidateLists: CandidateList[],
requireDifferentBackends: boolean,
index: number,
assignments: Map<string, Persona>,
selectedBackends: Set<Backend>,
): Map<string, Persona> | undefined {
if (index >= candidateLists.length) {
return assignments;
}
const candidateList = candidateLists[index];
if (candidateList === undefined) {
return assignments;
}
for (const candidate of candidateList.candidates) {
if (requireDifferentBackends && selectedBackends.has(candidate.backend)) {
continue;
}
const nextAssignments = new Map(assignments);
const nextBackends = new Set(selectedBackends);
nextAssignments.set(candidateList.roleId, candidate);
nextBackends.add(candidate.backend);
const result = assignCandidatesAt(
candidateLists,
requireDifferentBackends,
index + 1,
nextAssignments,
nextBackends,
);
if (result !== undefined) {
return result;
}
}
return undefined;
}
function normalizeOverride(override: BindingOverride | undefined): BindingOverride {
const parsed = BindingOverride.parse(override ?? {});
const normalized: BindingOverride = {};
if (parsed.persona !== undefined) {
normalized.persona = parsed.persona;
}
if (parsed.backend !== undefined) {
normalized.backend = parsed.backend;
}
return normalized;
}
function sortCandidates(personas: Persona[], role: TemplateRole) {
return [...personas].sort((left, right) => {
const leftPreferredRank = preferredBackendRank(left.backend, role);
const rightPreferredRank = preferredBackendRank(right.backend, role);
return (
leftPreferredRank - rightPreferredRank ||
right.version - left.version ||
compareCodeUnits(left.name, right.name) ||
compareCodeUnits(personaHash(left), personaHash(right))
);
});
}
function preferredBackendRank(backend: Backend, role: TemplateRole) {
const rank = role.preferredBackends.indexOf(backend);
if (rank >= 0) {
return rank;
}
return role.preferredBackends.length;
}
function isEligible(persona: Persona, role: TemplateRole, template: Template) {
return (
roleAllowed(persona, role) &&
capabilitiesCovered(persona, role) &&
riskCovered(persona, role, template)
);
}
function roleAllowed(persona: Persona, role: TemplateRole) {
return persona.allowedRoles === undefined || persona.allowedRoles.includes(role.id);
}
function capabilitiesCovered(persona: Persona, role: TemplateRole) {
const capabilities = new Set(persona.capabilities);
return role.requiredCapabilities.every((capability) => capabilities.has(capability));
}
function riskCovered(persona: Persona, role: TemplateRole, template: Template) {
const phaseRisk = template.phases
.filter((phase) => phase.roles.includes(role.id))
.reduce<number>((maxRisk, phase) => Math.max(maxRisk, riskRank[phase.risk]), riskRank.low);
return phaseRisk <= riskRank[persona.maxRiskLevel];
}
function isBackendAvailable(backend: Backend, availableBackends: readonly BackendConfig[]) {
if (backend === "fake") {
return true;
}
return availableBackends.some(
(backendConfig) =>
backendConfig.id === backend &&
backendConfig.enabled &&
isResolvedBinaryPath(backendConfig.binaryPath),
);
}
function isResolvedBinaryPath(path: string | undefined) {
return typeof path === "string" && path.length > 0 && isAbsolute(path);
}
function backendUnavailable(backend: string) {
return new DevflowError(`human_required:backend_unavailable:${backend}`, {
class: "human_required",
code: "backend_unavailable",
recoveryHint: `Enable ${backend} and ensure its binary resolves at process start.`,
});
}
function noEligiblePersona(roleId: string, reason?: string) {
return new DevflowError(
`human_required:no_eligible_persona:${roleId}${reason === undefined ? "" : `:${reason}`}`,
{
class: "human_required",
code: "no_eligible_persona",
recoveryHint: `Add or override a persona eligible for role ${roleId}.`,
},
);
}
function compareCodeUnits(left: string, right: string) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}

View File

@@ -1,9 +1,10 @@
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { loadConfigFromSources } from "./config.js"; import { loadConfigFromSources } from "./config.js";
import { DevflowError } from "./errors.js";
describe("config loader", () => { describe("config loader", () => {
it("loads .env, .env.local, then process env in descending precedence", () => { it("loads .env, .env.local, then process env in descending precedence", () => {
@@ -49,7 +50,100 @@ describe("config loader", () => {
expect(config.backends).toContainEqual({ id: "fake", enabled: true }); expect(config.backends).toContainEqual({ id: "fake", enabled: true });
}); });
it("parses backend registration from DEVFLOW_BACKENDS_JSON", () => { it("resolves backend binaries from PATH during config load", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
const binDir = join(root, "bin");
const codexBin = join(binDir, "codex");
mkdirSync(workspace);
mkdirSync(binDir);
writeFileSync(codexBin, "#!/bin/sh\nexit 0\n");
chmodSync(codexBin, 0o755);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
PATH: binDir,
DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]),
},
});
expect(config.backends).toEqual([
{ id: "fake", enabled: true },
{ id: "codex", enabled: true, binaryPath: realpathSync(codexBin) },
]);
});
it("keeps enabled real backends unavailable when their binary cannot be resolved", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
const emptyBin = join(root, "empty-bin");
mkdirSync(workspace);
mkdirSync(emptyBin);
const config = loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
PATH: emptyBin,
DEVFLOW_BACKENDS_JSON: JSON.stringify([{ id: "codex", enabled: true }]),
},
});
expect(config.backends).toEqual([
{ id: "fake", enabled: true },
{ id: "codex", enabled: true },
]);
});
it("requires LOG_LEVEL and classifies invalid config as fatal", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
let caught: unknown;
try {
loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
},
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(DevflowError);
expect((caught as DevflowError).class).toBe("fatal");
expect((caught as DevflowError).code).toBe("config_invalid");
expect((caught as DevflowError).cause).toBeDefined();
});
it("classifies malformed backend JSON as invalid config", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace");
mkdirSync(workspace);
expect(() =>
loadConfigFromSources({
cwd: root,
env: {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info",
DEVFLOW_BACKENDS_JSON: "{",
},
}),
).toThrow(DevflowError);
});
it("freezes config and backend registrations", () => {
const root = mkdtempSync(join(tmpdir(), "devflow-config-")); const root = mkdtempSync(join(tmpdir(), "devflow-config-"));
const workspace = join(root, "workspace"); const workspace = join(root, "workspace");
mkdirSync(workspace); mkdirSync(workspace);
@@ -60,15 +154,14 @@ describe("config loader", () => {
DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow",
WORKSPACE_ROOT: workspace, WORKSPACE_ROOT: workspace,
LOG_LEVEL: "info", LOG_LEVEL: "info",
DEVFLOW_BACKENDS_JSON: JSON.stringify([
{ id: "codex", enabled: true, binaryPath: "/usr/local/bin/codex" },
]),
}, },
}); });
expect(config.backends).toEqual([ expect(Object.isFrozen(config)).toBe(true);
{ id: "fake", enabled: true }, expect(Object.isFrozen(config.backends)).toBe(true);
{ id: "codex", enabled: true, binaryPath: "/usr/local/bin/codex" }, expect(Object.isFrozen(config.backends[0])).toBe(true);
]); expect(() => {
(config.backends[0] as { enabled: boolean }).enabled = false;
}).toThrow(TypeError);
}); });
}); });

View File

@@ -1,9 +1,10 @@
import { existsSync, readFileSync, realpathSync } from "node:fs"; import { constants, accessSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { resolve } from "node:path"; import { delimiter, isAbsolute, resolve } from "node:path";
import { parse } from "dotenv"; import { parse } from "dotenv";
import { z } from "zod"; import { z } from "zod";
import { Backend } from "./enums.js"; import { Backend } from "./enums.js";
import { DevflowError } from "./errors.js";
const LogLevel = z.enum(["trace", "debug", "info", "warn", "error"]); const LogLevel = z.enum(["trace", "debug", "info", "warn", "error"]);
@@ -13,32 +14,31 @@ export const BackendConfig = z.object({
binaryPath: z.string().optional(), binaryPath: z.string().optional(),
}); });
export const ConfigSchema = z export type BackendConfig = z.infer<typeof BackendConfig>;
.object({
const RawConfigSchema = z.object({
DATABASE_URL: z.string().min(1), DATABASE_URL: z.string().min(1),
WORKSPACE_ROOT: z.string().min(1), WORKSPACE_ROOT: z.string().min(1),
LOG_LEVEL: LogLevel.default("info"), LOG_LEVEL: LogLevel,
TEMPORAL_ADDRESS: z.string().optional(), TEMPORAL_ADDRESS: z.string().optional(),
MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4), MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4),
backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]), backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]),
}) });
.transform((value) => {
const canonicalWorkspaceRoot = realpathSync(resolve(value.WORKSPACE_ROOT));
const hasFakeBackend = value.backends.some((backend) => backend.id === "fake");
return Object.freeze({ type RawConfig = z.infer<typeof RawConfigSchema>;
...value,
WORKSPACE_ROOT: canonicalWorkspaceRoot,
backends: Object.freeze(
hasFakeBackend
? value.backends
: [{ id: "fake" as const, enabled: true }, ...value.backends],
),
});
});
export type BackendConfig = z.infer<typeof BackendConfig>; export type Config = Omit<RawConfig, "WORKSPACE_ROOT" | "backends"> & {
export type Config = z.infer<typeof ConfigSchema>; readonly WORKSPACE_ROOT: string;
readonly backends: readonly BackendConfig[];
};
export const ConfigSchema = RawConfigSchema.transform(
(value): Config =>
finalizeConfig(value, {
cwd: process.cwd(),
pathEnv: process.env.PATH,
}),
);
export interface LoadConfigOptions { export interface LoadConfigOptions {
cwd?: string; cwd?: string;
@@ -56,6 +56,7 @@ function readEnvFile(cwd: string, fileName: string): Record<string, string> {
} }
export function loadConfigFromSources(options: LoadConfigOptions = {}): Config { export function loadConfigFromSources(options: LoadConfigOptions = {}): Config {
try {
const cwd = options.cwd ?? process.cwd(); const cwd = options.cwd ?? process.cwd();
const env = options.env ?? process.env; const env = options.env ?? process.env;
const raw = { const raw = {
@@ -69,7 +70,17 @@ export function loadConfigFromSources(options: LoadConfigOptions = {}): Config {
normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT); normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT);
} }
return ConfigSchema.parse(normalizedRaw); return finalizeConfig(RawConfigSchema.parse(normalizedRaw), {
cwd,
pathEnv: env.PATH ?? process.env.PATH,
});
} catch (error) {
if (error instanceof DevflowError) {
throw error;
}
throw configInvalid(error);
}
} }
function normalizeRawConfig(raw: Record<string, string | undefined>): Record<string, unknown> { function normalizeRawConfig(raw: Record<string, string | undefined>): Record<string, unknown> {
@@ -91,3 +102,101 @@ export function getConfig(): Config {
cachedConfig ??= loadConfigFromSources(); cachedConfig ??= loadConfigFromSources();
return cachedConfig; return cachedConfig;
} }
function finalizeConfig(
value: RawConfig,
options: { cwd: string; pathEnv: string | undefined },
): Config {
const canonicalWorkspaceRoot = realpathSync(resolve(value.WORKSPACE_ROOT));
return Object.freeze({
...value,
WORKSPACE_ROOT: canonicalWorkspaceRoot,
backends: Object.freeze(normalizeBackends(value.backends, options)),
});
}
function normalizeBackends(
backends: BackendConfig[],
options: { cwd: string; pathEnv: string | undefined },
): BackendConfig[] {
const normalized = backends.map((backend) => normalizeBackend(backend, options));
const hasFakeBackend = normalized.some((backend) => backend.id === "fake");
if (hasFakeBackend) {
return normalized;
}
return [freezeBackend({ id: "fake", enabled: true }), ...normalized];
}
function normalizeBackend(
backend: BackendConfig,
options: { cwd: string; pathEnv: string | undefined },
): BackendConfig {
if (backend.id === "fake") {
return freezeBackend({ id: "fake", enabled: true });
}
if (!backend.enabled) {
return freezeBackend({ id: backend.id, enabled: false });
}
const resolvedPath = resolveBinaryPath(backend.binaryPath ?? backend.id, options);
if (resolvedPath === undefined) {
return freezeBackend({ id: backend.id, enabled: true });
}
return freezeBackend({ id: backend.id, enabled: true, binaryPath: resolvedPath });
}
function resolveBinaryPath(
binaryPath: string,
options: { cwd: string; pathEnv: string | undefined },
): string | undefined {
if (binaryPath.includes("/")) {
const candidate = isAbsolute(binaryPath) ? binaryPath : resolve(options.cwd, binaryPath);
return executableRealpath(candidate);
}
for (const pathEntry of (options.pathEnv ?? "").split(delimiter)) {
if (pathEntry.length === 0) {
continue;
}
const candidate = resolve(pathEntry, binaryPath);
const resolved = executableRealpath(candidate);
if (resolved !== undefined) {
return resolved;
}
}
return undefined;
}
function executableRealpath(path: string): string | undefined {
try {
const resolved = realpathSync(path);
if (!statSync(resolved).isFile()) {
return undefined;
}
accessSync(resolved, constants.X_OK);
return resolved;
} catch {
return undefined;
}
}
function freezeBackend(backend: BackendConfig): BackendConfig {
return Object.freeze(backend);
}
function configInvalid(cause: unknown) {
return new DevflowError("config_invalid", {
class: "fatal",
code: "config_invalid",
cause,
recoveryHint: "Fix .env, .env.local, environment variables, and backend registrations.",
});
}

View File

@@ -1,4 +1,5 @@
export * from "./config.js"; export * from "./config.js";
export * from "./binding.js";
export * from "./enums.js"; export * from "./enums.js";
export * from "./errors.js"; export * from "./errors.js";
export * from "./hash.js"; export * from "./hash.js";