Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/lib/lane-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { afterEach, describe, expect, it } from "vitest";
import {
getBacklogLane,
getClaimableLanes,
getConfiguredLanes,
getLaneById,
getLaneIds,
isClaimableLane,
isValidLane,
resetLaneConfig,
setLaneConfig,
} from "./lane-config";

describe("lane-config defaults", () => {
it("returns three default lanes", () => {
const lanes = getConfiguredLanes();
expect(lanes).toHaveLength(3);
expect(lanes.map((l) => l.id)).toEqual(["normal", "escalated", "backlog"]);
});

it("normal and escalated are claimable, backlog is not", () => {
expect(isClaimableLane("normal")).toBe(true);
expect(isClaimableLane("escalated")).toBe(true);
expect(isClaimableLane("backlog")).toBe(false);
});

it("getClaimableLanes returns only claimable lanes", () => {
const claimable = getClaimableLanes();
expect(claimable).toHaveLength(2);
expect(claimable.map((l) => l.id)).toEqual(["normal", "escalated"]);
});

it("getBacklogLane returns the non-claimable lane", () => {
const backlog = getBacklogLane();
expect(backlog).toBeDefined();
expect(backlog?.id).toBe("backlog");
expect(backlog?.claimable).toBe(false);
});

it("isValidLane returns correct values for defaults", () => {
expect(isValidLane("normal")).toBe(true);
expect(isValidLane("escalated")).toBe(true);
expect(isValidLane("backlog")).toBe(true);
expect(isValidLane("unknown")).toBe(false);
});

it("getLaneById returns the lane or undefined", () => {
expect(getLaneById("normal")?.title).toBe("Normal");
expect(getLaneById("nonexistent")).toBeUndefined();
});

it("getLaneIds returns all configured ids", () => {
expect(getLaneIds()).toEqual(["normal", "escalated", "backlog"]);
});

it("returns a deep copy (mutations do not leak)", () => {
const lanes = getConfiguredLanes();
lanes[0].id = "mutated";
expect(getConfiguredLanes()[0].id).toBe("normal");
});
});

describe("lane-config custom config", () => {
afterEach(() => {
resetLaneConfig();
});

it("accepts a single claimable lane plus backlog", () => {
setLaneConfig({
lanes: [
{ id: "default", title: "Default", claimable: true },
{ id: "backlog", title: "Backlog", claimable: false },
],
});

expect(getConfiguredLanes()).toHaveLength(2);
expect(isClaimableLane("default")).toBe(true);
expect(isClaimableLane("backlog")).toBe(false);
expect(getBacklogLane()?.id).toBe("backlog");
});

it("accepts multiple claimable lanes plus backlog", () => {
setLaneConfig({
lanes: [
{ id: "fast", title: "Fast Lane", claimable: true },
{ id: "slow", title: "Slow Lane", claimable: true },
{ id: "parked", title: "Parked", claimable: false },
],
});

expect(getClaimableLanes()).toHaveLength(2);
expect(isClaimableLane("fast")).toBe(true);
expect(isClaimableLane("slow")).toBe(true);
expect(isClaimableLane("parked")).toBe(false);
expect(getBacklogLane()?.id).toBe("parked");
});

it("rejects empty lane array", () => {
expect(() => setLaneConfig({ lanes: [] })).toThrow(
"Lane config must contain at least one lane",
);
});

it("rejects duplicate lane ids", () => {
expect(() =>
setLaneConfig({
lanes: [
{ id: "a", title: "A", claimable: true },
{ id: "a", title: "A dup", claimable: false },
],
}),
).toThrow("Duplicate lane id: a");
});

it("rejects empty lane id", () => {
expect(() =>
setLaneConfig({ lanes: [{ id: "", title: "X", claimable: true }] }),
).toThrow("Lane id must be a non-empty string");
});

it("rejects missing title", () => {
// @ts-expect-error — testing runtime validation
expect(() => setLaneConfig({ lanes: [{ id: "x", claimable: true }] })).toThrow(
'Lane "x" must have a non-empty title',
);
});

it("rejects all non-claimable lanes", () => {
expect(() =>
setLaneConfig({
lanes: [
{ id: "a", title: "A", claimable: false },
{ id: "b", title: "B", claimable: false },
],
}),
).toThrow("Lane config must contain at least one claimable lane");
});

it("supports optional fields (description, color, defaultAgent)", () => {
setLaneConfig({
lanes: [
{
id: "custom",
title: "Custom",
claimable: true,
description: "A custom lane",
color: "#ff0000",
defaultAgent: "custom-agent",
},
{ id: "backlog", title: "Backlog", claimable: false },
],
});

const lane = getLaneById("custom");
expect(lane?.description).toBe("A custom lane");
expect(lane?.color).toBe("#ff0000");
expect(lane?.defaultAgent).toBe("custom-agent");
});
});

