feat: add persona binding algorithm
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
429
packages/core/src/binding.test.ts
Normal file
429
packages/core/src/binding.test.ts
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
384
packages/core/src/binding.ts
Normal file
384
packages/core/src/binding.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
|
||||||
...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>;
|
type RawConfig = z.infer<typeof RawConfigSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
|
||||||
|
export type Config = Omit<RawConfig, "WORKSPACE_ROOT" | "backends"> & {
|
||||||
|
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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user