diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 22db057885..031283c159 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -10,6 +10,7 @@ export { type OpportunityRankInput, } from "./opportunity-ranker.js"; export * from "./governor/rate-limit.js"; +export * from "./portfolio/queue.js"; export { resolveAiPolicyVerdict, scanAiPolicyText, diff --git a/packages/gittensory-engine/src/portfolio/queue.ts b/packages/gittensory-engine/src/portfolio/queue.ts new file mode 100644 index 0000000000..50fa1fef7c --- /dev/null +++ b/packages/gittensory-engine/src/portfolio/queue.ts @@ -0,0 +1,197 @@ +/** + * Portfolio queue primitives (#2326). Pure bookkeeping for the miner's local cross-repo work queue: + * bucket items by repo, respect global/per-repo WIP caps, and select the next eligible batch in a + * deterministic diversified order. No IO, no Date, no randomness, and no enforcement/action logic. + */ + +export type PortfolioQueueItemState = "queued" | "in_progress"; + +export type PortfolioQueueItem = { + id: string; + repoFullName: string; + state: PortfolioQueueItemState; +}; + +export type PortfolioQueueBucket = { + repoFullName: string; + items: PortfolioQueueItem[]; +}; + +export type PortfolioQueue = { + buckets: PortfolioQueueBucket[]; +}; + +export type PortfolioCaps = { + globalWipCap: number; + perRepoWipCap: number; +}; + +type QueueSelectionBucket = { + repoFullName: string; + activeCount: number; + queuedItems: PortfolioQueueItem[]; + selectedCount: number; +}; + +const QUEUED_STATE: PortfolioQueueItemState = "queued"; +const ACTIVE_STATE: PortfolioQueueItemState = "in_progress"; + +function cleanId(value: string): string { + return value.trim(); +} + +function cleanRepoFullName(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeState(value: PortfolioQueueItemState): PortfolioQueueItemState { + return value === ACTIVE_STATE ? ACTIVE_STATE : QUEUED_STATE; +} + +function normalizeItem(item: PortfolioQueueItem): PortfolioQueueItem { + return { + id: cleanId(item.id), + repoFullName: cleanRepoFullName(item.repoFullName), + state: normalizeState(item.state), + }; +} + +function finiteNonNegativeInt(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +function normalizeCaps(caps: PortfolioCaps): { globalWipCap: number; perRepoWipCap: number } { + return { + globalWipCap: finiteNonNegativeInt(caps.globalWipCap), + perRepoWipCap: finiteNonNegativeInt(caps.perRepoWipCap), + }; +} + +function isActiveItem(item: PortfolioQueueItem): boolean { + return item.state === ACTIVE_STATE; +} + +function isQueuedItem(item: PortfolioQueueItem): boolean { + return item.state === QUEUED_STATE; +} + +function projectedLoad(bucket: QueueSelectionBucket): number { + return bucket.activeCount + bucket.selectedCount; +} + +function pickNextBucket( + buckets: QueueSelectionBucket[], + lastRepoFullName: string | null, +): QueueSelectionBucket | null { + const eligible = buckets.filter((bucket) => bucket.selectedCount < bucket.queuedItems.length); + if (eligible.length === 0) return null; + const alternates = + lastRepoFullName === null ? eligible : eligible.filter((bucket) => bucket.repoFullName !== lastRepoFullName); + const candidates = alternates.length > 0 ? alternates : eligible; + let winner = candidates[0]!; + for (const candidate of candidates.slice(1)) { + const winnerLoad = projectedLoad(winner); + const candidateLoad = projectedLoad(candidate); + if (candidateLoad < winnerLoad) { + winner = candidate; + } + } + return winner; +} + +function queueHasItem(queue: PortfolioQueue, itemId: string): boolean { + return queue.buckets.some((bucket) => bucket.items.some((item) => cleanId(item.id) === itemId)); +} + +/** Append one item to the queue, creating its repo bucket if needed. Duplicate/blank ids are ignored. Pure. */ +export function enqueueItem(queue: PortfolioQueue, item: PortfolioQueueItem): PortfolioQueue { + const normalizedItem = normalizeItem(item); + if (!normalizedItem.id || !normalizedItem.repoFullName || queueHasItem(queue, normalizedItem.id)) return queue; + const bucketIndex = queue.buckets.findIndex( + (bucket) => cleanRepoFullName(bucket.repoFullName) === normalizedItem.repoFullName, + ); + if (bucketIndex === -1) { + return { buckets: [...queue.buckets, { repoFullName: normalizedItem.repoFullName, items: [normalizedItem] }] }; + } + return { + buckets: queue.buckets.map((bucket, index) => + index === bucketIndex + ? { + repoFullName: normalizedItem.repoFullName, + items: [...bucket.items.map(normalizeItem), normalizedItem], + } + : bucket, + ), + }; +} + +/** Remove matching items by id; empty buckets disappear. Unknown/blank ids are a no-op. Pure. */ +export function dequeueItem(queue: PortfolioQueue, itemId: string): PortfolioQueue { + const targetId = cleanId(itemId); + if (!targetId) return queue; + let removed = false; + const buckets = queue.buckets.flatMap((bucket) => { + const items = bucket.items.filter((item) => { + const keep = cleanId(item.id) !== targetId; + if (!keep) removed = true; + return keep; + }); + return items.length > 0 ? [{ ...bucket, items }] : []; + }); + return removed ? { buckets } : queue; +} + +/** Select the next batch of queued items that fit within global/per-repo WIP caps. The batch always alternates + * repos when another repo still has an eligible item waiting; among those eligible repos, lower current load wins + * and ties keep stable bucket order. Pure. */ +export function nextEligibleItems(queue: PortfolioQueue, caps: PortfolioCaps): PortfolioQueueItem[] { + const normalizedCaps = normalizeCaps(caps); + if (normalizedCaps.globalWipCap === 0 || normalizedCaps.perRepoWipCap === 0) return []; + + const selectionBucketsByRepo = new Map(); + for (const bucket of queue.buckets) { + for (const normalizedItem of bucket.items.map(normalizeItem)) { + const repoFullName = normalizedItem.repoFullName; + const existing = selectionBucketsByRepo.get(repoFullName); + if (existing) { + if (isActiveItem(normalizedItem)) { + existing.activeCount += 1; + } else { + existing.queuedItems.push(normalizedItem); + } + continue; + } + selectionBucketsByRepo.set(repoFullName, { + repoFullName, + activeCount: isActiveItem(normalizedItem) ? 1 : 0, + queuedItems: isQueuedItem(normalizedItem) ? [normalizedItem] : [], + selectedCount: 0, + }); + } + } + + const selectionBuckets = Array.from(selectionBucketsByRepo.values()).map((bucket) => { + const remainingPerRepoCapacity = normalizedCaps.perRepoWipCap - bucket.activeCount; + return { + ...bucket, + queuedItems: remainingPerRepoCapacity > 0 ? bucket.queuedItems.slice(0, remainingPerRepoCapacity) : [], + }; + }); + + const totalActiveCount = selectionBuckets.reduce((sum, bucket) => sum + bucket.activeCount, 0); + const remainingGlobalSlots = normalizedCaps.globalWipCap - totalActiveCount; + if (remainingGlobalSlots <= 0) return []; + + const selected: PortfolioQueueItem[] = []; + let lastRepoFullName: string | null = null; + while (selected.length < remainingGlobalSlots) { + const nextBucket = pickNextBucket(selectionBuckets, lastRepoFullName); + if (nextBucket === null) break; + const nextItem = nextBucket.queuedItems[nextBucket.selectedCount]!; + selected.push(nextItem); + nextBucket.selectedCount += 1; + lastRepoFullName = nextBucket.repoFullName; + } + return selected; +} diff --git a/packages/gittensory-engine/test/portfolio-queue.test.ts b/packages/gittensory-engine/test/portfolio-queue.test.ts new file mode 100644 index 0000000000..11855a3bf9 --- /dev/null +++ b/packages/gittensory-engine/test/portfolio-queue.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + dequeueItem, + enqueueItem, + nextEligibleItems, + type PortfolioQueue, +} from "../dist/index.js"; + +const item = (id: string, repoFullName: string, state: "queued" | "in_progress" = "queued") => ({ + id, + repoFullName, + state, +}); + +const queueOf = (...items: Array>): PortfolioQueue => + items.reduce((queue, entry) => enqueueItem(queue, entry), { buckets: [] }); + +test("barrel: the public entrypoint re-exports the portfolio queue primitives", () => { + assert.equal(typeof enqueueItem, "function"); + assert.equal(typeof dequeueItem, "function"); + assert.equal(typeof nextEligibleItems, "function"); +}); + +test("nextEligibleItems: alternates repos when another eligible bucket exists", () => { + const queue = queueOf( + item("a-running", "acme/alpha", "in_progress"), + item("a-queued-1", "acme/alpha"), + item("a-queued-2", "acme/alpha"), + item("b-queued-1", "acme/beta"), + item("c-queued-1", "acme/gamma"), + ); + + assert.deepEqual( + nextEligibleItems(queue, { globalWipCap: 4, perRepoWipCap: 2 }).map((entry) => entry.id), + ["b-queued-1", "c-queued-1", "a-queued-1"], + ); +}); + +test("nextEligibleItems: repeats a repo only after the others are exhausted", () => { + const queue = queueOf( + item("a-queued-1", "acme/alpha"), + item("a-queued-2", "acme/alpha"), + item("b-queued-1", "acme/beta"), + ); + + assert.deepEqual( + nextEligibleItems(queue, { globalWipCap: 3, perRepoWipCap: 3 }).map((entry) => entry.id), + ["a-queued-1", "b-queued-1", "a-queued-2"], + ); +}); diff --git a/test/unit/portfolio-queue.test.ts b/test/unit/portfolio-queue.test.ts new file mode 100644 index 0000000000..45e80ca430 --- /dev/null +++ b/test/unit/portfolio-queue.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { + dequeueItem, + enqueueItem, + nextEligibleItems, + type PortfolioCaps, + type PortfolioQueue, + type PortfolioQueueItem, +} from "../../packages/gittensory-engine/src/portfolio/queue"; + +function item( + id: string, + repoFullName: string, + state: PortfolioQueueItem["state"] = "queued", +): PortfolioQueueItem { + return { id, repoFullName, state }; +} + +function queueOf(...items: PortfolioQueueItem[]): PortfolioQueue { + return items.reduce((queue, entry) => enqueueItem(queue, entry), { buckets: [] }); +} + +describe("portfolio queue primitives", () => { + it("enqueues by repo bucket, keeps insertion order, and ignores duplicate ids", () => { + const queue = queueOf( + item("a-1", "acme/alpha"), + item("b-1", "acme/beta"), + item("a-2", "acme/alpha"), + item("a-1", "acme/gamma"), + ); + + expect(queue).toEqual({ + buckets: [ + { repoFullName: "acme/alpha", items: [item("a-1", "acme/alpha"), item("a-2", "acme/alpha")] }, + { repoFullName: "acme/beta", items: [item("b-1", "acme/beta")] }, + ], + }); + }); + + it("ignores blank ids and blank repo names when enqueuing", () => { + const queue = queueOf(item("a-1", "acme/alpha")); + + expect(enqueueItem(queue, item(" ", "acme/beta"))).toBe(queue); + expect(enqueueItem(queue, item("b-1", " "))).toBe(queue); + }); + + it("trims identifiers and preserves an in-progress state when enqueuing", () => { + expect(enqueueItem({ buckets: [] }, item(" a-1 ", " acme/alpha ", "in_progress"))).toEqual({ + buckets: [{ repoFullName: "acme/alpha", items: [item("a-1", "acme/alpha", "in_progress")] }], + }); + }); + + it("treats repo full names case-insensitively when bucketing", () => { + expect(queueOf(item("a-1", "Owner/Repo"), item("a-2", "owner/repo"))).toEqual({ + buckets: [{ repoFullName: "owner/repo", items: [item("a-1", "owner/repo"), item("a-2", "owner/repo")] }], + }); + }); + + it("treats prebuilt queues with untrimmed ids as already containing the logical item", () => { + const queue: PortfolioQueue = { + buckets: [{ repoFullName: "acme/alpha", items: [{ id: " a-1 ", repoFullName: "acme/alpha", state: "queued" }] }], + }; + + expect(enqueueItem(queue, item("a-1", "acme/alpha"))).toBe(queue); + }); + + it("dequeues one item and drops an empty repo bucket", () => { + const queue = queueOf(item("a-1", "acme/alpha"), item("b-1", "acme/beta")); + + expect(dequeueItem(queue, "b-1")).toEqual({ + buckets: [{ repoFullName: "acme/alpha", items: [item("a-1", "acme/alpha")] }], + }); + expect(dequeueItem(queue, "missing")).toBe(queue); + }); + + it("treats a blank dequeue target as a no-op", () => { + const queue = queueOf(item("a-1", "acme/alpha")); + + expect(dequeueItem(queue, " ")).toBe(queue); + }); + + it("dequeues a logical id from a prebuilt queue even when the stored id is untrimmed", () => { + const queue: PortfolioQueue = { + buckets: [{ repoFullName: "acme/alpha", items: [{ id: " a-1 ", repoFullName: "acme/alpha", state: "queued" }] }], + }; + + expect(dequeueItem(queue, "a-1")).toEqual({ buckets: [] }); + }); + + it("returns no eligible items for an empty queue", () => { + expect(nextEligibleItems({ buckets: [] }, { globalWipCap: 2, perRepoWipCap: 1 })).toEqual([]); + }); + + it("returns no eligible items when a single repo is already at its WIP cap", () => { + const queue = queueOf( + item("a-running", "acme/alpha", "in_progress"), + item("a-queued-1", "acme/alpha"), + item("a-queued-2", "acme/alpha"), + ); + + expect(nextEligibleItems(queue, { globalWipCap: 3, perRepoWipCap: 1 })).toEqual([]); + }); + + it("returns no eligible items when either cap normalizes to zero", () => { + const queue = queueOf(item("a-queued-1", "acme/alpha")); + + expect(nextEligibleItems(queue, { globalWipCap: Number.POSITIVE_INFINITY, perRepoWipCap: 1 })).toEqual([]); + expect(nextEligibleItems(queue, { globalWipCap: 2, perRepoWipCap: -1 })).toEqual([]); + }); + + it("truncates fractional caps and treats NaN as zero", () => { + const queue = queueOf( + item("a-queued-1", "acme/alpha"), + item("a-queued-2", "acme/alpha"), + item("b-queued-1", "acme/beta"), + ); + + expect(nextEligibleItems(queue, { globalWipCap: 2.9, perRepoWipCap: 1.8 }).map((entry) => entry.id)).toEqual([ + "a-queued-1", + "b-queued-1", + ]); + expect(nextEligibleItems(queue, { globalWipCap: Number.NaN, perRepoWipCap: 2 })).toEqual([]); + }); + + it("applies one per-repo cap across case-variant prebuilt buckets", () => { + const queue: PortfolioQueue = { + buckets: [ + { repoFullName: "Owner/Repo", items: [item("a-queued-1", "Owner/Repo")] }, + { repoFullName: "owner/repo", items: [item("a-queued-2", "owner/repo")] }, + ], + }; + + expect(nextEligibleItems(queue, { globalWipCap: 2, perRepoWipCap: 1 }).map((entry) => entry.id)).toEqual([ + "a-queued-1", + ]); + }); + + it("enforces repo caps from each item's repo even when a prebuilt bucket label is wrong", () => { + const queue: PortfolioQueue = { + buckets: [ + { repoFullName: "acme/alpha", items: [item("b-running", "acme/beta", "in_progress")] }, + { repoFullName: "acme/beta", items: [item("b-queued-1", "acme/beta")] }, + ], + }; + + expect(nextEligibleItems(queue, { globalWipCap: 3, perRepoWipCap: 1 })).toEqual([]); + }); + + it("aggregates active counts across repeated prebuilt buckets for the same logical repo", () => { + const queue: PortfolioQueue = { + buckets: [ + { repoFullName: "acme/alpha", items: [item("a-running-1", "acme/alpha", "in_progress")] }, + { + repoFullName: "ACME/ALPHA", + items: [item("a-running-2", "acme/alpha", "in_progress"), item("a-queued-1", "acme/alpha")], + }, + ], + }; + + expect(nextEligibleItems(queue, { globalWipCap: 3, perRepoWipCap: 2 })).toEqual([]); + }); + + it("diversifies multi-repo selection and prefers the least represented repos first", () => { + const queue = queueOf( + item("a-running", "acme/alpha", "in_progress"), + item("a-queued-1", "acme/alpha"), + item("a-queued-2", "acme/alpha"), + item("b-queued-1", "acme/beta"), + item("c-queued-1", "acme/gamma"), + ); + const caps: PortfolioCaps = { globalWipCap: 4, perRepoWipCap: 2 }; + + expect(nextEligibleItems(queue, caps).map((entry) => entry.id)).toEqual([ + "b-queued-1", + "c-queued-1", + "a-queued-1", + ]); + }); + + it("reuses the same repo only after every other eligible repo is exhausted", () => { + const queue = queueOf( + item("a-queued-1", "acme/alpha"), + item("a-queued-2", "acme/alpha"), + item("b-queued-1", "acme/beta"), + ); + + expect(nextEligibleItems(queue, { globalWipCap: 3, perRepoWipCap: 3 }).map((entry) => entry.id)).toEqual([ + "a-queued-1", + "b-queued-1", + "a-queued-2", + ]); + }); + + it("continues selecting from the same repo when no alternate repo is eligible", () => { + const queue = queueOf(item("a-queued-1", "acme/alpha"), item("a-queued-2", "acme/alpha")); + + expect(nextEligibleItems(queue, { globalWipCap: 2, perRepoWipCap: 2 }).map((entry) => entry.id)).toEqual([ + "a-queued-1", + "a-queued-2", + ]); + }); + + it("returns no eligible items when global WIP is already full", () => { + const queue = queueOf( + item("a-running", "acme/alpha", "in_progress"), + item("b-running", "acme/beta", "in_progress"), + item("a-queued-1", "acme/alpha"), + item("b-queued-1", "acme/beta"), + ); + + expect(nextEligibleItems(queue, { globalWipCap: 2, perRepoWipCap: 2 })).toEqual([]); + }); +});