From 0d90cd97b60b3fdf365e4edbddfabfe5d7f3c018 Mon Sep 17 00:00:00 2001 From: chungyeong Date: Sun, 10 May 2026 00:31:18 +0900 Subject: [PATCH] feat: add persona binding algorithm --- docs/plan.md | 2 + packages/core/src/binding.test.ts | 429 ++++++++++++++++++++++++++++++ packages/core/src/binding.ts | 384 ++++++++++++++++++++++++++ packages/core/src/config.test.ts | 111 +++++++- packages/core/src/config.ts | 187 ++++++++++--- packages/core/src/index.ts | 1 + 6 files changed, 1066 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/binding.test.ts create mode 100644 packages/core/src/binding.ts diff --git a/docs/plan.md b/docs/plan.md index 860fadd..feb4ed4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1465,6 +1465,8 @@ Human required: - `artifact_timeout_exhausted` - `destructive_command_blocked` - `secret_access_blocked` +- `backend_unavailable` +- `no_eligible_persona` - `writeset_conflict` - `merge_conflict` - `objective_not_met` diff --git a/packages/core/src/binding.test.ts b/packages/core/src/binding.test.ts new file mode 100644 index 0000000..a1aacaf --- /dev/null +++ b/packages/core/src/binding.test.ts @@ -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> = {}): 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> & Pick, "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 }), + }); +} diff --git a/packages/core/src/binding.ts b/packages/core/src/binding.ts new file mode 100644 index 0000000..f633deb --- /dev/null +++ b/packages/core/src/binding.ts @@ -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; +export type BindingOverrides = z.infer; + +export interface BindTemplatePersonasInput { + runId: string; + template: Template; + personas: Persona[]; + templateHash: string; + availableBackends: readonly BackendConfig[]; + overrides?: Partial; +} + +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(); + + 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 | undefined { + return assignCandidatesAt(candidateLists, requireDifferentBackends, 0, new Map(), new Set()); +} + +function assignCandidatesAt( + candidateLists: CandidateList[], + requireDifferentBackends: boolean, + index: number, + assignments: Map, + selectedBackends: Set, +): Map | 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((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; +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 2d64053..3632da8 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -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 { join } from "node:path"; import { describe, expect, it } from "vitest"; import { loadConfigFromSources } from "./config.js"; +import { DevflowError } from "./errors.js"; describe("config loader", () => { 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 }); }); - 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 workspace = join(root, "workspace"); mkdirSync(workspace); @@ -60,15 +154,14 @@ describe("config loader", () => { DATABASE_URL: "postgres://devflow:devflow@localhost:5432/devflow", WORKSPACE_ROOT: workspace, LOG_LEVEL: "info", - DEVFLOW_BACKENDS_JSON: JSON.stringify([ - { id: "codex", enabled: true, binaryPath: "/usr/local/bin/codex" }, - ]), }, }); - expect(config.backends).toEqual([ - { id: "fake", enabled: true }, - { id: "codex", enabled: true, binaryPath: "/usr/local/bin/codex" }, - ]); + expect(Object.isFrozen(config)).toBe(true); + expect(Object.isFrozen(config.backends)).toBe(true); + expect(Object.isFrozen(config.backends[0])).toBe(true); + expect(() => { + (config.backends[0] as { enabled: boolean }).enabled = false; + }).toThrow(TypeError); }); }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d2da01a..b134f0c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync, realpathSync } from "node:fs"; -import { resolve } from "node:path"; +import { constants, accessSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { delimiter, isAbsolute, resolve } from "node:path"; import { parse } from "dotenv"; import { z } from "zod"; import { Backend } from "./enums.js"; +import { DevflowError } from "./errors.js"; const LogLevel = z.enum(["trace", "debug", "info", "warn", "error"]); @@ -13,32 +14,31 @@ export const BackendConfig = z.object({ binaryPath: z.string().optional(), }); -export const ConfigSchema = z - .object({ - DATABASE_URL: z.string().min(1), - WORKSPACE_ROOT: z.string().min(1), - LOG_LEVEL: LogLevel.default("info"), - TEMPORAL_ADDRESS: z.string().optional(), - MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4), - 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({ - ...value, - WORKSPACE_ROOT: canonicalWorkspaceRoot, - backends: Object.freeze( - hasFakeBackend - ? value.backends - : [{ id: "fake" as const, enabled: true }, ...value.backends], - ), - }); - }); - export type BackendConfig = z.infer; -export type Config = z.infer; + +const RawConfigSchema = z.object({ + DATABASE_URL: z.string().min(1), + WORKSPACE_ROOT: z.string().min(1), + LOG_LEVEL: LogLevel, + TEMPORAL_ADDRESS: z.string().optional(), + MAX_CONCURRENT_RUNS: z.coerce.number().int().positive().default(4), + backends: z.array(BackendConfig).default([{ id: "fake", enabled: true }]), +}); + +type RawConfig = z.infer; + +export type Config = Omit & { + 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 { cwd?: string; @@ -56,20 +56,31 @@ function readEnvFile(cwd: string, fileName: string): Record { } export function loadConfigFromSources(options: LoadConfigOptions = {}): Config { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; - const raw = { - ...readEnvFile(cwd, ".env"), - ...readEnvFile(cwd, ".env.local"), - ...env, - }; - const normalizedRaw = normalizeRawConfig(raw); + try { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const raw = { + ...readEnvFile(cwd, ".env"), + ...readEnvFile(cwd, ".env.local"), + ...env, + }; + const normalizedRaw = normalizeRawConfig(raw); - if (typeof normalizedRaw.WORKSPACE_ROOT === "string") { - normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT); + if (typeof normalizedRaw.WORKSPACE_ROOT === "string") { + normalizedRaw.WORKSPACE_ROOT = resolve(cwd, normalizedRaw.WORKSPACE_ROOT); + } + + return finalizeConfig(RawConfigSchema.parse(normalizedRaw), { + cwd, + pathEnv: env.PATH ?? process.env.PATH, + }); + } catch (error) { + if (error instanceof DevflowError) { + throw error; + } + + throw configInvalid(error); } - - return ConfigSchema.parse(normalizedRaw); } function normalizeRawConfig(raw: Record): Record { @@ -91,3 +102,101 @@ export function getConfig(): Config { cachedConfig ??= loadConfigFromSources(); 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.", + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2f2ad32..142c801 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from "./config.js"; +export * from "./binding.js"; export * from "./enums.js"; export * from "./errors.js"; export * from "./hash.js";