describe("lane-config reset", () => {
it("resetLaneConfig restores defaults", () => {
setLaneConfig({
lanes: [
{ id: "only", title: "Only", claimable: true },
],
});
expect(getConfiguredLanes()).toHaveLength(1);

resetLaneConfig();
expect(getConfiguredLanes()).toHaveLength(3);
expect(getLaneIds()).toEqual(["normal", "escalated", "backlog"]);
});
});
181 changes: 181 additions & 0 deletions src/lib/lane-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Central lane configuration module.
*
* Provides a typed, configurable lane model so that all lane consumers go
* through one helper instead of scattered string unions and constants.
*
* The default config preserves current behavior (normal / escalated / backlog).
*/

// ─── Types ────────────────────────────────────────────────────────────────────

/**
* A single lane definition.
*/
export interface LaneConfig {
/** Unique identifier (e.g. "normal", "escalated", "backlog") */
id: string;
/** Display title */
title: string;
/** Whether workers may claim issues in this lane */
claimable: boolean;
/** Optional human-readable description */
description?: string;
/** Optional hex color for UI rendering */
color?: string;
/** Default agent that handles this lane (optional) */
defaultAgent?: string;
}

/**
* Full lane configuration set.
*/
export interface LaneConfigSet {
lanes: LaneConfig[];
}

// ─── Defaults ─────────────────────────────────────────────────────────────────

/**
* Default lane configuration — equivalent to current behavior.
*
* - `normal`: claimable, standard worker lane
* - `escalated`: claimable, requires higher-judgment model support
* - `backlog`: non-claimable, needs grooming before work can start
*/
const DEFAULT_LANE_CONFIG: LaneConfigSet = {
lanes: [
{
id: "normal",
title: "Normal",
claimable: true,
description: "Standard execution lane for concrete, scoped implementation work.",
color: "#3b82f6",
},
{
id: "escalated",
title: "Escalated",
claimable: true,
description: "Requires higher-judgment model support (architecture, design, cross-service).",
color: "#f97316",
},
{
id: "backlog",
title: "Backlog",
claimable: false,
description: "Needs grooming before work can start. Not directly claimable.",
color: "#6b7280",
},
],
};

// ─── Config State ─────────────────────────────────────────────────────────────

let laneConfigSet: LaneConfigSet = DEFAULT_LANE_CONFIG;

/**
* Override the lane configuration (e.g. from environment or custom config file).
*
* Validates that every lane has a unique, non-empty id and that at least one
* claimable lane exists. Throws on invalid config.
*/
export function setLaneConfig(config: LaneConfigSet): void {
validateLaneConfigSet(config);
laneConfigSet = config;
}

/**
* Reset to the default lane configuration.
*/
export function resetLaneConfig(): void {
laneConfigSet = DEFAULT_LANE_CONFIG;
}

// ─── Public Helpers ───────────────────────────────────────────────────────────

/**
* Return all configured lanes (deep copy).
*/
export function getConfiguredLanes(): LaneConfig[] {
return laneConfigSet.lanes.map((lane) => ({ ...lane }));
}

/**
* Return only claimable lanes.
*/
export function getClaimableLanes(): LaneConfig[] {
return getConfiguredLanes().filter((lane) => lane.claimable);
}

/**
* Return the non-claimable "backlog" fallback lane, if configured.
* Returns `undefined` when no non-claimable lane exists.
*/
export function getBacklogLane(): LaneConfig | undefined {
return getConfiguredLanes().find((lane) => !lane.claimable);
}

/**
* Check whether a lane id is configured.
*/
export function isValidLane(id: string): boolean {
return getConfiguredLanes().some((lane) => lane.id === id);
}

/**
* Check whether a lane id is both configured and claimable.
*/
export function isClaimableLane(id: string): boolean {
return getConfiguredLanes().some((lane) => lane.id === id && lane.claimable);
}

/**
* Look up a lane by id. Returns `undefined` when not found.
*/
export function getLaneById(id: string): LaneConfig | undefined {
return getConfiguredLanes().find((lane) => lane.id === id);
}

/**
* Return all configured lane ids.
*/
export function getLaneIds(): string[] {
return getConfiguredLanes().map((lane) => lane.id);
}

// ─── Validation ───────────────────────────────────────────────────────────────

/**
* Validate a LaneConfigSet and throw on errors.
*/
function validateLaneConfigSet(config: LaneConfigSet): void {
if (!Array.isArray(config.lanes) || config.lanes.length === 0) {
throw new Error("Lane config must contain at least one lane");
}

const ids = new Set<string>();

for (const lane of config.lanes) {
if (typeof lane.id !== "string" || lane.id.trim().length === 0) {
throw new Error(`Lane id must be a non-empty string, got: ${JSON.stringify(lane.id)}`);
}

if (ids.has(lane.id)) {
throw new Error(`Duplicate lane id: ${lane.id}`);
}
ids.add(lane.id);

if (typeof lane.title !== "string" || lane.title.trim().length === 0) {
throw new Error(`Lane "${lane.id}" must have a non-empty title`);
}

if (typeof lane.claimable !== "boolean") {
throw new Error(`Lane "${lane.id}" must have a boolean claimable field`);
}
}

const hasClaimable = config.lanes.some((l) => l.claimable);
if (!hasClaimable) {
throw new Error("Lane config must contain at least one claimable lane");
}
}